|
| 1 | +#!/usr/bin/env node |
| 2 | +// Admin tooling: signs a compute-control-plane resource policy for |
| 3 | +// POST /organizations/:organizationId/compute/policies (see |
| 4 | +// server/src/compute/policy-signature.ts and |
| 5 | +// server/src/routes/compute-control.ts). This is a *different* signed |
| 6 | +// policy from app/scripts/sign-policy.js's desktop central-policy (a local |
| 7 | +// AppSettings lockdown) — this one governs the compute fleet's hard CPU/GPU |
| 8 | +// guardrails, and its verifier lives server-side, not in the desktop app. |
| 9 | +// |
| 10 | +// Usage: |
| 11 | +// node server/scripts/sign-compute-policy.js <draft.json> <private-key.pem> <organization-id> [output.json] |
| 12 | +// |
| 13 | +// <draft.json> shape (matches ResourcePolicyInput minus signature/issuedAt/ |
| 14 | +// expiresAt, which this script fills in): |
| 15 | +// { |
| 16 | +// "name": "Interactive pool guardrails", |
| 17 | +// "poolId": "3f2a...-uuid (optional — omit for an org-wide policy)", |
| 18 | +// "expiresInDays": 30, |
| 19 | +// "hardLimits": { "maxCpuThreads": 32, "maxAccelerators": 2 }, |
| 20 | +// "workloadClassLimits": { |
| 21 | +// "background": { "maxCpuThreads": 8, "allowCpuFallback": true } |
| 22 | +// } |
| 23 | +// } |
| 24 | +// |
| 25 | +// The private key must be an Ed25519 PEM (see |
| 26 | +// app/scripts/generate-policy-keypair.js's --curve ed25519 mode, or any |
| 27 | +// `openssl genpkey -algorithm ed25519` key) — the server's |
| 28 | +// createComputePolicySignatureVerifier() only ever verifies Ed25519. |
| 29 | +// |
| 30 | +// Output is the exact JSON body POST /compute/policies expects — pipe it |
| 31 | +// straight into that request (e.g. via curl -d @output.json), or paste it |
| 32 | +// into the admin console's "paste signed policy" step. |
| 33 | + |
| 34 | +import * as crypto from "node:crypto"; |
| 35 | +import * as fs from "node:fs"; |
| 36 | + |
| 37 | +const [, , draftPath, privateKeyPath, organizationId, outputPathArg] = process.argv; |
| 38 | +if (!draftPath || !privateKeyPath || !organizationId) { |
| 39 | + console.error("Usage: node sign-compute-policy.js <draft.json> <private-key.pem> <organization-id> [output.json]"); |
| 40 | + process.exit(1); |
| 41 | +} |
| 42 | + |
| 43 | +const draft = JSON.parse(fs.readFileSync(draftPath, "utf-8")); |
| 44 | +if (!draft.name || typeof draft.name !== "string") { |
| 45 | + console.error('draft.json must include a non-empty string "name"'); |
| 46 | + process.exit(1); |
| 47 | +} |
| 48 | +if (!Number.isFinite(draft.expiresInDays) || draft.expiresInDays <= 0) { |
| 49 | + console.error('draft.json must include a positive number "expiresInDays"'); |
| 50 | + process.exit(1); |
| 51 | +} |
| 52 | +if (!draft.hardLimits || typeof draft.hardLimits !== "object") { |
| 53 | + console.error('draft.json must include a "hardLimits" object (may be empty: {})'); |
| 54 | + process.exit(1); |
| 55 | +} |
| 56 | + |
| 57 | +const now = new Date(); |
| 58 | +const expiresAt = new Date(now.getTime() + draft.expiresInDays * 24 * 60 * 60 * 1000); |
| 59 | + |
| 60 | +// The exact unsigned shape createComputePolicySignatureVerifier() re-derives |
| 61 | +// server-side and compares the signature against — every field here must |
| 62 | +// match resourcePolicyInputSchema (minus signature) or the server's own |
| 63 | +// recomputed canonical payload will differ and verification will fail. |
| 64 | +const unsigned = { |
| 65 | + ...(draft.poolId ? { poolId: draft.poolId } : {}), |
| 66 | + name: draft.name, |
| 67 | + hardLimits: draft.hardLimits, |
| 68 | + workloadClassLimits: draft.workloadClassLimits ?? {}, |
| 69 | + issuedAt: now.toISOString(), |
| 70 | + expiresAt: expiresAt.toISOString(), |
| 71 | +}; |
| 72 | + |
| 73 | +// Mirrors server/src/compute/policy-signature.ts's canonicalize()/ |
| 74 | +// canonicalComputePolicyPayload() exactly — this script is standalone (not |
| 75 | +// part of the server's TS build) so it can't import that function directly. |
| 76 | +// If you change one, change both; policy-signature.test.ts's round-trip |
| 77 | +// tests are what would catch drift on the server side. |
| 78 | +function canonicalize(value) { |
| 79 | + if (Array.isArray(value)) return value.map(canonicalize); |
| 80 | + if (value && typeof value === "object") { |
| 81 | + const sorted = {}; |
| 82 | + for (const key of Object.keys(value).sort()) sorted[key] = canonicalize(value[key]); |
| 83 | + return sorted; |
| 84 | + } |
| 85 | + return value; |
| 86 | +} |
| 87 | +function canonicalComputePolicyPayload(orgId, input) { |
| 88 | + return JSON.stringify(canonicalize({ organizationId: orgId, ...input })); |
| 89 | +} |
| 90 | + |
| 91 | +const payloadString = canonicalComputePolicyPayload(organizationId, unsigned); |
| 92 | +const privateKey = crypto.createPrivateKey(fs.readFileSync(privateKeyPath, "utf-8")); |
| 93 | +// Unlike sign-policy.js's desktop central-policy (hex signature), the |
| 94 | +// compute-policy verifier expects base64 — see |
| 95 | +// createComputePolicySignatureVerifier()'s `Buffer.from(input.signature, |
| 96 | +// "base64")`. Getting this encoding wrong is the single easiest way to |
| 97 | +// produce a policy that silently fails verification (400 |
| 98 | +// invalid_compute_policy_signature) despite a technically-correct signature. |
| 99 | +const signature = crypto.sign(null, Buffer.from(payloadString, "utf-8"), privateKey); |
| 100 | + |
| 101 | +const signed = { ...unsigned, signature: signature.toString("base64") }; |
| 102 | + |
| 103 | +const outputPath = outputPathArg ?? "compute-policy.json"; |
| 104 | +fs.writeFileSync(outputPath, JSON.stringify(signed, null, 2)); |
| 105 | + |
| 106 | +console.log(`Wrote ${outputPath}`); |
| 107 | +console.log(` name: ${signed.name}`); |
| 108 | +console.log(` poolId: ${signed.poolId ?? "(org-wide)"}`); |
| 109 | +console.log(` issuedAt: ${signed.issuedAt}`); |
| 110 | +console.log(` expiresAt: ${signed.expiresAt}`); |
| 111 | +console.log(`\nPOST this file's contents to /organizations/${organizationId}/compute/policies to create it.`); |
0 commit comments