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 authorizationReturning 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(/\/$/, ""),