@uuaid/agent-commons 0.2.0-alpha.2 → 0.2.0-alpha.4

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.
Files changed (72) hide show
  1. package/CONFORMANCE.md +1 -1
  2. package/NOTICE +4 -7
  3. package/PROVENANCE.json +12 -17
  4. package/README.md +103 -12
  5. package/bin/agent-commons.mjs +8 -3
  6. package/examples/Dockerfile +1 -1
  7. package/package.json +5 -2
  8. package/src/carrier.mjs +7 -2
  9. package/src/channels.mjs +1 -1
  10. package/src/config.mjs +2 -1
  11. package/src/continuity.mjs +14 -7
  12. package/src/governance-scope.mjs +1 -1
  13. package/src/governance.mjs +4 -2
  14. package/src/index.d.ts +3 -1
  15. package/src/pillar.mjs +2 -2
  16. package/src/profiles.mjs +2 -1
  17. package/src/relay/auth.mjs +294 -0
  18. package/src/relay/board.mjs +223 -0
  19. package/src/relay/claims.mjs +25 -0
  20. package/src/relay/cli.mjs +49 -0
  21. package/src/relay/client.mjs +437 -0
  22. package/src/relay/docs/reconnect-v1.md +27 -0
  23. package/src/relay/docs/working-rules-v1.md +42 -0
  24. package/src/relay/enroll.mjs +232 -0
  25. package/src/relay/export-cli.mjs +47 -0
  26. package/src/relay/governance.mjs +1328 -0
  27. package/src/relay/mailbox.mjs +103 -0
  28. package/src/relay/records.mjs +146 -0
  29. package/src/relay/render.mjs +38 -0
  30. package/src/relay/roster.mjs +134 -0
  31. package/src/relay/routes.mjs +87 -0
  32. package/src/relay/schema.mjs +1081 -0
  33. package/src/relay/seed.mjs +89 -0
  34. package/src/relay/serve.mjs +116 -0
  35. package/src/relay/server.mjs +633 -0
  36. package/src/relay/signing.mjs +693 -0
  37. package/src/relay/test-mode.mjs +73 -0
  38. package/src/runtime.mjs +25 -12
  39. package/src/spaces/manifest.mjs +245 -0
  40. package/src/spaces/pillar-group.mjs +12 -0
  41. package/src/spaces/policy.mjs +229 -0
  42. package/src/spaces/store.mjs +325 -0
  43. package/src/trust.mjs +44 -5
  44. package/src/uuaid-namespace.d.mts +6 -0
  45. package/src/uuaid-namespace.mjs +48 -0
  46. package/src/vendor/pillar/blob/blob.mjs +12 -14
  47. package/src/vendor/pillar/groups-v2.mjs +1108 -0
  48. package/src/vendor/pillar/groups.mjs +175 -0
  49. package/src/vendor/pillar/identity/jcs.mjs +4 -5
  50. package/src/vendor/pillar/identity/keychain.mjs +51 -2
  51. package/src/vendor/pillar/net/carrier-client.mjs +29 -7
  52. package/src/vendor/pillar/net/refusals.mjs +4 -2
  53. package/test/channels.test.mjs +0 -268
  54. package/test/cohort.test.mjs +0 -36
  55. package/test/collaboration.test.mjs +0 -459
  56. package/test/continuity.test.mjs +0 -229
  57. package/test/custody.negative.test.mjs +0 -165
  58. package/test/derivation-parity.test.mjs +0 -19
  59. package/test/fixtures/derivation-vectors.json +0 -26
  60. package/test/fixtures/ed25519-small-order.vectors.json +0 -15
  61. package/test/governance-expected-red.expected-red.mjs +0 -241
  62. package/test/governance-scope.test.mjs +0 -142
  63. package/test/governance-t1-t10.test.mjs +0 -477
  64. package/test/governance.test.mjs +0 -514
  65. package/test/hold-admission.test.mjs +0 -1185
  66. package/test/keychain-strict-ed25519.test.mjs +0 -240
  67. package/test/offline.test.mjs +0 -152
  68. package/test/runtime-channels.test.mjs +0 -1140
  69. package/test/runtime.test.mjs +0 -201
  70. package/test/security-runtime.test.mjs +0 -104
  71. package/test/strict-ed25519-consumers.test.mjs +0 -234
  72. package/test/transport.test.mjs +0 -353
