@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,103 @@
1
+ // Cloud Commons relay: store-and-forward mailbox core (spec v3.2 sections
2
+ // 11.5 and 7.6). Each record for a member gets a per-member seq that is never
3
+ // reused. A read without a cursor resumes after the member's last ack, so a
4
+ // client that lost its state gets exactly what it had not acknowledged.
5
+
6
+ import { tx, getMeta } from "./schema.mjs";
7
+ import { RelayError, isRecordId } from "./records.mjs";
8
+ import { sha256Hex, canonicalBytes, appendLog } from "./signing.mjs";
9
+ import { jcs } from "../pillar.mjs";
10
+
11
+ export const MAILBOX_LIMITS = Object.freeze({
12
+ maxUndelivered: 500,
13
+ maxUndeliveredBytes: 2 * 1024 * 1024,
14
+ pageSize: 100,
15
+ ackedRetentionMs: 7 * 86400000,
16
+ unackedRetentionMs: 14 * 86400000,
17
+ });
18
+
19
+ export const relayEpoch = (db) => getMeta(db, "epoch");
20
+
21
+ function mailboxState(db, memberId) {
22
+ db.prepare("INSERT OR IGNORE INTO mailbox_state(member_id) VALUES (?)").run(memberId);
23
+ return db.prepare("SELECT next_seq, acked_through FROM mailbox_state WHERE member_id = ?").get(memberId);
24
+ }
25
+
26
+ function readableMember(db, memberId) {
27
+ const m = db.prepare("SELECT status FROM members WHERE member_id = ?").get(memberId);
28
+ if (!m || m.status !== "active") throw new RelayError("unauthorized", 401);
29
+ }
30
+
31
+ /** Deliver one record to a member's mailbox. Call inside the caller's transaction. Idempotent on record_id. */
32
+ export function deliverInTx(db, memberId, { record_id, sender, type, body }, now) {
33
+ if (!isRecordId(record_id)) throw new RelayError("invalid-field", 400, "record_id");
34
+ const member = db.prepare("SELECT status FROM members WHERE member_id = ?").get(memberId);
35
+ if (!member || member.status === "revoked") throw new RelayError("unknown-recipient", 404);
36
+ const text = jcs(body);
37
+ const sha = sha256Hex(canonicalBytes({ sender, type, body }));
38
+ const prior = db.prepare("SELECT seq, body_sha256 FROM mailbox WHERE member_id = ? AND record_id = ?").get(memberId, record_id);
39
+ if (prior) {
40
+ if (prior.body_sha256 !== sha) throw new RelayError("record-conflict", 409);
41
+ return { seq: prior.seq, duplicate: true };
42
+ }
43
+ const bytes = Buffer.byteLength(text, "utf8");
44
+ const load = db.prepare("SELECT count(*) n, coalesce(sum(bytes), 0) b FROM mailbox WHERE member_id = ? AND acked_at IS NULL AND expired_at IS NULL").get(memberId);
45
+ if (load.n >= MAILBOX_LIMITS.maxUndelivered || load.b + bytes > MAILBOX_LIMITS.maxUndeliveredBytes) throw new RelayError("mailbox-full", 507);
46
+ const state = mailboxState(db, memberId);
47
+ db.prepare(`INSERT INTO mailbox(member_id, seq, record_id, sender, type, body, body_sha256, bytes, created_at)
48
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(memberId, state.next_seq, record_id, sender, type, text, sha, bytes, now);
49
+ db.prepare("UPDATE mailbox_state SET next_seq = ? WHERE member_id = ?").run(state.next_seq + 1, memberId);
50
+ appendLog(db, "mailbox", { kind: "delivered", member_id: memberId, seq: state.next_seq, record_id, sender, type, sha256: sha }, now);
51
+ return { seq: state.next_seq, duplicate: false };
52
+ }
53
+
54
+ /**
55
+ * Read up to one page. `after` omitted resumes after the member's last ack (B8).
56
+ * Expired records are skipped; the page says where the next read starts.
57
+ */
58
+ export function readInbox(db, memberId, { after } = {}, now) {
59
+ return tx(db, () => {
60
+ readableMember(db, memberId);
61
+ const state = mailboxState(db, memberId);
62
+ const from = after === undefined ? state.acked_through : after;
63
+ if (!Number.isSafeInteger(from) || from < 0) throw new RelayError("invalid-field", 400, "after");
64
+ const rows = db.prepare(`SELECT seq, record_id, sender, type, body, created_at FROM mailbox
65
+ WHERE member_id = ? AND seq > ? AND expired_at IS NULL ORDER BY seq LIMIT ?`)
66
+ .all(memberId, from, MAILBOX_LIMITS.pageSize);
67
+ return {
68
+ epoch: relayEpoch(db),
69
+ acked_through: state.acked_through,
70
+ records: rows.map(r => ({ seq: r.seq, record_id: r.record_id, sender: r.sender, type: r.type, body: JSON.parse(r.body), created_at: r.created_at })),
71
+ next_after: rows.length ? rows.at(-1).seq : from,
72
+ };
73
+ });
74
+ }
75
+
76
+ /** Acknowledge through seq. Lower or equal acks are no-ops; acks beyond what was delivered are refused. */
77
+ export function ackInbox(db, memberId, through, now) {
78
+ return tx(db, () => {
79
+ readableMember(db, memberId);
80
+ const state = mailboxState(db, memberId);
81
+ if (!Number.isSafeInteger(through) || through < 0) throw new RelayError("invalid-field", 400, "through");
82
+ if (through >= state.next_seq) throw new RelayError("ack-beyond-delivered", 400);
83
+ if (through <= state.acked_through) return { acked_through: state.acked_through };
84
+ db.prepare("UPDATE mailbox SET acked_at = ? WHERE member_id = ? AND seq > ? AND seq <= ? AND acked_at IS NULL AND expired_at IS NULL")
85
+ .run(now, memberId, state.acked_through, through);
86
+ db.prepare("UPDATE mailbox_state SET acked_through = ? WHERE member_id = ?").run(through, memberId);
87
+ appendLog(db, "mailbox", { kind: "acked", member_id: memberId, through }, now);
88
+ return { acked_through: through };
89
+ });
90
+ }
91
+
92
+ /** Retention (7.6): acked records go after 7 days; unacked ones expire after 14 days with a logged event. */
93
+ export function pruneMailbox(db, now) {
94
+ return tx(db, () => {
95
+ const removed = db.prepare("DELETE FROM mailbox WHERE acked_at IS NOT NULL AND acked_at <= ?").run(now - MAILBOX_LIMITS.ackedRetentionMs).changes;
96
+ const stale = db.prepare("SELECT member_id, seq, record_id FROM mailbox WHERE acked_at IS NULL AND expired_at IS NULL AND created_at <= ?").all(now - MAILBOX_LIMITS.unackedRetentionMs);
97
+ for (const r of stale) {
98
+ db.prepare("UPDATE mailbox SET expired_at = ? WHERE member_id = ? AND seq = ?").run(now, r.member_id, r.seq);
99
+ appendLog(db, "mailbox", { kind: "expired", member_id: r.member_id, seq: r.seq, record_id: r.record_id }, now);
100
+ }
101
+ return { removed: Number(removed), expired: stale.length };
102
+ });
103
+ }
@@ -0,0 +1,146 @@
1
+ // Cloud Commons relay: record types, field allowlists, size caps and the
2
+ // text sanitizer (spec v3.2 sections 7.1-7.3, 11.4). Pure; no I/O.
3
+
4
+ export class RelayError extends Error {
5
+ constructor(code, status = 400, detail) {
6
+ super(detail ? `${code}: ${detail}` : code);
7
+ this.name = "RelayError";
8
+ this.code = code;
9
+ this.status = status;
10
+ }
11
+ }
12
+
13
+ export const RECORD_CAPS = Object.freeze({ record: 65536, patch: 262144, enrollment: 4096 });
14
+ export const PATCH_MAX_FILES = 20;
15
+ export const ROLES = Object.freeze(["C0", "C1", "C2", "C3"]);
16
+
17
+ // Signed fleet record formats (version tags). Kept here so the reducer and the
18
+ // offline export verifier share them without importing each other.
19
+ export const DECISION_V = "commons-relay-decision/1";
20
+ // /2 binds the verdict's position: seq is the signer's next verdict number on
21
+ // the decision, prev the sha256 of the signer's previous signed verdict body there.
22
+ export const APPROVAL_V = "commons-relay-approval/2";
23
+ export const VERIFY_V = "commons-relay-verify/1";
24
+ export const SECURITY_V = "commons-relay-security/1";
25
+ export const OWNER_CONFIRM_V = "commons-relay-owner-confirm/1";
26
+ export const COAUTHOR_V = "commons-relay-coauthor/1";
27
+ export const SEAT_MESSAGE_V = "commons-relay-seat-message/1";
28
+ export const FINDING_OPEN_V = "commons-relay-finding-open/1";
29
+ export const FINDING_CLOSE_V = "commons-relay-finding-close/1";
30
+ // The family a seat ASSERTS in its served_by, "measured:<family>[:<model>]". Under spec
31
+ // 9.8 an assertion alone earns no family credit: credit needs a measurer's
32
+ // attestation of that verdict whose family equals this assertion.
33
+ const MEASURED = /^measured:([a-z][a-z0-9-]{0,31})(?::[A-Za-z0-9._/@+-]{1,80})?$/;
34
+ export const measuredFamily = (servedBy) => MEASURED.exec(typeof servedBy === "string" ? servedBy : "")?.[1] ?? null;
35
+
36
+ // C5 (9.8): a measurer's evidence of which model really served a verdict's turn.
37
+ export const ATTEST_V = "commons-relay-attest/1";
38
+
39
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
40
+ const HEX64 = /^[0-9a-f]{64}$/;
41
+
42
+ /** JCS refuses fractions; the relay also refuses integers outside +/-(2^53-1) (B4). */
43
+ export function assertIntegerOnly(value, path = "body") {
44
+ if (typeof value === "number") {
45
+ if (!Number.isSafeInteger(value)) throw new RelayError("non-integer-number", 400, path);
46
+ return;
47
+ }
48
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
49
+ if (Array.isArray(value)) { value.forEach((v, i) => assertIntegerOnly(v, `${path}[${i}]`)); return; }
50
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
51
+ for (const [k, v] of Object.entries(value)) assertIntegerOnly(v, `${path}.${k}`);
52
+ return;
53
+ }
54
+ throw new RelayError("unsupported-value", 400, path);
55
+ }
56
+
57
+ // Refused, never stripped (7.3): C0 controls except tab and newline (CR included),
58
+ // DEL, C1 controls, every format character (Cf, which covers bidi controls,
59
+ // zero-width characters, BOM, soft hyphen, U+061C), and U+2028/U+2029.
60
+ const FORBIDDEN = new RegExp("[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u2028\\u2029\\p{Cf}]", "u");
61
+
62
+ export function sanitizeText(value, { field = "text", max, patch = false } = {}) {
63
+ if (typeof value !== "string") throw new RelayError("invalid-field", 400, `${field} must be text`);
64
+ if (!value.isWellFormed()) throw new RelayError("invalid-utf8", 400, field);
65
+ let text = value.normalize("NFC");
66
+ if (patch) text = text.replace(/\r\n/g, "\n");
67
+ if (FORBIDDEN.test(text)) throw new RelayError("hidden-character", 422, field);
68
+ if (max !== undefined && Buffer.byteLength(text, "utf8") > max) throw new RelayError("field-too-long", 400, field);
69
+ return text;
70
+ }
71
+
72
+ const id = (v, f) => { if (typeof v !== "string" || !ID.test(v)) throw new RelayError("invalid-field", 400, f); return v; };
73
+ const int = (v, f, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) => {
74
+ if (!Number.isSafeInteger(v) || v < min || v > max) throw new RelayError("invalid-field", 400, f);
75
+ return v;
76
+ };
77
+ const oneOf = (values) => (v, f) => { if (!values.includes(v)) throw new RelayError("invalid-field", 400, f); return v; };
78
+ const text = (max) => (v, f) => sanitizeText(v, { field: f, max });
79
+
80
+ // Member record types (7.2). minRole is enforced by the permission table in
81
+ // governance.mjs; it is repeated here only so the two tables can be compared.
82
+ export const MEMBER_RECORD_TYPES = Object.freeze({
83
+ heartbeat: { minRole: "C0", fields: { claim_id: [id, false], epoch: [(v, f) => int(v, f, { min: 1 }), false] } },
84
+ message: { minRole: "C0", fields: { lane: [id, true], text: [text(8192), true], reply_to: [id, false] } },
85
+ note: { minRole: "C0", fields: { text: [text(4096), true] } },
86
+ claim: { minRole: "C1", fields: { task_id: [id, true], lease_ms: [(v, f) => int(v, f, { min: 60000, max: 86400000 }), false] } },
87
+ release: { minRole: "C1", fields: { claim_id: [id, true], epoch: [(v, f) => int(v, f, { min: 1 }), true] } },
88
+ progress: { minRole: "C1", fields: { claim_id: [id, true], epoch: [(v, f) => int(v, f, { min: 1 }), true], text: [text(2048), true] } },
89
+ result: { minRole: "C1", fields: { claim_id: [id, true], epoch: [(v, f) => int(v, f, { min: 1 }), true], kind: [oneOf(["patch", "report"]), true], body: [null, true] } },
90
+ evidence: { minRole: "C1", fields: { submission_id: [id, true], kind: [id, true], text: [text(16384), true] } },
91
+ "review-request": { minRole: "C1", fields: { submission_id: [id, true] } },
92
+ propose: { minRole: "C2", fields: { title: [text(120), true], description: [text(4096), true], lane: [id, true], acceptance: [text(4096), true] } },
93
+ "advisory-review": { minRole: "C2", fields: { target: [id, true], verdict: [oneOf(["looks-good", "concern"]), true], text: [text(4096), true] } },
94
+ });
95
+
96
+ /**
97
+ * Validate one member record {type, record_id, body}. Order (7.1): size cap,
98
+ * known type, integer-only, strict field allowlist, per-field checks and
99
+ * sanitizing. Returns a normalized copy; never mutates the input.
100
+ */
101
+ export function validateMemberRecord(record) {
102
+ if (!record || typeof record !== "object" || Array.isArray(record)) throw new RelayError("invalid-record", 400);
103
+ const type = record.type;
104
+ const cap = type === "result" ? RECORD_CAPS.patch : RECORD_CAPS.record;
105
+ let bytes;
106
+ try { bytes = Buffer.byteLength(JSON.stringify(record), "utf8"); } catch { throw new RelayError("invalid-record", 400); }
107
+ if (bytes > cap) throw new RelayError("record-too-large", 413);
108
+ const spec = Object.hasOwn(MEMBER_RECORD_TYPES, type) ? MEMBER_RECORD_TYPES[type] : null;
109
+ if (!spec) throw new RelayError("unknown-record-type", 400, String(type).slice(0, 40));
110
+ for (const key of Object.keys(record)) if (!["type", "record_id", "body"].includes(key)) throw new RelayError("unknown-field", 400, key);
111
+ assertIntegerOnly(record);
112
+ const recordId = id(record.record_id, "record_id");
113
+ const body = record.body;
114
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new RelayError("invalid-record", 400, "body");
115
+ for (const key of Object.keys(body)) if (!Object.hasOwn(spec.fields, key)) throw new RelayError("unknown-field", 400, key);
116
+ const out = {};
117
+ for (const [field, [check, required]] of Object.entries(spec.fields)) {
118
+ if (!Object.hasOwn(body, field) || body[field] === null) {
119
+ if (required) throw new RelayError("missing-field", 400, field);
120
+ continue;
121
+ }
122
+ out[field] = check ? check(body[field], field) : body[field];
123
+ }
124
+ if (type === "heartbeat" && (out.claim_id === undefined) !== (out.epoch === undefined)) throw new RelayError("invalid-field", 400, "claim_id and epoch go together");
125
+ if (type === "result") out.body = validateResultBody(out.kind, body.body);
126
+ return { type, record_id: recordId, body: out };
127
+ }
128
+
129
+ /** A patch result is a list of {path, diff}, one diff per file, at most 20 files (12.1). A report is text. */
130
+ function validateResultBody(kind, value) {
131
+ if (kind === "report") return sanitizeText(value, { field: "body", max: RECORD_CAPS.record });
132
+ if (!Array.isArray(value) || value.length === 0) throw new RelayError("invalid-field", 400, "body must list patch files");
133
+ if (value.length > PATCH_MAX_FILES) throw new RelayError("too-many-files", 400);
134
+ const seen = new Set();
135
+ return value.map((file, i) => {
136
+ if (!file || typeof file !== "object" || Array.isArray(file)) throw new RelayError("invalid-field", 400, `body[${i}]`);
137
+ for (const key of Object.keys(file)) if (!["path", "diff"].includes(key)) throw new RelayError("unknown-field", 400, `body[${i}].${key}`);
138
+ const path = sanitizeText(file.path, { field: `body[${i}].path`, max: 512 });
139
+ if (seen.has(path)) throw new RelayError("invalid-field", 400, `duplicate path ${path}`);
140
+ seen.add(path);
141
+ return { path, diff: sanitizeText(file.diff, { field: `body[${i}].diff`, max: RECORD_CAPS.patch, patch: true }) };
142
+ });
143
+ }
144
+
145
+ export const isHex64 = (v) => typeof v === "string" && HEX64.test(v);
146
+ export const isRecordId = (v) => typeof v === "string" && ID.test(v);
@@ -0,0 +1,38 @@
1
+ // Cloud Commons relay: seat-side renderer (spec v3.2 section 7.4).
2
+ // The header is built from relay metadata, never from record text. Content is
3
+ // inert text in a fence longer than any backtick run inside it: no HTML, no
4
+ // markdown rendering, no links or images. Record content never carries an
5
+ // order, a grant, an approval or a credential request, even when it reads as one.
6
+
7
+ const DOT = " \u00B7 ";
8
+
9
+ function fence(text) {
10
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(m => m[0].length));
11
+ return "`".repeat(Math.max(3, longest + 1));
12
+ }
13
+
14
+ /** meta: relay-owned fields {type, member_id, agent, display_name, role, at}. content: the record's text. */
15
+ export function renderMemberRecord(meta, content) {
16
+ const text = typeof content === "string" ? content : JSON.stringify(content, null, 2);
17
+ const header = [
18
+ "EXTERNAL (untrusted)",
19
+ String(meta.type),
20
+ `member ${meta.member_id} (current ${meta.agent ?? "unknown"})`,
21
+ JSON.stringify(String(meta.display_name ?? "")),
22
+ `role ${meta.role ?? "none"}`,
23
+ new Date(meta.at).toISOString(),
24
+ ].join(DOT);
25
+ const f = fence(text);
26
+ return `${header}\n${f}text\n${text}\n${f}`;
27
+ }
28
+
29
+ /** Render the pending seat feed for a lane, oldest first, using relay metadata only. */
30
+ export function renderSeatFeed(db, lane) {
31
+ const rows = db.prepare(`SELECT f.*, p.display_name, m.role,
32
+ (SELECT agent FROM keys k WHERE k.principal_kind = 'member' AND k.principal_id = f.member_id AND k.status = 'active') AS agent
33
+ FROM seat_feed f JOIN members m USING(member_id) JOIN member_profiles p USING(member_id)
34
+ WHERE f.lane = ? AND f.state = 'pending' ORDER BY f.feed_id`).all(lane);
35
+ return rows.map(r => renderMemberRecord(
36
+ { type: r.type, member_id: r.member_id, agent: r.agent, display_name: r.display_name, role: r.role, at: r.at },
37
+ JSON.parse(r.body)));
38
+ }
@@ -0,0 +1,134 @@
1
+ // Cloud Commons relay: owner-signed roster and policy files (spec v3.2
2
+ // sections 9.1, 9.2 D3, 12.3). The relay loads both at start and refuses to start
3
+ // on a bad signature. No API changes either one. The owner key is pinned by the
4
+ // operator, outside the files, so a file cannot vouch for itself.
5
+
6
+ import { tx, getMeta, setMeta, openRelayDb } from "./schema.mjs";
7
+ import { RelayError, isRecordId, sanitizeText } from "./records.mjs";
8
+ import { verifyBody, canonicalBytes, sha256Hex, appendLog } from "./signing.mjs";
9
+ import { keyBindsAgentUuaid } from "../uuaid-namespace.mjs";
10
+ import { jcs } from "../pillar.mjs";
11
+
12
+ export const ROSTER_V = "commons-relay-roster/1";
13
+ export const POLICY_V = "commons-relay-policy/1";
14
+ const KINDS = ["ac-seat", "verifier", "security", "scope", "owner", "measurer"];
15
+ const HEX64 = /^[0-9a-f]{64}$/;
16
+
17
+ function verifyOwnerSigned(signed, version, ownerPublicKey, what) {
18
+ if (typeof ownerPublicKey !== "string" || !HEX64.test(ownerPublicKey)) throw new RelayError(`${what}-owner-key-unpinned`, 500);
19
+ const body = signed?.body;
20
+ if (!body || typeof body !== "object" || body.v !== version) throw new RelayError(`${what}-invalid`, 500, "version");
21
+ if (!Number.isSafeInteger(body.version) || body.version < 1 || !Number.isSafeInteger(body.issued_at)) throw new RelayError(`${what}-invalid`, 500, "version/issued_at");
22
+ if (!verifyBody(ownerPublicKey, body, signed.sig)) throw new RelayError(`${what}-signature`, 500);
23
+ return { body, sha: sha256Hex(canonicalBytes({ body, sig: signed.sig })) };
24
+ }
25
+
26
+ /** Version rule shared by both files: same version must be the same bytes; never go back. */
27
+ function versionCheck(db, what, version, sha) {
28
+ const current = Number(getMeta(db, `${what}_version`) ?? 0);
29
+ if (version < current) throw new RelayError(`${what}-version-rollback`, 500);
30
+ if (version === current) {
31
+ if (getMeta(db, `${what}_sha256`) !== sha) throw new RelayError(`${what}-version-reused`, 500);
32
+ return false; // already loaded
33
+ }
34
+ return true;
35
+ }
36
+
37
+ /**
38
+ * Load the owner-signed roster. New principals are added with their keys; an
39
+ * existing principal's kind, family and key never change; a principal absent
40
+ * from a newer roster is retired and its key revoked. A retired principal cannot
41
+ * return (one key ever).
42
+ */
43
+ export function loadRoster(db, signed, { ownerPublicKey, now }) {
44
+ const { body, sha } = verifyOwnerSigned(signed, ROSTER_V, ownerPublicKey, "roster");
45
+ for (const key of Object.keys(body)) if (!["v", "version", "issued_at", "principals"].includes(key)) throw new RelayError("roster-invalid", 500, key);
46
+ const list = body.principals;
47
+ if (!Array.isArray(list) || !list.length) throw new RelayError("roster-invalid", 500, "principals");
48
+ const ids = new Set(), keys = new Set();
49
+ for (const p of list) {
50
+ for (const key of Object.keys(p)) if (!["principal_id", "kind", "runtime_family", "public_key", "agent"].includes(key)) throw new RelayError("roster-invalid", 500, key);
51
+ if (!isRecordId(p.principal_id) || ids.has(p.principal_id)) throw new RelayError("roster-invalid", 500, "principal_id");
52
+ if (!KINDS.includes(p.kind)) throw new RelayError("roster-invalid", 500, "kind");
53
+ if (p.runtime_family !== null && !(typeof p.runtime_family === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(p.runtime_family))) throw new RelayError("roster-invalid", 500, "runtime_family");
54
+ if (!HEX64.test(p.public_key ?? "") || keys.has(p.public_key)) throw new RelayError("roster-invalid", 500, "public_key");
55
+ if (!keyBindsAgentUuaid(p.agent, p.public_key) || !p.agent.startsWith("uuaid:agentnet:")) throw new RelayError("roster-invalid", 500, "agent");
56
+ ids.add(p.principal_id); keys.add(p.public_key);
57
+ }
58
+ const owners = list.filter(p => p.kind === "owner");
59
+ if (owners.length !== 1 || owners[0].public_key !== ownerPublicKey) throw new RelayError("roster-owner-mismatch", 500);
60
+ return tx(db, () => {
61
+ if (!versionCheck(db, "roster", body.version, sha)) return { loaded: false, version: body.version };
62
+ for (const p of list) {
63
+ const existing = db.prepare(`SELECT f.kind, f.runtime_family, f.status, k.public_key FROM fleet_principals f
64
+ LEFT JOIN keys k ON k.principal_kind = 'fleet' AND k.principal_id = f.principal_id WHERE f.principal_id = ?`).get(p.principal_id);
65
+ if (!existing) {
66
+ db.prepare("INSERT INTO fleet_principals(principal_id, kind, runtime_family, status) VALUES (?, ?, ?, 'active')").run(p.principal_id, p.kind, p.runtime_family);
67
+ db.prepare("INSERT INTO keys(public_key, agent, principal_kind, principal_id, status, added_at, revoked_at) VALUES (?, ?, 'fleet', ?, 'active', ?, NULL)")
68
+ .run(p.public_key, p.agent, p.principal_id, now);
69
+ } else {
70
+ if (existing.status === "retired") throw new RelayError("roster-retired-principal", 500, p.principal_id);
71
+ if (existing.kind !== p.kind || existing.runtime_family !== p.runtime_family || existing.public_key !== p.public_key)
72
+ throw new RelayError("roster-binding-changed", 500, p.principal_id);
73
+ }
74
+ }
75
+ for (const { principal_id } of db.prepare("SELECT principal_id FROM fleet_principals WHERE status = 'active'").all()) {
76
+ if (ids.has(principal_id)) continue;
77
+ db.prepare("UPDATE fleet_principals SET status = 'retired' WHERE principal_id = ?").run(principal_id);
78
+ db.prepare("UPDATE keys SET status = 'revoked', revoked_at = ? WHERE principal_kind = 'fleet' AND principal_id = ? AND status <> 'revoked'").run(now, principal_id);
79
+ }
80
+ db.prepare("INSERT INTO signed_files(kind, version, body, sig, loaded_at) VALUES ('roster', ?, ?, ?, ?)").run(body.version, jcs(body), signed.sig, now);
81
+ setMeta(db, "roster_version", body.version);
82
+ setMeta(db, "roster_sha256", sha);
83
+ appendLog(db, "security", { kind: "roster.loaded", version: body.version, sha256: sha, principals: list.length }, now);
84
+ return { loaded: true, version: body.version };
85
+ });
86
+ }
87
+
88
+ /** Load the owner-signed policy: lane owners, the 12.3 security path list and the C5 model -> family map. */
89
+ export function loadPolicy(db, signed, { ownerPublicKey, now }) {
90
+ const { body, sha } = verifyOwnerSigned(signed, POLICY_V, ownerPublicKey, "policy");
91
+ for (const key of Object.keys(body)) if (!["v", "version", "issued_at", "lanes", "security_paths", "family_map"].includes(key)) throw new RelayError("policy-invalid", 500, key);
92
+ // C5 (9.8): the fixed map of a served model name to its family. Exactly one pattern must
93
+ // match a model for it to earn credit; unknown names earn nothing.
94
+ const map = body.family_map;
95
+ if (!Array.isArray(map) || !map.length || map.some(e => !e || typeof e !== "object" || Object.keys(e).some(k => !["pattern", "family"].includes(k))
96
+ || typeof e.pattern !== "string" || !/^[A-Za-z][A-Za-z0-9.*?_-]{0,63}$/.test(e.pattern)
97
+ || typeof e.family !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(e.family))) throw new RelayError("policy-invalid", 500, "family_map");
98
+ if (new Set(map.map(e => e.pattern)).size !== map.length) throw new RelayError("policy-invalid", 500, "family_map duplicate pattern");
99
+ if (!Array.isArray(body.lanes) || !body.lanes.length) throw new RelayError("policy-invalid", 500, "lanes");
100
+ if (!Array.isArray(body.security_paths) || !body.security_paths.length) throw new RelayError("policy-invalid", 500, "security_paths");
101
+ const paths = body.security_paths.map((p, i) => sanitizeText(p, { field: `security_paths[${i}]`, max: 512 }));
102
+ if (paths.some(p => !p.length)) throw new RelayError("policy-invalid", 500, "security_paths");
103
+ return tx(db, () => {
104
+ if (!versionCheck(db, "policy", body.version, sha)) return { loaded: false, version: body.version };
105
+ for (const l of body.lanes) {
106
+ if (!l || !isRecordId(l.lane) || Object.keys(l).some(k => !["lane", "owner_seat"].includes(k))) throw new RelayError("policy-invalid", 500, "lane");
107
+ const seat = db.prepare("SELECT kind, status FROM fleet_principals WHERE principal_id = ?").get(l.owner_seat ?? "");
108
+ if (!seat || seat.kind !== "ac-seat" || seat.status !== "active") throw new RelayError("policy-invalid", 500, `lane owner ${l.owner_seat}`);
109
+ db.prepare("INSERT INTO lanes(lane, owner_seat) VALUES (?, ?) ON CONFLICT(lane) DO UPDATE SET owner_seat = excluded.owner_seat").run(l.lane, l.owner_seat);
110
+ }
111
+ const row = db.prepare("INSERT INTO policy_family_map(policy_version, pattern, family) VALUES (?, ?, ?)");
112
+ for (const e of map) row.run(body.version, e.pattern, e.family);
113
+ setMeta(db, "security_paths", JSON.stringify(paths));
114
+ db.prepare("INSERT INTO signed_files(kind, version, body, sig, loaded_at) VALUES ('policy', ?, ?, ?, ?)").run(body.version, jcs(body), signed.sig, now);
115
+ setMeta(db, "policy_version", body.version);
116
+ setMeta(db, "policy_sha256", sha);
117
+ appendLog(db, "security", { kind: "policy.loaded", version: body.version, sha256: sha }, now);
118
+ return { loaded: true, version: body.version };
119
+ });
120
+ }
121
+
122
+ /** Start a relay: open relay.db, then load the signed roster and policy. Any failure closes it and refuses. */
123
+ export function openRelay({ path = ":memory:", roster, policy, ownerPublicKey, now }) {
124
+ if (!roster || !policy) throw new RelayError("relay-needs-roster-and-policy", 500);
125
+ const db = openRelayDb(path);
126
+ try {
127
+ loadRoster(db, roster, { ownerPublicKey, now });
128
+ loadPolicy(db, policy, { ownerPublicKey, now });
129
+ return db;
130
+ } catch (error) {
131
+ db.close();
132
+ throw error;
133
+ }
134
+ }
@@ -0,0 +1,87 @@
1
+ // Cloud Commons relay: the request front door (spec v3.4.5 6.5 endpoint inventory, 6.1
2
+ // browser-origin refusal (A8), 7.1 ingress order and size caps, pending members).
3
+ // Pure: no listener and no I/O. The server reads the method, path, headers and Content-Length, asks
4
+ // classifyRequest before reading any body byte, then authenticates with admitRequest.
5
+ //
6
+ // Order, before any authentication work:
7
+ // 1. the closed route inventory (routes.json): any other method or path -> 404 not-found;
8
+ // 2. on every path outside /v1/fleet/: an Origin, Sec-Fetch-Site or Sec-Fetch-Mode header that is
9
+ // PRESENT, even empty -> 403 browser-origin-refused (before any signature, nonce or invite work;
10
+ // principal-independent, so not an oracle);
11
+ // 3. the size cap chosen from the path (7.1): one byte over -> 413.
12
+ // Then admitRequest: the 6.1 signature (auth.mjs); a pending member only on GET /v1/status;
13
+ // a member on a fleet path, or a fleet principal on a member path -> the uniform 401; on a fleet
14
+ // record (sent as the signed record { body, sig }) the signer must equal body.by (6.5, C1) ->
15
+ // otherwise the uniform 401; a member record path's <type> must equal the record's type.
16
+
17
+ import { RelayError } from "./records.mjs";
18
+ import { MEMBER_RECORD_TYPES } from "./records.mjs";
19
+ import { authenticateRequest, signerMatchesBy, UNAUTHORIZED } from "./auth.mjs";
20
+
21
+ export const KB = 1024;
22
+ /** 6.5 fleet record types, closed (the fleet record table; v3.4.4 adds review.changes and review.reject). */
23
+ export const FLEET_RECORD_TYPES = Object.freeze([
24
+ "invite.create", "rebind.cancel", "enroll.confirm", "rekey.confirm",
25
+ "task.open", "task.close", "task.assign", "claim.release", "progress.accept", "role.c1", "clock.resume",
26
+ "review.changes", "review.reject",
27
+ "decision.propose", "verdict", "served_by.attest", "finding.open", "finding.close", "coauthor.declare",
28
+ "verify.result", "security.approve", "suspend", "message", "owner-record.confirm", "freeze", "veto", "unfreeze",
29
+ ]);
30
+ /** 6.5 fleet views, closed. */
31
+ export const FLEET_VIEWS = Object.freeze(["alerts", "queue", "decisions", "members", "quarantine", "owner-outbox"]);
32
+ export const BROWSER_HEADERS = Object.freeze(["origin", "sec-fetch-site", "sec-fetch-mode"]);
33
+
34
+ /**
35
+ * The closed route inventory (6.5). `auth`: "invite" (5.2 enrollment flow) or "signed" (6.1).
36
+ * `principal`: who may call it. `cap`: body bytes (0 = no body). Exported for routes.json.
37
+ */
38
+ export const ROUTES = Object.freeze([
39
+ ...["enroll", "rekey", "rebind"].map(kind => ({ method: "POST", path: `/v1/${kind}`, auth: "invite", principal: null, cap: 4 * KB, flow: kind })),
40
+ { method: "GET", path: "/v1/status", auth: "signed", principal: "member", cap: 0, allowPending: true },
41
+ { method: "GET", path: "/v1/seed", auth: "signed", principal: "member", cap: 0 },
42
+ { method: "GET", path: "/v1/inbox", auth: "signed", principal: "member", cap: 0 },
43
+ { method: "POST", path: "/v1/inbox/ack", auth: "signed", principal: "member", cap: 1 * KB },
44
+ { method: "GET", path: "/v1/claims", auth: "signed", principal: "member", cap: 0 },
45
+ { method: "GET", path: "/v1/board", auth: "signed", principal: "member", cap: 0 },
46
+ ...Object.keys(MEMBER_RECORD_TYPES).map(type => ({ method: "POST", path: `/v1/records/${type}`, auth: "signed", principal: "member", cap: type === "result" ? 256 * KB : 64 * KB, recordType: type })),
47
+ ...FLEET_RECORD_TYPES.map(type => ({ method: "POST", path: `/v1/fleet/records/${type}`, auth: "signed", principal: "fleet", cap: 64 * KB, recordType: type })),
48
+ ...FLEET_VIEWS.map(view => ({ method: "GET", path: `/v1/fleet/${view}`, auth: "signed", principal: "fleet", cap: 0, view })),
49
+ ].map(r => Object.freeze(r)));
50
+ const BY_KEY = new Map(ROUTES.map(r => [`${r.method} ${r.path}`, r]));
51
+
52
+ const isFleetPath = (path) => typeof path === "string" && path.startsWith("/v1/fleet/");
53
+ const refusal = (status, error) => ({ ok: false, status, body: { error } });
54
+
55
+ /**
56
+ * Classify a request before any body byte is read and before any authentication work.
57
+ * `headers` uses lower-case names (as node:http gives them). `contentLength` is the declared body
58
+ * size in bytes (0 or undefined when there is none); the server must also stop reading at the cap.
59
+ * Returns { ok: true, route } or a refusal { ok: false, status, body }.
60
+ */
61
+ export function classifyRequest({ method, path, headers = {}, contentLength = 0 }) {
62
+ const route = BY_KEY.get(`${method} ${path}`);
63
+ if (!route) return refusal(404, "not-found");
64
+ // A8: refuse on the header's PRESENCE (an empty value included), on every non-fleet path.
65
+ if (!isFleetPath(path) && BROWSER_HEADERS.some(h => Object.hasOwn(headers, h))) return refusal(403, "browser-origin-refused");
66
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) return refusal(400, "bad-content-length");
67
+ if (contentLength > route.cap) return refusal(413, "too-large");
68
+ return { ok: true, route };
69
+ }
70
+
71
+ /**
72
+ * Admit a classified, signed request (6.1 auth + 6.5 principal rules). `req` = { method, path,
73
+ * query, headers, body } with `body` the parsed JSON. Returns { ok: true, route, principal } or a
74
+ * refusal. The enrollment-flow routes (auth "invite") are not handled here (5.2).
75
+ */
76
+ export function admitRequest(db, route, req, { aud, now }) {
77
+ if (route.auth !== "signed") throw new RelayError("route-not-signed", 500, route.path);
78
+ const a = authenticateRequest(db, req, { aud, now, allowPending: route.allowPending === true });
79
+ if (!a.ok) return a;
80
+ const p = a.principal;
81
+ // Only GET /v1/status admits a pending member (authenticateRequest enforces allowPending).
82
+ if (p.kind !== route.principal) return { ok: false, ...UNAUTHORIZED };
83
+ // A fleet record travels as the signed record { body, sig } (9.2); its body's `by` must be the signer.
84
+ if (route.principal === "fleet" && route.recordType && !signerMatchesBy(p, req.body?.body)) return { ok: false, ...UNAUTHORIZED };
85
+ if (route.recordType && route.principal === "member" && req.body?.type !== route.recordType) return refusal(400, "type-mismatch");
86
+ return { ok: true, route, principal: p };
87
+ }