diff --git a/server/src/routes/tool-access-connection-intent.test.ts b/server/src/routes/tool-access-connection-intent.test.ts index 13499df4ab..ff5f76b6a5 100644 --- a/server/src/routes/tool-access-connection-intent.test.ts +++ b/server/src/routes/tool-access-connection-intent.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vitest"; -import { connectionIntentOAuthOutcomeHtml } from "./tool-access.js"; +import { + cloudConnectorEnrollmentReturnPath, + connectionIntentOAuthOutcomeHtml, +} from "./tool-access.js"; + +describe("Cloud connector enrollment return path", () => { + it("returns to the company-prefixed Connections page", () => { + expect(cloudConnectorEnrollmentReturnPath("APP")).toBe( + "/APP/apps/connections?cloud_connector=enrolled", + ); + }); + + it("encodes the company prefix as one path segment", () => { + expect(cloudConnectorEnrollmentReturnPath("QA / Apps")).toBe( + "/QA%20%2F%20Apps/apps/connections?cloud_connector=enrolled", + ); + }); +}); describe("connection intent OAuth callback document", () => { it.each(["connected", "declined", "failed"] as const)( diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index ebee2c5fc2..2c1a0a5944 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -177,6 +177,10 @@ export function connectionIntentOAuthOutcomeHtml(input: { return `Connection authorization

Returning to Paperclip…

