@sars267/pi-delegation-broker 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ const INPUT_DIGEST = /^[a-f0-9]{64}$/;
2
+ const EVENT_TYPES = new Set(["succeeded", "rate_limited", "auth_fatal", "failed_before_effect"]);
3
+
4
+ function nonNegativeInteger(value, name) {
5
+ if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
6
+ return value;
7
+ }
8
+
9
+ function normalizeEvent(event) {
10
+ if (!event || !EVENT_TYPES.has(event.type)) throw new Error("fake provider event type is not allowed");
11
+ const delayMs = event.delayMs === undefined ? 0 : nonNegativeInteger(event.delayMs, "delayMs");
12
+ if (delayMs > 10_000) throw new Error("fake provider delayMs must be at most 10000");
13
+ if (event.type === "succeeded") {
14
+ // resultRef crosses back into the child response; keep it opaque and
15
+ // identifier-shaped so a controller cannot accidentally put text/secrets
16
+ // into an assistant message or durable provider event.
17
+ if (typeof event.resultRef !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(event.resultRef)) throw new Error("succeeded event requires an identifier-shaped resultRef");
18
+ return {
19
+ type: "succeeded",
20
+ resultRef: event.resultRef,
21
+ usage: {
22
+ input: nonNegativeInteger(event.usage?.input ?? 0, "usage.input"),
23
+ output: nonNegativeInteger(event.usage?.output ?? 0, "usage.output"),
24
+ costMicros: nonNegativeInteger(event.usage?.costMicros ?? 0, "usage.costMicros"),
25
+ },
26
+ delayMs,
27
+ };
28
+ }
29
+ if (event.type === "rate_limited") {
30
+ if (!Number.isInteger(event.retryAfterMs) || event.retryAfterMs < 1 || event.retryAfterMs > 86_400_000) throw new Error("rate_limited event requires bounded retryAfterMs");
31
+ return { type: "rate_limited", retryAfterMs: event.retryAfterMs, delayMs };
32
+ }
33
+ if (event.type === "auth_fatal") return { type: "auth_fatal", delayMs };
34
+ return { type: "failed_before_effect", reasonCode: typeof event.reasonCode === "string" ? event.reasonCode.slice(0, 80) : "unknown", delayMs };
35
+ }
36
+
37
+ function waitForDelay(delayMs, signal) {
38
+ if (signal?.aborted) return Promise.reject(new Error("provider_attempt_aborted"));
39
+ if (delayMs === 0) return Promise.resolve();
40
+ return new Promise((resolve, reject) => {
41
+ const timer = setTimeout(done, delayMs);
42
+ function done() {
43
+ signal?.removeEventListener("abort", aborted);
44
+ resolve();
45
+ }
46
+ function aborted() {
47
+ clearTimeout(timer);
48
+ signal?.removeEventListener("abort", aborted);
49
+ reject(new Error("provider_attempt_aborted"));
50
+ }
51
+ signal?.addEventListener("abort", aborted, { once: true });
52
+ });
53
+ }
54
+
55
+ /** Controller-configured deterministic provider substitute. It never receives raw input or credentials. */
56
+ export class ScriptedFakeProvider {
57
+ #plans = new Map();
58
+
59
+ configure(taskId, events) {
60
+ if (typeof taskId !== "string" || !taskId || !Array.isArray(events) || events.length === 0) {
61
+ throw new Error("fake provider plan needs taskId and at least one event");
62
+ }
63
+ this.#plans.set(taskId, events.map(normalizeEvent));
64
+ return { status: "configured", taskId, eventCount: events.length };
65
+ }
66
+
67
+ async attempt(lease, inputDigest, signal) {
68
+ if (typeof inputDigest !== "string" || !INPUT_DIGEST.test(inputDigest)) throw new Error("provider attempt requires a SHA-256 input digest");
69
+ const plan = this.#plans.get(lease.taskId);
70
+ if (!plan || plan.length === 0) throw new Error("no fake provider event configured for task");
71
+ // Do not consume the scripted outcome until its cancellable pre-effect
72
+ // delay completes. A disconnected/aborted child therefore records no
73
+ // provider event and cannot turn cancellation into a hidden side effect.
74
+ const planned = plan[0];
75
+ await waitForDelay(planned.delayMs, signal);
76
+ if (signal?.aborted) throw new Error("provider_attempt_aborted");
77
+ plan.shift();
78
+ const { delayMs: _delayMs, ...event } = planned;
79
+ return structuredClone(event);
80
+ }
81
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public API for the non-production, single-host Pi delegation broker reference.
3
+ * It intentionally contains no provider credential, HTTP client, or automatic
4
+ * provider failover implementation.
5
+ */
6
+ export { SqliteLeaseBroker } from "./broker.mjs";
7
+ export { BrokerIpcServer, requestBrokerIpc } from "./ipc.mjs";
8
+ export { provisionBrokeredAgentDir } from "./isolated-child-config.mjs";
9
+ export { signedRegistryMessage, verifySignedRegistry } from "./signed-registry.mjs";
10
+ export { SingleHostBrokerSupervisor } from "./supervisor.mjs";
11
+ export { BrokeredLaunchResolver } from "./trusted-launch-resolver.mjs";
package/src/ipc.mjs ADDED
@@ -0,0 +1,249 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, rmSync, statSync } from "node:fs";
3
+ import { createConnection, createServer } from "node:net";
4
+ import { dirname } from "node:path";
5
+ const MAX_REQUEST_BYTES = 64 * 1024;
6
+ const CHILD_METHODS = new Set(["heartbeat", "release", "providerAttempt"]);
7
+ const CONTROLLER_METHODS = new Set(["reserve", "issueLeaseCapability", "markRateLimited", "markUnknown", "markHealthy", "release", "configureFakeProvider"]);
8
+
9
+ function sameSecret(left, right) {
10
+ if (typeof left !== "string" || typeof right !== "string") return false;
11
+ const a = Buffer.from(left);
12
+ const b = Buffer.from(right);
13
+ return a.length === b.length && timingSafeEqual(a, b);
14
+ }
15
+
16
+ function privateSocketParent(socketPath) {
17
+ const parent = dirname(socketPath);
18
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
19
+ const stat = statSync(parent);
20
+ if (!stat.isDirectory() || (stat.mode & 0o077) !== 0) {
21
+ throw new Error(`Broker socket parent must be an owner-only directory: ${parent}`);
22
+ }
23
+ }
24
+
25
+ function validateTtl(value) {
26
+ if (value === undefined) return 30_000;
27
+ if (!Number.isInteger(value) || value < 1_000 || value > 60_000) throw new Error("heartbeat ttlMs must be an integer between 1000 and 60000");
28
+ return value;
29
+ }
30
+
31
+ /**
32
+ * Line-delimited JSON IPC for the local controller and a lease-scoped child.
33
+ * The controller token is never returned to a child. A child capability can
34
+ * only heartbeat/release the one currently active lease it represents.
35
+ */
36
+ export class BrokerIpcServer {
37
+ #broker;
38
+ #socketPath;
39
+ #controllerToken;
40
+ #fakeProvider;
41
+ #server;
42
+ #connections = new Set();
43
+
44
+ constructor({ broker, socketPath, controllerToken = randomBytes(32).toString("base64url"), fakeProvider }) {
45
+ if (!broker || !socketPath) throw new Error("Broker IPC server needs broker and socketPath");
46
+ this.#broker = broker;
47
+ this.#socketPath = socketPath;
48
+ this.#controllerToken = controllerToken;
49
+ this.#fakeProvider = fakeProvider;
50
+ this.#server = createServer((socket) => {
51
+ this.#connections.add(socket);
52
+ socket.once("close", () => this.#connections.delete(socket));
53
+ this.#handle(socket);
54
+ });
55
+ }
56
+
57
+ get socketPath() { return this.#socketPath; }
58
+ /** Parent-only bootstrap secret. Do not pass to child environment or logs. */
59
+ get controllerToken() { return this.#controllerToken; }
60
+
61
+ async start() {
62
+ privateSocketParent(this.#socketPath);
63
+ if (existsSync(this.#socketPath)) {
64
+ if (!lstatSync(this.#socketPath).isSocket()) throw new Error(`Refusing to remove non-socket broker path: ${this.#socketPath}`);
65
+ rmSync(this.#socketPath);
66
+ }
67
+ await new Promise((resolve, reject) => {
68
+ const onError = (error) => { this.#server.off("listening", onListening); reject(error); };
69
+ const onListening = () => { this.#server.off("error", onError); resolve(); };
70
+ this.#server.once("error", onError);
71
+ this.#server.once("listening", onListening);
72
+ this.#server.listen(this.#socketPath);
73
+ });
74
+ chmodSync(this.#socketPath, 0o600);
75
+ return { socketPath: this.#socketPath };
76
+ }
77
+
78
+ /**
79
+ * Drain local requests briefly, then destroy remaining sockets. Destroying a
80
+ * delayed child attempt also aborts its fake pre-effect transport, avoiding
81
+ * a daemon shutdown that waits indefinitely for an untrusted child.
82
+ */
83
+ async stop({ drainMs = 2_000 } = {}) {
84
+ if (!Number.isInteger(drainMs) || drainMs < 0 || drainMs > 60_000) throw new Error("drainMs must be an integer between 0 and 60000");
85
+ if (this.#server.listening) {
86
+ await new Promise((resolve, reject) => {
87
+ const timer = setTimeout(() => {
88
+ for (const socket of this.#connections) socket.destroy();
89
+ }, drainMs);
90
+ timer.unref?.();
91
+ this.#server.close((error) => {
92
+ clearTimeout(timer);
93
+ error ? reject(error) : resolve();
94
+ });
95
+ });
96
+ }
97
+ if (existsSync(this.#socketPath) && lstatSync(this.#socketPath).isSocket()) rmSync(this.#socketPath);
98
+ }
99
+
100
+ #handle(socket) {
101
+ socket.setEncoding("utf8");
102
+ let buffered = "";
103
+ let handling = false;
104
+ let responded = false;
105
+ const abortController = new AbortController();
106
+ // requestBrokerIpc keeps its write side open until the response. A client
107
+ // abort destroys the connection; race that against a delayed fake attempt
108
+ // so cancellation is observed before the scripted outcome is consumed.
109
+ socket.once("close", () => {
110
+ if (!responded) abortController.abort();
111
+ });
112
+ socket.on("data", (chunk) => {
113
+ if (handling) return;
114
+ buffered += chunk;
115
+ if (Buffer.byteLength(buffered) > MAX_REQUEST_BYTES) {
116
+ responded = true;
117
+ return this.#respond(socket, { id: null, ok: false, error: "request_too_large" });
118
+ }
119
+ const newline = buffered.indexOf("\n");
120
+ if (newline < 0) return;
121
+ handling = true;
122
+ const line = buffered.slice(0, newline);
123
+ buffered = "";
124
+ let request;
125
+ try { request = JSON.parse(line); } catch {
126
+ responded = true;
127
+ return this.#respond(socket, { id: null, ok: false, error: "invalid_json" });
128
+ }
129
+ void Promise.resolve(this.#dispatch(request, Date.now(), abortController.signal)).then(
130
+ (result) => {
131
+ if (abortController.signal.aborted) return;
132
+ responded = true;
133
+ this.#respond(socket, { id: request?.id ?? null, ok: true, result });
134
+ },
135
+ (error) => {
136
+ if (abortController.signal.aborted) return;
137
+ responded = true;
138
+ // Do not stringify raw request fields or authorization in an error.
139
+ this.#respond(socket, { id: request?.id ?? null, ok: false, error: error instanceof Error ? error.message : "internal_error" });
140
+ },
141
+ );
142
+ });
143
+ socket.on("error", () => socket.destroy());
144
+ }
145
+
146
+ #respond(socket, response) {
147
+ if (!socket.destroyed && !socket.writableEnded) socket.end(`${JSON.stringify(response)}\n`);
148
+ }
149
+
150
+ async #dispatch(request, now, signal) {
151
+ if (!request || typeof request.method !== "string") throw new Error("invalid_request");
152
+ const { method, params = {}, authorization } = request;
153
+ if (sameSecret(authorization, this.#controllerToken)) {
154
+ if (!CONTROLLER_METHODS.has(method)) throw new Error("controller_method_not_allowed");
155
+ if (method === "reserve") return this.#broker.reserve(params.contract, now);
156
+ if (method === "issueLeaseCapability") return this.#broker.issueLeaseCapability(params.leaseId, params.fencingToken, now);
157
+ if (method === "markRateLimited") return this.#broker.markRateLimited(params.resourceId, params.retryAfterMs, now);
158
+ if (method === "markUnknown") return this.#broker.markUnknown(params.resourceId, now, params.reason);
159
+ if (method === "markHealthy") return this.#broker.markHealthy(params.resourceId, now);
160
+ if (method === "release") return this.#broker.release(params.leaseId, params.fencingToken, "controller release", now);
161
+ if (method === "configureFakeProvider") {
162
+ if (!this.#fakeProvider) throw new Error("fake_provider_unavailable");
163
+ return this.#fakeProvider.configure(params.taskId, params.events);
164
+ }
165
+ }
166
+ if (!CHILD_METHODS.has(method)) throw new Error("unauthorized");
167
+ const capability = this.#broker.leaseForCapability(authorization, now);
168
+ if (capability.status !== "authorized") throw new Error("unauthorized");
169
+ if (method === "heartbeat") return this.#broker.heartbeat(capability.lease.leaseId, capability.lease.fencingToken, now, validateTtl(params.ttlMs));
170
+ if (method === "release") return this.#broker.release(capability.lease.leaseId, capability.lease.fencingToken, "child release", now);
171
+ return this.#providerAttempt(capability.lease, params.inputDigest, signal);
172
+ }
173
+
174
+ async #providerAttempt(lease, inputDigest, signal) {
175
+ // Publishing this core must not turn an absent real provider proxy into a
176
+ // successful fake model call. Tests opt into ScriptedFakeProvider explicitly.
177
+ if (!this.#fakeProvider) throw new Error("provider_transport_unavailable");
178
+ const event = await this.#fakeProvider.attempt(lease, inputDigest, signal);
179
+ if (signal?.aborted) throw new Error("provider_attempt_aborted");
180
+ // A delayed attempt must re-check the lease at observation time, not at
181
+ // socket admission time. Otherwise a result arriving after TTL could be
182
+ // recorded/returned under an already expired fencing boundary.
183
+ const observedAt = Date.now();
184
+ const telemetry = this.#broker.recordProviderEvent(lease.leaseId, lease.fencingToken, inputDigest, event, observedAt);
185
+ if (telemetry.status !== "recorded") throw new Error("lease no longer active");
186
+ if (event.type === "succeeded") {
187
+ if (lease.enforcement.output === "hard" && lease.maxOutputTokens !== undefined && event.usage.output > lease.maxOutputTokens) {
188
+ this.#broker.release(lease.leaseId, lease.fencingToken, "hard output budget exceeded", observedAt);
189
+ return { status: "budget_exceeded", maxOutputTokens: lease.maxOutputTokens };
190
+ }
191
+ return { status: "succeeded", resultRef: event.resultRef, usage: event.usage };
192
+ }
193
+ if (event.type === "rate_limited") {
194
+ const cooldown = this.#broker.markRateLimited(lease.resourceId, event.retryAfterMs, observedAt);
195
+ this.#broker.release(lease.leaseId, lease.fencingToken, "provider rate limited", observedAt);
196
+ return { status: "rate_limited", retryAfterMs: event.retryAfterMs, capacityGroup: cooldown.capacityGroup, retryAt: cooldown.until };
197
+ }
198
+ if (event.type === "auth_fatal") {
199
+ this.#broker.markUnknown(lease.resourceId, observedAt, "provider auth fatal");
200
+ this.#broker.release(lease.leaseId, lease.fencingToken, "provider auth fatal", observedAt);
201
+ return { status: "auth_fatal" };
202
+ }
203
+ this.#broker.release(lease.leaseId, lease.fencingToken, "provider failed before effect", observedAt);
204
+ return { status: "failed_before_effect", reasonCode: event.reasonCode };
205
+ }
206
+ }
207
+
208
+ /** One-request-per-connection client used by fake controller/provider tests. */
209
+ export function requestBrokerIpc({ socketPath, authorization, method, params = {}, signal }) {
210
+ return new Promise((resolve, reject) => {
211
+ if (signal?.aborted) return reject(new Error("aborted"));
212
+ const socket = createConnection(socketPath);
213
+ socket.setEncoding("utf8");
214
+ let buffered = "";
215
+ let settled = false;
216
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
217
+ const fail = (error) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ cleanup();
221
+ reject(error);
222
+ };
223
+ const succeed = (result) => {
224
+ if (settled) return;
225
+ settled = true;
226
+ cleanup();
227
+ resolve(result);
228
+ };
229
+ const onAbort = () => {
230
+ socket.destroy();
231
+ fail(new Error("aborted"));
232
+ };
233
+ signal?.addEventListener("abort", onAbort, { once: true });
234
+ socket.once("connect", () => {
235
+ // Keep the write side open until the broker replies. That lets an abort
236
+ // be distinguishable from a normal one-request/one-response exchange.
237
+ if (!settled) socket.write(`${JSON.stringify({ id: randomBytes(8).toString("hex"), authorization, method, params })}\n`);
238
+ });
239
+ socket.on("data", (chunk) => { buffered += chunk; });
240
+ socket.once("error", fail);
241
+ socket.once("end", () => {
242
+ try {
243
+ const response = JSON.parse(buffered.trim());
244
+ if (!response.ok) throw new Error(response.error);
245
+ succeed(response.result);
246
+ } catch (error) { fail(error); }
247
+ });
248
+ });
249
+ }
@@ -0,0 +1,27 @@
1
+ import { mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ /**
5
+ * Controller-side provisioning for a brokered child. The directory holds no
6
+ * provider credential: its only persistent setting disables Pi-local retries.
7
+ * Lease routing/capability stays ephemeral in the child's environment.
8
+ */
9
+ export function provisionBrokeredAgentDir(path) {
10
+ const agentDir = resolve(path);
11
+ mkdirSync(agentDir, { recursive: true, mode: 0o700 });
12
+ const stat = statSync(agentDir);
13
+ if (!stat.isDirectory() || (stat.mode & 0o077) !== 0) {
14
+ throw new Error(`Brokered agent dir must be owner-only: ${agentDir}`);
15
+ }
16
+ const settings = {
17
+ retry: {
18
+ // The broker, rather than Pi's same-child retry loop, owns recovery and
19
+ // any compatible replacement decision.
20
+ enabled: false,
21
+ provider: { maxRetries: 0 },
22
+ },
23
+ };
24
+ const settingsPath = join(agentDir, "settings.json");
25
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
26
+ return { agentDir, settingsPath };
27
+ }
@@ -0,0 +1,154 @@
1
+ import { createHash, createPublicKey, verify } from "node:crypto";
2
+
3
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
4
+ const ENFORCEMENT = new Set(["hard", "metered_best_effort", "unavailable"]);
5
+ const PROFILE_STATUS = new Set(["approved", "disabled"]);
6
+
7
+ function isPlainObject(value) {
8
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
9
+ const prototype = Object.getPrototypeOf(value);
10
+ return prototype === Object.prototype || prototype === null;
11
+ }
12
+
13
+ function canonicalize(value) {
14
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
15
+ if (typeof value === "number") {
16
+ if (!Number.isFinite(value) || !Number.isSafeInteger(value)) throw new Error("Signed registry contains a non-finite or unsafe number");
17
+ return JSON.stringify(value);
18
+ }
19
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
20
+ if (!isPlainObject(value)) throw new Error("Signed registry must contain JSON objects only");
21
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`;
22
+ }
23
+
24
+ function ownKeysExactly(value, keys, label) {
25
+ if (!isPlainObject(value)) throw new Error(`${label} must be an object`);
26
+ const actual = Object.keys(value).sort();
27
+ const expected = [...keys].sort();
28
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
29
+ throw new Error(`${label} has unsupported or missing fields`);
30
+ }
31
+ }
32
+
33
+ function requireIdentifier(value, label) {
34
+ if (typeof value !== "string" || !IDENTIFIER.test(value)) throw new Error(`${label} is not a bounded identifier`);
35
+ return value;
36
+ }
37
+
38
+ function requireTimestamp(value, label) {
39
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive safe-integer timestamp`);
40
+ return value;
41
+ }
42
+
43
+ function freeze(value) {
44
+ if (Array.isArray(value)) value.forEach(freeze);
45
+ else if (isPlainObject(value)) Object.values(value).forEach(freeze);
46
+ return Object.freeze(value);
47
+ }
48
+
49
+ function normalizedBrokerRegistry(registry) {
50
+ ownKeysExactly(registry, ["registryVersion", "issuedAt", "expiresAt", "profiles", "capacityGroups", "resources"], "Signed registry");
51
+ requireIdentifier(registry.registryVersion, "registryVersion");
52
+ const issuedAt = requireTimestamp(registry.issuedAt, "issuedAt");
53
+ const expiresAt = requireTimestamp(registry.expiresAt, "expiresAt");
54
+ if (expiresAt <= issuedAt) throw new Error("Signed registry expiresAt must be after issuedAt");
55
+ if (!isPlainObject(registry.profiles) || !isPlainObject(registry.capacityGroups) || !isPlainObject(registry.resources)) {
56
+ throw new Error("Signed registry profiles, capacityGroups and resources must be objects");
57
+ }
58
+
59
+ const profiles = {};
60
+ for (const [id, profile] of Object.entries(registry.profiles)) {
61
+ requireIdentifier(id, `profile ${id}`);
62
+ ownKeysExactly(profile, ["status", "supports"], `profile ${id}`);
63
+ if (typeof profile.status !== "string" || !PROFILE_STATUS.has(profile.status)) throw new Error(`profile ${id} has invalid status`);
64
+ if (!Array.isArray(profile.supports) || profile.supports.length === 0) throw new Error(`profile ${id} supports must be a non-empty array`);
65
+ const supports = profile.supports.map((capability) => requireIdentifier(capability, `profile ${id} support`));
66
+ if (new Set(supports).size !== supports.length) throw new Error(`profile ${id} has duplicate supports`);
67
+ profiles[id] = { status: profile.status, supports };
68
+ }
69
+ if (Object.keys(profiles).length === 0) throw new Error("Signed registry must contain a profile");
70
+
71
+ const capacityGroups = {};
72
+ for (const [id, group] of Object.entries(registry.capacityGroups)) {
73
+ requireIdentifier(id, `capacity group ${id}`);
74
+ ownKeysExactly(group, ["maxConcurrent"], `capacity group ${id}`);
75
+ if (!Number.isSafeInteger(group.maxConcurrent) || group.maxConcurrent < 1 || group.maxConcurrent > 10_000) {
76
+ throw new Error(`capacity group ${id} has invalid maxConcurrent`);
77
+ }
78
+ capacityGroups[id] = { maxConcurrent: group.maxConcurrent };
79
+ }
80
+ if (Object.keys(capacityGroups).length === 0) throw new Error("Signed registry must contain a capacity group");
81
+
82
+ const resources = {};
83
+ for (const [id, resource] of Object.entries(registry.resources)) {
84
+ requireIdentifier(id, `resource ${id}`);
85
+ ownKeysExactly(resource, ["capacityGroup", "profile", "enforcement"], `resource ${id}`);
86
+ requireIdentifier(resource.capacityGroup, `resource ${id} capacityGroup`);
87
+ requireIdentifier(resource.profile, `resource ${id} profile`);
88
+ if (!capacityGroups[resource.capacityGroup]) throw new Error(`resource ${id} references unknown capacity group`);
89
+ if (!profiles[resource.profile]) throw new Error(`resource ${id} references unknown profile`);
90
+ ownKeysExactly(resource.enforcement, ["input", "output", "cost"], `resource ${id} enforcement`);
91
+ const enforcement = {};
92
+ for (const dimension of ["input", "output", "cost"]) {
93
+ const value = resource.enforcement[dimension];
94
+ if (typeof value !== "string" || !ENFORCEMENT.has(value)) throw new Error(`resource ${id} has invalid ${dimension} enforcement`);
95
+ enforcement[dimension] = value;
96
+ }
97
+ resources[id] = { capacityGroup: resource.capacityGroup, profile: resource.profile, enforcement };
98
+ }
99
+ if (Object.keys(resources).length === 0) throw new Error("Signed registry must contain a resource");
100
+
101
+ return {
102
+ registryVersion: registry.registryVersion,
103
+ issuedAt,
104
+ expiresAt,
105
+ brokerRegistry: { profiles, capacityGroups, resources },
106
+ };
107
+ }
108
+
109
+ function decodeSignature(value) {
110
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(value)) throw new Error("Signed registry signature must be an Ed25519 base64url signature");
111
+ const signature = Buffer.from(value, "base64url");
112
+ if (signature.length !== 64) throw new Error("Signed registry signature has an invalid length");
113
+ return signature;
114
+ }
115
+
116
+ /** Canonical bytes that an offline release signer must sign with Ed25519. */
117
+ export function signedRegistryMessage({ schemaVersion, keyId, registry }) {
118
+ if (schemaVersion !== 1) throw new Error("Signed registry schemaVersion must equal 1");
119
+ requireIdentifier(keyId, "keyId");
120
+ // Validate semantics before signing too, so a release tool cannot produce a
121
+ // valid-looking envelope whose payload a supervisor later refuses.
122
+ normalizedBrokerRegistry(registry);
123
+ return Buffer.from(canonicalize({ schemaVersion, keyId, registry }), "utf8");
124
+ }
125
+
126
+ /**
127
+ * Verify a controller-owned signed registry before it reaches SQLite admission.
128
+ * `trustedKeys` is a controller/release-pipeline keyring (keyId -> public key),
129
+ * never child configuration and never an account/provider credential.
130
+ */
131
+ export function verifySignedRegistry(envelope, { trustedKeys, now = Date.now() } = {}) {
132
+ ownKeysExactly(envelope, ["schemaVersion", "keyId", "registry", "signature"], "Signed registry envelope");
133
+ if (!isPlainObject(trustedKeys)) throw new Error("Signed registry requires a trusted keyring");
134
+ const message = signedRegistryMessage(envelope);
135
+ const key = trustedKeys[envelope.keyId];
136
+ if (!key) throw new Error(`Signed registry keyId is not trusted: ${envelope.keyId}`);
137
+ let publicKey;
138
+ try { publicKey = createPublicKey(key); } catch { throw new Error("Signed registry trusted public key is invalid"); }
139
+ if (publicKey.asymmetricKeyType !== "ed25519") throw new Error("Signed registry trusted key must be Ed25519");
140
+ if (!verify(null, message, publicKey, decodeSignature(envelope.signature))) throw new Error("Signed registry signature verification failed");
141
+ const normalized = normalizedBrokerRegistry(envelope.registry);
142
+ if (!Number.isSafeInteger(now) || now < normalized.issuedAt || now >= normalized.expiresAt) {
143
+ throw new Error("Signed registry is not currently valid");
144
+ }
145
+ const fingerprint = createHash("sha256").update(message).digest("hex");
146
+ return freeze({
147
+ keyId: envelope.keyId,
148
+ registryVersion: normalized.registryVersion,
149
+ issuedAt: normalized.issuedAt,
150
+ expiresAt: normalized.expiresAt,
151
+ fingerprint,
152
+ brokerRegistry: normalized.brokerRegistry,
153
+ });
154
+ }