@twinsearth/w2m-dsh-plugin 1.2.5 → 1.2.7

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.
@@ -11,7 +11,7 @@
11
11
  * there is intentionally no snapshot/serialization hook yet.
12
12
  */
13
13
 
14
- import { createHash, randomBytes } from 'node:crypto';
14
+ import { createHash, randomBytes , verify as verifyBytes } from 'node:crypto';
15
15
 
16
16
  // v0.7.7: fleet scoping. Imported rather than inlined so the rule has one home with its own
17
17
  // exhaustive tests — three lines inside this object would be untestable without a live relay.
@@ -23,7 +23,8 @@ import { scopeFleetId } from '../chatops/fleet-wiring.mjs';
23
23
  // v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
24
24
  import { planJoin } from '../chatops/fleet-join.mjs';
25
25
  import { validateTaskDependencies } from './task-deps.mjs';
26
- import { buildMembers, setMemberRole as applyMemberRole } from './fleet-members.mjs';
26
+ import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
27
+ import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
27
28
 
28
29
  export const PROTOCOL_VERSION = 1;
29
30
 
@@ -733,6 +734,9 @@ export class RabbitState {
733
734
  created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
734
735
  command_hash: t.command_hash ?? null,
735
736
  attempt: t.attempt ?? 1,
737
+ // v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
738
+ depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
739
+ assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
736
740
  leases: new Map(),
737
741
  cancelled: false,
738
742
  cancel_reason: null,
@@ -750,6 +754,16 @@ export class RabbitState {
750
754
  this.tasks.set(task.task_id, task);
751
755
  return true;
752
756
  }
757
+ case 'member.role': {
758
+ /**
759
+ * v1.2.6: a role survives a restart. The ledger entry already existed (setMemberRole writes it); replaying it here is
760
+ * what stops "the relay restarted" from silently demoting every owner to the default worker.
761
+ */
762
+ if (typeof entry.machine_id !== 'string' || entry.machine_id === '') return false;
763
+ if (isMemberRole(entry.role) !== true) return false;
764
+ this.roles.set(entry.machine_id, entry.role);
765
+ return false;
766
+ }
753
767
  case 'lease.changed': {
754
768
  const task = this.tasks.get(entry.task_id);
755
769
  const lease = task?.leases.get(entry.machine_id);
@@ -1906,6 +1920,31 @@ export class RabbitState {
1906
1920
  if (typeof dedupeKeyBase !== 'string' || dedupeKeyBase.length !== 64) {
1907
1921
  throw new ProtocolError('INTERNAL', 'failed to compute dedupe_key');
1908
1922
  }
1923
+ /**
1924
+ * v1.2.7: a dependency that is not settled means this task is NOT dispatched yet. Its leases are recorded as `waiting`,
1925
+ * which the sweeper ignores and redelivery skips, and `_releaseDependents` moves them to offers when the graph allows it.
1926
+ */
1927
+ const dependencyStates = (task.depends_on ?? []).map((depId) => {
1928
+ const dep = this.tasks.get(depId);
1929
+ if (dep === undefined) return 'unknown';
1930
+ if (dep.cancelled === true) return 'cancelled';
1931
+ return this._taskSettled(dep) === true ? 'completed' : 'waiting';
1932
+ });
1933
+ const graphBlocked = dependencyStates.some((state) => state === 'cancelled' || state === 'unknown');
1934
+ const graphReady = graphBlocked !== true && dependencyStates.every((state) => state === 'completed');
1935
+ const waitingOn = graphReady === true ? [] : (task.depends_on ?? []);
1936
+ /**
1937
+ * v1.2.7: an assignee is a restriction, not a label. Only that machine gets a lease; another machine cannot accidentally
1938
+ * take work that was meant for one host. An unknown assignee is refused by name rather than producing a task nobody holds.
1939
+ */
1940
+ if (task.assignee !== null && !this.devices.has(task.assignee)) {
1941
+ throw new ProtocolError('BAD_REQUEST', 'assignee is not a known device on this relay', { assignee: task.assignee });
1942
+ }
1943
+ if (task.assignee !== null) {
1944
+ const narrowed = targets.filter((device) => device.machine_id === task.assignee);
1945
+ targets.length = 0;
1946
+ targets.push(...narrowed);
1947
+ }
1909
1948
  let lastSeq = this.seq;
1910
1949
  targets.forEach((device, i) => {
1911
1950
  const index = mode === 'replicate' ? 0 : i % indexTotal;
@@ -1939,6 +1978,12 @@ export class RabbitState {
1939
1978
  task.leases.set(device.machine_id, lease);
1940
1979
  return;
1941
1980
  }
1981
+ if (graphReady !== true) {
1982
+ lease.state = 'waiting';
1983
+ lease.waiting_on = waitingOn;
1984
+ task.leases.set(device.machine_id, lease);
1985
+ return;
1986
+ }
1942
1987
  const gate = gateMachine(task, device);
1943
1988
  if (gate) {
1944
1989
  lease.state = 'refused';
@@ -1991,6 +2036,9 @@ export class RabbitState {
1991
2036
  attempt: task.attempt,
1992
2037
  deadline_ms: task.deadline_ms,
1993
2038
  // v0.4.0: routing metadata, so a restarted relay re-offers with the same facts.
2039
+ // v1.2.7: the dependency graph and assignment belong to the task record, not to a reader's memory of it.
2040
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2041
+ assignee: task.assignee ?? null,
1994
2042
  origin_machine_id: task.origin_machine_id ?? null,
1995
2043
  p2p: task.p2p ?? null,
1996
2044
  },
@@ -2200,6 +2248,47 @@ export class RabbitState {
2200
2248
  .map((t) => this.taskSummary(t));
2201
2249
  }
2202
2250
 
2251
+ /** v1.2.7: a task is settled when no lease can still produce a result. Dependencies wait for that, not for a wording. */
2252
+ _taskSettled(task) {
2253
+ for (const lease of task.leases.values()) if (ACTIVE_LEASE_STATES.includes(lease.state)) return false;
2254
+ return true;
2255
+ }
2256
+
2257
+ /**
2258
+ * v1.2.7: release tasks whose prerequisites are all settled.
2259
+ *
2260
+ * Called after a result is stored: a dependency that finished must actually move its dependents from `waiting` to an offer.
2261
+ * Without this the graph would be validated, stored, and then never scheduled -- the exact "validated but not enforced" gap.
2262
+ */
2263
+ _releaseDependents(completedTaskId = null) {
2264
+ const released = [];
2265
+ for (const candidate of this.tasks.values()) {
2266
+ if (!Array.isArray(candidate.depends_on) || candidate.depends_on.length === 0) continue;
2267
+ if (completedTaskId !== null && !candidate.depends_on.includes(completedTaskId)) continue;
2268
+ const waiting = [...candidate.leases.values()].filter((lease) => lease.state === 'waiting');
2269
+ if (waiting.length === 0) continue;
2270
+ let ready = true;
2271
+ for (const depId of candidate.depends_on) {
2272
+ const dep = this.tasks.get(depId);
2273
+ if (dep === undefined) { ready = false; break; }
2274
+ if (dep.cancelled === true) { ready = false; break; }
2275
+ if (this._taskSettled(dep) !== true) { ready = false; break; }
2276
+ }
2277
+ if (ready !== true) continue;
2278
+ for (const lease of waiting) {
2279
+ lease.state = 'queued';
2280
+ lease.claimed_at_ms = this.nowMs();
2281
+ lease.lease_until_ms = this.nowMs() + this.leaseWindowMs();
2282
+ const device = this.devices.get(lease.machine_id);
2283
+ if (device === undefined) continue;
2284
+ this.emitOffer(candidate, lease);
2285
+ }
2286
+ this.emit('notice', { level: 'info', code: 'TASK_READY', message: candidate.task_id + ' is ready: dependencies settled', task_id: candidate.task_id });
2287
+ released.push(candidate.task_id);
2288
+ }
2289
+ return released;
2290
+ }
2291
+
2203
2292
  taskSummary(task) {
2204
2293
  return {
2205
2294
  task_id: task.task_id,
@@ -2211,9 +2300,32 @@ export class RabbitState {
2211
2300
  degraded: task.degraded,
2212
2301
  leases: [...task.leases.values()].map((l) => l.machine_id),
2213
2302
  lease_states: Object.fromEntries([...task.leases.values()].map((l) => [l.machine_id, l.state])),
2303
+ /**
2304
+ * v1.2.7: the state the scheduler is actually in, derived here rather than by each reader.
2305
+ *
2306
+ * Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
2307
+ * map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
2308
+ */
2309
+ status: (() => {
2310
+ if (task.cancelled === true) return 'cancelled';
2311
+ const leases = [...task.leases.values()];
2312
+ if (leases.some((lease) => lease.state === 'waiting')) return 'waiting';
2313
+ if (leases.some((lease) => ACTIVE_LEASE_STATES.includes(lease.state))) return 'active';
2314
+ const results = [...this.results.values()].filter((record) => record?.envelope?.task_id === task.task_id);
2315
+ if (results.length > 0 && results.every((record) => ['failed', 'crashed', 'refused', 'timeout'].includes(record?.envelope?.status))) return 'failed';
2316
+ return this._taskSettled(task) === true ? 'done' : 'unknown';
2317
+ })(),
2318
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2319
+ assignee: task.assignee ?? null,
2320
+ waiting_on: [...task.leases.values()].some((l) => l.state === 'waiting') ? (Array.isArray(task.depends_on) ? task.depends_on : []) : [],
2214
2321
  };
2215
2322
  }
2216
2323
 
2324
+ /** v1.2.6: the role this machine holds, for the permission matrix. Unknown means worker, never owner. */
2325
+ roleOf(machineId) {
2326
+ return typeof machineId === 'string' ? (this.roles.get(machineId) ?? null) : null;
2327
+ }
2328
+
2217
2329
  /** v1.2.5: the fleet's members, with the role each holds and when it joined. */
2218
2330
  listMembers() {
2219
2331
  return buildMembers({ devices: this.devices, roles: this.roles });
@@ -2434,6 +2546,9 @@ export class RabbitState {
2434
2546
  }
2435
2547
  if (changed) task.degraded = 'partial';
2436
2548
  }
2549
+ // v1.2.7: a lease that expires (or a machine that refuses) is a settlement too -- the dependent graph must move.
2550
+ this._releaseDependents();
2551
+
2437
2552
  return expired;
2438
2553
  }
2439
2554
 
@@ -2631,6 +2746,42 @@ export class RabbitState {
2631
2746
  return { deduped: true, task_id: taskId, dedupe_key: dedupeKey, seq: prior.seq };
2632
2747
  }
2633
2748
 
2749
+ /**
2750
+ * v1.2.6: verify the envelope signature against the key this machine registered WHEN IT PAIRED.
2751
+ *
2752
+ * This is the difference between "the result matches the key it carries" and "the key belongs to this machine". The relay is
2753
+ * the place that can answer the second question, because pairing is the moment the operator (or the account) let the machine
2754
+ * in, and that is the only moment its signing key has a reason to be believed. A signature whose key differs from the
2755
+ * registered one is bad_signature even when it verifies against itself.
2756
+ */
2757
+ let signatureStatus = 'unsigned';
2758
+ const signature = envelope.signature ?? null;
2759
+ if (signature !== null) {
2760
+ signatureStatus = 'bad_signature';
2761
+ if (signature.alg !== 'ed25519') {
2762
+ signatureStatus = 'unsupported_algorithm';
2763
+ } else {
2764
+ const registeredKey = machineId === null ? null : (typeof this.devices.get(machineId)?.public_key === 'string' ? this.devices.get(machineId).public_key : null);
2765
+ if (registeredKey === null) {
2766
+ signatureStatus = 'unbound';
2767
+ } else if (signature.public_key !== registeredKey) {
2768
+ signatureStatus = 'bad_signature';
2769
+ } else {
2770
+ const key = publicKeyFromBase64(registeredKey);
2771
+ const payload = canonicalSignedPayload({
2772
+ task_id: envelope.task_id,
2773
+ argv_hash: envelope.command_hash,
2774
+ machine_id: envelope.machine_id,
2775
+ ts: signature.ts,
2776
+ });
2777
+ try {
2778
+ signatureStatus = key !== null && verifyBytes(null, Buffer.from(payload, 'utf8'), key, Buffer.from(String(signature.value ?? ''), 'base64'))
2779
+ ? 'signed' : 'bad_signature';
2780
+ } catch { signatureStatus = 'bad_signature'; }
2781
+ }
2782
+ }
2783
+ }
2784
+
2634
2785
  const now = this.nowMs();
2635
2786
  let envelopeShaOk = null;
2636
2787
  if (typeof envelope.envelope_sha256 === 'string') {
@@ -2649,6 +2800,7 @@ export class RabbitState {
2649
2800
  missing: [...new Set(missing)],
2650
2801
  problems,
2651
2802
  envelope_sha256_ok: envelopeShaOk,
2803
+ signature_status: signatureStatus,
2652
2804
  },
2653
2805
  };
2654
2806
 
@@ -2685,6 +2837,9 @@ export class RabbitState {
2685
2837
  });
2686
2838
 
2687
2839
  // §7: results go into the ledger so the dedupe index survives a restart.
2840
+ // v1.2.7: a settled task may be the prerequisite that frees its dependents.
2841
+ this._releaseDependents(taskId);
2842
+
2688
2843
  this._ledger({ type: 'result.stored', task_id: taskId, machine_id: machineId, result: record });
2689
2844
  if (machineId) {
2690
2845
  const changed = task.leases.get(machineId);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The fleet chat's end-to-end encryption.
3
+ *
4
+ * ## Why the relay never sees the key
5
+ *
6
+ * The group key is HKDF over the fleet key, and the fleet key is derived from the account identity on each machine -- it never
7
+ * travels to the relay. So the relay stores ciphertext it cannot open, and the honest consequence is written down rather than
8
+ * discovered later: the relay cannot search message content, and the quota it enforces is measured on ciphertext length.
9
+ *
10
+ * ## Why the metadata is next to the ciphertext, not inside it
11
+ *
12
+ * `{v, alg, iv, tag}` has to be readable by every client of every version, including one that cannot decrypt. Keeping it in a
13
+ * plain field means a reader can say "encrypted with aes-256-gcm, version 1" even when it has no key, instead of guessing from
14
+ * random-looking text.
15
+ *
16
+ * ## Backward compatibility, both directions
17
+ *
18
+ * A message with no `enc` field is plaintext and stays readable. A reader with no key keeps the ciphertext and marks it
19
+ * `encrypted: true` rather than throwing: an unreadable message must not break a whole page.
20
+ */
21
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
22
+
23
+ export const CHAT_CIPHER = Object.freeze({ version: 1, alg: 'aes-256-gcm' });
24
+ const KEY_BYTES = 32;
25
+ const IV_BYTES = 12;
26
+
27
+ /**
28
+ * Turn the fleet key into the chat group key. Deterministic: every machine in the fleet derives the same key from the same
29
+ * fleet key, which is what makes a message encrypted on one machine readable on another.
30
+ *
31
+ * @param {string} fleetKey base64 (the usual shape) or a passphrase
32
+ * @returns {Buffer|null}
33
+ */
34
+ export function deriveChatKey(fleetKey) {
35
+ if (typeof fleetKey !== 'string' || fleetKey.trim() === '') return null;
36
+ try {
37
+ let material = null;
38
+ try {
39
+ const decoded = Buffer.from(fleetKey.trim(), 'base64');
40
+ material = decoded.length >= 16 ? decoded : Buffer.from(fleetKey.trim(), 'utf8');
41
+ } catch {
42
+ material = Buffer.from(fleetKey.trim(), 'utf8');
43
+ }
44
+ return Buffer.from(hkdfSync('sha256', material, Buffer.from('w2m-fleet-chat-salt'), Buffer.from('fleet-chat-v1'), KEY_BYTES));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {Buffer|null} key
52
+ * @param {string} text
53
+ * @returns {{ok: true, text: string, enc: {v: number, alg: string, iv: string, tag: string}} | {ok: false, code: string}}
54
+ */
55
+ export function encryptChatText(key, text) {
56
+ if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
57
+ if (typeof text !== 'string' || text === '') return { ok: false, code: 'CHAT_EMPTY_TEXT' };
58
+ const iv = randomBytes(IV_BYTES);
59
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
60
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(text, 'utf8')), cipher.final()]);
61
+ const tag = cipher.getAuthTag();
62
+ return {
63
+ ok: true,
64
+ text: ciphertext.toString('base64'),
65
+ enc: { v: CHAT_CIPHER.version, alg: CHAT_CIPHER.alg, iv: iv.toString('base64'), tag: tag.toString('base64') },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * @param {Buffer|null} key
71
+ * @param {{text?: string, enc?: object|null}} message
72
+ * @returns {{ok: true, text: string} | {ok: false, code: string}}
73
+ */
74
+ export function decryptChatMessage(key, message = {}) {
75
+ const enc = message?.enc ?? null;
76
+ if (enc === null || enc === undefined) {
77
+ // Plaintext: the pre-encryption shape, still readable by everyone.
78
+ return typeof message?.text === 'string' ? { ok: true, text: message.text } : { ok: false, code: 'CHAT_TEXT_MISSING' };
79
+ }
80
+ if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
81
+ if (enc.alg !== CHAT_CIPHER.alg || enc.v !== CHAT_CIPHER.version) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
82
+ try {
83
+ const iv = Buffer.from(String(enc.iv ?? ''), 'base64');
84
+ const tag = Buffer.from(String(enc.tag ?? ''), 'base64');
85
+ if (iv.length !== IV_BYTES || tag.length !== 16) return { ok: false, code: 'CHAT_BAD_ENCRYPTION' };
86
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
87
+ decipher.setAuthTag(tag);
88
+ const plain = Buffer.concat([decipher.update(Buffer.from(String(message.text ?? ''), 'base64')), decipher.final()]);
89
+ return { ok: true, text: plain.toString('utf8') };
90
+ } catch {
91
+ return { ok: false, code: 'CHAT_DECRYPT_FAILED' };
92
+ }
93
+ }
94
+
95
+ /** True when a message carries the encryption metadata, whatever its text looks like. */
96
+ export function isEncrypted(message) {
97
+ return message?.enc !== null && message?.enc !== undefined && typeof message.enc === 'object';
98
+ }
@@ -41,7 +41,8 @@ const SHA256 = /^[0-9a-f]{64}$/;
41
41
  * A missing or malformed hash is refused by name -- the reduce step must never run on a partial fleet and call the answer
42
42
  * complete.
43
43
  *
44
- * @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
44
+ * @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string, index?: number}>,
45
+ * expectedIndexTotal?: number|null,
45
46
  * reducerMachineId?: string}} input
46
47
  * @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
47
48
  * {ok: false, code: string, detail: object}}
@@ -51,6 +52,15 @@ export function buildReducePlan(input = {}) {
51
52
  if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
52
53
  const results = Array.isArray(input.results) ? input.results : null;
53
54
  if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
55
+ /**
56
+ * v1.2.7: what the task SAID would be produced, not just what arrived.
57
+ *
58
+ * Without an expected count the plan can only describe the reports it was handed, so a split run where one shard never
59
+ * reported produced a plan that looked complete. The count comes from the task (index_total); a missing index is refused
60
+ * by name before the reduce step can run on a partial fleet.
61
+ */
62
+ const expectedTotal = Number.isInteger(input.expectedIndexTotal) && input.expectedIndexTotal > 0 ? input.expectedIndexTotal : null;
63
+ const seenIndexes = new Set();
54
64
 
55
65
  const seen = new Set();
56
66
  const inputs = [];
@@ -59,6 +69,14 @@ export function buildReducePlan(input = {}) {
59
69
  if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
60
70
  if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
61
71
  seen.add(machineId);
72
+ if (expectedTotal !== null) {
73
+ const index = Number.isInteger(result?.index) ? result.index : null;
74
+ if (index === null) return { ok: false, code: 'MAP_INDEX_MISSING', detail: { machineId } };
75
+ if (index < 0 || index >= expectedTotal) {
76
+ return { ok: false, code: 'MAP_INDEX_OUT_OF_RANGE', detail: { machineId, index, expectedIndexTotal: expectedTotal } };
77
+ }
78
+ seenIndexes.add(index);
79
+ }
62
80
  const mode = result?.mode === 'blob' ? 'blob' : 'inline';
63
81
  if (mode === 'blob') {
64
82
  const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
@@ -68,5 +86,10 @@ export function buildReducePlan(input = {}) {
68
86
  inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
69
87
  }
70
88
  }
89
+ if (expectedTotal !== null) {
90
+ const missing = [];
91
+ for (let index = 0; index < expectedTotal; index += 1) if (!seenIndexes.has(index)) missing.push(index);
92
+ if (missing.length > 0) return { ok: false, code: 'MISSING_SHARD', detail: { missing, expectedIndexTotal: expectedTotal } };
93
+ }
71
94
  return { ok: true, reducerMachineId: reducer, inputs };
72
95
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Envelope signatures: what a machine signed, and how a reader checks it.
3
+ *
4
+ * ## Why the payload is exactly four fields
5
+ *
6
+ * A signature over "the whole envelope" is impossible to verify reproducibly: the envelope carries hashes, timestamps and
7
+ * transport facts that change between the signing machine and the reader. So the machine signs the four facts that identify
8
+ * THE COMMAND: task id, argv hash (the envelope's command_hash), machine id, and when it signed. Everything else in the
9
+ * envelope is covered indirectly through those, and a reader verifies exactly this tuple.
10
+ *
11
+ * ## Why verification returns a status rather than throwing
12
+ *
13
+ * "unsigned" is not an error -- a machine running an older build, or one configured without a device key, is a fact to report.
14
+ * "bad_signature" is the interesting case: the data does not match the signature, which is exactly what tampering looks like.
15
+ * A caller must see the difference, so both are values.
16
+ */
17
+ import { createPublicKey, verify } from 'node:crypto';
18
+
19
+ /** The DER prefix for an Ed25519 SubjectPublicKeyInfo; the 32 raw key bytes follow. */
20
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
21
+
22
+ /**
23
+ * The canonical bytes a machine signs. Sorted keys, no whitespace, so the same tuple always produces the same bytes.
24
+ *
25
+ * @param {{task_id: string, argv_hash: string, machine_id: string, ts: string}} input
26
+ * @returns {string}
27
+ */
28
+ export function canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts }) {
29
+ return JSON.stringify({
30
+ argv_hash: String(argvHash ?? ''),
31
+ machine_id: String(machineId ?? ''),
32
+ task_id: String(taskId ?? ''),
33
+ ts: String(ts ?? ''),
34
+ });
35
+ }
36
+
37
+ /**
38
+ * @param {string|null} publicKeyBase64
39
+ * @returns {import('node:crypto').KeyObject|null}
40
+ */
41
+ export function publicKeyFromBase64(publicKeyBase64) {
42
+ if (typeof publicKeyBase64 !== 'string' || publicKeyBase64 === '') return null;
43
+ try {
44
+ const raw = Buffer.from(publicKeyBase64, 'base64');
45
+ if (raw.length !== 32) return null;
46
+ return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {{signature?: object|null, task_id?: string, argv_hash?: string, machine_id?: string, ts?: string}} input
54
+ * @returns {'signed'|'unsigned'|'bad_signature'|'unsupported_algorithm'}
55
+ */
56
+ export function verifyEnvelopeSignature({ signature = null, task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts } = {}) {
57
+ if (signature === null || signature === undefined || typeof signature !== 'object') return 'unsigned';
58
+ if (signature.alg !== 'ed25519') return 'unsupported_algorithm';
59
+ if (typeof signature.value !== 'string' || signature.value === '') return 'bad_signature';
60
+ const key = publicKeyFromBase64(signature.public_key);
61
+ if (key === null) return 'bad_signature';
62
+ const payload = canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts: signature.ts ?? ts });
63
+ try {
64
+ const ok = verify(null, Buffer.from(payload, 'utf8'), key, Buffer.from(signature.value, 'base64'));
65
+ return ok ? 'signed' : 'bad_signature';
66
+ } catch {
67
+ return 'bad_signature';
68
+ }
69
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The permission matrix: which fleet role may perform which action.
3
+ *
4
+ * ## Why a table, and why refusals are named
5
+ *
6
+ * A role check scattered across call sites drifts: one route checks, another forgets, and the matrix exists only in a document.
7
+ * This table is the single answer, and a refusal always names the action, the role required and the role held -- an
8
+ * "access denied" without those three facts is a support ticket, not a diagnostic.
9
+ *
10
+ * ## Why the levels are ordinal
11
+ *
12
+ * Roles form an order (observer < worker < owner). Stating a minimum level rather than enumerating every allowed role means a
13
+ * new role slots in between two existing ones without rewriting every action.
14
+ */
15
+ export const FLEET_ACTIONS = Object.freeze(['read_private', 'vote', 'join', 'dispatch', 'approve', 'manage_members']);
16
+
17
+ /** Lower number = fewer rights. */
18
+ export const ROLE_LEVEL = Object.freeze({ observer: 0, worker: 1, owner: 2 });
19
+
20
+ /** The minimum level an action needs. `manage_members` is owner-only; approving is owner-only for the same reason. */
21
+ export const ACTION_MIN_LEVEL = Object.freeze({
22
+ read_private: 0,
23
+ vote: 1,
24
+ join: 1,
25
+ dispatch: 1,
26
+ approve: 2,
27
+ manage_members: 2,
28
+ });
29
+
30
+ /** The role a level maps back to, for a refusal that says which role would have been enough. */
31
+ export function roleForLevel(level) {
32
+ const best = Object.entries(ROLE_LEVEL).filter(([, value]) => value === level)[0];
33
+ return best === undefined ? null : best[0];
34
+ }
35
+
36
+ /**
37
+ * @param {{role?: string|null, action: string}} input
38
+ * @returns {{ok: true, action: string, role: string}} | {ok: false, code: 'FORBIDDEN_ACTION', action: string, required_level: number, current_level: number, required_role: string|null, current_role: string}}
39
+ */
40
+ export function can({ role = null, action } = {}) {
41
+ const currentRole = typeof role === 'string' && role in ROLE_LEVEL ? role : 'worker'; // unknown device = worker, never owner
42
+ if (!FLEET_ACTIONS.includes(action)) {
43
+ return {
44
+ ok: false, code: 'FORBIDDEN_ACTION', action, required_level: null, current_level: ROLE_LEVEL[currentRole],
45
+ required_role: null, current_role: currentRole,
46
+ };
47
+ }
48
+ const requiredLevel = ACTION_MIN_LEVEL[action];
49
+ const currentLevel = ROLE_LEVEL[currentRole];
50
+ if (currentLevel >= requiredLevel) return { ok: true, action, role: currentRole };
51
+ return {
52
+ ok: false,
53
+ code: 'FORBIDDEN_ACTION',
54
+ action,
55
+ required_level: requiredLevel,
56
+ current_level: currentLevel,
57
+ required_role: roleForLevel(requiredLevel),
58
+ current_role: currentRole,
59
+ };
60
+ }
61
+
62
+ /** Every action with its verdict for one role -- the board an operator reads. */
63
+ export function matrixFor(role) {
64
+ return FLEET_ACTIONS.map((action) => ({ action, ...can({ role, action }) }));
65
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Output redaction: secrets a command prints are replaced before they leave this machine.
3
+ *
4
+ * ## Why a replacement, not a deletion
5
+ *
6
+ * Deleting the line would keep the secret out of the envelope and also destroy the evidence: line numbers and structure are
7
+ * what make a failure reproducible. So each match becomes [redacted:class] in place.
8
+ *
9
+ * ## Why nothing here touches a hash
10
+ *
11
+ * stdout_sha256 and stdout_bytes cover the RAW bytes and are computed by the executor. Redacting the text must not feed back
12
+ * into them: the envelope's hashes stay evidence, and only the human-readable heads/tails are filtered. The same is true of
13
+ * blobs -- a blob's name is the hash of its bytes, and rewriting them would make the name a lie.
14
+ *
15
+ * ## Why it can be turned off
16
+ *
17
+ * An operator debugging a credential problem needs the original. Turning it off is allowed and SAYS SO (unredacted: true in the
18
+ * envelope), because a redaction that silently does nothing is worse than no redaction at all.
19
+ */
20
+
21
+ /** Each rule: a class name and the pattern that finds it. Order matters only for the private-key block. */
22
+ const RULES = Object.freeze([
23
+ { cls: 'private-key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g },
24
+ { cls: 'api-key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
25
+ { cls: 'api-key', pattern: /\bnpm_[A-Za-z0-9]{20,}\b/g },
26
+ { cls: 'api-key', pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
27
+ { cls: 'api-key', pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
28
+ { cls: 'api-key', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
29
+ { cls: 'aws-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
30
+ ]);
31
+
32
+ /**
33
+ * @param {string} text
34
+ * @returns {{text: string, replaced: Array<{cls: string, count: number}>}}
35
+ */
36
+ export function redactText(text) {
37
+ if (typeof text !== 'string' || text === '') return { text: typeof text === 'string' ? text : '', replaced: [] };
38
+ let out = text;
39
+ const counts = new Map();
40
+ for (const rule of RULES) {
41
+ let hits = 0;
42
+ out = out.replace(rule.pattern, () => { hits += 1; return '[redacted:' + rule.cls + ']'; });
43
+ if (hits > 0) counts.set(rule.cls, (counts.get(rule.cls) ?? 0) + hits);
44
+ }
45
+ return { text: out, replaced: [...counts.entries()].map(([cls, count]) => ({ cls, count })) };
46
+ }
47
+
48
+ /** The envelope fields that carry command output and are therefore filtered. */
49
+ export const REDACTABLE_FIELDS = Object.freeze(['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail']);
50
+
51
+ /**
52
+ * @param {object} envelope
53
+ * @param {{enabled?: boolean}} [options]
54
+ * @returns {{envelope: object, redacted: Array<{field: string, cls: string, count: number}>}}
55
+ */
56
+ export function redactEnvelope(envelope, { enabled = true } = {}) {
57
+ if (envelope === null || typeof envelope !== 'object') return { envelope, redacted: [] };
58
+ if (enabled !== true) return { envelope: { ...envelope, unredacted: true }, redacted: [] };
59
+ const next = { ...envelope };
60
+ const redacted = [];
61
+ for (const field of REDACTABLE_FIELDS) {
62
+ const value = next[field];
63
+ if (typeof value !== 'string' || value === '') continue;
64
+ const result = redactText(value);
65
+ next[field] = result.text;
66
+ for (const hit of result.replaced) redacted.push({ field, ...hit });
67
+ }
68
+ if (redacted.length > 0) next.redacted = redacted;
69
+ return { envelope: next, redacted };
70
+ }