mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Slack connectors turn authenticated messages into company work. > - Direct callbacks need an awake tenant. > - Cloud can retain authenticated asynchronous events until the tenant wakes. > - Replay must preserve the original callback proof and current endpoint authority. > - Configuration changes must retain registration responsibility through failures. > - This PR enrolls verified Slack identities and drains sealed events through the normal SDK owner. ## Linked Issues or Issue Description **Subsystem affected** Server chat connectors and signed Cloud connector delivery. **Problem or motivation** Direct Slack Events API delivery depends on an awake tenant. Broker outages, lost responses and configuration changes must preserve delivery and registration responsibility. Enrollment failures must also leave other connector events able to drain. **Proposed solution** Stage nonsecret enrollment or retirement intent inside the local configuration transaction. Let the existing connection-event owner perform broker requests and retries. Replay sealed events through the current Slack SDK with their original authentication evidence. **Alternatives considered** An early acknowledgment for every callback changes command and modal semantics. A second delivery ledger duplicates existing ownership. Reusing a new stack identity to retire old registrations crosses an authorization boundary. A companion Cloud broker change is required. Related setup fixes: Refs #18837, Refs #18878. Kickoff searches found no competing async enrollment consumer. The ROADMAP's connector setup and access controls stay in place. ## What Changed - Persist enrollment and credential-free retirement intent before local configuration commits. Broker failure does not make a completed pause or removal look unsuccessful. - Bind company, endpoint, connection, public ID, generation and authorized actor. Preserve lost-response responsibility and recover only the supported same-instance configuration outage. - Permit an explicit move to a new authorized stack only after confirmed old retirement. Keep the old identity until the new broker receipt matches the current intent and generation. - Show permanent signing-credential failures as attention. Preserve transient retries and reject uncertain inbound authentication before SDK admission. - Drain events and acknowledgments before enrollment reconciliation. Persist a bounded retry on the exact busy intent so other due endpoints can enroll or retire. Preserve current custody under concurrent changes. - Stage retirement in the Apps removal transaction too. Archive the endpoint and connection, retain the original subject and instance, then revoke credentials. Retirement does not need the deleted Slack credential. - Acknowledge a malformed sealed Slack receipt as invalid once. Keep actual broker outages on the retry path. - Run the real configuration, SDK ingress, generation fences and public Apps removal paths in the default SQL suite. Keep Vitest transport helpers outside production services. - Preserve original callback bytes, signature, time and public URL evidence. Use the existing transactional chat-delivery ledger for redelivery. - Provide a checked-in prewire guard and launcher using the current Node executable. Add broker, worker, SDK, SQL and controlled-process acceptance. - Stabilize AI account summaries by ID and grant ID. This fixes a CI migration-repeat equality failure without changing SQL, authorization or reconnect behavior. - Open the four configured database connections before the enrollment test freezes its clock. Keep all 35 original assertions and all timeouts. - Measure status-card dispatch entry before its mock does database work. Keep the original strict 500 ms bound and check any still-armed earlier deadline. Bound that fixture's pool to four connections. ## Verification - Candidate: `54fa701d41b4ced62f4ff338d39b946015670ae3`. Normal merges preserve master `47027e7` and all owned changes. A read-only merge assessment against fetched master `cb5d7e44` is conflict-free. The only overlapping file has separate AI test additions. The branch has not merged that later master; fresh remote checks must validate the current PR test tree. - At this head, normal server production typechecking and declaration emission passed in 35.24s. The affected actual CI test shard passed in 6.27s. Other unchanged CI layers, 92 affected pure controls and 12 guard controls retain their source-matched results. - All **15 default real-PostgreSQL cases passed in 34.66s**, with no skips or filtering. They cover normal configure and SDK ingress, rollback, stale generations, malformed receipts, busy-lease fairness, concurrent intent custody, unknown errors, and public Apps removal. Removal tests check actual credential revocation, retained retirement through outage and repeat removal, one ordinary retry and confirmed retirement, transaction rollback and foreign-company refusal. - The public removal counterfactual failed before the fix: archival and credential revocation passed, but the inactive intent was absent. The same test source passed after the staging call. Separate counterfactuals also reproduced busy-endpoint starvation and malformed-receipt retries. Original failures remain retained. - The paired broker/SDK/process journey passed in 31.98s at ancestor `0324c011`. It preserves callback proof, lost-ACK redelivery, exactly one comment, one delivery and one successful controlled process. Its test, producer, SDK, guard and runtime bytes are unchanged. The new public Apps archive branch is not called by that journey; its evidence carries on that checked basis. - The new SQL cases need no companion Cloud checkout. Normal CI runs them with the repository fixture. For an existing dedicated PostgreSQL fixture, set `PAPERCLIP_SLACK_OWNER_TEST_DATABASE_URL` and `PAPERCLIP_TEST_POSTGRES_ADMIN_URL`, then run `node server/scripts/test-slack-cloud-owner.mjs` with Node24. The optional paired launcher still requires the normally built companion Cloud checkout. - Every pool was bounded to four or fewer connections. The final 15-case run sampled three runtime clients plus one observer. The independent final audit found zero clients and all five recorded processes absent. Earlier closing-socket observations are kept separate from final cleanup. - Earlier unrelated CI failures are documented: [enrollment timeout](https://github.com/paperclipai/paperclip/actions/runs/41944373937/job/126973483754) and [status-card observation bound](https://github.com/paperclipai/paperclip/actions/runs/41944373937/job/126973483843). The enrollment test retains all 35 assertions and its original timeouts. A controlled connection probe established the frozen-clock mechanism; the original remote access history remains unknown. The status-card fixture measures dispatch entry with the original strict 500ms bound. A real delayed-dispatch control fails that bound. - Full local workspace typecheck, build and test inventory was not run because its embedded-cluster and wider resource paths exceed this local lease. Exact-head remote CI now passes: 54 successful checks, three skips and Ponytail's neutral check, with aggregate verification green and the legacy security context independently confirmed successful. Both complete bot reviews are 5/5, with all 13 paginated review threads resolved. Seven execution jobs originally reported runner shutdown; one diagnostic rerun recovered them and the dependent aggregate check. Original logs remain retained, and the underlying shutdown cause is unknown. This is recovery evidence, not a product-fix claim. - Provider responses are controlled. This proves local SQL, broker, SDK and process behavior. It does not prove live Slack, agent tools, synchronous callbacks or deployed suspension. ## Risks The companion broker is required. Outage or conflicting authority can leave explicit pending or attention responsibility. A move between stacks requires confirmation from the old authorized owner first. Synchronous commands, actions, modal validation, options and challenges retain their deadlines and endpoint blocker. This slice grants no full Slack endpoint sleep exemption. No deployed suspension or savings claim is made. ## Model Used OpenAI `gpt-6.1-sol` workers through Codex, with reasoning, repository tools and code execution. The served backend revision and context limit are not exposed. A GPT-6-family coordinator performed technical review and integration. ## 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 they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Codex <noreply@openai.com>
899 lines
40 KiB
TypeScript
899 lines
40 KiB
TypeScript
import { providerRetryAt } from "./provider-retry-after.js";
|
|
import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared";
|
|
import {
|
|
createDecipheriv,
|
|
createHash,
|
|
createPrivateKey,
|
|
createPublicKey,
|
|
diffieHellman,
|
|
hkdfSync,
|
|
randomUUID,
|
|
sign,
|
|
type KeyObject,
|
|
} from "node:crypto";
|
|
import {
|
|
DEFAULT_OWNERSHIP_AVAILABILITY,
|
|
type AppDefinition,
|
|
GITHUB_CONNECTOR_PROFILES,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
isGitHubConnectorProfileId,
|
|
isGoogleWorkspaceConnectorProfileId,
|
|
isGoogleWorkspaceScopeGrant,
|
|
type GitHubConnectorProfileId,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "@paperclipai/shared";
|
|
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 = [
|
|
"https://www.googleapis.com/auth/gmail.readonly",
|
|
"https://www.googleapis.com/auth/gmail.compose",
|
|
] as const;
|
|
export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
|
|
|
|
export type PaperclipCloudConnectorEnvironment = "development" | "test" | "staging" | "production";
|
|
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app" | "slack-events";
|
|
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot";
|
|
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack";
|
|
|
|
export type PaperclipCloudConnectorConfig = {
|
|
baseUrl: string;
|
|
instanceId: string;
|
|
environment: PaperclipCloudConnectorEnvironment;
|
|
signPrivateKey: string;
|
|
sealPrivateKey: string;
|
|
};
|
|
|
|
export type SealedConnectorCredentials = {
|
|
v: 1;
|
|
accessToken: string;
|
|
refreshToken: string | null;
|
|
tokenType: string;
|
|
accessTokenExpiresAt: string | null;
|
|
refreshTokenExpiresAt: string | null;
|
|
scopes: string[];
|
|
subject: string;
|
|
companyId: string;
|
|
instanceId: string;
|
|
environment: PaperclipCloudConnectorEnvironment;
|
|
provider: PaperclipCloudConnectorProvider;
|
|
profile: string;
|
|
appSlug?: string;
|
|
slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string };
|
|
};
|
|
|
|
export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" };
|
|
export type SealedGoogleWorkspaceCredentials = SealedGmailCredentials;
|
|
|
|
export type SealedConnectorEvents = {
|
|
v: 1;
|
|
instanceId: string;
|
|
environment: PaperclipCloudConnectorEnvironment;
|
|
leaseId: string;
|
|
events: Array<{
|
|
id: string;
|
|
provider: "github" | "slack";
|
|
event: string;
|
|
action: string | null;
|
|
installationId: string | null;
|
|
repositoryId: string | null;
|
|
payload: Record<string, unknown>;
|
|
createdAt: string;
|
|
bindingIds: string[];
|
|
}>;
|
|
};
|
|
|
|
type SealedEnvelopeProfileId = PaperclipCloudConnectorProfileId | "slack.events.v1";
|
|
|
|
type SealedEnvelope = {
|
|
v: 1;
|
|
alg: "X25519-HKDF-SHA256-A256GCM";
|
|
purpose: "initial" | "access" | "events";
|
|
provider: PaperclipCloudConnectorProvider;
|
|
profile: SealedEnvelopeProfileId;
|
|
epk: string;
|
|
iv: string;
|
|
ct: string;
|
|
};
|
|
|
|
type ConnectorResponse = {
|
|
slackManager?: unknown;
|
|
confirmationUrl?: unknown;
|
|
authorizationUrl?: unknown;
|
|
handoff?: unknown;
|
|
expiresAt?: unknown;
|
|
scopes?: unknown;
|
|
claimId?: unknown;
|
|
sealed?: unknown;
|
|
profiles?: unknown;
|
|
protocolVersion?: unknown;
|
|
providers?: unknown;
|
|
active?: unknown;
|
|
status?: unknown;
|
|
leaseId?: unknown;
|
|
events?: unknown;
|
|
acknowledged?: unknown;
|
|
githubApps?: { version: number };
|
|
slackEvents?: { version: number };
|
|
enrollment?: unknown;
|
|
retirement?: unknown;
|
|
eventWakeVersion?: unknown;
|
|
};
|
|
|
|
const ENDPOINTS: Record<PaperclipCloudConnectorOperation, string> = {
|
|
"slack-events": "/v1/connector/slack-events",
|
|
"github-app": "/v1/connector/github-apps",
|
|
status: "/v1/connector/instance-status",
|
|
session: "/v1/connector/sessions",
|
|
claim: "/v1/connector/claims",
|
|
refresh: "/v1/connector/refresh",
|
|
revoke: "/v1/connector/revoke",
|
|
"webhook-bind": "/v1/connector/webhook-bindings",
|
|
"event-lease": "/v1/connector/events/lease",
|
|
"event-ack": "/v1/connector/events/ack",
|
|
};
|
|
const JWS_TYP = "paperclip-cloud-connector-request+jwt";
|
|
const SEAL_ALGORITHM = "X25519-HKDF-SHA256-A256GCM";
|
|
const AES_TAG_BYTES = 16;
|
|
const RAW_PRIVATE_KEY_BYTES = 32;
|
|
const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
const X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
|
|
|
|
// Only fixed protocol codes may enter errors/logs. Never retain the broker's
|
|
// message, request data, return URI, or an arbitrary error/code string.
|
|
const BROKER_REJECTION_REASONS = new Set([
|
|
"RETURN_ORIGIN_NOT_ENROLLED", "INVALID_RETURN_URI", "INVALID_REQUEST",
|
|
"UNKNOWN_INSTANCE", "INSTANCE_APPROVAL_REQUIRED", "INSTANCE_SUSPENDED",
|
|
"ENVIRONMENT_MISMATCH", "PROFILE_NOT_AVAILABLE", "PROFILE_NOT_ELIGIBLE",
|
|
"REQUEST_EXPIRED", "REQUEST_REPLAYED", "SIGNATURE_REJECTED",
|
|
"AUDIENCE_MISMATCH", "OPERATION_MISMATCH", "PAYLOAD_MISMATCH",
|
|
"MALFORMED_REQUEST", "UNSUPPORTED_ALGORITHM", "RATE_LIMITED",
|
|
"PROVIDER_OPERATION_FAILED", "PROVIDER_REAUTHORIZATION_REQUIRED",
|
|
"SLACK_ENROLLMENT_GENERATION_CHANGED", "SLACK_ENROLLMENT_IDENTITY_CHANGED",
|
|
"SLACK_ENROLLMENT_CHANGED", "SLACK_ENROLLMENT_UNSETTLED", "SLACK_ENROLLMENT_DISABLED",
|
|
"SLACK_COMPANY_MISMATCH", "SLACK_EVENTS_UNAVAILABLE",
|
|
]);
|
|
const UNKNOWN_BROKER_REASON = "UNKNOWN_BROKER_ERROR";
|
|
|
|
async function readBrokerRejectionReason(response: Response): Promise<string> {
|
|
if (response.bodyUsed || response.body?.locked) return UNKNOWN_BROKER_REASON;
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return UNKNOWN_BROKER_REASON;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
(async () => {
|
|
const chunks: Uint8Array[] = [];
|
|
let size = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
size += value.byteLength;
|
|
if (size > 4_096) return UNKNOWN_BROKER_REASON;
|
|
chunks.push(value);
|
|
}
|
|
const body: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
const reason = body && typeof body === "object" && "error" in body ? body.error : null;
|
|
return typeof reason === "string" && BROKER_REJECTION_REASONS.has(reason)
|
|
? reason : UNKNOWN_BROKER_REASON;
|
|
})(),
|
|
new Promise<string>((resolve) => {
|
|
timer = setTimeout(() => resolve(UNKNOWN_BROKER_REASON), 500);
|
|
}),
|
|
]);
|
|
} catch {
|
|
return UNKNOWN_BROKER_REASON;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
// Diagnostic reads and cancellation must never delay the original failure.
|
|
void reader.cancel().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** Stable public code/status with only allowlisted broker diagnostics. */
|
|
export class PaperclipCloudConnectorError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly code: string,
|
|
readonly status?: number,
|
|
readonly retryAt?: number,
|
|
readonly brokerReason?: string,
|
|
) {
|
|
super(message);
|
|
this.name = "PaperclipCloudConnectorError";
|
|
}
|
|
}
|
|
|
|
export function paperclipCloudConnectorConfigFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): PaperclipCloudConnectorConfig | null {
|
|
const localIdentity = loadPaperclipCloudConnectorIdentity();
|
|
const legacyConfigured = [
|
|
env.PAPERCLIP_ID_CONNECTOR_INSTANCE_ID,
|
|
env.PAPERCLIP_ID_CONNECTOR_SIGN_PRIVATE_KEY,
|
|
env.PAPERCLIP_ID_CONNECTOR_SEAL_PRIVATE_KEY,
|
|
env.PAPERCLIP_ID_CONNECTOR_ENVIRONMENT,
|
|
env.PAPERCLIP_ID_CONNECTOR_BASE_URL,
|
|
].some((value) => Boolean(value?.trim()));
|
|
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",
|
|
);
|
|
}
|
|
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)
|
|
|| "https://my.paperclip.app";
|
|
const values = [instanceId, signPrivateKey, sealPrivateKey, environment];
|
|
if (values.some((value) => !value)) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE");
|
|
}
|
|
if (environment !== "development" && environment !== "test" && environment !== "staging" && environment !== "production") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector environment is invalid", "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
const parsedBaseUrl = new URL(baseUrl);
|
|
if (parsedBaseUrl.protocol !== "https:" && !(parsedBaseUrl.protocol === "http:" && isLoopback(parsedBaseUrl.hostname))) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector URL must use HTTPS", "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
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 === "test.paperclip.app" && environment !== "test")
|
|
|| (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(/\/$/, ""),
|
|
instanceId: instanceId!,
|
|
environment,
|
|
signPrivateKey: signPrivateKey!,
|
|
sealPrivateKey: sealPrivateKey!,
|
|
};
|
|
}
|
|
|
|
export function createPaperclipCloudConnector(input: {
|
|
config: PaperclipCloudConnectorConfig;
|
|
request?: typeof fetch;
|
|
now?: () => number;
|
|
}) {
|
|
const config = input.config;
|
|
const request = input.request ?? fetch;
|
|
const now = input.now ?? Date.now;
|
|
const signingKey = privateKey(config.signPrivateKey, "ed25519");
|
|
const sealKey = privateKey(config.sealPrivateKey, "x25519");
|
|
let eventWakeVersion: 1 | null = null;
|
|
|
|
async function call(
|
|
operation: PaperclipCloudConnectorOperation,
|
|
claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string; eventWakeVersion?: 0 | 1 },
|
|
secret?: { field: "refreshToken" | "token" | "binding" | "acknowledgement" | "enrollment"; value: string },
|
|
): Promise<ConnectorResponse> {
|
|
const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString();
|
|
const issuedAt = Math.floor(now() / 1000);
|
|
const payload: Record<string, unknown> = {
|
|
iss: config.instanceId,
|
|
aud: endpoint,
|
|
sub: claims.subject,
|
|
cid: claims.companyId,
|
|
env: config.environment,
|
|
op: operation,
|
|
iat: issuedAt,
|
|
exp: issuedAt + 60,
|
|
jti: randomUUID(),
|
|
};
|
|
if (claims.eventWakeVersion !== undefined) payload.ewv = claims.eventWakeVersion;
|
|
if (claims.returnUri !== undefined) payload.ruri = claims.returnUri;
|
|
if (claims.returnState !== undefined) payload.rst = claims.returnState;
|
|
if (claims.claimId !== undefined) payload.cl = claims.claimId;
|
|
if (claims.redemptionId !== undefined) payload.rid = claims.redemptionId;
|
|
if (claims.profile !== undefined) {
|
|
const definition = connectorProfileDefinition(claims.profile);
|
|
payload.prv = definition.provider;
|
|
payload.prf = claims.profile;
|
|
if (claims.scopes && (claims.profile !== "workspace.all" || !isGoogleWorkspaceScopeGrant(claims.scopes))) {
|
|
throw new PaperclipCloudConnectorError("Invalid requested service permissions", "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
payload.scp = [...(claims.scopes ?? definition.scopes)];
|
|
}
|
|
if (secret) payload.sh = await sha256Base64Url(secret.value);
|
|
const body = {
|
|
request: signRequest(payload, signingKey),
|
|
...(secret ? { [secret.field]: secret.value } : {}),
|
|
};
|
|
let response: Response;
|
|
try {
|
|
response = await request(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector is unavailable", "CONNECTOR_UNAVAILABLE");
|
|
}
|
|
if (operation === "revoke" && response.status === 204) return {};
|
|
if (!response.ok) {
|
|
const reason = await readBrokerRejectionReason(response);
|
|
throw new PaperclipCloudConnectorError(
|
|
`Paperclip Cloud connector rejected the request (operation=${operation}, status=${response.status}, reason=${reason})`,
|
|
response.status === 409 ? "REAUTHORIZATION_REQUIRED" : "CONNECTOR_REQUEST_FAILED",
|
|
response.status,
|
|
providerRetryAt(response.headers.get("retry-after"), now()),
|
|
reason,
|
|
);
|
|
}
|
|
try {
|
|
return await response.json() as ConnectorResponse;
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid response", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
}
|
|
|
|
function openCredentials(
|
|
response: ConnectorResponse,
|
|
purpose: SealedEnvelope["purpose"],
|
|
subject: string,
|
|
companyId: string,
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
requestedScopes?: string[],
|
|
): SealedConnectorCredentials {
|
|
const definition = connectorProfileDefinition(profile);
|
|
const envelope = parseEnvelope(response.sealed, purpose, definition.provider, profile);
|
|
const credentials = unseal(
|
|
envelope,
|
|
sealKey,
|
|
config.instanceId,
|
|
config.environment,
|
|
definition.provider,
|
|
profile,
|
|
requestedScopes ?? definition.scopes,
|
|
);
|
|
if (
|
|
credentials.instanceId !== config.instanceId
|
|
|| credentials.environment !== config.environment
|
|
|| credentials.subject !== subject
|
|
|| credentials.companyId !== companyId
|
|
|| credentials.provider !== definition.provider
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud credential binding did not match", "CONNECTOR_BINDING_MISMATCH");
|
|
}
|
|
if (credentials.profile !== profile) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH");
|
|
}
|
|
if (!(profile === "workspace.all" ? isGoogleWorkspaceScopeGrant(credentials.scopes) : sameStringSet(credentials.scopes, definition.scopes))) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud scope grant did not match", "REAUTHORIZATION_REQUIRED");
|
|
}
|
|
return credentials;
|
|
}
|
|
|
|
return {
|
|
eventWakeVersion: () => eventWakeVersion,
|
|
async slackEventsAvailable() {
|
|
const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" });
|
|
return response.active === true && response.slackEvents?.version === 1;
|
|
},
|
|
async slackEventEnrollment(values: { subject: string; companyId: string; enrollment: Record<string, unknown> }) {
|
|
const response = await call("slack-events", values, { field: "enrollment", value: JSON.stringify(values.enrollment) });
|
|
return values.enrollment.active === false ? response.retirement : response.enrollment;
|
|
},
|
|
async githubAppsAvailable() {
|
|
const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" });
|
|
return response.active === true && response.githubApps?.version === 2;
|
|
},
|
|
async githubApp(values: { subject: string; companyId: string; binding: Record<string, unknown> }) {
|
|
return await call("github-app", values, { field: "binding", value: JSON.stringify(values.binding) }) as unknown as import("@paperclipai/shared").GitHubAppCloudState;
|
|
},
|
|
async claimGitHubApp(values: { subject: string; companyId: string; claimId: string; redemptionId: string }) {
|
|
const response = await call("claim", { ...values, profile: "github.bot" });
|
|
const envelope = parseEnvelope(response.sealed, "initial", "github", "github.bot");
|
|
const opened = decryptEnvelope(envelope, sealKey, config.instanceId, config.environment, "github", "github.bot", []) as Record<string, unknown>;
|
|
if (opened.v !== 1 || opened.instanceId !== config.instanceId || opened.environment !== config.environment
|
|
|| opened.subject !== values.subject || opened.companyId !== values.companyId || opened.provider !== "github"
|
|
|| opened.profile !== "github.bot" || !sameStringSet(opened.scopes, [])) throw badEnvelope();
|
|
return opened;
|
|
},
|
|
async getInstanceStatus(): Promise<"active" | "suspended" | "removed"> {
|
|
let response: ConnectorResponse;
|
|
try {
|
|
response = await call("status", {
|
|
subject: "instance-status",
|
|
companyId: "instance-status",
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof PaperclipCloudConnectorError && error.status === 401) return "removed";
|
|
throw error;
|
|
}
|
|
if (response.status === "active" && response.active === true) return "active";
|
|
if (response.status === "suspended" && response.active === false) return "suspended";
|
|
if (response.status === "removed" && response.active === false) return "removed";
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid instance status", "CONNECTOR_BAD_RESPONSE");
|
|
},
|
|
async getCapabilities(): Promise<PaperclipCloudConnectorProfileId[]> {
|
|
let response: ConnectorResponse;
|
|
try {
|
|
response = await call("status", {
|
|
subject: "instance-capabilities",
|
|
companyId: "instance-capabilities",
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles)) return [];
|
|
return [...new Set(response.profiles.flatMap((value) =>
|
|
typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : []
|
|
))];
|
|
},
|
|
async getSlackManagerAppId(): Promise<string | null> {
|
|
const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" });
|
|
const manager = response.slackManager;
|
|
if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager")
|
|
|| !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null;
|
|
return manager.appId;
|
|
},
|
|
async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri: string; returnState: string }) {
|
|
const profile = values.profile ?? "gmail.draft";
|
|
const response = await call("session", { ...values, profile });
|
|
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid session", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
let confirmationUrl: URL;
|
|
try {
|
|
confirmationUrl = new URL(response.confirmationUrl);
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
const expectedBroker = new URL(config.baseUrl);
|
|
if (
|
|
confirmationUrl.origin !== expectedBroker.origin
|
|
|| confirmationUrl.pathname !== "/connections/confirm"
|
|
|| confirmationUrl.username
|
|
|| confirmationUrl.password
|
|
|| confirmationUrl.hash
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
let authorizationUrl: URL | undefined;
|
|
if (response.authorizationUrl !== undefined) {
|
|
if (typeof response.authorizationUrl !== "string") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
try {
|
|
authorizationUrl = new URL(response.authorizationUrl);
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
if (
|
|
authorizationUrl.protocol !== "https:"
|
|
|| authorizationUrl.username
|
|
|| authorizationUrl.password
|
|
|| authorizationUrl.hash
|
|
|| !isExpectedProviderAuthorizationUrl(profile, authorizationUrl)
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
}
|
|
const handoff = parseCloudHandoff(response.handoff);
|
|
return {
|
|
authorizationUrl: authorizationUrl?.toString() ?? confirmationUrl.toString(),
|
|
expiresAt: response.expiresAt,
|
|
...(handoff ? { handoff } : {}),
|
|
};
|
|
},
|
|
async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; claimId: string; redemptionId: string }) {
|
|
const profile = values.profile ?? "gmail.draft";
|
|
return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile, values.scopes);
|
|
},
|
|
async refresh(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; refreshToken: string }) {
|
|
const profile = values.profile ?? "gmail.draft";
|
|
return openCredentials(
|
|
await call("refresh", { ...values, profile }, { field: "refreshToken", value: values.refreshToken }),
|
|
sealPurpose("access", profile),
|
|
values.subject,
|
|
values.companyId,
|
|
profile,
|
|
);
|
|
},
|
|
async revoke(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; token: string }) {
|
|
await call("revoke", { ...values, profile: values.profile ?? "gmail.draft" }, { field: "token", value: values.token });
|
|
},
|
|
async setWebhookBinding(values: {
|
|
subject: string;
|
|
companyId: string;
|
|
id: string;
|
|
installationId: string;
|
|
connectionId: string;
|
|
grantId: string;
|
|
active: boolean;
|
|
accessToken?: string;
|
|
}) {
|
|
if (values.active && !values.accessToken) {
|
|
throw new PaperclipCloudConnectorError(
|
|
"GitHub access token is required to authorize a webhook binding",
|
|
"CONNECTOR_CONFIG_INVALID",
|
|
);
|
|
}
|
|
const binding = JSON.stringify({
|
|
id: values.id,
|
|
installationId: values.installationId,
|
|
connectionId: values.connectionId,
|
|
grantId: values.grantId,
|
|
active: values.active,
|
|
...(values.active ? { accessToken: values.accessToken } : {}),
|
|
});
|
|
await call("webhook-bind", { ...values, profile: "github.code" }, { field: "binding", value: binding });
|
|
},
|
|
async leaseEvents(values: { subject: string; companyId: string; eventWakeVersion?: 0 | 1 }): Promise<{ leaseId: string; events: SealedConnectorEvents["events"]; failedEventIds?: string[] } | null> {
|
|
const response = await call("event-lease", values);
|
|
eventWakeVersion = response.eventWakeVersion === 1 && values.eventWakeVersion !== 0 ? 1 : null;
|
|
if (Array.isArray(response.events) && response.events.length === 0) return null;
|
|
if (typeof response.leaseId !== "string") {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid event lease", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
const envelope = parseEnvelope(response.sealed, "events", "github", "github.code");
|
|
const opened = unsealEvents(envelope, sealKey, config.instanceId, config.environment);
|
|
if (opened.leaseId !== response.leaseId) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector event lease did not match", "CONNECTOR_BINDING_MISMATCH");
|
|
}
|
|
const events: SealedConnectorEvents["events"] = [];
|
|
const failedEventIds: string[] = [];
|
|
for (const event of opened.events) {
|
|
const packet = event.payload.githubApp;
|
|
try {
|
|
if (isRecord(packet) && packet.sealed !== undefined) {
|
|
const sealed = parseEnvelope(packet.sealed, "events", "github", "github.bot");
|
|
const delivery = decryptEnvelope(sealed, sealKey, config.instanceId, config.environment, "github", "github.bot", []) as Record<string, unknown>;
|
|
if (!isRecord(delivery) || delivery.v !== 1 || delivery.instanceId !== config.instanceId || delivery.environment !== config.environment
|
|
|| delivery.registrationId !== packet.registrationId) throw badEnvelope();
|
|
event.payload = { githubApp: delivery };
|
|
}
|
|
if (event.provider === "slack") {
|
|
const packet = event.payload.slackEvent;
|
|
if (!isRecord(packet) || typeof packet.bindingId !== "string" || packet.sealed === undefined) throw badEnvelope();
|
|
const sealed = parseEnvelope(packet.sealed, "events", "slack", "slack.events.v1");
|
|
const delivery = decryptEnvelope(sealed, sealKey, config.instanceId, config.environment, "slack", "slack.events.v1", []);
|
|
if (!isRecord(delivery) || delivery.version !== 1 || delivery.instanceId !== config.instanceId
|
|
|| delivery.environment !== config.environment || delivery.bindingId !== packet.bindingId
|
|
|| delivery.acceptedAt !== event.createdAt) throw badEnvelope();
|
|
event.payload = { slackEvent: delivery };
|
|
}
|
|
events.push(event);
|
|
} catch {
|
|
failedEventIds.push(event.id);
|
|
}
|
|
}
|
|
return { leaseId: opened.leaseId, events, ...(failedEventIds.length ? { failedEventIds } : {}) };
|
|
},
|
|
async acknowledgeEvents(values: { subject: string; companyId: string; leaseId: string; deliveryIds: string[] }): Promise<number> {
|
|
const acknowledgement = JSON.stringify({ leaseId: values.leaseId, deliveryIds: values.deliveryIds });
|
|
const response = await call("event-ack", values, { field: "acknowledgement", value: acknowledgement });
|
|
const acknowledged = response.acknowledged;
|
|
if (typeof acknowledged !== "number" || !Number.isSafeInteger(acknowledged) || acknowledged < 0) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid acknowledgement", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
return acknowledged;
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseCloudHandoff(value: unknown): { kind: "paperclip_cloud"; session: string } | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const session = record.session;
|
|
if (
|
|
record.kind !== "tenant_background"
|
|
|| typeof session !== "string"
|
|
|| session.length < 16
|
|
|| session.length > 512
|
|
|| !/^[A-Za-z0-9_-]+$/.test(session)
|
|
) {
|
|
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
return { kind: "paperclip_cloud", session };
|
|
}
|
|
|
|
export type PaperclipCloudConnector = ReturnType<typeof createPaperclipCloudConnector>;
|
|
export type PaperclipCloudGoogleWorkspaceConnector = PaperclipCloudConnector;
|
|
|
|
/** Accept persisted Paperclip ID-era records while all new records use the Cloud strategy. */
|
|
export function isPaperclipCloudConnectorStrategy(value: unknown): boolean {
|
|
return value === "paperclip_cloud_connector" || value === "paperclip_id_connector";
|
|
}
|
|
|
|
/** Use the same signed instance profiles for catalog display and setup validation. */
|
|
export function appWithPaperclipCloudConnectorAvailability(
|
|
app: AppDefinition,
|
|
profiles: readonly string[],
|
|
): AppDefinition {
|
|
const enabledProfiles = new Set(profiles);
|
|
const methods = app.methods.filter((method) =>
|
|
!isPaperclipCloudConnectorStrategy(method.oauthStrategy)
|
|
|| Boolean(method.connectorProfile && enabledProfiles.has(method.connectorProfile))
|
|
);
|
|
return {
|
|
...app,
|
|
methods,
|
|
ownershipAvailability: {
|
|
...DEFAULT_OWNERSHIP_AVAILABILITY,
|
|
...app.ownershipAvailability,
|
|
platform_shared: methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy)),
|
|
},
|
|
};
|
|
}
|
|
|
|
let capabilityCache: { key: string; expiresAt: number; profiles: PaperclipCloudConnectorProfileId[] } | null = null;
|
|
let capabilityCacheGeneration = 0;
|
|
|
|
export function invalidatePaperclipCloudConnectorCapabilities(): void {
|
|
capabilityCacheGeneration += 1;
|
|
capabilityCache = null;
|
|
}
|
|
|
|
export async function paperclipCloudConnectorCapabilitiesFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Promise<PaperclipCloudConnectorProfileId[]> {
|
|
let config: PaperclipCloudConnectorConfig | null;
|
|
try {
|
|
config = paperclipCloudConnectorConfigFromEnv(env);
|
|
} catch (error) {
|
|
// Gallery discovery is useful even while connector enrollment is pending or
|
|
// local connector settings are incomplete. Treat every connector-config
|
|
// error as "no managed profiles" here; enrollment/status surfaces still
|
|
// report the actionable configuration problem.
|
|
if (error instanceof PaperclipCloudConnectorError) return [];
|
|
throw error;
|
|
}
|
|
if (!config) return [];
|
|
const key = `${config.baseUrl}|${config.instanceId}|${config.environment}`;
|
|
if (capabilityCache?.key === key && capabilityCache.expiresAt > Date.now()) return capabilityCache.profiles;
|
|
const generation = capabilityCacheGeneration;
|
|
const connector = createPaperclipCloudConnector({ config });
|
|
const profiles = await connector.getCapabilities();
|
|
if (generation === capabilityCacheGeneration) {
|
|
capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles };
|
|
}
|
|
return profiles;
|
|
}
|
|
|
|
function signRequest(payload: Record<string, unknown>, key: KeyObject): string {
|
|
const header = { alg: "EdDSA", typ: JWS_TYP };
|
|
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
|
|
return `${signingInput}.${sign(null, Buffer.from(signingInput, "utf8"), key).toString("base64url")}`;
|
|
}
|
|
|
|
function privateKey(value: string, curve: "ed25519" | "x25519"): KeyObject {
|
|
try {
|
|
let parsed: KeyObject;
|
|
if (value.includes("BEGIN PRIVATE KEY")) {
|
|
parsed = createPrivateKey(value);
|
|
} else {
|
|
const raw = Buffer.from(value, "base64url");
|
|
parsed = raw.length === RAW_PRIVATE_KEY_BYTES
|
|
? createPrivateKey({
|
|
key: Buffer.concat([curve === "ed25519" ? ED25519_PKCS8_PREFIX : X25519_PKCS8_PREFIX, raw]),
|
|
format: "der",
|
|
type: "pkcs8",
|
|
})
|
|
: createPrivateKey({ key: raw, format: "der", type: "pkcs8" });
|
|
}
|
|
if (parsed.asymmetricKeyType !== curve) throw new Error("wrong key type");
|
|
return parsed;
|
|
} catch {
|
|
throw new PaperclipCloudConnectorError(`Paperclip Cloud ${curve} private key is invalid`, "CONNECTOR_CONFIG_INVALID");
|
|
}
|
|
}
|
|
|
|
function parseEnvelope(
|
|
value: unknown,
|
|
purpose: SealedEnvelope["purpose"],
|
|
provider: PaperclipCloudConnectorProvider,
|
|
profile: SealedEnvelopeProfileId,
|
|
): SealedEnvelope {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw badEnvelope();
|
|
const candidate = value as Partial<SealedEnvelope>;
|
|
if (candidate.v !== 1 || candidate.alg !== SEAL_ALGORITHM || candidate.purpose !== purpose
|
|
|| candidate.provider !== provider || candidate.profile !== profile
|
|
|| typeof candidate.epk !== "string" || typeof candidate.iv !== "string" || typeof candidate.ct !== "string") {
|
|
throw badEnvelope();
|
|
}
|
|
return candidate as SealedEnvelope;
|
|
}
|
|
|
|
function unseal(
|
|
envelope: SealedEnvelope,
|
|
recipientPrivateKey: KeyObject,
|
|
instanceId: string,
|
|
environment: string,
|
|
provider: PaperclipCloudConnectorProvider,
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
scopes: readonly string[],
|
|
): SealedConnectorCredentials {
|
|
try {
|
|
const parsed = decryptEnvelope(envelope, recipientPrivateKey, instanceId, environment, provider, profile, scopes) as Partial<SealedConnectorCredentials>;
|
|
if (parsed.v !== 1 || typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0
|
|
|| !(parsed.refreshToken === null || typeof parsed.refreshToken === "string")
|
|
|| typeof parsed.tokenType !== "string"
|
|
|| !(parsed.accessTokenExpiresAt === null || typeof parsed.accessTokenExpiresAt === "string")
|
|
|| !(parsed.refreshTokenExpiresAt === null || typeof parsed.refreshTokenExpiresAt === "string")
|
|
|| !Array.isArray(parsed.scopes) || !parsed.scopes.every((scope) => typeof scope === "string")
|
|
|| typeof parsed.subject !== "string" || typeof parsed.companyId !== "string"
|
|
|| typeof parsed.instanceId !== "string" || typeof parsed.environment !== "string"
|
|
|| !(parsed.appSlug === undefined || (typeof parsed.appSlug === "string" && /^[a-z0-9-]{1,100}$/.test(parsed.appSlug)))
|
|
|| parsed.provider !== provider || parsed.profile !== profile) {
|
|
throw badEnvelope();
|
|
}
|
|
return parsed as SealedConnectorCredentials;
|
|
} catch (error) {
|
|
if (error instanceof PaperclipCloudConnectorError) throw error;
|
|
throw badEnvelope();
|
|
}
|
|
}
|
|
|
|
function unsealEvents(
|
|
envelope: SealedEnvelope,
|
|
recipientPrivateKey: KeyObject,
|
|
instanceId: string,
|
|
environment: PaperclipCloudConnectorEnvironment,
|
|
): SealedConnectorEvents {
|
|
try {
|
|
const parsed = decryptEnvelope(envelope, recipientPrivateKey, instanceId, environment, "github", "github.code", []) as Partial<SealedConnectorEvents>;
|
|
if (parsed.v !== 1 || parsed.instanceId !== instanceId || parsed.environment !== environment
|
|
|| typeof parsed.leaseId !== "string" || !Array.isArray(parsed.events)) throw badEnvelope();
|
|
for (const event of parsed.events) {
|
|
if (!isRecord(event) || typeof event.id !== "string" || !["github", "slack"].includes(event.provider)
|
|
|| typeof event.event !== "string" || !isRecord(event.payload) || !Array.isArray(event.bindingIds)
|
|
|| !event.bindingIds.every((id) => typeof id === "string")) throw badEnvelope();
|
|
}
|
|
return parsed as SealedConnectorEvents;
|
|
} catch (error) {
|
|
if (error instanceof PaperclipCloudConnectorError) throw error;
|
|
throw badEnvelope();
|
|
}
|
|
}
|
|
|
|
function decryptEnvelope(
|
|
envelope: SealedEnvelope,
|
|
recipientPrivateKey: KeyObject,
|
|
instanceId: string,
|
|
environment: string,
|
|
provider: PaperclipCloudConnectorProvider,
|
|
profile: SealedEnvelopeProfileId,
|
|
scopes: readonly string[],
|
|
): unknown {
|
|
const ephemeralRaw = Buffer.from(envelope.epk, "base64url");
|
|
if (ephemeralRaw.length !== 32) throw badEnvelope();
|
|
const ephemeralKey = createPublicKey({
|
|
key: Buffer.concat([X25519_SPKI_PREFIX, ephemeralRaw]),
|
|
format: "der",
|
|
type: "spki",
|
|
});
|
|
const recipientJwk = createPublicKey(recipientPrivateKey).export({ format: "jwk" }) as { x?: string };
|
|
if (!recipientJwk.x) throw badEnvelope();
|
|
const recipientRaw = Buffer.from(recipientJwk.x, "base64url");
|
|
if (envelope.provider !== provider || envelope.profile !== profile) throw badEnvelope();
|
|
const aad = Buffer.from([
|
|
1,
|
|
SEAL_ALGORITHM,
|
|
envelope.purpose,
|
|
instanceId,
|
|
environment,
|
|
provider,
|
|
profile,
|
|
[...scopes].sort().join(" "),
|
|
].join("\n"), "utf8");
|
|
const key = Buffer.from(hkdfSync(
|
|
"sha256",
|
|
diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }),
|
|
Buffer.concat([ephemeralRaw, recipientRaw]),
|
|
// The combined profile's scope binding exceeds Node's 1024-byte HKDF-info
|
|
// limit. Its new wire contract hashes that binding; legacy profiles retain
|
|
// their original derivation. GCM still authenticates the complete AAD.
|
|
provider === "google" && profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad,
|
|
32,
|
|
));
|
|
const iv = Buffer.from(envelope.iv, "base64url");
|
|
const combined = Buffer.from(envelope.ct, "base64url");
|
|
if (iv.length !== 12 || combined.length <= AES_TAG_BYTES) throw badEnvelope();
|
|
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: AES_TAG_BYTES });
|
|
decipher.setAAD(aad);
|
|
decipher.setAuthTag(combined.subarray(-AES_TAG_BYTES));
|
|
const plaintext = Buffer.concat([
|
|
decipher.update(combined.subarray(0, -AES_TAG_BYTES)),
|
|
decipher.final(),
|
|
]);
|
|
return JSON.parse(plaintext.toString("utf8")) as unknown;
|
|
}
|
|
|
|
function badEnvelope() {
|
|
return new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid sealed credential", "CONNECTOR_BAD_RESPONSE");
|
|
}
|
|
|
|
function sealPurpose(
|
|
kind: "initial" | "access",
|
|
_profile: PaperclipCloudConnectorProfileId,
|
|
): SealedEnvelope["purpose"] {
|
|
return kind;
|
|
}
|
|
|
|
function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): {
|
|
provider: PaperclipCloudConnectorProvider;
|
|
scopes: readonly string[];
|
|
} {
|
|
if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES };
|
|
if (profile === "github.bot") return { provider: "github", scopes: [] };
|
|
if (isAsanaConnectorProfileId(profile)) {
|
|
return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES };
|
|
}
|
|
if (isGitHubConnectorProfileId(profile)) {
|
|
return { provider: "github", scopes: GITHUB_CONNECTOR_PROFILES[profile].scopes };
|
|
}
|
|
return { provider: "google", scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes };
|
|
}
|
|
|
|
function isExpectedProviderAuthorizationUrl(
|
|
profile: PaperclipCloudConnectorProfileId,
|
|
url: URL,
|
|
): boolean {
|
|
if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize";
|
|
if (isAsanaConnectorProfileId(profile)) {
|
|
return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize";
|
|
}
|
|
if (isGitHubConnectorProfileId(profile)) {
|
|
return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize";
|
|
}
|
|
return url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
|
|
}
|
|
|
|
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
|
|
return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value);
|
|
}
|
|
|
|
async function sha256Base64Url(value: string): Promise<string> {
|
|
return createHash("sha256").update(value, "utf8").digest("base64url");
|
|
}
|
|
|
|
function base64Url(value: string): string {
|
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
}
|
|
|
|
function sameStringSet(value: unknown, expected: readonly string[]): boolean {
|
|
return Array.isArray(value)
|
|
&& value.every((item) => typeof item === "string")
|
|
&& value.length === expected.length
|
|
&& expected.every((item) => value.includes(item));
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isLoopback(hostname: string): boolean {
|
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
}
|