fix(connector): isolate broker enrollment targets (#12609)
## Thinking Path > - Paperclip lets operators connect provider accounts for their agents. > - Self-hosted instances can enroll with the Paperclip Cloud OAuth broker. > - A local identity can remain on disk when an operator changes the broker from production to staging. > - The old code combined the new target with the old identity and produced a verification link that Cloud could not accept. > - The final enrollment callback also returned to an unscoped Apps path. > - This pull request binds each identity to one broker target and returns to the correct company route. > - The benefit is a fail-closed enrollment flow that works across Cloud targets and company-prefixed routes. ## Linked Issues or Issue Description Refs: #12600 **What happened?** A self-hosted instance with a saved connector identity could switch its broker base URL and environment. Paperclip then used the saved identity with the new target. The enrollment page received an unknown draft. A successful callback also opened an unscoped Apps path, which the UI treated as a company prefix. **Expected behavior** Paperclip must use one atomic identity and broker target. A target change must never mix old keys with a new broker. A completed enrollment must return to the initiating company's Connections page. **Steps to reproduce** 1. Start a self-hosted Paperclip instance and create a pending connector enrollment against the production broker. 2. Set the connector base URL and environment to staging. 3. Start enrollment again and open the returned verification URL. 4. Complete enrollment and inspect the final browser route. **Paperclip version or commit** `300a89ec1` **Deployment mode** Local dev (`pnpm dev`) through private HTTPS. ## What Changed - Resolve the connector broker and environment as one target. - Rotate a non-active identity when an administrator explicitly starts enrollment for a different target. - Reject active target changes and broker/environment mismatches. - Treat managed environment identity fields as one atomic tuple. - Require the Cloud verification URL to contain only the exact enrollment identifier. - Validate the configured target again before the instance redeems an enrollment callback. - Return successful enrollment callbacks to the company-prefixed Connections page. - Add regression tests for target isolation, managed identity precedence, URL validation, and the return path. ## Verification - `pnpm exec vitest run server/src/services/paperclip-cloud-connector-enrollment.test.ts server/src/services/paperclip-cloud-connector.test.ts server/src/routes/tool-access-connection-intent.test.ts` (31 tests passed) - `pnpm --filter @paperclipai/server typecheck` - `pnpm -r typecheck` - `pnpm build` - `git diff --check` - Completed a staging Cloud enrollment from a local Paperclip instance through private HTTPS. - Confirmed the instance reports an active staging enrollment for its exact HTTPS origin. - Confirmed the company-prefixed Connections route renders the enrollment success state. - `pnpm test:run` also reached five unrelated macOS harness failures. Two compare `/var` with `/private/var`. Three expect listener-fixture failures that do not occur on this host. The same five failures reproduce when the two workspace-runtime suites run alone. ## Risks - Low risk. The change affects only Paperclip Cloud connector identity selection and the enrollment return route. - An active identity now fails closed when an operator changes its broker target. The operator must restore the original target or perform a new enrollment flow. - Starting a new target replaces a non-active draft, so its previous one-time approval link no longer works. - This change does not alter provider tokens, grants, catalogs, or tool calls. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5.6 Sol. The work used high-reasoning mode, repository tools, code execution, browser automation, and parallel review subagents. ## 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 either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and the scoped tests 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3173944561
commit
3475e33fc7
|
|
@ -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)(
|
||||
|
|
|
|||
|
|
@ -177,6 +177,10 @@ export function connectionIntentOAuthOutcomeHtml(input: {
|
|||
return `<!doctype html><html><head><meta charset="utf-8"><title>Connection authorization</title></head><body><p>Returning to Paperclip…</p><script>const message=${message};const targetOrigin=${targetOrigin}||window.location.origin;if(window.opener&&window.opener!==window){window.opener.postMessage(message,targetOrigin);window.close();}else{window.location.replace(${fallback});}</script></body></html>`;
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<void>((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",
|
||||
|
|
|
|||
|
|
@ -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<PaperclipCloudConnectorEnrollmentStatus> {
|
||||
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<PaperclipCloudConnectorEnrollmentStatus> {
|
||||
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<PaperclipCloudConnectorEnrollmentStatus> {
|
||||
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<PaperclipCloudConnectorIdentity, "brokerBaseUrl" | "environment"> {
|
||||
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<PaperclipCloudConnectorIdentity, "brokerBaseUrl" | "environment">,
|
||||
): 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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(/\/$/, ""),
|
||||
|
|
|
|||
Loading…
Reference in New Issue