@@ -0,0 +1,294 @@
1
+ // Cloud Commons relay: request authentication and response signing (spec v3.4.5 sections 6.1,
2
+ // 6.2, 6.3 and 6.5 "Signer = by").
3
+ //
4
+ // Port source (B1): the identity repo at commit cf48f0e, ported verbatim:
5
+ // packages/pillar-client/src/identity-request.ts - the header name and grammar, the strict
6
+ // parser (:95-114), the descriptor (:54-66), the body hash (:71-74), the signing bytes
7
+ // (:77-79) and verifyIdentityRequest;
8
+ // packages/pillar-client/src/identity.ts - isStrictEd25519PublicKey and verifyBytesStrict
9
+ // (RFC 8032 strict: canonical A and R, never a small-order or mixed-order key);
10
+ // apps/api/src/self-auth.ts:41-77 - the verifier order: a stale request is refused unverified;
11
+ // a future-dated one is verified and refused only after its nonce is spent.
12
+ // The port passes the vector docs/standards/agent-vault-wallet/vectors/identity-auth.json at that
13
+ // commit, with the vector's own audience and namespace (test/relay-auth.test.mjs), before it is
14
+ // used with the relay's.
15
+ //
16
+ // The relay's differences (6.1, 6.3): the audience is one configured string compared byte for byte
17
+ // (never the Host header); the principal header `commons-principal` names the agent and must be an
18
+ // agentnet id (B3); the key comes from the relay's own keys table (D-i); every principal failure is
19
+ // the same 401 {"error":"unauthorized"} after a check against a fixed dummy key; the nonce is spent
20
+ // (UNIQUE(principal, nonce)) before the future check; a replay is 409. On every fleet record
21
+ // the authenticated signer must equal the body's `by` (6.5).
22
+ //
23
+ // 6.2: every relay response carries `commons-relay-sig`, Ed25519 by the relay key over
24
+ // JCS({aud, request_nonce, status, body_sha256}); signRelayResponse takes the relay key seed from
25
+ // its caller (the server) and verifyRelayResponse is the client's check against the pinned key.
26
+ //
27
+ // This module performs no network I/O and stores no private key. The server applies the size
28
+ // cap, the route inventory and the browser-origin refusal before calling authenticateRequest.
29
+
30
+ import { createHash, randomBytes } from "node:crypto";
31
+ import { ed25519 } from "@noble/curves/ed25519.js";
32
+ import { jcs } from "../pillar.mjs";
33
+ import { localIdFromKey } from "../vendor/pillar/identity/keychain.mjs";
34
+ import { keyBindsAgentUuaid } from "../uuaid-namespace.mjs";
35
+ import { RelayError, assertIntegerOnly } from "./records.mjs";
36
+ import { tx } from "./schema.mjs";
37
+
38
+ // --- verbatim port: identity-request.ts (cf48f0e) ----------------------------------------
39
+
40
+ export const IDENTITY_AUTH_HEADER = "uuaid-identity-auth";
41
+ export const IDENTITY_REQUEST_TYPE = "uuaid.identity.request.v1";
42
+ /** The methods an identity key may sign. */
43
+ export const IDENTITY_REQUEST_METHODS = Object.freeze(["GET", "PUT", "POST", "DELETE", "PATCH"]);
44
+
45
+ /** The object the identity key signs. Never transmitted: the verifier rebuilds it. */
46
+ export function identityRequestDescriptor(i) {
47
+ return {
48
+ type: IDENTITY_REQUEST_TYPE,
49
+ aud: i.audience,
50
+ agent: i.agent,
51
+ method: i.method.toUpperCase(),
52
+ path: i.path,
53
+ query: i.query ?? "",
54
+ body_sha256: i.bodySha256,
55
+ created: i.created,
56
+ nonce: i.nonce,
57
+ };
58
+ }
59
+
60
+ /** sha256 hex of JCS(body); null when there is no body. */
61
+ export function identityRequestBodyHash(body) {
62
+ if (body === undefined || body === null) return null;
63
+ return createHash("sha256").update(Buffer.from(jcs(body), "utf8")).digest("hex");
64
+ }
65
+
66
+ /** The exact bytes the identity key signs. */
67
+ export function identityRequestSigningBytes(d) {
68
+ return Buffer.from(`${IDENTITY_REQUEST_TYPE}\n${jcs(d)}`, "utf8");
69
+ }
70
+
71
+ export function formatIdentityAuthHeader(h) {
72
+ return `v=${h.v}; created=${h.created}; nonce=${h.nonce}; ed25519=${h.ed25519}`;
73
+ }
74
+
75
+ const CREATED_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
76
+
77
+ /** Parse the header strictly: every field once, nothing else. Null on any defect. */
78
+ export function parseIdentityAuthHeader(value) {
79
+ if (typeof value !== "string" || value.length > 512) return null;
80
+ const fields = new Map();
81
+ for (const part of value.split(";")) {
82
+ const t = part.trim();
83
+ const eq = t.indexOf("=");
84
+ if (eq <= 0) return null;
85
+ const k = t.slice(0, eq);
86
+ if (fields.has(k)) return null;
87
+ fields.set(k, t.slice(eq + 1));
88
+ }
89
+ const keys = ["v", "created", "nonce", "ed25519"];
90
+ if (fields.size !== keys.length || !keys.every((k) => fields.has(k))) return null;
91
+ const created = fields.get("created");
92
+ if (fields.get("v") !== "1") return null;
93
+ if (!CREATED_RE.test(created) || Number.isNaN(Date.parse(created))) return null;
94
+ if (!/^[0-9a-f]{32,64}$/.test(fields.get("nonce"))) return null;
95
+ if (!/^[0-9a-f]{128}$/.test(fields.get("ed25519"))) return null;
96
+ return { v: "1", created, nonce: fields.get("nonce"), ed25519: fields.get("ed25519") };
97
+ }
98
+
99
+ // --- verbatim port: identity.ts (cf48f0e) -------------------------------------------------
100
+
101
+ export function isStrictEd25519PublicKey(pubKeyHex) {
102
+ if (typeof pubKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(pubKeyHex)) return false;
103
+ const hex = pubKeyHex.toLowerCase();
104
+ try {
105
+ const point = ed25519.Point.fromHex(hex, false);
106
+ return point.toHex() === hex && !point.isSmallOrder() && point.isTorsionFree();
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ export function verifyBytesStrict(pubKeyHex, msg, sigHex) {
113
+ if (!isStrictEd25519PublicKey(pubKeyHex)) return false;
114
+ if (typeof sigHex !== "string" || !/^[0-9a-f]{128}$/i.test(sigHex)) return false;
115
+ try {
116
+ return ed25519.verify(Buffer.from(sigHex.toLowerCase(), "hex"), msg, Buffer.from(pubKeyHex.toLowerCase(), "hex"), { zip215: false });
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /** Verify a parsed header against a rebuilt descriptor and the agent's identity key (RFC 8032, prime-order key). */
123
+ export function verifyIdentityRequest(d, h, publicKeyHex) {
124
+ if (d.created !== h.created || d.nonce !== h.nonce) return false;
125
+ return verifyBytesStrict(publicKeyHex, identityRequestSigningBytes(d), h.ed25519);
126
+ }
127
+
128
+ /**
129
+ * Sign a request with an identity key seed (the reference client's signer; tests). Returns the
130
+ * header value. The seed never leaves the caller. `created` and `nonce` overrides are for fixed
131
+ * test vectors only.
132
+ */
133
+ export function signIdentityRequest({ seedHex, namespace = "agentnet", method, path, query, body, audience, created, nonce }) {
134
+ const seed = Buffer.from(seedHex, "hex");
135
+ const pub = Buffer.from(ed25519.getPublicKey(seed));
136
+ const agent = `uuaid:${namespace}:agent:${localIdFromKey(pub)}`;
137
+ const c = created ?? new Date().toISOString();
138
+ const n = nonce ?? randomBytes(16).toString("hex");
139
+ const d = identityRequestDescriptor({ audience, agent, method, path, query, bodySha256: identityRequestBodyHash(body), created: c, nonce: n });
140
+ const sig = Buffer.from(ed25519.sign(identityRequestSigningBytes(d), seed)).toString("hex");
141
+ return { agent, publicKey: pub.toString("hex"), header: formatIdentityAuthHeader({ v: "1", created: c, nonce: n, ed25519: sig }) };
142
+ }
143
+
144
+ // --- the relay verifier (6.1, 6.3) ----------------------------------------------------------
145
+
146
+ export const PRINCIPAL_HEADER = "commons-principal";
147
+ /** The test audience (6.1, B5): `.test` is reserved (RFC 6761); a deployed relay is configured with its own origin. */
148
+ export const RELAY_TEST_AUD = "http://commons-relay.test:8080";
149
+ export const AUTH_MAX_AGE_MS = 5 * 60 * 1000;
150
+ export const AUTH_MAX_FUTURE_MS = 60 * 1000;
151
+ export const NONCE_KEEP_MS = 10 * 60 * 1000; // covers the 6-minute acceptance span (6.1)
152
+
153
+ /** The uniform refusal (6.3): the same status and body for every principal or signature failure. */
154
+ export const UNAUTHORIZED = Object.freeze({ status: 401, body: Object.freeze({ error: "unauthorized" }) });
155
+ const STALE = Object.freeze({ status: 401, body: Object.freeze({ error: "stale" }) }); // principal-independent (6.3)
156
+ const REPLAY = Object.freeze({ status: 409, body: Object.freeze({ error: "replay" }) }); // reachable only with a valid signature
157
+
158
+ // A fixed, valid, strict key with no known seed holder: the refusal path for an unknown or
159
+ // ineligible principal still runs one signature check, so it takes similar time (6.3).
160
+ const DUMMY_PUBLIC_KEY = Buffer.from(ed25519.getPublicKey(createHash("sha256").update("commons-relay.dummy-key.v1").digest())).toString("hex");
161
+
162
+ const AGENTNET_AGENT = /^uuaid:agentnet:agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
163
+ /** B3: an agentnet agent id (the only namespace the relay accepts). */
164
+ export const isAgentnetAgent = (value) => typeof value === "string" && AGENTNET_AGENT.test(value);
165
+ const header = (headers, name) => {
166
+ const v = headers?.[name] ?? headers?.[name.toLowerCase()];
167
+ return Array.isArray(v) ? null : v; // a repeated header is a malformed request
168
+ };
169
+
170
+ /**
171
+ * The principal a key row names, with the facts the verifier needs, read from the relay's tables
172
+ * on every request with no cache (M3). Null when the agent has no key row.
173
+ */
174
+ function principalOf(db, agent) {
175
+ const k = db.prepare("SELECT public_key, agent, principal_kind, principal_id, status FROM keys WHERE agent = ?").get(agent);
176
+ if (!k) return null;
177
+ if (k.principal_kind === "fleet") {
178
+ const f = db.prepare("SELECT kind, status FROM fleet_principals WHERE principal_id = ?").get(k.principal_id);
179
+ return { kind: "fleet", id: k.principal_id, agent, publicKey: k.public_key, keyStatus: k.status, fleetKind: f?.kind ?? null, status: f?.status ?? null };
180
+ }
181
+ const m = db.prepare("SELECT status, role FROM members WHERE member_id = ?").get(k.principal_id);
182
+ return { kind: "member", id: k.principal_id, agent, publicKey: k.public_key, keyStatus: k.status, status: m?.status ?? null, role: m?.role ?? null };
183
+ }
184
+
185
+ /**
186
+ * Whether this principal may be authenticated on this request (6.1 step 3):
187
+ * - a fleet principal: an active key of an active roster principal;
188
+ * - a member: an active key and status active; or, only where `allowPending` (GET /v1/status
189
+ * only), a pending member on its pending enrollment key.
190
+ * Suspended, revoked, unknown, retired and revoked-key principals are ineligible: uniform 401.
191
+ */
192
+ function eligible(p, allowPending) {
193
+ if (!p) return false;
194
+ if (p.kind === "fleet") return p.keyStatus === "active" && p.status === "active";
195
+ if (p.keyStatus === "active" && p.status === "active") return true;
196
+ return allowPending && p.keyStatus === "pending" && p.status === "pending";
197
+ }
198
+
199
+ /**
200
+ * Authenticate one request (6.1 steps 2-6; 3 and 8 read the tables on every request).
201
+ * `req` = { method, path, query, headers, body } with `body` the parsed JSON body (undefined when
202
+ * there is none); `aud` the relay's configured audience; `now` the relay clock in ms.
203
+ * Returns { ok: true, principal } or { ok: false, status, body }. Throws RelayError
204
+ * non-integer-number (400) for a body JCS cannot hash, before any signature or nonce work (B4).
205
+ */
206
+ /** 6.1 `created`: the header grammar's RFC 3339 UTC form (shared with the enrollment flow). */
207
+ export const isCreated = (value) => typeof value === "string" && CREATED_RE.test(value) && !Number.isNaN(Date.parse(value));
208
+ /** 6.1 `nonce`: 32-64 lower-case hex (shared with the enrollment flow). */
209
+ export const isNonce = (value) => typeof value === "string" && /^[0-9a-f]{32,64}$/.test(value);
210
+
211
+ /**
212
+ * 6.1 step 5 / 5.2 step 6 (B9): spend (principal, nonce) in the one request-nonce table, pruning
213
+ * expired rows. Returns false on a replay. The row is kept NONCE_KEEP_MS past max(created, now).
214
+ */
215
+ export function spendNonce(db, principal, nonce, createdMs, now) {
216
+ return tx(db, () => {
217
+ db.prepare("DELETE FROM request_nonces WHERE expires_at <= ?").run(now);
218
+ const r = db.prepare("INSERT INTO request_nonces(principal, nonce, expires_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING")
219
+ .run(principal, nonce, Math.max(createdMs, now) + NONCE_KEEP_MS);
220
+ return r.changes === 1;
221
+ });
222
+ }
223
+
224
+ export function authenticateRequest(db, req, { aud, now, allowPending = false }) {
225
+ if (typeof aud !== "string" || !aud) throw new RelayError("relay-aud-unset", 500);
226
+ if (!Number.isSafeInteger(now)) throw new RelayError("invalid-time", 500);
227
+ // Step 2: parse; a stale request is refused before any principal or signature work.
228
+ const h = parseIdentityAuthHeader(header(req.headers, IDENTITY_AUTH_HEADER));
229
+ const agent = header(req.headers, PRINCIPAL_HEADER);
230
+ if (!h || typeof agent !== "string") return { ok: false, ...UNAUTHORIZED };
231
+ const created = Date.parse(h.created);
232
+ if (created < now - AUTH_MAX_AGE_MS) return { ok: false, ...STALE };
233
+ // B4: integer-only bodies; hashing fails before any signature work, so no nonce is spent.
234
+ if (req.body !== undefined && req.body !== null) assertIntegerOnly(req.body);
235
+ const bodySha256 = identityRequestBodyHash(req.body);
236
+ // Step 3: the principal's current key, from the relay's own tables (D-i); never from the request.
237
+ const p = AGENTNET_AGENT.test(agent) ? principalOf(db, agent) : null;
238
+ const ok = eligible(p, allowPending);
239
+ const key = ok ? p.publicKey : DUMMY_PUBLIC_KEY;
240
+ // Step 4: strict Ed25519; the key derives the agent in agentnet (B3); the signature over the
241
+ // descriptor the relay rebuilds from the request it received, with its own audience (B5).
242
+ const d = identityRequestDescriptor({ audience: aud, agent, method: req.method, path: req.path, query: req.query ?? "", bodySha256, created: h.created, nonce: h.nonce });
243
+ const verified = verifyIdentityRequest(d, h, key) && isStrictEd25519PublicKey(key) && keyBindsAgentUuaid(agent, key);
244
+ if (!ok || !verified) return { ok: false, ...UNAUTHORIZED };
245
+ // Step 5: spend the nonce before the future check. It stays spent even if a later step fails.
246
+ if (!spendNonce(db, agent, h.nonce, created, now)) return { ok: false, ...REPLAY };
247
+ // Step 6: more than 60 s ahead is refused, after the nonce is spent.
248
+ if (created > now + AUTH_MAX_FUTURE_MS) return { ok: false, ...STALE };
249
+ return { ok: true, principal: { kind: p.kind, id: p.id, agent, status: p.status, ...(p.kind === "fleet" ? { fleetKind: p.fleetKind } : { role: p.role }) } };
250
+ }
251
+
252
+ /**
253
+ * 6.5 "Signer = by": a fleet record is accepted only from the fleet principal its
254
+ * body names. A member principal, or any other fleet principal, gets the uniform 401, so a captured
255
+ * signed body cannot be re-submitted by another principal.
256
+ */
257
+ export function signerMatchesBy(principal, body) {
258
+ return !!principal && principal.kind === "fleet" && !!body && typeof body === "object" && body.by === principal.id;
259
+ }
260
+
261
+ // --- relay authenticity (6.2) ----------------------------------------------------------------
262
+
263
+ /** 6.2: the header that carries the relay's signature on every response. */
264
+ export const RELAY_SIG_HEADER = "commons-relay-sig";
265
+
266
+ /**
267
+ * 6.2: the bytes the relay signs for one response: JCS({aud, request_nonce, status, body_sha256}),
268
+ * with body_sha256 defined as in 6.1 (sha256 hex of JCS(body), or null when there is no body).
269
+ * `requestNonce` is the request's own 6.1 nonce, or null when the request carried no header that parses.
270
+ */
271
+ export function relayResponseSigningBytes({ aud, requestNonce, status, body }) {
272
+ if (typeof aud !== "string" || !aud) throw new RelayError("relay-aud-unset", 500);
273
+ if (!Number.isSafeInteger(status) || status < 100 || status > 599) throw new RelayError("invalid-status", 500);
274
+ if (requestNonce !== null && !(typeof requestNonce === "string" && /^[0-9a-f]{32,64}$/.test(requestNonce))) throw new RelayError("invalid-nonce", 500);
275
+ return Buffer.from(jcs({ aud, request_nonce: requestNonce, status, body_sha256: identityRequestBodyHash(body) }), "utf8");
276
+ }
277
+
278
+ /** Sign a response with the relay key seed (32-byte hex, held by the server only). Returns 128 hex. */
279
+ export function signRelayResponse(relaySeedHex, response) {
280
+ return Buffer.from(ed25519.sign(relayResponseSigningBytes(response), Buffer.from(relaySeedHex, "hex"))).toString("hex");
281
+ }
282
+
283
+ /** A client's check (6.2): strict Ed25519 against the pinned 64-hex relay key, byte for byte. */
284
+ export function verifyRelayResponse(relayPublicKeyHex, response, sigHex) {
285
+ if (typeof relayPublicKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(relayPublicKeyHex)) return false;
286
+ let bytes;
287
+ try { bytes = relayResponseSigningBytes(response); } catch { return false; }
288
+ return isStrictEd25519PublicKey(relayPublicKeyHex) && verifyBytesStrict(relayPublicKeyHex, bytes, sigHex);
289
+ }
290
+
291
+ /** The request's 6.1 nonce for the response signature: the strict header parse, or null. */
292
+ export function requestNonceOf(headers) {
293
+ return parseIdentityAuthHeader(header(headers, IDENTITY_AUTH_HEADER))?.nonce ?? null;
294
+ }
@@ -0,0 +1,223 @@
1
+ // Cloud Commons relay: work board reducer (spec v3.2 sections 11 and 12.1).
2
+ // Every member record runs in one transaction: freeze and status check,
3
+ // idempotency, sweep, permission check, handler, receipt. Only the relay clock
4
+ // decides lease expiry; nothing is replayed after expiry or suspension.
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { tx, getMeta, setMeta } from "./schema.mjs";
8
+ import { openClaim, LEASE_MAX_MS, CLAIM_CAPS } from "./claims.mjs";
9
+ import { RelayError, validateMemberRecord } from "./records.mjs";
10
+ import { appendLog, sha256Hex, canonicalBytes } from "./signing.mjs";
11
+ import { roleOf, permits, isFrozen, expireInTx } from "./governance.mjs";
12
+ import { validatePatchPath, validateUnifiedDiff } from "../collaboration.mjs";
13
+ import { jcs } from "../pillar.mjs";
14
+
15
+ export { LEASE_MAX_MS, CLAIM_CAPS };
16
+ export const HEARTBEAT_FLOOR_MS = 60000;
17
+ export const CLOCK_ROLLBACK_MS = 5000;
18
+ export const SWEEP_INTERVAL_MS = 15000;
19
+ export const TRIAGE_ATTEMPTS = 3;
20
+
21
+ /**
22
+ * Expire live claims whose lease has passed. If the relay clock went back by
23
+ * more than 5 s, expiries freeze and stay frozen until an AC seat clears the
24
+ * latch (fail closed). Call inside the caller's transaction.
25
+ */
26
+ export function sweep(db, now) {
27
+ // While frozen no lease expires (9.2 Freeze); unfreeze extends live leases by the frozen interval.
28
+ if (isFrozen(db)) return { frozen: true, expired: [] };
29
+ const highWater = Number(getMeta(db, "clock_high_water") ?? 0);
30
+ if (getMeta(db, "clock_suspect") === "1") return { frozen: true, expired: [] };
31
+ if (now < highWater - CLOCK_ROLLBACK_MS) {
32
+ setMeta(db, "clock_suspect", "1");
33
+ appendLog(db, "security", { kind: "clock.suspect", now, high_water: highWater }, highWater);
34
+ db.prepare("INSERT INTO alerts(at, kind, subject, detail) VALUES (?, 'clock.suspect', NULL, ?)").run(highWater, jcs({ now, high_water: highWater }));
35
+ return { frozen: true, expired: [] };
36
+ }
37
+ if (now > highWater) setMeta(db, "clock_high_water", String(now));
38
+ // 5.3 and the D items (5.4): unconfirmed enrollments of all three kinds expire after 72 h (the
39
+ // pending key is revoked for ever), and dead re-bind attempts are recorded. Never while frozen
40
+ // or clock-suspect: the sweep has returned above.
41
+ expireInTx(db, now);
42
+ const expired = db.prepare("SELECT claim_id, task_id, member_id, epoch FROM claims WHERE state = 'live' AND lease_until <= ?").all(now);
43
+ for (const c of expired) {
44
+ db.prepare("UPDATE claims SET state = 'expired', released_at = ?, released_reason = 'lease' WHERE claim_id = ?").run(now, c.claim_id);
45
+ const attempts = db.prepare("SELECT attempts FROM tasks WHERE task_id = ?").get(c.task_id).attempts + 1;
46
+ db.prepare("UPDATE tasks SET attempts = ?, status = ?, updated_at = ? WHERE task_id = ?")
47
+ .run(attempts, attempts >= TRIAGE_ATTEMPTS ? "needs-triage" : "open", now, c.task_id);
48
+ appendLog(db, "board", { kind: "claim.expired", claim_id: c.claim_id, task_id: c.task_id, member_id: c.member_id, epoch: c.epoch, attempts }, now);
49
+ }
50
+ return { frozen: false, expired: expired.map(c => ({ ...c })) };
51
+ }
52
+
53
+ export const runSweep = (db, now) => tx(db, () => sweep(db, now));
54
+
55
+ /** Fencing (11.3): the named claim must be this member's live claim at this epoch. */
56
+ function liveClaim(db, member, claimId, epoch) {
57
+ const c = db.prepare("SELECT c.*, d.lease_ms FROM claims c JOIN claim_details d USING(claim_id) WHERE c.claim_id = ?").get(claimId);
58
+ // Another member's claim is indistinguishable from no claim.
59
+ if (!c || c.member_id !== member.member_id) throw new RelayError("unknown-claim", 404);
60
+ if (c.state === "live" && c.epoch === epoch) return c;
61
+ // R7: a result closes its claim; nothing more may be written against it.
62
+ if (c.state === "submitted") throw new RelayError("CLAIM_CLOSED", 409);
63
+ const newest = db.prepare("SELECT max(epoch) e FROM claims WHERE task_id = ?").get(c.task_id).e;
64
+ if (c.epoch !== epoch || newest > c.epoch) throw new RelayError("STALE_EPOCH", 409);
65
+ throw new RelayError("CLAIM_EXPIRED", 409);
66
+ }
67
+
68
+ const HANDLERS = {
69
+ heartbeat(db, m, role, rec, now) {
70
+ if (m.last_heartbeat_at !== null && now - m.last_heartbeat_at < HEARTBEAT_FLOOR_MS) throw new RelayError("heartbeat-too-fast", 429);
71
+ db.prepare("UPDATE member_profiles SET last_heartbeat_at = ?, first_heartbeat_at = coalesce(first_heartbeat_at, ?) WHERE member_id = ?").run(now, now, m.member_id);
72
+ if (rec.body.claim_id === undefined) return { kind: "heartbeat", at: now };
73
+ const c = liveClaim(db, m, rec.body.claim_id, rec.body.epoch);
74
+ const leaseUntil = Math.max(c.lease_until, now + c.lease_ms);
75
+ db.prepare("UPDATE claims SET lease_until = ? WHERE claim_id = ?").run(leaseUntil, c.claim_id);
76
+ return { kind: "heartbeat", at: now, claim_id: c.claim_id, epoch: c.epoch, lease_until: leaseUntil };
77
+ },
78
+
79
+ message(db, m, role, rec, now) {
80
+ if (rec.body.lane !== m.lane) throw new RelayError("forbidden", 403, "lane");
81
+ const { lastInsertRowid } = db.prepare("INSERT INTO seat_feed(lane, member_id, record_id, type, body, at, state) VALUES (?, ?, ?, 'message', ?, ?, 'pending')")
82
+ .run(m.lane, m.member_id, rec.record_id, jcs(rec.body), now);
83
+ return { kind: "message", feed_id: Number(lastInsertRowid) };
84
+ },
85
+
86
+ note(db, m, role, rec, now) {
87
+ db.prepare("INSERT INTO ext_quarantine(member_id, record_id, body, at) VALUES (?, ?, ?, ?)").run(m.member_id, rec.record_id, jcs(rec.body), now);
88
+ return { kind: "note", quarantined: true };
89
+ },
90
+
91
+ claim(db, m, role, rec, now) {
92
+ const task = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(rec.body.task_id);
93
+ if (!task) throw new RelayError("unknown-task", 404);
94
+ if (task.status === "claimed") throw new RelayError("CLAIM_HELD", 409);
95
+ if (task.status !== "open") throw new RelayError("task-not-open", 409);
96
+ return { kind: "claim", ...openClaim(db, { taskId: task.task_id, memberId: m.member_id, role, leaseMs: rec.body.lease_ms, now }) };
97
+ },
98
+
99
+ release(db, m, role, rec, now) {
100
+ const c = liveClaim(db, m, rec.body.claim_id, rec.body.epoch);
101
+ db.prepare("UPDATE claims SET state = 'released', released_at = ?, released_reason = 'member' WHERE claim_id = ?").run(now, c.claim_id);
102
+ db.prepare("UPDATE tasks SET status = 'open', updated_at = ? WHERE task_id = ?").run(now, c.task_id);
103
+ return { kind: "release", claim_id: c.claim_id, task_id: c.task_id };
104
+ },
105
+
106
+ progress(db, m, role, rec, now) {
107
+ const c = liveClaim(db, m, rec.body.claim_id, rec.body.epoch);
108
+ db.prepare("INSERT INTO progress(member_id, record_id, claim_id, text, at) VALUES (?, ?, ?, ?, ?)").run(m.member_id, rec.record_id, c.claim_id, rec.body.text, now);
109
+ return { kind: "progress", claim_id: c.claim_id, epoch: c.epoch };
110
+ },
111
+
112
+ result(db, m, role, rec, now) {
113
+ const c = liveClaim(db, m, rec.body.claim_id, rec.body.epoch);
114
+ const task = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(c.task_id);
115
+ let stored;
116
+ if (rec.body.kind === "patch") {
117
+ const allowed = JSON.parse(task.paths);
118
+ for (const file of rec.body.body) {
119
+ try {
120
+ const path = validatePatchPath(file.path, allowed);
121
+ if (path !== file.path) throw new Error("path is not in normal form");
122
+ validateUnifiedDiff(file.path, file.diff);
123
+ } catch (error) {
124
+ throw new RelayError("invalid-patch", 422, String(error?.message ?? error).slice(0, 200));
125
+ }
126
+ }
127
+ stored = jcs(rec.body.body);
128
+ } else stored = rec.body.body;
129
+ const submissionId = randomUUID(), sha = sha256Hex(stored);
130
+ db.prepare("INSERT INTO submissions(submission_id, member_id, state) VALUES (?, ?, 'in-review')").run(submissionId, m.member_id);
131
+ db.prepare(`INSERT INTO submission_details(submission_id, task_id, claim_id, epoch, kind, body, body_sha256, created_at)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(submissionId, c.task_id, c.claim_id, c.epoch, rec.body.kind, stored, sha, now);
133
+ db.prepare("UPDATE claims SET state = 'submitted', released_at = ?, released_reason = 'submitted' WHERE claim_id = ?").run(now, c.claim_id);
134
+ db.prepare("UPDATE tasks SET status = 'in-review', updated_at = ? WHERE task_id = ?").run(now, c.task_id);
135
+ return { kind: "result", submission_id: submissionId, task_id: c.task_id, body_sha256: sha };
136
+ },
137
+
138
+ evidence(db, m, role, rec, now) {
139
+ const s = db.prepare("SELECT member_id, state FROM submissions WHERE submission_id = ?").get(rec.body.submission_id);
140
+ if (!s || s.member_id !== m.member_id) throw new RelayError("unknown-submission", 404);
141
+ db.prepare("INSERT INTO evidence(member_id, record_id, submission_id, kind, text, at) VALUES (?, ?, ?, ?, ?, ?)")
142
+ .run(m.member_id, rec.record_id, rec.body.submission_id, rec.body.kind, rec.body.text, now);
143
+ // Advisory only: evidence is context for reviewers and never counts toward D1.
144
+ return { kind: "evidence", submission_id: rec.body.submission_id, advisory: true };
145
+ },
146
+
147
+ "review-request"(db, m, role, rec, now) {
148
+ const s = db.prepare("SELECT member_id FROM submissions WHERE submission_id = ?").get(rec.body.submission_id);
149
+ if (!s || s.member_id !== m.member_id) throw new RelayError("unknown-submission", 404);
150
+ db.prepare("INSERT INTO seat_feed(lane, member_id, record_id, type, body, at, state) VALUES (?, ?, ?, 'review-request', ?, ?, 'pending')")
151
+ .run(m.lane, m.member_id, rec.record_id, jcs(rec.body), now);
152
+ return { kind: "review-request", submission_id: rec.body.submission_id };
153
+ },
154
+
155
+ propose(db, m, role, rec, now) {
156
+ // A proposal is a request to seats. A D0 task.open by the lane owner opens it as a task (R4).
157
+ db.prepare("INSERT INTO seat_feed(lane, member_id, record_id, type, body, at, state) VALUES (?, ?, ?, 'propose', ?, ?, 'pending')")
158
+ .run(rec.body.lane, m.member_id, rec.record_id, jcs(rec.body), now);
159
+ return { kind: "propose", queued: true };
160
+ },
161
+
162
+ "advisory-review"(db, m, role, rec, now) {
163
+ db.prepare("INSERT INTO seat_feed(lane, member_id, record_id, type, body, at, state) VALUES (?, ?, ?, 'advisory-review', ?, ?, 'pending')")
164
+ .run(m.lane, m.member_id, rec.record_id, jcs(rec.body), now);
165
+ return { kind: "advisory-review", counts: false };
166
+ },
167
+ };
168
+
169
+ function activeMember(db, memberId) {
170
+ const m = db.prepare(`SELECT m.member_id, m.status, m.role, p.display_name, p.platform, p.lane, p.first_heartbeat_at, p.last_heartbeat_at
171
+ FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?`).get(memberId);
172
+ // Status is read from the table inside the write transaction, every time (M3).
173
+ if (!m || m.status !== "active") throw new RelayError("unauthorized", 401);
174
+ return m;
175
+ }
176
+
177
+ /**
178
+ * Apply one member record. `memberId` is the authenticated member: request
179
+ * verification supplies it in the server, and a test supplies it directly.
180
+ */
181
+ export function handleMemberRecord(db, memberId, record, now) {
182
+ const rec = validateMemberRecord(record);
183
+ const sha = sha256Hex(canonicalBytes(rec));
184
+ return tx(db, () => {
185
+ if (isFrozen(db)) throw new RelayError("frozen", 503);
186
+ const m = activeMember(db, memberId);
187
+ const prior = db.prepare("SELECT record_sha256, receipt FROM member_records WHERE member_id = ? AND record_id = ?").get(memberId, rec.record_id);
188
+ if (prior) {
189
+ if (prior.record_sha256 !== sha) throw new RelayError("record-conflict", 409);
190
+ return { ...JSON.parse(prior.receipt), replay: true };
191
+ }
192
+ sweep(db, now);
193
+ const role = roleOf(db, memberId);
194
+ if (!permits("member", role, rec.type)) throw new RelayError("forbidden", 403, rec.type);
195
+ const receipt = HANDLERS[rec.type](db, m, role, rec, now);
196
+ db.prepare("INSERT INTO member_records(member_id, record_id, type, record_sha256, receipt, at) VALUES (?, ?, ?, ?, ?, ?)")
197
+ .run(memberId, rec.record_id, rec.type, sha, jcs(receipt), now);
198
+ appendLog(db, "board", { kind: `member.${rec.type}`, member_id: memberId, record_id: rec.record_id, record_sha256: sha }, now);
199
+ return receipt;
200
+ });
201
+ }
202
+
203
+ /** The member's own live claims (B8): task, claim id, epoch, lease. */
204
+ export function ownClaims(db, memberId, now) {
205
+ return tx(db, () => {
206
+ activeMember(db, memberId);
207
+ sweep(db, now);
208
+ return db.prepare(`SELECT c.task_id, c.claim_id, c.epoch, c.lease_until FROM claims c JOIN claim_details d USING(claim_id)
209
+ WHERE c.member_id = ? AND c.state = 'live' ORDER BY d.created_at, c.claim_id`).all(memberId).map(r => ({ ...r }));
210
+ });
211
+ }
212
+
213
+ /** Member board view (7.5): status only; a claimant is shown only to itself. */
214
+ export function boardView(db, memberId, now) {
215
+ return tx(db, () => {
216
+ activeMember(db, memberId);
217
+ sweep(db, now);
218
+ return db.prepare(`SELECT t.task_id, t.title, t.lane, t.kind, t.status, t.base_commit,
219
+ EXISTS (SELECT 1 FROM claims c WHERE c.task_id = t.task_id AND c.state = 'live' AND c.member_id = ?) AS mine
220
+ FROM tasks t WHERE t.status IN ('open','claimed','in-review','held') ORDER BY t.created_at`)
221
+ .all(memberId).map(r => ({ ...r, mine: r.mine === 1 }));
222
+ });
223
+ }
@@ -0,0 +1,25 @@
1
+ // Cloud Commons relay: opening a claim (spec v3.2 section 11.3). One path
2
+ // for a member's own claim, a lane owner's task.assign and the new claim a seat's
3
+ // "changes" issues, so epochs, leases and caps cannot drift between them.
4
+ // Call inside the caller's transaction.
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { RelayError } from "./records.mjs";
8
+
9
+ export const LEASE_MAX_MS = 86400000;
10
+ export const CLAIM_CAPS = Object.freeze({ C0: 0, C1: 1, C2: 3, C3: 5 });
11
+
12
+ /** decisionId: the seat decision that opened the claim (task.assign, review.changes), recorded for its reversal (C3). */
13
+ export function openClaim(db, { taskId, memberId, role, leaseMs, now, decisionId = null }) {
14
+ const live = db.prepare("SELECT count(*) n FROM claims WHERE member_id = ? AND state = 'live'").get(memberId).n;
15
+ if (live >= (CLAIM_CAPS[role] ?? 0)) throw new RelayError("claim-cap", 409);
16
+ const lease = Math.min(leaseMs ?? LEASE_MAX_MS, LEASE_MAX_MS);
17
+ const epoch = (db.prepare("SELECT max(epoch) e FROM claims WHERE task_id = ?").get(taskId).e ?? 0) + 1;
18
+ const claimId = randomUUID();
19
+ db.prepare(`INSERT INTO claims(claim_id, task_id, member_id, epoch, state, lease_until, released_reason, released_at)
20
+ VALUES (?, ?, ?, ?, 'live', ?, NULL, NULL)`).run(claimId, taskId, memberId, epoch, now + lease);
21
+ db.prepare("INSERT INTO claim_details(claim_id, lease_ms, created_at) VALUES (?, ?, ?)").run(claimId, lease, now);
22
+ if (decisionId) db.prepare("INSERT INTO claim_origins(claim_id, decision_id) VALUES (?, ?)").run(claimId, decisionId);
23
+ db.prepare("UPDATE tasks SET status = 'claimed', updated_at = ? WHERE task_id = ?").run(now, taskId);
24
+ return { task_id: taskId, claim_id: claimId, epoch, lease_until: now + lease };
25
+ }
@@ -0,0 +1,49 @@
1
+ // Cloud Commons relay: the `agent-commons relay ...` command.
2
+ //
3
+ // agent-commons relay serve ... the production relay (serve.mjs; no test flag)
4
+ // agent-commons relay <client command> ... the reference client (client.mjs runClientCommand):
5
+ // mint | enroll --invite-file <f> [--display-name <s> --platform <s>] | adopt-next-key | status |
6
+ // seed | claims | board | send --type <t> --record-id <id> --body-json <json> | drain |
7
+ // run-once [--wait <s>] | handled | outbox
8
+ // every client command takes --state <absolute private directory> and optionally --relay-url <url>
9
+ // (default: the relay origin pinned from the invite).
10
+ //
11
+ // Security invariant: neither path accepts --test-mode, --test-dir or --test-interface; they are unknown arguments
12
+ // here and exit 2. The test entrypoints are src/relay/server.mjs and src/relay/client.mjs, run directly.
13
+ // Seat (fleet) commands are not here: they sign with seat keys whose custody is an owner decision.
14
+
15
+ import { serveMain } from "./serve.mjs";
16
+ import { CLIENT_COMMANDS, CLIENT_VALUE_FLAGS, runClientCommand } from "./client.mjs";
17
+
18
+ export const RELAY_USAGE = `agent-commons relay serve --aud https://<host> --bind <loopback ip> [--port <n>] --db <relay*.db>
19
+ --roster <signed roster> --policy <signed policy> --owner-public-key <64 hex> --relay-key-file <key seed file> [--access-log <file>]
20
+ agent-commons relay ${CLIENT_COMMANDS.join("|")} --state <private dir> [--relay-url <url>]
21
+ enroll --invite-file <f> [--display-name <s> --platform <s>]; send --type <t> --record-id <id> --body-json <json>; run-once [--wait <s>]`;
22
+
23
+ /** Run the relay command. Returns the exit status it set (the server keeps the process alive). */
24
+ export async function relayCli(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
25
+ const [command, ...rest] = argv;
26
+ if (command === "serve") { await serveMain(rest, { stdout, stderr }); return process.exitCode ?? 0; }
27
+ if (command === undefined || command === "help" || command === "--help") { stdout.write(`${RELAY_USAGE}\n`); return 0; }
28
+ const o = {};
29
+ let usage = !CLIENT_COMMANDS.includes(command);
30
+ for (let i = 0; !usage && i < rest.length; i++) {
31
+ if (!CLIENT_VALUE_FLAGS.includes(rest[i]) || i + 1 >= rest.length || Object.hasOwn(o, rest[i].slice(2))) { usage = true; break; }
32
+ o[rest[i].slice(2)] = rest[++i];
33
+ }
34
+ if (usage) {
35
+ stdout.write(`${JSON.stringify({ ok: false, error: "usage" })}\n`);
36
+ stderr.write(`${RELAY_USAGE}\n`);
37
+ process.exitCode = 2;
38
+ return 2;
39
+ }
40
+ try {
41
+ const result = await runClientCommand(command, o);
42
+ stdout.write(`${JSON.stringify({ ok: true, result })}\n`);
43
+ return 0;
44
+ } catch (error) {
45
+ stdout.write(`${JSON.stringify({ ok: false, error: error?.code ?? "error", status: error?.status ?? null, detail: String(error?.message ?? error).slice(0, 300) })}\n`);
46
+ process.exitCode = 1;
47
+ return 1;
48
+ }
49
+ }