`; } +export function cloudConnectorEnrollmentReturnPath(issuePrefix: string): string { + return `/${encodeURIComponent(issuePrefix)}/apps/connections?cloud_connector=enrolled`; +} + export function toolAccessRoutes( db: Db, options: { @@ -888,6 +892,14 @@ function connectorEnrollmentPrincipal(req: Request): string { if (pending?.initiatedBy && pending.initiatedBy !== connectorEnrollmentPrincipal(req)) { throw notFound("Paperclip Cloud enrollment not found"); } + const [company] = pending?.companyId + ? await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, pending.companyId)) + .limit(1) + : []; + if (!company) throw notFound("Paperclip Cloud enrollment not found"); let status; try { status = await completePaperclipCloudConnectorEnrollment({ enrollmentId, approvalCode, state }); @@ -905,7 +917,7 @@ function connectorEnrollmentPrincipal(req: Request): string { details: { environment: status.environment, status: status.status }, }); } - res.redirect(303, "/apps/connections?cloud_connector=enrolled"); + res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix)); }); const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => { diff --git a/server/src/services/paperclip-cloud-connector-enrollment.test.ts b/server/src/services/paperclip-cloud-connector-enrollment.test.ts index 1272e94b86..6443eafef3 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.test.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.test.ts @@ -115,6 +115,27 @@ describe("Paperclip Cloud self-host enrollment", () => { expect(paperclipCloudConnectorEnrollmentStatus().status).toBe("not_configured"); }); + it.each([ + "https://my.example.test/connections/enroll?id=another-enrollment", + "https://my.example.test/connections/enroll", + "https://my.example.test/connections/enroll?id=enroll-test&next=%2Faccount", + "https://my.example.test/connections/enroll?id=enroll-test#fragment", + "https://user@my.example.test/connections/enroll?id=enroll-test", + ])("rejects an imprecise broker verification destination: %s", async (verificationUrl) => { + await expect(startPaperclipCloudConnectorEnrollment({ + origin: "https://private.example.test", + env: { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.example.test", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "development", + }, + request: vi.fn(async () => Response.json({ + enrollmentId: "enroll-test", + verificationUrl, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, { status: 201 })) as typeof fetch, + })).rejects.toThrow(/invalid enrollment destination/); + }); + it("serializes overlapping starts and reuses one unexpired enrollment", async () => { let releaseBroker!: () => void; const brokerMayRespond = new Promise((resolve) => { @@ -174,6 +195,270 @@ describe("Paperclip Cloud self-host enrollment", () => { })).toMatchObject({ environment: "staging" }); }); + it("rotates a non-active identity instead of mixing enrollment targets", async () => { + const origin = "https://private.example.test"; + const productionRequest = vi.fn(async () => Response.json({ + enrollmentId: "enroll-production", + verificationUrl: "https://my.paperclip.app/connections/enroll?id=enroll-production", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, { status: 201 })); + await startPaperclipCloudConnectorEnrollment({ + origin, + env: { PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.paperclip.app" }, + request: productionRequest as typeof fetch, + }); + const productionIdentity = loadPaperclipCloudConnectorIdentity()!; + + expect(paperclipCloudConnectorEnrollmentStatus({ + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + })).toEqual({ + configured: false, + status: "unverified", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: null, + environment: "staging", + origins: [], + }); + + const stagingRequest = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe("https://my-staging.paperclip.app/v1/connector/enrollments"); + expect(JSON.parse(String(init?.body))).toMatchObject({ environment: "staging", origin }); + return Response.json({ + enrollmentId: "enroll-staging", + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-staging", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, { status: 201 }); + }); + const stagingStatus = await startPaperclipCloudConnectorEnrollment({ + origin, + env: { PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app" }, + request: stagingRequest as typeof fetch, + }); + const stagingIdentity = loadPaperclipCloudConnectorIdentity()!; + + expect(stagingStatus).toMatchObject({ + status: "pending", + brokerBaseUrl: "https://my-staging.paperclip.app", + environment: "staging", + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-staging", + }); + expect(stagingIdentity.instanceId).not.toBe(productionIdentity.instanceId); + expect(stagingIdentity.signPublicKey).not.toBe(productionIdentity.signPublicKey); + expect(stagingIdentity.sealPublicKey).not.toBe(productionIdentity.sealPublicKey); + expect(stagingRequest).toHaveBeenCalledOnce(); + }); + + it("fails closed when the configured target changes during callback or after activation", async () => { + const origin = "https://private.example.test"; + const productionEnv = { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "production", + }; + const request = vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith("/v1/connector/enrollments")) { + return Response.json({ + enrollmentId: "enroll-production", + verificationUrl: "https://my.paperclip.app/connections/enroll?id=enroll-production", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, { status: 201 }); + } + return Response.json({ + id: loadPaperclipCloudConnectorIdentity()?.instanceId, + environment: "production", + origins: [origin], + }); + }); + await startPaperclipCloudConnectorEnrollment({ origin, env: productionEnv, request: request as typeof fetch }); + const pending = loadPaperclipCloudConnectorIdentity()!; + const changedTargetRequest = vi.fn(); + + await expect(completePaperclipCloudConnectorEnrollment({ + enrollmentId: "enroll-production", + approvalCode: "approval-code", + state: pending.pending!.returnState, + env: { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + }, + request: changedTargetRequest as typeof fetch, + })).rejects.toThrow(/Invalid or expired/); + expect(changedTargetRequest).not.toHaveBeenCalled(); + + await completePaperclipCloudConnectorEnrollment({ + enrollmentId: "enroll-production", + approvalCode: "approval-code", + state: pending.pending!.returnState, + env: productionEnv, + request: request as typeof fetch, + }); + const activeIdentity = loadPaperclipCloudConnectorIdentity()!; + const activeSwitchRequest = vi.fn(); + const stagingEnv = { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + }; + + await expect(startPaperclipCloudConnectorEnrollment({ + origin, + env: stagingEnv, + request: activeSwitchRequest as typeof fetch, + })).rejects.toThrow(/another target/); + expect(activeSwitchRequest).not.toHaveBeenCalled(); + expect(loadPaperclipCloudConnectorIdentity()).toEqual(activeIdentity); + expect(paperclipCloudConnectorConfigFromEnv(stagingEnv)).toBeNull(); + }); + + it("treats managed environment identity as an atomic override of local identity", async () => { + const origin = "https://private.example.test"; + const productionEnv = { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "production", + }; + const request = vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith("/v1/connector/enrollments")) { + return Response.json({ + enrollmentId: "enroll-production", + verificationUrl: "https://my.paperclip.app/connections/enroll?id=enroll-production", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, { status: 201 }); + } + return Response.json({ + id: loadPaperclipCloudConnectorIdentity()?.instanceId, + environment: "production", + origins: [origin], + }); + }); + await startPaperclipCloudConnectorEnrollment({ origin, env: productionEnv, request: request as typeof fetch }); + const pending = loadPaperclipCloudConnectorIdentity()!; + await completePaperclipCloudConnectorEnrollment({ + enrollmentId: "enroll-production", + approvalCode: "approval-code", + state: pending.pending!.returnState, + env: productionEnv, + request: request as typeof fetch, + }); + const localIdentity = loadPaperclipCloudConnectorIdentity()!; + + const managedEnv = { + PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: "managed-staging-instance", + PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: "managed-signing-key", + PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: "managed-sealing-key", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + PAPERCLIP_PUBLIC_URL: "https://managed-stack.example.test", + }; + expect(paperclipCloudConnectorEnrollmentStatus(managedEnv)).toEqual({ + configured: true, + status: "active", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "managed-staging-instance", + environment: "staging", + origins: ["https://managed-stack.example.test"], + }); + expect(paperclipCloudConnectorConfigFromEnv(managedEnv)).toMatchObject({ + baseUrl: "https://my-staging.paperclip.app", + instanceId: "managed-staging-instance", + environment: "staging", + signPrivateKey: "managed-signing-key", + sealPrivateKey: "managed-sealing-key", + }); + expect(loadPaperclipCloudConnectorIdentity()).toEqual(localIdentity); + + for (const omitted of [ + "PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID", + "PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY", + "PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY", + "PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT", + ] as const) { + const partialManagedEnv: NodeJS.ProcessEnv = { ...managedEnv }; + delete partialManagedEnv[omitted]; + expect(paperclipCloudConnectorEnrollmentStatus(partialManagedEnv)).toMatchObject({ + configured: false, + status: "unverified", + instanceId: null, + environment: "staging", + }); + expect(() => paperclipCloudConnectorConfigFromEnv(partialManagedEnv)).toThrow(/incomplete/); + expect(loadPaperclipCloudConnectorIdentity()).toEqual(localIdentity); + } + + expect(paperclipCloudConnectorConfigFromEnv(productionEnv)).toMatchObject({ + baseUrl: localIdentity.brokerBaseUrl, + instanceId: localIdentity.instanceId, + environment: localIdentity.environment, + signPrivateKey: localIdentity.signPrivateKey, + sealPrivateKey: localIdentity.sealPrivateKey, + }); + }); + + it("rejects known Cloud broker and environment mismatches", () => { + const managedIdentity = { + PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: "managed-instance", + PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: "managed-signing-key", + PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: "managed-sealing-key", + }; + const mismatches = [ + { + ...managedIdentity, + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + }, + { + ...managedIdentity, + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "production", + }, + ]; + for (const env of mismatches) { + expect(() => paperclipCloudConnectorEnrollmentStatus(env)).toThrow(/do not match/); + expect(() => paperclipCloudConnectorConfigFromEnv(env)).toThrow(/do not match/); + } + expect(() => paperclipCloudConnectorEnrollmentStatus({ + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + })).toThrow(/do not match/); + }); + + it("does not create or complete self-host enrollment with managed identity configuration", async () => { + const origin = "https://private.example.test"; + const localEnv = { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + }; + const enrollmentRequest = vi.fn(async () => Response.json({ + enrollmentId: "enroll-local", + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-local", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, { status: 201 })); + await startPaperclipCloudConnectorEnrollment({ + origin, + env: localEnv, + request: enrollmentRequest as typeof fetch, + }); + const pendingIdentity = loadPaperclipCloudConnectorIdentity()!; + const managedEnv = { + ...localEnv, + PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: "managed-staging-instance", + PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: "managed-signing-key", + PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: "managed-sealing-key", + }; + const managedRequest = vi.fn(); + + await expect(startPaperclipCloudConnectorEnrollment({ + origin, + env: managedEnv, + request: managedRequest as typeof fetch, + })).rejects.toThrow(/unavailable with managed identity/); + await expect(completePaperclipCloudConnectorEnrollment({ + enrollmentId: "enroll-local", + approvalCode: "approval-code", + state: pendingIdentity.pending!.returnState, + env: managedEnv, + request: managedRequest as typeof fetch, + })).rejects.toThrow(/Invalid or expired/); + expect(managedRequest).not.toHaveBeenCalled(); + expect(loadPaperclipCloudConnectorIdentity()).toEqual(pendingIdentity); + }); + it("does not treat legacy Paperclip ID keys as a Cloud enrollment", () => { expect(paperclipCloudConnectorEnrollmentStatus({ PAPERCLIP_ID_CONNECTOR_INSTANCE_ID: "legacy-instance", diff --git a/server/src/services/paperclip-cloud-connector-enrollment.ts b/server/src/services/paperclip-cloud-connector-enrollment.ts index ac886deccd..c5a2d0db74 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.ts @@ -72,31 +72,47 @@ export function paperclipCloudConnectorEnrollmentStatus( env: NodeJS.ProcessEnv = process.env, ): PaperclipCloudConnectorEnrollmentStatus { const identity = loadPaperclipCloudConnectorIdentity(); - const brokerBaseUrl = normalizeBrokerOrigin( - env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL - ?? identity?.brokerBaseUrl - ?? "https://my.paperclip.app", - ); - const environment = connectorEnvironment(env, identity?.environment, brokerBaseUrl); - if (!identity) { - const managedInstanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim(); - const managedKeysPresent = Boolean( - env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim() - && env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim(), - ); - if (managedInstanceId && managedKeysPresent) { - const publicOrigin = env.PAPERCLIP_PUBLIC_URL ? normalizeInstanceOrigin(env.PAPERCLIP_PUBLIC_URL) : undefined; + const managedInstanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim(); + const managedSignPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim(); + const managedSealPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim(); + const managedEnvironment = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim(); + const hasManagedIdentityOverride = hasManagedConnectorIdentityOverride(env); + if (hasManagedIdentityOverride) { + const { brokerBaseUrl, environment } = connectorTarget(env); + if (!managedInstanceId || !managedSignPrivateKey || !managedSealPrivateKey || !managedEnvironment) { return { - configured: true, - status: "active", + configured: false, + status: "unverified", brokerBaseUrl, - instanceId: managedInstanceId, + instanceId: null, environment, - origins: publicOrigin ? [publicOrigin] : [], + origins: [], }; } + const publicOrigin = env.PAPERCLIP_PUBLIC_URL ? normalizeInstanceOrigin(env.PAPERCLIP_PUBLIC_URL) : undefined; + return { + configured: true, + status: "active", + brokerBaseUrl, + instanceId: managedInstanceId, + environment, + origins: publicOrigin ? [publicOrigin] : [], + }; + } + const { brokerBaseUrl, environment } = connectorTarget(env, identity); + if (!identity) { return { configured: false, status: "not_configured", brokerBaseUrl, instanceId: null, environment, origins: [] }; } + if (!identityMatchesTarget(identity, { brokerBaseUrl, environment })) { + return { + configured: false, + status: "unverified", + brokerBaseUrl, + instanceId: null, + environment, + origins: [], + }; + } return { configured: identity.status === "active", status: identity.status, @@ -132,8 +148,23 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: { }): Promise { const env = input.env ?? process.env; const request = input.request ?? fetch; + if (hasManagedConnectorIdentityOverride(env)) { + throw new Error("Paperclip Cloud self-host enrollment is unavailable with managed identity configuration"); + } const origin = normalizeInstanceOrigin(input.origin); - let identity = loadPaperclipCloudConnectorIdentity() ?? createIdentity(env); + const existingIdentity = loadPaperclipCloudConnectorIdentity(); + const target = connectorTarget(env, existingIdentity); + let identity: PaperclipCloudConnectorIdentity; + if (!existingIdentity) { + identity = createIdentity(env); + } else if (!identityMatchesTarget(existingIdentity, target)) { + if (existingIdentity.status === "active") { + throw new Error("Paperclip Cloud connector is enrolled with another target"); + } + identity = createIdentity(env); + } else { + identity = existingIdentity; + } if (identity.status === "pending" && identity.pending && Date.parse(identity.pending.expiresAt) > Date.now()) { if (identity.pending.origin !== origin) { throw new Error("Paperclip Cloud enrollment is already pending for another origin"); @@ -169,7 +200,11 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: { throw new Error("Paperclip Cloud returned an invalid enrollment response"); } const verificationUrl = new URL(body.verificationUrl); - if (verificationUrl.origin !== identity.brokerBaseUrl || verificationUrl.pathname !== "/connections/enroll") { + if (verificationUrl.origin !== identity.brokerBaseUrl + || verificationUrl.username || verificationUrl.password || verificationUrl.hash + || verificationUrl.pathname !== "/connections/enroll" + || verificationUrl.searchParams.size !== 1 + || verificationUrl.searchParams.get("id") !== body.enrollmentId) { throw new Error("Paperclip Cloud returned an invalid enrollment destination"); } identity = { @@ -196,6 +231,7 @@ export async function completePaperclipCloudConnectorEnrollment(input: { enrollmentId: string; approvalCode: string; state: string; + env?: NodeJS.ProcessEnv; request?: typeof fetch; }): Promise { return withEnrollmentMutationLock(() => completePaperclipCloudConnectorEnrollmentUnlocked(input)); @@ -205,13 +241,16 @@ async function completePaperclipCloudConnectorEnrollmentUnlocked(input: { enrollmentId: string; approvalCode: string; state: string; + env?: NodeJS.ProcessEnv; request?: typeof fetch; }): Promise { const identity = loadPaperclipCloudConnectorIdentity(); const pending = identity?.pending; - if (!identity || !pending || identity.status !== "pending" + if (hasManagedConnectorIdentityOverride(input.env ?? process.env) + || !identity || !pending || identity.status !== "pending" || pending.enrollmentId !== input.enrollmentId || pending.returnState !== input.state - || Date.parse(pending.expiresAt) <= Date.now()) { + || Date.parse(pending.expiresAt) <= Date.now() + || !identityMatchesTarget(identity, connectorTarget(input.env ?? process.env, identity))) { throw new Error("Invalid or expired Paperclip Cloud enrollment state"); } const audience = `${identity.brokerBaseUrl}/v1/connector/enrollment-claims`; @@ -247,17 +286,17 @@ async function completePaperclipCloudConnectorEnrollmentUnlocked(input: { pending: undefined, enrolledAt: new Date().toISOString(), }); - return paperclipCloudConnectorEnrollmentStatus(); + return paperclipCloudConnectorEnrollmentStatus(input.env ?? process.env); } function createIdentity(env: NodeJS.ProcessEnv): PaperclipCloudConnectorIdentity { const signing = generateKeyPairSync("ed25519"); const sealing = generateKeyPairSync("x25519"); - const brokerBaseUrl = normalizeBrokerOrigin(env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL ?? "https://my.paperclip.app"); + const { brokerBaseUrl, environment } = connectorTarget(env); const identity: PaperclipCloudConnectorIdentity = { version: IDENTITY_VERSION, instanceId: `inst_${randomUUID()}`, - environment: connectorEnvironment(env, undefined, brokerBaseUrl), + environment, brokerBaseUrl, signPrivateKey: rawKey(signing.privateKey, "d"), signPublicKey: rawKey(signing.publicKey, "x"), @@ -306,11 +345,44 @@ function connectorEnvironment( : host === "my-staging.paperclip.app" ? "staging" : "development"; - const value = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT ?? fallback ?? inferred; + 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 === "my-staging.paperclip.app" && value !== "staging")) { + throw new Error("Paperclip Cloud connector broker and environment do not match"); + } return value; } +function connectorTarget( + env: NodeJS.ProcessEnv, + identity?: PaperclipCloudConnectorIdentity | null, +): Pick { + const brokerOverride = env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL?.trim() || undefined; + const brokerBaseUrl = normalizeBrokerOrigin( + brokerOverride ?? identity?.brokerBaseUrl ?? "https://my.paperclip.app", + ); + return { + brokerBaseUrl, + environment: connectorEnvironment(env, brokerOverride ? undefined : identity?.environment, brokerBaseUrl), + }; +} + +function identityMatchesTarget( + identity: PaperclipCloudConnectorIdentity, + target: Pick, +): boolean { + return identity.brokerBaseUrl === target.brokerBaseUrl && identity.environment === target.environment; +} + +function hasManagedConnectorIdentityOverride(env: NodeJS.ProcessEnv): boolean { + return [ + env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID, + env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY, + env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY, + ].some((value) => Boolean(value?.trim())); +} + function isEnvironment(value: unknown): value is LocalConnectorEnvironment { return value === "development" || value === "staging" || value === "production"; } diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 77133544ae..3c446639a4 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -14,7 +14,10 @@ import { isGoogleWorkspaceConnectorProfileId, type GoogleWorkspaceConnectorProfileId, } from "@paperclipai/shared"; -import { loadPaperclipCloudConnectorIdentity } from "./paperclip-cloud-connector-enrollment.js"; +import { + loadPaperclipCloudConnectorIdentity, + paperclipCloudConnectorEnrollmentStatus, +} from "./paperclip-cloud-connector-enrollment.js"; export const GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1"; export const GMAIL_CONNECTOR_SCOPES = [ @@ -106,7 +109,6 @@ export function paperclipCloudConnectorConfigFromEnv( env: NodeJS.ProcessEnv = process.env, ): PaperclipCloudConnectorConfig | null { const localIdentity = loadPaperclipCloudConnectorIdentity(); - const hasActiveLocalIdentity = localIdentity?.status === "active"; const legacyConfigured = [ env.PAPERCLIP_ID_CONNECTOR_INSTANCE_ID, env.PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY, @@ -114,32 +116,30 @@ export function paperclipCloudConnectorConfigFromEnv( env.PAPERCLIP_ID_CONNECTOR_ENVIRONMENT, env.PAPERCLIP_ID_CONNECTOR_BASE_URL, ].some((value) => Boolean(value?.trim())); - const cloudConfigured = [ - env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID, - env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY, - env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY, - env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT, - env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL, - ].some((value) => Boolean(value?.trim())); - if (!cloudConfigured && !hasActiveLocalIdentity && legacyConfigured) { + const managedInstanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim(); + const managedSignPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim(); + const managedSealPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim(); + const managedEnvironment = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim(); + const hasManagedIdentityOverride = [managedInstanceId, managedSignPrivateKey, managedSealPrivateKey] + .some(Boolean); + const localStatus = hasManagedIdentityOverride ? null : paperclipCloudConnectorEnrollmentStatus(env); + const hasActiveLocalIdentity = localIdentity?.status === "active" && localStatus?.configured === true; + if (!hasManagedIdentityOverride && !hasActiveLocalIdentity && legacyConfigured) { throw new PaperclipCloudConnectorError( "Paperclip ID connector settings use an incompatible legacy protocol; enroll this instance with Paperclip Cloud", "CONNECTOR_MIGRATION_REQUIRED", ); } - const instanceId = env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID?.trim() - || (hasActiveLocalIdentity ? localIdentity.instanceId : undefined); - const signPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY?.trim() - || (hasActiveLocalIdentity ? localIdentity.signPrivateKey : undefined); - const sealPrivateKey = env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY?.trim() - || (hasActiveLocalIdentity ? localIdentity.sealPrivateKey : undefined); - const environment = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim() - || (hasActiveLocalIdentity ? localIdentity.environment : undefined); + if (!hasManagedIdentityOverride && !hasActiveLocalIdentity) return null; + + const instanceId = hasManagedIdentityOverride ? managedInstanceId : localIdentity!.instanceId; + const signPrivateKey = hasManagedIdentityOverride ? managedSignPrivateKey : localIdentity!.signPrivateKey; + const sealPrivateKey = hasManagedIdentityOverride ? managedSealPrivateKey : localIdentity!.sealPrivateKey; + const environment = hasManagedIdentityOverride ? managedEnvironment : localIdentity!.environment; const baseUrl = env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL?.trim() - || (hasActiveLocalIdentity ? localIdentity.brokerBaseUrl : undefined) + || (hasActiveLocalIdentity ? localIdentity!.brokerBaseUrl : undefined) || "https://my.paperclip.app"; const values = [instanceId, signPrivateKey, sealPrivateKey, environment]; - if (values.every((value) => !value)) return null; if (values.some((value) => !value)) { throw new PaperclipCloudConnectorError("Paperclip Cloud connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE"); } @@ -153,6 +153,14 @@ export function paperclipCloudConnectorConfigFromEnv( if (parsedBaseUrl.username || parsedBaseUrl.password || parsedBaseUrl.search || parsedBaseUrl.hash) { throw new PaperclipCloudConnectorError("Paperclip Cloud connector URL is invalid", "CONNECTOR_CONFIG_INVALID"); } + const brokerHost = parsedBaseUrl.hostname.toLowerCase(); + if ((brokerHost === "my.paperclip.app" && environment !== "production") + || (brokerHost === "my-staging.paperclip.app" && environment !== "staging")) { + throw new PaperclipCloudConnectorError( + "Paperclip Cloud connector broker and environment do not match", + "CONNECTOR_CONFIG_INVALID", + ); + } parsedBaseUrl.pathname = parsedBaseUrl.pathname.replace(/\/$/, ""); return { baseUrl: parsedBaseUrl.toString().replace(/\/$/, ""),