mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
Retain Cloud event replay through durable enrollment and signed tenant hints. Save credential binding changes and retry deadlines in existing grant metadata. Preserve receipt idempotency, lifecycle admission and mixed-version fallback. Co-Authored-By: Paperclip <noreply@paperclip.ing>
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
import type { RequestHandler } from "express";
|
|
import { logger } from "./logger.js";
|
|
import {
|
|
CLOUD_CONTROL_HEADER,
|
|
verifyCloudControlAssertion,
|
|
type CloudControlAction,
|
|
} from "../services/cloud-runtime-identity.js";
|
|
|
|
/** Endpoint → method → the one action a control assertion must name to take it. */
|
|
const ACTIONS_BY_ENDPOINT: Record<string, Record<string, CloudControlAction>> = {
|
|
"/api/instance/connector-events/wake": { POST: "connector-events:wake" },
|
|
"/api/instance/task-drain": {
|
|
GET: "task-drain:read",
|
|
POST: "task-drain:start",
|
|
DELETE: "task-drain:stop",
|
|
},
|
|
"/api/instance/lifecycle": {
|
|
GET: "lifecycle:read",
|
|
},
|
|
"/api/instance/lifecycle/unarchive-primary": {
|
|
POST: "lifecycle:unarchive-primary",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Accepts Cloud's signed control assertion only on the closed endpoint set
|
|
* above — the task-drain hold the deploy path uses, and the lifecycle
|
|
* read-back/unarchive pair behind the Cloud archive sync. The JWS is the entire
|
|
* authorization: a valid assertion installs a synthetic instance-admin board
|
|
* actor (replacing whatever weaker actor the request carried), each assertion
|
|
* is bound to exactly one method's action, and the header is rejected loudly
|
|
* anywhere else so it can never become an ambient credential. Instances
|
|
* without a Cloud stack identity reject every assertion. The browser-facing
|
|
* Cloud proxy strips this header, and possession of the shared tenant-session
|
|
* token cannot mint it.
|
|
*/
|
|
export function cloudControlMiddleware(): RequestHandler {
|
|
return (req, res, next) => {
|
|
const assertion = req.get(CLOUD_CONTROL_HEADER)?.trim();
|
|
if (!assertion) {
|
|
next();
|
|
return;
|
|
}
|
|
// Express's non-strict routing treats a trailing slash as the same
|
|
// route; the endpoint check must agree with it.
|
|
const normalizedPath = req.path.length > 1 && req.path.endsWith("/") ? req.path.slice(0, -1) : req.path;
|
|
const expectedAction = ACTIONS_BY_ENDPOINT[normalizedPath]?.[req.method];
|
|
if (!expectedAction) {
|
|
res.status(400).json({ error: "cloud_control_wrong_endpoint" });
|
|
return;
|
|
}
|
|
try {
|
|
verifyCloudControlAssertion({ compactJws: assertion, expectedAction });
|
|
} catch (error) {
|
|
logger.warn({ err: error }, "Rejected Cloud control assertion");
|
|
res.status(401).json({ error: "invalid_cloud_control_assertion" });
|
|
return;
|
|
}
|
|
req.actor = {
|
|
type: "board",
|
|
userId: "paperclip-cloud",
|
|
userName: "Paperclip Cloud",
|
|
userEmail: null,
|
|
isInstanceAdmin: true,
|
|
source: "cloud_control",
|
|
};
|
|
next();
|
|
};
|
|
}
|