@synoi/gateway-lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1642 @@
1
+ "use strict";
2
+ /**
3
+ * gap/local-ingest-router.ts — /local/* localhost ingest API per ADR_014 Section 3.
4
+ *
5
+ * Status: PARTIAL (SHIPPED-against-test-keys). Auth/exposure surface is
6
+ * PARTIAL-pending-Security+Adversary-panel. Production keys PAPER + ceremony-gated.
7
+ *
8
+ * Placement: TS gateway layer (same pattern as /v1/demo and /internal/gate).
9
+ * Mount: app.use('/local', localIngestRouter) BEFORE app.use('/v1', authMiddleware).
10
+ *
11
+ * Security model (load-bearing; a Security+Adversary panel follows):
12
+ * 1. LOOPBACK-ONLY: identical guard to consult-gate-router.ts. req.socket.remoteAddress
13
+ * must be 127.0.0.x / ::1 / ::ffff:127.0.0.x. Checked FIRST, before any route.
14
+ * Non-loopback -> 403. Never trust X-Forwarded-For or any proxy header.
15
+ * 2. HOST-HEADER: reject any /local/* request whose Host header is not 127.0.0.1:PORT
16
+ * or localhost:PORT. This defeats DNS-rebinding: the attacker controls the DNS
17
+ * response but the browser sends the attacker origin as the Host header, which
18
+ * won't match 127.0.0.1. Checked after the socket check.
19
+ * 3. CUSTOM-HEADER: require X-SynOI-Local: 1 on every /local/* request. Browsers
20
+ * cannot set arbitrary custom headers on cross-origin no-cors requests, so this
21
+ * defeats CSRF without a per-request token. The local dashboard always sets it.
22
+ * 4. OPERATOR-AUTH on /local/decide: the request body must carry a valid
23
+ * operator_pubkey_hex + operator_signature over the canonical decision payload
24
+ * (matching the OID-binding pattern from demo-router). Wrong key -> 403.
25
+ * Missing -> 400. No anonymous approve/deny.
26
+ * 5. FAIL-CLOSED: malformed body -> 400/reject. Unknown pending_oid -> 404.
27
+ * Timeout -> auto-deny (via checkTimeouts sweep, per SHIPPED provisional+auto-lift
28
+ * primitive in revocation.ts line 149). Never auto-allow.
29
+ * 6. BUNDLE_OID GRANT CHECK at /local/gate: bundle_oid must exist in gap_grants as an
30
+ * active (non-revoked, non-expired) grant. Unknown or revoked bundle -> 403. This is
31
+ * an Axis-3 subset; Axis 3 full entitlement remains PARTIAL (panel decides scope).
32
+ * 7. RATE-LIMIT + PENDING-STORE CAP: /local/gate enforces MAX_PENDING_ACTIONS. At cap,
33
+ * further calls get 429. A per-IP (loopback) rate limiter with a token-bucket also
34
+ * applies. The pending store stays bounded in memory.
35
+ * 8. SWEEP BATCHING: sweepExpiredPending() processes at most SWEEP_BATCH_SIZE expired
36
+ * entries per tick and yields with setImmediate between batches, keeping the event
37
+ * loop responsive even if the store grows before the cap was added.
38
+ * 9. CHALLENGE NONCE TRUST MODEL AND ORACLE HARDENING (ADR_016 Findings 2+3):
39
+ * (a) The challenge_nonce in the /local/gate 201 response is trusted to the
40
+ * gate-submitting process (the AI agent). The /local/decide-challenge endpoint
41
+ * is the dashboard UX channel, not the agent channel. The machine-local
42
+ * loopback boundary (layers 1-3 above) is the primary control that separates
43
+ * these channels; there is no cryptographic separation between processes on
44
+ * the same loopback. This is the intended threat model for v1 local mode.
45
+ * (b) The challenge_nonce is NOT consumed on /local/decide-challenge fetch
46
+ * (intentional). It is inert without the enrolled operator's Ed25519 key.
47
+ * Re-fetching the nonce does not let an unauthenticated caller decide: the
48
+ * decide payload signature must verify against the enrolled operator's key.
49
+ * (c) /local/decide-challenge collapses OperatorNotEnrolledForTenantError and
50
+ * OperatorNotEnrolledError both to 404, making the response indistinguishable
51
+ * from "pending_oid not found." This closes two existence oracles: wrong-tenant
52
+ * callers cannot probe whether a pending_oid is real, and unenrolled callers
53
+ * cannot determine whether they are enrolled anywhere.
54
+ * (d) Both the fetch-challenge and decide canonical payloads include tenant_id so
55
+ * signatures are bound to the specific tenant. A signature obtained for one
56
+ * tenant cannot be replayed against a pending action owned by another tenant.
57
+ *
58
+ * Endpoints (ADR_014 Section 3.3, all bound to 127.0.0.1):
59
+ * GET /local/health liveness
60
+ * POST /local/gate ingest action -> PENDING; emit governed-action.requested receipt
61
+ * GET /local/pending list pending actions awaiting decision
62
+ * POST /local/decide HITL approve/deny; emits governed-action.allowed|denied receipt
63
+ * GET /local/receipts receipt stream (all three own subjects)
64
+ * GET /local/receipts/:oid single receipt by OID
65
+ *
66
+ * Deny-on-timeout: a background sweep runs every TIMEOUT_SWEEP_MS; any PENDING action
67
+ * older than PENDING_TIMEOUT_MS is auto-denied with a signed governed-action.denied receipt.
68
+ *
69
+ * Bind note: the full gateway listener binds 0.0.0.0 (production infra needs it for
70
+ * health-check traffic on non-loopback interfaces). The /local surface is protected by
71
+ * layers 1-3 above (socket address + Host header + custom header), which together
72
+ * constitute the loopback-equivalent defense for /local/* without restricting the
73
+ * entire server to 127.0.0.1. The panel may decide an additional split-listener is
74
+ * warranted; surfaced here rather than breaking non-/local routes.
75
+ *
76
+ * SECURITY NOTE: "PARTIAL-pending-panel" -- the operator-auth surface here relies on
77
+ * Ed25519 signature verification against a caller-supplied public key. The OID-binding
78
+ * check (sha256(pubkey) == operator_oid) ensures the key matches the declared identity
79
+ * but the operator session itself (who issued the operator OID) is NOT authenticated by
80
+ * a genesis-rooted chain in this slice. A Security+Adversary panel reviews this surface
81
+ * before any production exposure.
82
+ *
83
+ * No em dashes. No AI attribution.
84
+ */
85
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
86
+ if (k2 === undefined) k2 = k;
87
+ var desc = Object.getOwnPropertyDescriptor(m, k);
88
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
89
+ desc = { enumerable: true, get: function() { return m[k]; } };
90
+ }
91
+ Object.defineProperty(o, k2, desc);
92
+ }) : (function(o, m, k, k2) {
93
+ if (k2 === undefined) k2 = k;
94
+ o[k2] = m[k];
95
+ }));
96
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
97
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
98
+ }) : function(o, v) {
99
+ o["default"] = v;
100
+ });
101
+ var __importStar = (this && this.__importStar) || (function () {
102
+ var ownKeys = function(o) {
103
+ ownKeys = Object.getOwnPropertyNames || function (o) {
104
+ var ar = [];
105
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
106
+ return ar;
107
+ };
108
+ return ownKeys(o);
109
+ };
110
+ return function (mod) {
111
+ if (mod && mod.__esModule) return mod;
112
+ var result = {};
113
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
114
+ __setModuleDefault(result, mod);
115
+ return result;
116
+ };
117
+ })();
118
+ var __importDefault = (this && this.__importDefault) || function (mod) {
119
+ return (mod && mod.__esModule) ? mod : { "default": mod };
120
+ };
121
+ Object.defineProperty(exports, "__esModule", { value: true });
122
+ exports.MAX_CONCURRENT_OUTCOME_WAITS = exports.MAX_OUTCOME_WAIT_MS = exports.DEFAULT_MAX_LOCAL_RECEIPT_OIDS = exports.DEFAULT_TIMEOUT_SWEEP_MS = exports.DEFAULT_PENDING_TIMEOUT_MS = exports._pendingStore = exports.GATE_RATE_MAX_CALLS = exports.GATE_RATE_WINDOW_MS = exports.MAX_PENDING_ACTIONS = exports.LOCAL_CUSTOM_HEADER_VALUE = exports.LOCAL_CUSTOM_HEADER_NAME = exports._setLiteMode = exports.isLiteMode = void 0;
123
+ exports._resetGateRateLimit = _resetGateRateLimit;
124
+ exports._setPendingTimeoutMs = _setPendingTimeoutMs;
125
+ exports.sweepExpiredPending = sweepExpiredPending;
126
+ exports._setMaxLocalReceiptOids = _setMaxLocalReceiptOids;
127
+ exports._registerLocalReceiptOid = _registerLocalReceiptOid;
128
+ exports._listLocalReceipts = _listLocalReceipts;
129
+ exports._clearLocalReceiptTracking = _clearLocalReceiptTracking;
130
+ exports.startLocalIngestSweep = startLocalIngestSweep;
131
+ exports.stopLocalIngestSweep = stopLocalIngestSweep;
132
+ exports._resetActiveLongPolls = _resetActiveLongPolls;
133
+ const express_1 = __importDefault(require("express"));
134
+ const node_crypto_1 = require("node:crypto");
135
+ const fs = __importStar(require("node:fs"));
136
+ const path = __importStar(require("node:path"));
137
+ const os = __importStar(require("node:os"));
138
+ const operator_enrollment_1 = require("./operator-enrollment");
139
+ const store_1 = require("./store");
140
+ const sraid_1 = require("@synoi/sraid");
141
+ const verify_router_1 = require("../verify-router");
142
+ const gap_1 = require("@synoi/gap");
143
+ const lite_signing_key_1 = require("./lite-signing-key");
144
+ const lite_mode_1 = require("./lite-mode");
145
+ Object.defineProperty(exports, "isLiteMode", { enumerable: true, get: function () { return lite_mode_1.isLiteMode; } });
146
+ /** daemon.ts / TEST ONLY: explicitly select the lite self-sign receipt tier. */
147
+ exports._setLiteMode = lite_mode_1.setLiteMode;
148
+ // DSSE payloadType for a GAP object (ADR_007 payloadType split). Duplicated
149
+ // here rather than imported from ./receipt-sign (which is the ONE constant
150
+ // this router needed from that module): receipt-sign.ts imports
151
+ // ./signing-oracle (the KMS-hybrid managed-custody oracle), and this router
152
+ // is also mounted by the standalone lite daemon entrypoint (src/daemon.ts),
153
+ // which must have NO reachable import edge into KMS (ADR_014 Section 10.2 /
154
+ // 10.3, enforced by the CI dep-graph assertion). A single string-literal
155
+ // constant is worth duplicating to break that edge; matches
156
+ // receipt-sign.ts's SYNOI_GAP_RECEIPT_PAYLOAD_TYPE and @synoi/verify's
157
+ // V2_PAYLOAD_TYPE byte-for-byte (both 'application/vnd.synoi.gap+json').
158
+ const GAP_RECEIPT_PAYLOAD_TYPE = 'application/vnd.synoi.gap+json';
159
+ // ── Loopback guard (reuse same logic as consult-gate-router.ts) ───────────────
160
+ //
161
+ // Security note: req.socket.remoteAddress is the real peer address; it cannot
162
+ // be spoofed via HTTP headers. We deliberately do NOT read X-Forwarded-For.
163
+ const LOOPBACK_ADDRESSES = new Set([
164
+ '127.0.0.1',
165
+ '::1',
166
+ '::ffff:127.0.0.1',
167
+ ]);
168
+ function isLoopbackAddress(addr) {
169
+ if (!addr)
170
+ return false;
171
+ if (LOOPBACK_ADDRESSES.has(addr))
172
+ return true;
173
+ if (addr.startsWith('127.'))
174
+ return true;
175
+ if (addr.startsWith('::ffff:127.'))
176
+ return true;
177
+ return false;
178
+ }
179
+ // ── #2: Host-header + custom-header validation ────────────────────────────────
180
+ //
181
+ // These two checks run after the socket-address check and defeat DNS-rebinding
182
+ // and CSRF respectively.
183
+ /**
184
+ * Validate the Host header for /local/* requests.
185
+ * Accepts: 127.0.0.1[:PORT], localhost[:PORT].
186
+ * Rejects: everything else (including attacker-controlled domains).
187
+ */
188
+ function isValidLocalHost(host) {
189
+ if (!host)
190
+ return false;
191
+ // Strip the port if present.
192
+ const hostOnly = host.replace(/:\d+$/, '');
193
+ return hostOnly === '127.0.0.1' || hostOnly === 'localhost';
194
+ }
195
+ /** The required custom header name and value. */
196
+ exports.LOCAL_CUSTOM_HEADER_NAME = 'x-synoi-local';
197
+ exports.LOCAL_CUSTOM_HEADER_VALUE = '1';
198
+ // ── #4: Rate-limit + pending-store cap ───────────────────────────────────────
199
+ //
200
+ // MAX_PENDING_ACTIONS: absolute cap on the in-memory pending store. /local/gate
201
+ // returns 429 if the store is already at this size. This bounds memory and
202
+ // prevents a flood attack from inflating the store indefinitely.
203
+ //
204
+ // GATE_RATE_WINDOW_MS / GATE_RATE_MAX_CALLS: token-bucket parameters for the
205
+ // /local/gate endpoint. All callers are loopback so a single global bucket is
206
+ // sufficient for v1. A per-principal bucket would be more precise but the
207
+ // caller set is always a single operator in the v1 model.
208
+ //
209
+ // SWEEP_BATCH_SIZE: maximum number of expired entries processed per event-loop
210
+ // tick in sweepExpiredPending(). Yields with setImmediate between batches.
211
+ exports.MAX_PENDING_ACTIONS = 100;
212
+ exports.GATE_RATE_WINDOW_MS = 10_000; // 10-second window
213
+ exports.GATE_RATE_MAX_CALLS = 50; // max 50 /local/gate calls per window
214
+ let _gateCallTimestamps = [];
215
+ /** TEST ONLY: reset the rate-limit window. */
216
+ function _resetGateRateLimit() {
217
+ _gateCallTimestamps = [];
218
+ }
219
+ /** Returns true if the call is within the rate limit, false if over limit. */
220
+ function _checkAndRecordGateCall() {
221
+ const now = Date.now();
222
+ // Evict timestamps older than the window.
223
+ _gateCallTimestamps = _gateCallTimestamps.filter(t => now - t < exports.GATE_RATE_WINDOW_MS);
224
+ if (_gateCallTimestamps.length >= exports.GATE_RATE_MAX_CALLS)
225
+ return false;
226
+ _gateCallTimestamps.push(now);
227
+ return true;
228
+ }
229
+ const SWEEP_BATCH_SIZE = 20;
230
+ // ── Operator-auth helpers (OID-binding pattern from demo-router.ts) ───────────
231
+ //
232
+ // Security F6 (2026-07-12 quality gate): `operator_oid` ("oid-" + 64 lowercase
233
+ // hex chars, i.e. sha256(operator_pubkey) with a distinct prefix) is a SEPARATE
234
+ // identity namespace from the content-addressed `sha256:<64hex>` OID namespace
235
+ // every GAP CDRO (receipts, grants, etc.) uses. Both are sha256 digests
236
+ // expressed as 64 lowercase hex characters, so the two namespaces are
237
+ // syntactically similar enough to invite confusion if hand-read: an
238
+ // operator_oid identifies WHO (the hash of a specific Ed25519 public key,
239
+ // verifyOidBinding below), never WHAT (the hash of an object's canonical
240
+ // content, computeGapOid / @synoi/sraid's oid.ts). The "oid-" vs "sha256:"
241
+ // prefix is what disambiguates them on the wire; never compare or interchange
242
+ // the two without first checking the prefix.
243
+ function isValidOperatorOid(oid) {
244
+ return typeof oid === 'string' && /^oid-[0-9a-f]{64}$/.test(oid);
245
+ }
246
+ function isValidPubkeyHex(hex) {
247
+ return typeof hex === 'string' && /^[0-9a-f]{64}$/.test(hex);
248
+ }
249
+ function verifyOidBinding(operator_oid, operator_pubkey_hex) {
250
+ try {
251
+ const pubBytes = Buffer.from(operator_pubkey_hex, 'hex');
252
+ const computed = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
253
+ return computed === operator_oid;
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ }
259
+ // Canonicalization for operator-signature payloads uses the SINGLE canonical
260
+ // serializer imported from @synoi/sraid (RFC 8785 JCS). The previous local
261
+ // `canonicalizeForSigning` copy was byte-identical to @synoi/sraid for every
262
+ // in-use payload (verified: all values are strings + OIDs); it is removed so
263
+ // there is exactly one canonicalization + signature path. Out-of-domain inputs
264
+ // (NaN/Infinity/Date/undefined-array-element) now reject-loud via @synoi/sraid
265
+ // instead of silently corrupting the signed bytes.
266
+ function verifyOperatorSignature(canonical, operator_pubkey_hex, operator_signature) {
267
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
268
+ const { ed25519 } = require('@noble/curves/ed25519');
269
+ try {
270
+ return ed25519.verify(Buffer.from(operator_signature, 'hex'), Buffer.from(canonical, 'utf8'), Buffer.from(operator_pubkey_hex, 'hex'));
271
+ }
272
+ catch {
273
+ return false;
274
+ }
275
+ }
276
+ /** Exported for tests to inspect. Not part of the public API surface. */
277
+ exports._pendingStore = new Map();
278
+ // ── Timeout sweep (deny-on-timeout, fail-closed) ──────────────────────────────
279
+ //
280
+ // PENDING_TIMEOUT_MS: how long an action may wait before it is auto-denied.
281
+ // Default 5 minutes for v1. Exportable so tests can override via module mock
282
+ // without relying on real wall-clock waits.
283
+ //
284
+ // The sweep emits a signed governed-action.denied receipt for each expired
285
+ // action, exactly the same receipt as an explicit deny decision, so the
286
+ // timeout is indistinguishable from a human deny in the audit trail.
287
+ //
288
+ // OPERATOR note: the auto-deny receipt uses a sentinel operator_oid equal to
289
+ // 'oid-' followed by exactly 64 zero characters ('oid-' + '0'.repeat(64)) to
290
+ // indicate daemon-originated auto-deny rather than an operator decision. The
291
+ // pending_oid is the authority reference. This sentinel is explicitly rejected
292
+ // at the TOP of /local/decide validation (sentinel_reserved) so it cannot be
293
+ // presented as an operator identity. This is the SHIPS-against-test-keys
294
+ // sentinel; a production slot would use the daemon key identity.
295
+ const TIMEOUT_SENTINEL_OID = 'oid-' + '0'.repeat(64);
296
+ exports.DEFAULT_PENDING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
297
+ exports.DEFAULT_TIMEOUT_SWEEP_MS = 10_000; // 10 seconds
298
+ let _pendingTimeoutMs = exports.DEFAULT_PENDING_TIMEOUT_MS;
299
+ /** TEST ONLY: override the pending timeout. Returns a restore fn. */
300
+ function _setPendingTimeoutMs(ms) {
301
+ const prior = _pendingTimeoutMs;
302
+ _pendingTimeoutMs = ms;
303
+ return () => { _pendingTimeoutMs = prior; };
304
+ }
305
+ function sweepExpiredPending() {
306
+ const now = Date.now();
307
+ const allExpiredOids = [];
308
+ for (const [oid, action] of exports._pendingStore) {
309
+ if (now - action.ingested_at_ms >= _pendingTimeoutMs) {
310
+ allExpiredOids.push(oid);
311
+ }
312
+ }
313
+ if (allExpiredOids.length === 0)
314
+ return [];
315
+ // Process the first batch synchronously (so callers in tests get the result).
316
+ // Subsequent batches are yielded with setImmediate to avoid blocking the event loop.
317
+ const synchronousExpired = [];
318
+ function processBatch(oids) {
319
+ for (const oid of oids) {
320
+ const action = exports._pendingStore.get(oid);
321
+ if (action === undefined)
322
+ continue; // already removed (race guard)
323
+ _emitDeniedReceipt(action, TIMEOUT_SENTINEL_OID, 'timeout: no operator decision within the allowed window');
324
+ exports._pendingStore.delete(oid);
325
+ }
326
+ }
327
+ const firstBatch = allExpiredOids.slice(0, SWEEP_BATCH_SIZE);
328
+ processBatch(firstBatch);
329
+ synchronousExpired.push(...firstBatch);
330
+ // Schedule remaining batches asynchronously.
331
+ if (allExpiredOids.length > SWEEP_BATCH_SIZE) {
332
+ let offset = SWEEP_BATCH_SIZE;
333
+ function scheduleNext() {
334
+ if (offset >= allExpiredOids.length)
335
+ return;
336
+ const batch = allExpiredOids.slice(offset, offset + SWEEP_BATCH_SIZE);
337
+ offset += SWEEP_BATCH_SIZE;
338
+ setImmediate(() => {
339
+ processBatch(batch);
340
+ scheduleNext();
341
+ });
342
+ }
343
+ scheduleNext();
344
+ }
345
+ return synchronousExpired;
346
+ }
347
+ // ── Local receipt OID registry (must precede _buildReceiptBase) ───────────────
348
+ //
349
+ // Tracks OIDs of receipts emitted by this router so /local/receipts can list
350
+ // them without a full-table scan. Declared here so _buildReceiptBase can call
351
+ // _localReceiptOids.add() before the exported helper functions below.
352
+ //
353
+ // Adversary #5 (2026-07-12 quality gate, MEDIUM): this registry used to be a
354
+ // plain Set that grew without bound for the lifetime of the process, and
355
+ // GET /local/receipts (_listLocalReceipts) did one getReceipt() SQLite read
356
+ // per entry -- an unbounded, O(N) cost per call as the receipt count grows
357
+ // across a long-running daemon's lifetime.
358
+ //
359
+ // Fix: cap it at MAX_LOCAL_RECEIPT_OIDS, evicting the OLDEST-registered oid
360
+ // (FIFO, via Map's insertion-order iteration) once the cap is reached. This
361
+ // bounds both the memory (the Set itself) and the per-call SQLite read cost
362
+ // (_listLocalReceipts never reads more than the cap). The evicted oid's
363
+ // underlying receipt is NOT deleted from gap_receipts (store.ts) -- it
364
+ // remains fetchable individually via GET /local/receipts/:oid, which reads
365
+ // the SQLite table directly (store.ts:691-694), not this registry. Only the
366
+ // STREAM LISTING (GET /local/receipts, the dashboard's "Receipts" tab) is
367
+ // bounded to the most recent MAX_LOCAL_RECEIPT_OIDS; nothing is destroyed.
368
+ //
369
+ // A direct SQLite range query (store.ts already has listReceipts(tenantId,
370
+ // opts), PC-17/PC-18) was considered and rejected for THIS surface: local
371
+ // receipts span potentially many operator tenant_ids
372
+ // (tenant:local-${principal_oid}, one per enrolled operator in the v1
373
+ // single-machine model), and listReceipts is tenant-scoped by design (its
374
+ // WHERE tenant_id = ? clause is the multi-tenant isolation boundary
375
+ // elsewhere in the gateway) -- there is no existing "all local receipts
376
+ // across tenants" query, and inventing one would touch tenant-isolation
377
+ // semantics outside this fix's scope. The bounded in-memory registry keeps
378
+ // the existing "one dashboard, one machine, all local receipts" semantics
379
+ // unchanged while fixing the actual unboundedness.
380
+ exports.DEFAULT_MAX_LOCAL_RECEIPT_OIDS = 500;
381
+ /**
382
+ * A Set-like registry (same .add / .clear / for-of interface every call site
383
+ * already uses) bounded at `cap` entries, evicting the oldest (FIFO, by
384
+ * insertion order) once full. Re-adding an already-present oid is a no-op
385
+ * (does not refresh its position) -- a receipt oid is only ever registered
386
+ * once, at emission time, so recency-on-read semantics do not apply here.
387
+ */
388
+ class BoundedOidRegistry {
389
+ cap;
390
+ entries = new Map();
391
+ constructor(cap) {
392
+ this.cap = cap;
393
+ }
394
+ add(oid) {
395
+ if (this.entries.has(oid))
396
+ return;
397
+ if (this.entries.size >= this.cap) {
398
+ const oldest = this.entries.keys().next().value;
399
+ if (oldest !== undefined)
400
+ this.entries.delete(oldest);
401
+ }
402
+ this.entries.set(oid, true);
403
+ }
404
+ has(oid) {
405
+ return this.entries.has(oid);
406
+ }
407
+ clear() {
408
+ this.entries.clear();
409
+ }
410
+ /** TEST ONLY: change the cap at runtime (does not retroactively evict). */
411
+ setCap(n) {
412
+ this.cap = n;
413
+ }
414
+ get size() {
415
+ return this.entries.size;
416
+ }
417
+ [Symbol.iterator]() {
418
+ return this.entries.keys();
419
+ }
420
+ }
421
+ const _localReceiptOids = new BoundedOidRegistry(exports.DEFAULT_MAX_LOCAL_RECEIPT_OIDS);
422
+ /** TEST ONLY: override the cap (e.g. to a small number) so a bounded-growth
423
+ * vector does not need to actually emit hundreds of real receipts. */
424
+ function _setMaxLocalReceiptOids(n) {
425
+ _localReceiptOids.setCap(n);
426
+ }
427
+ /** Register a receipt OID as emitted by the local-ingest router. */
428
+ function _registerLocalReceiptOid(oid) {
429
+ _localReceiptOids.add(oid);
430
+ }
431
+ /** Return all receipts emitted by the local-ingest router, newest first. */
432
+ function _listLocalReceipts() {
433
+ const out = [];
434
+ for (const oid of _localReceiptOids) {
435
+ const r = (0, store_1.getReceipt)(oid);
436
+ if (r !== null)
437
+ out.push(r);
438
+ }
439
+ out.sort((a, b) => b.created_at_ms - a.created_at_ms);
440
+ return out;
441
+ }
442
+ /** Clear local receipt tracking (for test teardown). */
443
+ function _clearLocalReceiptTracking() {
444
+ _localReceiptOids.clear();
445
+ }
446
+ // ── Receipt emission helpers ──────────────────────────────────────────────────
447
+ //
448
+ // Subjects per ADR_014 Section 1.1 bundle receipt_subjects:
449
+ // governed-action.requested -- held PENDING, pre-decision provenance
450
+ // governed-action.allowed -- gate decision: allow; action may run
451
+ // governed-action.denied -- gate decision: deny; action must NOT run
452
+ const GOVERNED_ACTION_BUNDLE_OID = 'capbundle/1'; // per ADR_014 Section 1
453
+ /**
454
+ * Build a receipt-v2 CDRO for the three local receipt subjects.
455
+ *
456
+ * Signing: canonicalize(cdroContentCore(cdro)) is signed over DSSE with the
457
+ * active hybrid signer (Ed25519 + ML-DSA-65). This matches what verifyReceiptV2
458
+ * in @synoi/verify expects: it recomputes canonicalize(cdroContentCore(receipt))
459
+ * and checks it equals the attestation envelope payload, then verifies both sigs.
460
+ *
461
+ * Store compat: saveReceipt reads r.oid, r.tenant_id, r.body.subject_kind,
462
+ * r.body.subject_oid, r.body.status, r.body.initiator.actor_oid,
463
+ * r.body.initiated_at_ms, r.body.resolved_at_ms -- all present in body below.
464
+ * The full CDRO is stored as JSON so getReceipt returns the v2 shape intact.
465
+ */
466
+ function _buildDecisionReceiptV2(action, subject, subject_oid, decision, status, operator_oid, detail) {
467
+ const now = Date.now();
468
+ const tenant_id = `tenant:local-${action.principal_oid}`;
469
+ // Build the body: includes all fields saveReceipt column-extracts plus the
470
+ // AARM decision verb and authority block for audit chain compat.
471
+ const body = {
472
+ subject_kind: 'capability_invocation',
473
+ subject_oid,
474
+ decision,
475
+ action_kind: action.action_kind,
476
+ panel_id: action.panel_id,
477
+ decision_oid: action.originating_receipt_oid ?? null,
478
+ initiator: { actor_oid: operator_oid, actor_type: 'human_user' },
479
+ status,
480
+ authority: {
481
+ grant_oid: action.bundle_oid,
482
+ subject_oid: action.principal_oid,
483
+ },
484
+ capability_grant_oids: [action.bundle_oid],
485
+ initiated_at_ms: now,
486
+ resolved_at_ms: now,
487
+ cited_oracle_inputs: [],
488
+ ...(detail !== undefined ? { detail } : {}),
489
+ };
490
+ // Build the content core (oid and attestation absent at this stage).
491
+ // getSignerKeyId() may throw when keys are not yet initialized; fall back to
492
+ // a placeholder so the receipt is still stored (matches signGapReceiptV2 contract:
493
+ // "never throws -- a signing failure is logged and the receipt is returned
494
+ // without an attestation field, which a verifier treats as unsigned, not valid").
495
+ let signerKeyId = 'uninitialised';
496
+ try {
497
+ signerKeyId = (0, verify_router_1.getSignerKeyId)();
498
+ }
499
+ catch { /* keys not initialised yet */ }
500
+ const coreObj = {
501
+ type: 'gap:decision_receipt',
502
+ sraid_version: '2.0',
503
+ receipt_scheme: 'synoi.receipt/v2',
504
+ tenant_id,
505
+ created_at_ms: now,
506
+ created_by: signerKeyId,
507
+ subject,
508
+ body,
509
+ };
510
+ // Compute OID via sraid cdroOid (= oidOf(cdroContentCore(coreObj))).
511
+ // coreObj has no oid/attestation yet so cdroContentCore returns it unchanged.
512
+ const oid = (0, sraid_1.cdroOid)(coreObj);
513
+ // The DSSE payload is canonicalize(cdroContentCore(fullCdro)).
514
+ // cdroContentCore strips oid and attestation, so this equals canonicalize(coreObj).
515
+ const fullWithOid = { oid, ...coreObj };
516
+ const payload = (0, sraid_1.canonicalize)((0, sraid_1.cdroContentCore)(fullWithOid));
517
+ // Signing: never throws -- failure is logged and receipt is returned without
518
+ // attestation (verifier treats missing attestation as unsigned, not valid).
519
+ let attestation;
520
+ try {
521
+ attestation = (0, verify_router_1.signDssePayload)(GAP_RECEIPT_PAYLOAD_TYPE, payload);
522
+ }
523
+ catch (err) {
524
+ process.stderr.write(`[local-ingest] v2 sign failed oid=${oid}: ${err instanceof Error ? err.message : String(err)}\n`);
525
+ }
526
+ // Assemble the complete CDRO.
527
+ const cdro = {
528
+ oid,
529
+ ...coreObj,
530
+ ...(attestation !== undefined ? { attestation } : {}),
531
+ };
532
+ // Cast to GapDecisionReceipt for store compat. The store reads the fields
533
+ // enumerated above (all present in body) and persists JSON.stringify(r),
534
+ // so getReceipt returns the full v2 CDRO shape including type and subject.
535
+ const r = cdro;
536
+ (0, store_1.saveReceipt)(r);
537
+ _localReceiptOids.add(oid);
538
+ return r;
539
+ }
540
+ /**
541
+ * Lite self-sign receipt tier (ADR_014 Section 10.1/10.3): builds and signs a
542
+ * governed-action decision receipt using @synoi/gap's `receipt()` one-liner
543
+ * against the per-instance operator-owned key (lite-signing-key.ts), instead
544
+ * of the gateway's KMS-hybrid DSSE tier (_buildDecisionReceiptV2 above).
545
+ *
546
+ * This is intentionally a NARROWER body than _buildDecisionReceiptV2: it
547
+ * commits to the OSS @synoi/gap GapDecisionReceiptBody schema (subject_kind,
548
+ * subject_oid, initiator, status, detail, timing) rather than the gateway's
549
+ * internally-enriched shape (action_kind/panel_id/decision/authority/
550
+ * decision_oid as separate typed fields). The gateway-specific enrichment has
551
+ * no OSS-schema home on this tier, so it is folded into `detail` as free text
552
+ * instead of silently dropped. This is the accepted lite-vs-full divergence:
553
+ * lite commits to the public GAP receipt shape a third party can verify with
554
+ * nothing but @synoi/gap + @synoi/verify; the full gateway's enriched shape
555
+ * stays internal-audit-trail-only.
556
+ *
557
+ * decisionStatus follows @synoi/gap's own DecisionStatus enum ('pending' for
558
+ * the requested receipt, 'ok' for allow, 'denied' for deny) -- the SAME two
559
+ * values ('ok'/'denied') the gateway's own status field already used for
560
+ * allow/deny before this tier existed (see _emitAllowedReceipt/
561
+ * _emitDeniedReceipt below), so _decisionVerdict (below) reads either shape
562
+ * without ambiguity.
563
+ */
564
+ function _buildLiteSelfSignReceipt(action, subjectOid, decisionStatus, operator_oid, extraDetail) {
565
+ const keyPair = (0, lite_signing_key_1.loadOrCreateLiteSigningKey)();
566
+ const detailParts = [
567
+ `action_kind=${action.action_kind}`,
568
+ `panel_id=${action.panel_id}`,
569
+ `bundle_oid=${action.bundle_oid}`,
570
+ ...(action.originating_receipt_oid !== null && action.originating_receipt_oid !== undefined
571
+ ? [`originating_receipt_oid=${action.originating_receipt_oid}`]
572
+ : []),
573
+ ...(extraDetail !== undefined ? [extraDetail] : []),
574
+ ];
575
+ const r = (0, gap_1.receipt)({
576
+ tenantId: `tenant:local-${action.principal_oid}`,
577
+ subjectKind: 'capability_invocation',
578
+ subjectOid,
579
+ initiator: { actor_oid: operator_oid, actor_type: 'human_user' },
580
+ status: decisionStatus,
581
+ detail: detailParts.join('; '),
582
+ }, { keyPair });
583
+ const gapReceipt = r.envelope;
584
+ (0, store_1.saveReceipt)(gapReceipt);
585
+ _localReceiptOids.add(gapReceipt.oid);
586
+ return gapReceipt;
587
+ }
588
+ /**
589
+ * Read the effective allow/deny verdict off a decision receipt, tolerant of
590
+ * both receipt shapes this router can emit: the full-gateway
591
+ * _buildDecisionReceiptV2 shape (body.decision: 'allow'|'deny'|'step_up') and
592
+ * the lite self-sign @synoi/gap receipt() shape (no body.decision field;
593
+ * body.status: 'ok'|'denied' carries the verdict instead -- and already did,
594
+ * even on the full-gateway shape, for these two cases). Returns null for a
595
+ * receipt that carries neither (e.g. the 'requested' receipt itself, whose
596
+ * status is 'pending').
597
+ */
598
+ function _decisionVerdict(r) {
599
+ const decision = r.body.decision;
600
+ if (decision === 'allow')
601
+ return 'allow';
602
+ if (decision === 'deny')
603
+ return 'deny';
604
+ if (r.body.status === 'ok')
605
+ return 'allow';
606
+ if (r.body.status === 'denied')
607
+ return 'deny';
608
+ return null;
609
+ }
610
+ function _emitRequestedReceipt(action) {
611
+ if ((0, lite_mode_1.isLiteMode)()) {
612
+ return _buildLiteSelfSignReceipt(action, action.bundle_oid, 'pending', action.principal_oid);
613
+ }
614
+ return _buildDecisionReceiptV2(action, 'governed-action.requested', action.bundle_oid, 'step_up', 'pending', action.principal_oid, `governed-action.requested: action_kind=${action.action_kind} awaiting operator decision`);
615
+ }
616
+ function _emitAllowedReceipt(action, operator_oid, reason) {
617
+ const detail = reason !== undefined ? reason : 'governed-action.allowed: operator approved';
618
+ if ((0, lite_mode_1.isLiteMode)()) {
619
+ return _buildLiteSelfSignReceipt(action, action.pending_oid, 'ok', operator_oid, detail);
620
+ }
621
+ return _buildDecisionReceiptV2(action, 'governed-action.allowed', action.pending_oid, 'allow', 'ok', operator_oid, detail);
622
+ }
623
+ function _emitDeniedReceipt(action, operator_oid, reason) {
624
+ const detail = reason !== undefined ? reason : 'governed-action.denied: operator denied';
625
+ if ((0, lite_mode_1.isLiteMode)()) {
626
+ return _buildLiteSelfSignReceipt(action, action.pending_oid, 'denied', operator_oid, detail);
627
+ }
628
+ return _buildDecisionReceiptV2(action, 'governed-action.denied', action.pending_oid, 'deny', 'denied', operator_oid, detail);
629
+ }
630
+ // ── Background timeout sweep ──────────────────────────────────────────────────
631
+ let _sweepTimer = null;
632
+ /** Start the timeout sweep. Called by startLocalIngestSweep() at gateway init. */
633
+ function startLocalIngestSweep(intervalMs = exports.DEFAULT_TIMEOUT_SWEEP_MS) {
634
+ if (_sweepTimer !== null)
635
+ return;
636
+ _sweepTimer = setInterval(() => { sweepExpiredPending(); }, intervalMs);
637
+ if (_sweepTimer.unref)
638
+ _sweepTimer.unref(); // don't prevent process exit
639
+ }
640
+ /** Stop the sweep (for tests). */
641
+ function stopLocalIngestSweep() {
642
+ if (_sweepTimer !== null) {
643
+ clearInterval(_sweepTimer);
644
+ _sweepTimer = null;
645
+ }
646
+ }
647
+ // ── Router ────────────────────────────────────────────────────────────────────
648
+ const router = express_1.default.Router();
649
+ router.use(express_1.default.json({ limit: '64kb' }));
650
+ // LOOPBACK-ONLY guard (runs FIRST, before every route).
651
+ // PARTIAL-pending-Security+Adversary-panel: loopback enforcement is built and
652
+ // tested; the panel will review the full exposure surface.
653
+ router.use((req, res, next) => {
654
+ const peer = req.socket.remoteAddress;
655
+ if (!isLoopbackAddress(peer)) {
656
+ res.status(403).json({
657
+ error: 'forbidden',
658
+ detail: '/local/* is loopback-only; remote access is not permitted',
659
+ // Security note: 403 (not 404) to signal that the resource exists but
660
+ // access is denied. The panel may decide 404 is preferable to avoid
661
+ // confirming endpoint existence; tagging for panel review.
662
+ });
663
+ return;
664
+ }
665
+ next();
666
+ });
667
+ // #2a: HOST-HEADER guard (DNS-rebinding defense, runs after socket check).
668
+ // A browser making a cross-origin request to 127.0.0.1 will set Host to the
669
+ // ATTACKER domain (via DNS rebinding). This rejects it before any handler.
670
+ // Accepting: 127.0.0.1[:PORT] and localhost[:PORT] only.
671
+ router.use((req, res, next) => {
672
+ const host = req.headers['host'];
673
+ if (!isValidLocalHost(host)) {
674
+ res.status(403).json({
675
+ error: 'forbidden',
676
+ detail: '/local/* requires Host: 127.0.0.1 or Host: localhost; DNS-rebinding not permitted',
677
+ });
678
+ return;
679
+ }
680
+ next();
681
+ });
682
+ // #2b: CUSTOM-HEADER guard (CSRF defense, runs after host check).
683
+ // Browsers cannot set X-SynOI-Local on cross-origin no-cors requests, so this
684
+ // defeats CSRF for STATE-CHANGING calls without a per-request token.
685
+ //
686
+ // EXEMPTION: GET /dashboard and GET /dashboard/* (the page-load navigation
687
+ // itself -- both the built-portal express.static branch and the lite HTML
688
+ // fallback below register under this same path). A top-level browser
689
+ // navigation CANNOT set a custom header (no <a>/address-bar mechanism exists
690
+ // for it), so requiring this header on the page load makes the dashboard
691
+ // permanently unreachable by an operator actually typing/clicking the URL --
692
+ // found 2026-07-11 while browser-testing the lite dashboard fallback (ADR_014
693
+ // Section 10.3 renderer item 3); the SAME gap silently affected the
694
+ // built-portal branch too, since both register after this guard. The
695
+ // dashboard's OWN subsequent fetch() calls (POST /local/decide, GET
696
+ // /local/pending, etc.) DO set the header -- exactly what the dashboard
697
+ // registration comment always assumed; only the very first page-load request
698
+ // needed the exemption. Loopback + Host-header guards (both run before this
699
+ // one) still apply IN FULL to the exempted routes; this exemption is
700
+ // CSRF-scope only, and a plain GET here returns static HTML, not a
701
+ // state-changing effect.
702
+ router.use((req, res, next) => {
703
+ if (req.method === 'GET' && (req.path === '/dashboard' || req.path.startsWith('/dashboard/'))) {
704
+ next();
705
+ return;
706
+ }
707
+ const hdr = req.headers[exports.LOCAL_CUSTOM_HEADER_NAME];
708
+ if (hdr !== exports.LOCAL_CUSTOM_HEADER_VALUE) {
709
+ res.status(403).json({
710
+ error: 'forbidden',
711
+ detail: `${exports.LOCAL_CUSTOM_HEADER_NAME}: ${exports.LOCAL_CUSTOM_HEADER_VALUE} is required on all /local/* requests`,
712
+ });
713
+ return;
714
+ }
715
+ next();
716
+ });
717
+ // ── GET /local/health ─────────────────────────────────────────────────────────
718
+ //
719
+ // ADR_016 Section 3.3 / 2.1: includes enrolled + tenant_id? so the dashboard
720
+ // can branch to the first-run view without a failed decide.
721
+ router.get('/health', (_req, res) => {
722
+ // Probe the enrollment record for any active first-run-local entry.
723
+ let enrolled = false;
724
+ let tenant_id;
725
+ try {
726
+ const record = (0, operator_enrollment_1.loadEnrollmentRecord)();
727
+ if (record !== null) {
728
+ const activeLocal = record.enrolled_operators.find(e => e.status === 'active' && e.enrollment_source === 'first-run-local');
729
+ if (activeLocal !== undefined) {
730
+ enrolled = true;
731
+ tenant_id = activeLocal.tenant_id;
732
+ }
733
+ }
734
+ }
735
+ catch { /* fail-closed: report not-enrolled */ }
736
+ res.json({
737
+ status: 'ok',
738
+ surface: 'local-ingest',
739
+ adr: 'ADR_014 Section 3 / ADR_016',
740
+ pending_count: exports._pendingStore.size,
741
+ keys: 'TEST-KEYS',
742
+ // ADR_016 Section 3.3: enrollment status for dashboard first-run branching.
743
+ enrolled,
744
+ ...(tenant_id !== undefined ? { tenant_id } : {}),
745
+ // Honest tag: this surface is PARTIAL-pending-Security+Adversary-panel.
746
+ // Do not read 'ok' here as a panel clearance.
747
+ tag: 'PARTIAL-pending-panel',
748
+ });
749
+ });
750
+ // ── GET /local/dashboard (+ /local/dashboard/*) -- static asset serve ────────
751
+ //
752
+ // ADR_016 Section 3.2: serve the built operator dashboard static assets same-origin
753
+ // from the daemon. The portal Next.js app is built to static output and the assets
754
+ // placed in SYNOI_DASHBOARD_DIR (env) or the default path below. This route sits
755
+ // BEHIND the three pre-route guards (socket + Host + custom-header), so remote peers
756
+ // cannot fetch dashboard assets (ADR_016 Section 5 residual / Section 3.2 flag).
757
+ //
758
+ // In development (no SYNOI_DASHBOARD_DIR, no built assets): the route returns a
759
+ // minimal HTML stub so the daemon boots without a portal build present.
760
+ //
761
+ // The dashboard calls /local/* over the same origin and same port; no CORS is needed.
762
+ const DASHBOARD_ENV_DIR = process.env['SYNOI_DASHBOARD_DIR'];
763
+ const DASHBOARD_DEFAULT = path.join(os.homedir(), '.synoi', 'dashboard');
764
+ const DASHBOARD_DIR = DASHBOARD_ENV_DIR ?? DASHBOARD_DEFAULT;
765
+ const DASHBOARD_BUILT = DASHBOARD_DIR !== '' && fs.existsSync(DASHBOARD_DIR);
766
+ // Adversary #2 (2026-07-12 quality gate): the dashboard set no framing
767
+ // defense, so it could be iframed by a malicious co-located page and
768
+ // clickjacked into an operator approving an action they never saw. Applied
769
+ // to EVERY /local/dashboard* response, both branches below (express.static
770
+ // AND the lite HTML fallback), mirroring the pattern already shipped at
771
+ // device-page.ts:185-188.
772
+ router.use('/dashboard', (_req, res, next) => {
773
+ res.setHeader('X-Frame-Options', 'DENY');
774
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
775
+ res.setHeader('X-Content-Type-Options', 'nosniff');
776
+ res.setHeader('Referrer-Policy', 'no-referrer');
777
+ next();
778
+ });
779
+ if (DASHBOARD_BUILT) {
780
+ // Serve static assets (html/css/js) under /local/dashboard.
781
+ // express.static is mounted on the sub-path after the three router-level guards.
782
+ router.use('/dashboard', express_1.default.static(DASHBOARD_DIR, {
783
+ index: 'index.html',
784
+ maxAge: 0, // no client caching -- operator dashboard is live state
785
+ etag: false,
786
+ fallthrough: true,
787
+ }));
788
+ // SPA fallback: any /local/dashboard/* path that isn't a static file returns index.html.
789
+ router.get('/dashboard/*', (_req, res) => {
790
+ res.sendFile(path.join(DASHBOARD_DIR, 'index.html'));
791
+ });
792
+ }
793
+ else {
794
+ // No built portal assets (ADR_016 Section 3.2's "development" case, and the
795
+ // ONLY case the lite daemon has: Tauri/portal packaging is deferred to
796
+ // v1.x per ADR_014 Section 3.4). ADR_014 Section 10.3 renderer item 3:
797
+ // this is a generic, MAXIMALLY-CONSERVATIVE, self-contained HTML fallback,
798
+ // not a placeholder -- it is the real operator surface for lite. Zero
799
+ // external build deps (no bundler, no framework, no CDN script): one
800
+ // static HTML file with inline CSS + vanilla JS, using only the browser's
801
+ // native Web Crypto Ed25519 API to sign the operator's decisions.
802
+ //
803
+ // It renders every pending action's args VERBATIM (no summarization that
804
+ // could hide a dangerous argument), flags any action_kind outside the
805
+ // three declared ADR_005 Section 5 values ("command" | "render" |
806
+ // "background-refresh") as highest-risk and demands an explicit "I have
807
+ // reviewed the raw arguments" confirmation before Approve is enabled, and
808
+ // wires approve/deny through the FULL signed-assent ceremony: GET
809
+ // /local/decide-challenge/:pending_oid (operator signs the fetch-challenge
810
+ // payload) -> operator signs the canonical decide payload -> POST
811
+ // /local/decide. It emits NO other state-changing control besides that and
812
+ // the one-time POST /local/enroll bootstrap ceremony (itself a signed,
813
+ // verified action, not a bare bypass) -- the signed-control invariant.
814
+ //
815
+ // Read once at module load and cached; the file rarely changes at runtime
816
+ // (unlike the built-portal branch above, which intentionally serves live
817
+ // off disk with no caching for iterative portal development).
818
+ const LITE_DASHBOARD_HTML = fs.readFileSync(path.join(__dirname, 'lite-dashboard.html'), 'utf8');
819
+ router.get('/dashboard', (_req, res) => {
820
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
821
+ res.send(LITE_DASHBOARD_HTML);
822
+ });
823
+ router.get('/dashboard/*', (_req, res) => {
824
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
825
+ res.send(LITE_DASHBOARD_HTML);
826
+ });
827
+ }
828
+ // ── POST /local/enroll ────────────────────────────────────────────────────────
829
+ //
830
+ // First-run enrollment endpoint (ADR_016 Section 2). Loopback-only (inherits the
831
+ // three router-level guards). Server-derives tenant_id = `tenant:local-${operator_oid}`;
832
+ // the body MUST NOT carry a tenant. Verifies enrollment_signature (proof of key
833
+ // possession, Section 2.2) then calls the SHIPPED enrollOperator.
834
+ //
835
+ // Body: { operator_oid, operator_pubkey_hex, device_label, confirm: true,
836
+ // enrollment_signature }
837
+ // -> 201 { enrolled: true, operator_oid, tenant_id, enrollment_source }
838
+ // -> 400 oid_binding_mismatch | invalid_request
839
+ // -> 403 signature_invalid
840
+ // -> 409 already_enrolled
841
+ router.post('/enroll', (req, res) => {
842
+ const body = req.body;
843
+ // confirm: true is the deliberate-act gate (ADR_016 Section 2.2).
844
+ if (body.confirm !== true) {
845
+ res.status(400).json({ error: 'confirm must be true', type: 'invalid_request' });
846
+ return;
847
+ }
848
+ // Validate required fields.
849
+ if (!isValidOperatorOid(body.operator_oid)) {
850
+ res.status(400).json({ error: 'operator_oid must be "oid-" + 64 lowercase hex chars', type: 'invalid_request' });
851
+ return;
852
+ }
853
+ if (!isValidPubkeyHex(body.operator_pubkey_hex)) {
854
+ res.status(400).json({ error: 'operator_pubkey_hex must be 64 lowercase hex chars', type: 'invalid_request' });
855
+ return;
856
+ }
857
+ if (typeof body.device_label !== 'string' || !body.device_label) {
858
+ res.status(400).json({ error: 'device_label required', type: 'invalid_request' });
859
+ return;
860
+ }
861
+ if (typeof body.enrollment_signature !== 'string' || !/^[0-9a-f]{128}$/.test(body.enrollment_signature)) {
862
+ res.status(400).json({ error: 'enrollment_signature must be 128 lowercase hex chars', type: 'invalid_request' });
863
+ return;
864
+ }
865
+ const operator_oid = body.operator_oid;
866
+ const operator_pubkey_hex = body.operator_pubkey_hex;
867
+ const device_label = body.device_label;
868
+ const enrollment_signature = body.enrollment_signature;
869
+ // Sentinel-OID guard: the timeout sentinel is reserved for daemon auto-deny;
870
+ // reject it at enroll too (defense-in-depth, ADR_016 Section 2.1).
871
+ if (operator_oid === TIMEOUT_SENTINEL_OID) {
872
+ res.status(400).json({
873
+ error: 'operator_oid is the reserved timeout sentinel and cannot be used as an operator identity',
874
+ type: 'sentinel_reserved',
875
+ });
876
+ return;
877
+ }
878
+ // OID-binding: sha256(pubkey) must equal operator_oid (ADR_016 Section 2.1 step 2).
879
+ if (!verifyOidBinding(operator_oid, operator_pubkey_hex)) {
880
+ res.status(400).json({
881
+ error: 'operator_oid does not match sha256(operator_pubkey_hex)',
882
+ type: 'oid_binding_mismatch',
883
+ });
884
+ return;
885
+ }
886
+ // Derive tenant server-side (NEVER from body, ADR_016 Section 2.1 step 3).
887
+ const tenant_id = `tenant:local-${operator_oid}`;
888
+ // Verify enrollment_signature over the canonical enroll payload (Section 2.2).
889
+ // This proves the caller holds the private half of the enrolling key.
890
+ const enrollCanonical = (0, sraid_1.canonicalize)({
891
+ action: 'enroll',
892
+ device_label,
893
+ operator_oid,
894
+ tenant_id,
895
+ });
896
+ if (!verifyOperatorSignature(enrollCanonical, operator_pubkey_hex, enrollment_signature)) {
897
+ res.status(403).json({ error: 'enrollment_signature does not verify', type: 'signature_invalid' });
898
+ return;
899
+ }
900
+ // Call the SHIPPED enrollOperator; map already-enrolled throw to 409.
901
+ try {
902
+ (0, operator_enrollment_1.enrollOperator)(tenant_id, operator_pubkey_hex, device_label, { source: 'first-run-local' });
903
+ }
904
+ catch (e) {
905
+ const msg = e.message ?? '';
906
+ if (msg.includes('already enrolled')) {
907
+ res.status(409).json({ error: 'operator already enrolled for this tenant', type: 'already_enrolled' });
908
+ return;
909
+ }
910
+ res.status(500).json({ error: 'enrollment failed', type: 'internal_error' });
911
+ return;
912
+ }
913
+ res.status(201).json({
914
+ enrolled: true,
915
+ operator_oid,
916
+ tenant_id,
917
+ enrollment_source: 'first-run-local',
918
+ });
919
+ });
920
+ // ── GET /local/decide-challenge/:pending_oid ──────────────────────────────────
921
+ //
922
+ // Authenticated nonce delivery for the dashboard (ADR_016 Section 4.2).
923
+ // Returns the per-pending challenge_nonce ONLY to an enrolled operator who
924
+ // proves possession of their key by signing a fetch-challenge canonical payload.
925
+ // Does NOT consume the nonce. F3 is not re-opened (Section 4.3).
926
+ //
927
+ // Auth headers: X-Operator-OID, X-Operator-Pubkey, X-Operator-Challenge-Sig
928
+ // Runs assertEnrolledOperatorAuth (steps 1-3) for tenant:local-${pending.principal_oid}.
929
+ //
930
+ // -> 200 { pending_oid, challenge_nonce }
931
+ // -> 400 invalid_request (missing fields)
932
+ // -> 403 signature_invalid | operator_not_enrolled_for_tenant
933
+ // -> 404 pending_oid not found or already decided
934
+ router.get('/decide-challenge/:pending_oid', (req, res) => {
935
+ const pending_oid = typeof req.params['pending_oid'] === 'string' ? req.params['pending_oid'] : '';
936
+ if (!pending_oid) {
937
+ res.status(400).json({ error: 'pending_oid required', type: 'invalid_request' });
938
+ return;
939
+ }
940
+ // Extract operator auth from headers.
941
+ const operator_oid = req.headers['x-operator-oid'];
942
+ const operator_pubkey_hex = req.headers['x-operator-pubkey'];
943
+ const challenge_sig = req.headers['x-operator-challenge-sig'];
944
+ if (typeof operator_oid !== 'string' || !isValidOperatorOid(operator_oid)) {
945
+ res.status(400).json({ error: 'X-Operator-OID must be "oid-" + 64 lowercase hex chars', type: 'invalid_request' });
946
+ return;
947
+ }
948
+ if (typeof operator_pubkey_hex !== 'string' || !isValidPubkeyHex(operator_pubkey_hex)) {
949
+ res.status(400).json({ error: 'X-Operator-Pubkey must be 64 lowercase hex chars', type: 'invalid_request' });
950
+ return;
951
+ }
952
+ if (typeof challenge_sig !== 'string' || !/^[0-9a-f]{128}$/.test(challenge_sig)) {
953
+ res.status(400).json({ error: 'X-Operator-Challenge-Sig must be 128 lowercase hex chars', type: 'invalid_request' });
954
+ return;
955
+ }
956
+ // Look up the pending action. Unknown or already-decided -> 404.
957
+ const storedAction = exports._pendingStore.get(pending_oid);
958
+ if (storedAction === undefined) {
959
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
960
+ return;
961
+ }
962
+ // Derive the action tenant (same derivation as /local/decide line 760).
963
+ const action_tenant_id = `tenant:local-${storedAction.principal_oid}`;
964
+ // Build the canonical fetch-challenge payload (ADR_016 Section 4.2).
965
+ // Fix 2 (ADR_016): include tenant_id so signatures are not portable across
966
+ // tenants. A signature over {action, operator_oid, pending_oid} without
967
+ // tenant_id could be replayed against a different tenant's action store.
968
+ const canonical = (0, sraid_1.canonicalize)({
969
+ action: 'fetch-challenge',
970
+ operator_oid,
971
+ pending_oid,
972
+ tenant_id: action_tenant_id,
973
+ });
974
+ // Run steps 1-3 of enrolled-operator auth (assertEnrolledOperatorAuth).
975
+ // On any failure -> 403, no nonce disclosed.
976
+ try {
977
+ (0, operator_enrollment_1.assertEnrolledOperatorAuth)(action_tenant_id, operator_oid, operator_pubkey_hex, challenge_sig, canonical);
978
+ }
979
+ catch (e) {
980
+ if (e instanceof operator_enrollment_1.OperatorNotEnrolledForTenantError) {
981
+ // Fix 1 (ADR_016): return 404 instead of 403 so callers cannot distinguish
982
+ // "enrolled for wrong tenant" (action exists) from "action not found".
983
+ // Returning 403 here was an existence oracle: a wrong-tenant caller could
984
+ // probe whether a pending_oid is real by observing 403 vs 404.
985
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
986
+ return;
987
+ }
988
+ if (e instanceof operator_enrollment_1.OperatorNotEnrolledError) {
989
+ // Fix 3 (ADR_016): collapse enrolled-anywhere oracle. Previously, an unenrolled
990
+ // caller got 403 (not_enrolled) while a wrong-tenant caller got 404 (Fix 1).
991
+ // The difference reveals whether the caller is enrolled anywhere at all.
992
+ // Return 404 here too so the response is indistinguishable from "action not found".
993
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
994
+ return;
995
+ }
996
+ if (e instanceof operator_enrollment_1.EnrollmentIntegrityError) {
997
+ const detail = e.message;
998
+ if (detail.includes('oid_binding_mismatch')) {
999
+ res.status(400).json({ error: 'operator_oid does not match sha256(X-Operator-Pubkey)', type: 'oid_binding_mismatch' });
1000
+ return;
1001
+ }
1002
+ if (detail.includes('signature_invalid')) {
1003
+ res.status(403).json({ error: 'X-Operator-Challenge-Sig does not verify', type: 'signature_invalid' });
1004
+ return;
1005
+ }
1006
+ res.status(400).json({ error: detail, type: 'invalid_request' });
1007
+ return;
1008
+ }
1009
+ res.status(500).json({ error: 'internal error', type: 'internal_error' });
1010
+ return;
1011
+ }
1012
+ // Auth passed: return the stored nonce. Do NOT consume it (consume is at /decide).
1013
+ res.status(200).json({
1014
+ pending_oid,
1015
+ challenge_nonce: storedAction.challenge_nonce,
1016
+ });
1017
+ });
1018
+ // ── POST /local/gate principal_oid derive-or-validate (ADR_016 v1.1) ─────────
1019
+ //
1020
+ // Derive rule (v1 ergonomic win, single-operator deployment):
1021
+ // If principal_oid is OMITTED or empty in the body:
1022
+ // - ZERO active non-demo enrolled operators -> 409 not_enrolled (clear error)
1023
+ // - EXACTLY ONE active non-demo enrolled op -> derive from that operator's OID
1024
+ // - TWO OR MORE active non-demo enrolled ops -> 409 principal_oid_ambiguous (clear error)
1025
+ // If principal_oid IS present:
1026
+ // - validate that it matches an active enrolled operator for tenant:local-<principal_oid>
1027
+ // - mismatch -> 400 principal_not_enrolled (early rejection, not silent decide-time 403)
1028
+ //
1029
+ // The D1 limitation note (receipt attests operator-APPROVAL not principal PROVENANCE
1030
+ // for the general multi-party case) remains intact. This is the v1 single-operator guard.
1031
+ /**
1032
+ * Returns the set of active non-demo enrolled operators from the current record.
1033
+ * Returns an empty array if the record cannot be loaded.
1034
+ */
1035
+ function _getActiveLocalOperators() {
1036
+ try {
1037
+ const record = (0, operator_enrollment_1.loadEnrollmentRecord)();
1038
+ if (record === null)
1039
+ return [];
1040
+ return record.enrolled_operators.filter(e => e.status === 'active' &&
1041
+ e.enrollment_source !== 'demo-session' &&
1042
+ e.tenant_id.startsWith('tenant:local-'));
1043
+ }
1044
+ catch {
1045
+ return [];
1046
+ }
1047
+ }
1048
+ // ── POST /local/gate ──────────────────────────────────────────────────────────
1049
+ //
1050
+ // Ingest an AI action. Body: { bundle_oid, principal_oid?, panel_id, action_kind,
1051
+ // originating_receipt_oid?, args? }
1052
+ // principal_oid is OPTIONAL: if omitted the daemon derives it from the single
1053
+ // active enrolled operator (ADR_016 v1.1 ergonomics). Returns:
1054
+ // { pending_oid, status: "pending", requested_receipt_oid, ingested_at }
1055
+ router.post('/gate', (req, res) => {
1056
+ // #4: Rate-limit check (before any store interaction).
1057
+ if (!_checkAndRecordGateCall()) {
1058
+ res.status(429).json({
1059
+ error: 'too_many_requests',
1060
+ detail: `Rate limit: max ${exports.GATE_RATE_MAX_CALLS} calls per ${exports.GATE_RATE_WINDOW_MS}ms window`,
1061
+ type: 'rate_limited',
1062
+ });
1063
+ return;
1064
+ }
1065
+ // #4: Pending-store cap (before any store interaction).
1066
+ if (exports._pendingStore.size >= exports.MAX_PENDING_ACTIONS) {
1067
+ res.status(429).json({
1068
+ error: 'too_many_requests',
1069
+ detail: `Pending store at capacity (${exports.MAX_PENDING_ACTIONS}); resolve or await timeout of existing actions`,
1070
+ type: 'pending_cap_exceeded',
1071
+ });
1072
+ return;
1073
+ }
1074
+ const body = req.body;
1075
+ if (typeof body.bundle_oid !== 'string' || !body.bundle_oid) {
1076
+ res.status(400).json({ error: 'bundle_oid required', type: 'invalid_request' });
1077
+ return;
1078
+ }
1079
+ if (typeof body.panel_id !== 'string' || !body.panel_id) {
1080
+ res.status(400).json({ error: 'panel_id required', type: 'invalid_request' });
1081
+ return;
1082
+ }
1083
+ if (typeof body.action_kind !== 'string' || !body.action_kind) {
1084
+ res.status(400).json({ error: 'action_kind required', type: 'invalid_request' });
1085
+ return;
1086
+ }
1087
+ // #3: Bundle-OID grant check (Axis-3 subset).
1088
+ // bundle_oid must exist in gap_grants as an active (non-revoked, non-expired) grant.
1089
+ // A bundle_oid that is unknown, revoked, or expired is rejected 403 here.
1090
+ // NOTE: The test-key bootstrap sentinel 'capbundle/1' is whitelisted for test runs
1091
+ // (SYNOI_ALLOW_EPHEMERAL_KEYS=true), because in the test harness no real grants
1092
+ // are written to the DB. In production (SYNOI_ALLOW_EPHEMERAL_KEYS unset/false),
1093
+ // the grant must actually exist in gap_grants.
1094
+ const bundleOid = body.bundle_oid;
1095
+ // Re-panel F2 (2026-06-22): isTestMode requires BOTH SYNOI_ALLOW_EPHEMERAL_KEYS=true
1096
+ // AND NODE_ENV !== 'production'. A prod deploy with the env flag set must not
1097
+ // reopen the capbundle/1 sentinel bypass.
1098
+ const isTestMode = process.env['SYNOI_ALLOW_EPHEMERAL_KEYS'] === 'true' &&
1099
+ process.env['NODE_ENV'] !== 'production';
1100
+ const isBootstrapSentinel = bundleOid === 'capbundle/1';
1101
+ if (!isTestMode || !isBootstrapSentinel) {
1102
+ // Reject if the bundle_oid is known-revoked (covers explicit revocations).
1103
+ if ((0, store_1.isOidRevoked)(bundleOid)) {
1104
+ res.status(403).json({
1105
+ error: 'forbidden',
1106
+ detail: `bundle_oid '${bundleOid}' is revoked`,
1107
+ type: 'bundle_revoked',
1108
+ });
1109
+ return;
1110
+ }
1111
+ // Reject if the grant does not exist at all in the grants table.
1112
+ const grant = (0, store_1.getGrant)(bundleOid);
1113
+ if (grant === null) {
1114
+ res.status(403).json({
1115
+ error: 'forbidden',
1116
+ detail: `bundle_oid '${bundleOid}' is not a known capability grant`,
1117
+ type: 'bundle_unknown',
1118
+ });
1119
+ return;
1120
+ }
1121
+ // Reject if the grant is expired.
1122
+ if (grant.body.expires_at_ms !== undefined && grant.body.expires_at_ms !== null && grant.body.expires_at_ms <= Date.now()) {
1123
+ res.status(403).json({
1124
+ error: 'forbidden',
1125
+ detail: `bundle_oid '${bundleOid}' grant has expired`,
1126
+ type: 'bundle_expired',
1127
+ });
1128
+ return;
1129
+ }
1130
+ // Grant exists and is active. Proceed.
1131
+ }
1132
+ const originating_receipt_oid = typeof body.originating_receipt_oid === 'string' ? body.originating_receipt_oid : null;
1133
+ // ── principal_oid derive-or-validate (ADR_016 v1.1) ──────────────────────
1134
+ //
1135
+ // Runs AFTER bundle/panel/action_kind validation (so bundle errors surface first).
1136
+ //
1137
+ // If omitted: derive from the single active enrolled operator (common v1 case).
1138
+ // If present: validate that it corresponds to an active enrolled operator.
1139
+ const principalOidRaw = typeof body.principal_oid === 'string' ? body.principal_oid.trim() : '';
1140
+ let resolved_principal_oid;
1141
+ if (!principalOidRaw) {
1142
+ // DERIVE path: inspect the enrollment record.
1143
+ const activeOps = _getActiveLocalOperators();
1144
+ if (activeOps.length === 0) {
1145
+ // Zero enrolled operators: reject with a clear actionable error.
1146
+ res.status(409).json({
1147
+ error: 'no active enrolled operator; enroll an operator first via POST /local/enroll, or pass principal_oid explicitly',
1148
+ type: 'not_enrolled',
1149
+ });
1150
+ return;
1151
+ }
1152
+ if (activeOps.length > 1) {
1153
+ // Multiple enrolled operators: cannot auto-derive; require explicit principal_oid.
1154
+ res.status(409).json({
1155
+ error: 'multiple active enrolled operators; pass principal_oid explicitly to identify which operator governs this action',
1156
+ type: 'principal_oid_ambiguous',
1157
+ });
1158
+ return;
1159
+ }
1160
+ // Exactly one: derive.
1161
+ resolved_principal_oid = activeOps[0].operator_oid;
1162
+ }
1163
+ else {
1164
+ // VALIDATE path: principal_oid was provided; check it matches an enrolled operator.
1165
+ if (!isValidOperatorOid(principalOidRaw)) {
1166
+ res.status(400).json({
1167
+ error: 'principal_oid must be "oid-" + 64 lowercase hex chars',
1168
+ type: 'invalid_request',
1169
+ });
1170
+ return;
1171
+ }
1172
+ const expectedTenant = `tenant:local-${principalOidRaw}`;
1173
+ const activeOps = _getActiveLocalOperators();
1174
+ const match = activeOps.find(e => e.operator_oid === principalOidRaw && e.tenant_id === expectedTenant);
1175
+ if (match === undefined) {
1176
+ // Early reject: fail-closed rather than queuing an action no one can approve.
1177
+ res.status(400).json({
1178
+ error: `principal_oid '${principalOidRaw}' does not match an active enrolled operator; enroll first or verify the OID`,
1179
+ type: 'principal_not_enrolled',
1180
+ });
1181
+ return;
1182
+ }
1183
+ resolved_principal_oid = principalOidRaw;
1184
+ }
1185
+ // Build action. We emit the .requested receipt first to get the OID, then
1186
+ // store the action with that OID as the pending_oid.
1187
+ const now = Date.now();
1188
+ const challenge_nonce = (0, node_crypto_1.randomBytes)(32).toString('hex');
1189
+ // Temporary action object for receipt emission (pending_oid filled after).
1190
+ // principal_oid is resolved_principal_oid (derived or validated above, never raw body).
1191
+ const partial = {
1192
+ bundle_oid: body.bundle_oid,
1193
+ principal_oid: resolved_principal_oid,
1194
+ panel_id: body.panel_id,
1195
+ action_kind: body.action_kind,
1196
+ originating_receipt_oid,
1197
+ args: body.args ?? null,
1198
+ ingested_at_ms: now,
1199
+ status: 'pending',
1200
+ };
1201
+ // Emit the governed-action.requested receipt (signed, precondition-certificate ordering).
1202
+ const requestedReceipt = _emitRequestedReceipt({
1203
+ ...partial,
1204
+ pending_oid: 'pending-placeholder',
1205
+ requested_receipt_oid: 'pending-placeholder',
1206
+ challenge_nonce,
1207
+ });
1208
+ // The receipt OID IS the pending_oid (content-addressed).
1209
+ const pending_oid = requestedReceipt.oid;
1210
+ const action = {
1211
+ ...partial,
1212
+ pending_oid,
1213
+ challenge_nonce,
1214
+ requested_receipt_oid: pending_oid,
1215
+ };
1216
+ exports._pendingStore.set(pending_oid, action);
1217
+ res.status(201).json({
1218
+ pending_oid,
1219
+ status: 'pending',
1220
+ requested_receipt_oid: pending_oid,
1221
+ challenge_nonce,
1222
+ ingested_at: new Date(now).toISOString(),
1223
+ });
1224
+ });
1225
+ // ── GET /local/pending ────────────────────────────────────────────────────────
1226
+ router.get('/pending', (_req, res) => {
1227
+ // Re-panel finding #3 (2026-06-22): challenge_nonce is deliberately NOT included
1228
+ // here. It is already returned in the POST /local/gate 201 response, so the client
1229
+ // has it. Exposing it on every listing poll lets any loopback process enumerate live
1230
+ // nonces, which is the nonce-farming attack surface closed by this fix.
1231
+ const pending = Array.from(exports._pendingStore.values()).map(a => ({
1232
+ pending_oid: a.pending_oid,
1233
+ bundle_oid: a.bundle_oid,
1234
+ principal_oid: a.principal_oid,
1235
+ panel_id: a.panel_id,
1236
+ action_kind: a.action_kind,
1237
+ originating_receipt_oid: a.originating_receipt_oid,
1238
+ ingested_at: new Date(a.ingested_at_ms).toISOString(),
1239
+ status: a.status,
1240
+ }));
1241
+ res.json({ pending, count: pending.length });
1242
+ });
1243
+ // ── GET /local/pending/:pending_oid ───────────────────────────────────────────
1244
+ //
1245
+ // ADR_014 Section 10.3 renderer item 4. The list endpoint above (GET /pending)
1246
+ // deliberately omits `args` -- returning every pending action's full argument
1247
+ // payload on every poll would let any loopback process enumerate all live
1248
+ // actions' arguments at once. This endpoint returns ONE pending action's full
1249
+ // detail, including `args`, on-demand -- the approval `form` panel calls this
1250
+ // once the operator has selected which pending action to review, not on every
1251
+ // list refresh. Same re-panel-finding-#3 reasoning as the list endpoint applies
1252
+ // to challenge_nonce: it is NOT included here either (already delivered via the
1253
+ // POST /local/gate 201 response and re-derivable by an enrolled operator via
1254
+ // GET /local/decide-challenge/:pending_oid, so there is no operational reason
1255
+ // to also expose it on a read-only detail fetch, and every additional exposure
1256
+ // point is additional nonce-farming surface).
1257
+ //
1258
+ // Inherits the three router-level guards (loopback, Host, custom-header) --
1259
+ // no separate auth is added here, same as GET /local/receipts/:oid and GET
1260
+ // /local/gate/:pending_oid: this is a read of an action already visible via
1261
+ // the list endpoint, just with the args field included.
1262
+ router.get('/pending/:pending_oid', (req, res) => {
1263
+ const pending_oid = typeof req.params['pending_oid'] === 'string' ? req.params['pending_oid'] : '';
1264
+ if (!pending_oid) {
1265
+ res.status(400).json({ error: 'pending_oid required', type: 'invalid_request' });
1266
+ return;
1267
+ }
1268
+ const a = exports._pendingStore.get(pending_oid);
1269
+ if (a === undefined) {
1270
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
1271
+ return;
1272
+ }
1273
+ res.json({
1274
+ pending_oid: a.pending_oid,
1275
+ bundle_oid: a.bundle_oid,
1276
+ principal_oid: a.principal_oid,
1277
+ panel_id: a.panel_id,
1278
+ action_kind: a.action_kind,
1279
+ originating_receipt_oid: a.originating_receipt_oid,
1280
+ args: a.args,
1281
+ ingested_at: new Date(a.ingested_at_ms).toISOString(),
1282
+ status: a.status,
1283
+ });
1284
+ });
1285
+ // ── POST /local/decide ────────────────────────────────────────────────────────
1286
+ //
1287
+ // OPERATOR-AUTH: body must carry operator_oid, operator_pubkey_hex,
1288
+ // operator_signature over canonicalize({choice, pending_oid, operator_oid}).
1289
+ //
1290
+ // PARTIAL-pending-Security+Adversary-panel: the OID-binding check ensures the
1291
+ // supplied pubkey matches the declared operator OID (sha256 binding). It does
1292
+ // NOT verify that this operator OID is enrolled / trusted by a genesis-rooted
1293
+ // chain. That check is a future step after panel review.
1294
+ router.post('/decide', (req, res) => {
1295
+ const body = req.body;
1296
+ // Validate required decision fields.
1297
+ if (typeof body.pending_oid !== 'string' || !body.pending_oid) {
1298
+ res.status(400).json({ error: 'pending_oid required', type: 'invalid_request' });
1299
+ return;
1300
+ }
1301
+ if (body.choice !== 'allow' && body.choice !== 'deny') {
1302
+ res.status(400).json({ error: 'choice must be "allow" or "deny"', type: 'invalid_request' });
1303
+ return;
1304
+ }
1305
+ // Sentinel-OID guard (defense-in-depth, must run before OID-binding check).
1306
+ // TIMEOUT_SENTINEL_OID is reserved for daemon-originated auto-deny receipts;
1307
+ // it passes isValidOperatorOid (all-zero hex is syntactically valid) but must
1308
+ // NEVER be presentable as an operator identity. Reject early with a distinct
1309
+ // type so the audit trail is unambiguous.
1310
+ if (body.operator_oid === TIMEOUT_SENTINEL_OID) {
1311
+ res.status(400).json({
1312
+ error: 'operator_oid is the reserved timeout sentinel and cannot be used as an operator identity',
1313
+ type: 'sentinel_reserved',
1314
+ });
1315
+ return;
1316
+ }
1317
+ // Validate operator identity fields (OPERATOR-AUTH, load-bearing).
1318
+ if (!isValidOperatorOid(body.operator_oid)) {
1319
+ res.status(400).json({ error: 'operator_oid must be "oid-" + 64 lowercase hex chars', type: 'invalid_request' });
1320
+ return;
1321
+ }
1322
+ if (!isValidPubkeyHex(body.operator_pubkey_hex)) {
1323
+ res.status(400).json({ error: 'operator_pubkey_hex must be 64 lowercase hex chars', type: 'invalid_request' });
1324
+ return;
1325
+ }
1326
+ if (typeof body.operator_signature !== 'string' || !/^[0-9a-f]{128}$/.test(body.operator_signature)) {
1327
+ res.status(400).json({ error: 'operator_signature must be 128 lowercase hex chars', type: 'invalid_request' });
1328
+ return;
1329
+ }
1330
+ // challenge_nonce is required for replay protection (ADR_015).
1331
+ if (typeof body.challenge_nonce !== 'string' || !body.challenge_nonce) {
1332
+ res.status(400).json({ error: 'challenge_nonce required', type: 'invalid_request' });
1333
+ return;
1334
+ }
1335
+ // Look up the action BEFORE auth to extract stored nonce and tenant.
1336
+ // Do not delete yet.
1337
+ const storedAction = exports._pendingStore.get(body.pending_oid);
1338
+ const stored_nonce = storedAction?.challenge_nonce;
1339
+ // Derive the tenant from the pending action's principal_oid (ADR_015 Section 10.2.A).
1340
+ // This is the same derivation used by _buildReceiptBase.
1341
+ // When storedAction is undefined the action does not exist (timed out, double-decide,
1342
+ // or unknown oid). In that case action_tenant_id is empty: Step-2 in assertEnrolledOperator
1343
+ // will fail with OperatorNotEnrolledForTenantError, which we map to 404 (same as the
1344
+ // nonce_missing path, so the enrolled-but-replay and enrolled-but-unknown cases
1345
+ // both return 404 consistently -- no information leakage to unenrolled callers who
1346
+ // still get 403/409 from the OID-binding / enrollment_missing steps that fire earlier).
1347
+ const action_tenant_id = storedAction !== undefined
1348
+ ? `tenant:local-${storedAction.principal_oid}`
1349
+ : '';
1350
+ // Build canonical payload WITH challenge_nonce (ADR_015 Section 3.5).
1351
+ // Fix 2 (ADR_016): include tenant_id so signatures are bound to the specific
1352
+ // tenant. Without tenant_id, a signature over a decide payload could be
1353
+ // presented to any tenant that accepted the same operator_oid.
1354
+ const canonical = (0, sraid_1.canonicalize)({
1355
+ choice: body.choice,
1356
+ operator_oid: body.operator_oid,
1357
+ pending_oid: body.pending_oid,
1358
+ challenge_nonce: body.challenge_nonce,
1359
+ tenant_id: action_tenant_id,
1360
+ });
1361
+ // Enrollment check: OID-binding + tenant-scoped enrolled-member + signature + freshness.
1362
+ // Passes action_tenant_id as the first arg (ADR_015 Section 10.2.A).
1363
+ try {
1364
+ (0, operator_enrollment_1.assertEnrolledOperator)(action_tenant_id, body.operator_oid, body.operator_pubkey_hex, body.operator_signature, canonical, body.challenge_nonce, stored_nonce);
1365
+ }
1366
+ catch (e) {
1367
+ if (e instanceof operator_enrollment_1.OperatorNotEnrolledForTenantError) {
1368
+ // When storedAction was undefined we used tenant_id=''. If the operator IS enrolled
1369
+ // for some tenant but there is no action, this error means the action does not exist
1370
+ // (already consumed / timed out / never existed). Return 404 in that case to
1371
+ // preserve the original replay -> 404 behavior. If storedAction was present but
1372
+ // the operator is enrolled for a different tenant, return 403.
1373
+ if (storedAction === undefined) {
1374
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
1375
+ }
1376
+ else {
1377
+ res.status(403).json({ error: e.message, type: e.code });
1378
+ }
1379
+ return;
1380
+ }
1381
+ if (e instanceof operator_enrollment_1.OperatorNotEnrolledError) {
1382
+ const code = e.code;
1383
+ res.status(code === 'not_enrolled' ? 403 : 409).json({ error: e.message, type: code });
1384
+ return;
1385
+ }
1386
+ if (e instanceof operator_enrollment_1.EnrollmentIntegrityError) {
1387
+ const detail = e.message;
1388
+ if (detail.includes('oid_binding_mismatch')) {
1389
+ res.status(400).json({ error: 'operator_oid does not match sha256(operator_pubkey_hex)', type: 'oid_binding_mismatch' });
1390
+ return;
1391
+ }
1392
+ if (detail.includes('signature_invalid')) {
1393
+ res.status(403).json({ error: 'operator_signature does not verify', type: 'signature_invalid' });
1394
+ return;
1395
+ }
1396
+ if (detail.includes('stale_or_replayed')) {
1397
+ res.status(409).json({ error: detail, type: 'stale_or_replayed' });
1398
+ return;
1399
+ }
1400
+ if (detail.includes('nonce_missing')) {
1401
+ // nonce_missing means the action was not in the store at lookup time.
1402
+ // Treat this as not-found (action timed out, was already decided, etc.).
1403
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
1404
+ return;
1405
+ }
1406
+ res.status(400).json({ error: detail, type: 'invalid_request' });
1407
+ return;
1408
+ }
1409
+ res.status(500).json({ error: 'internal error', type: 'internal_error' });
1410
+ return;
1411
+ }
1412
+ // Re-check that the action still exists (may have timed out between lookup and auth).
1413
+ const action = exports._pendingStore.get(body.pending_oid);
1414
+ if (action === undefined) {
1415
+ res.status(404).json({ error: 'pending_oid not found or already decided', type: 'not_found' });
1416
+ return;
1417
+ }
1418
+ // Remove from pending BEFORE emitting the receipt (fail-closed: if receipt
1419
+ // emission fails we have already removed the action so it cannot be
1420
+ // double-decided).
1421
+ exports._pendingStore.delete(body.pending_oid);
1422
+ const reason = typeof body.reason === 'string' ? body.reason : undefined;
1423
+ const operator_oid = body.operator_oid;
1424
+ // Emit the decision receipt (signed, precondition-certificate ordering).
1425
+ let decisionReceipt;
1426
+ if (body.choice === 'allow') {
1427
+ decisionReceipt = _emitAllowedReceipt(action, operator_oid, reason);
1428
+ }
1429
+ else {
1430
+ decisionReceipt = _emitDeniedReceipt(action, operator_oid, reason);
1431
+ }
1432
+ // The action is released to the caller ONLY on allow, ONLY after the receipt
1433
+ // is signed (precondition-certificate ordering per ADR_014 Section 1.6).
1434
+ res.status(200).json({
1435
+ decision: body.choice,
1436
+ decision_oid: decisionReceipt.oid,
1437
+ receipt_oid: decisionReceipt.oid,
1438
+ // The action may run ONLY if decision == 'allow' AND the caller verifies
1439
+ // the receipt at /local/receipts/:oid. Do NOT run the action on deny.
1440
+ action_released: body.choice === 'allow',
1441
+ verify_url: `/local/receipts/${decisionReceipt.oid}`,
1442
+ });
1443
+ });
1444
+ // ── GET /local/gate/:pending_oid ─────────────────────────────────────────────
1445
+ //
1446
+ // ADR_014 Section 9.1 / 9.2 outcome endpoint.
1447
+ //
1448
+ // Returns the resolution status of a submitted pending action. The agent
1449
+ // calls this (NOT /local/decide -- that is operator-only). Three logical phases:
1450
+ //
1451
+ // 1. pending_oid still in _pendingStore -> status:"pending"
1452
+ // Optional ?wait_ms=<n> long-poll: park up to min(wait_ms, MAX_WAIT_MS)
1453
+ // checking _pendingStore on a 250ms tick; return early when the action
1454
+ // leaves the store (decided or swept). On window elapse with action still
1455
+ // pending: return 200 status:"pending". Mirrors /v1/risk/hitl/:id/wait.
1456
+ //
1457
+ // 2. pending_oid gone from _pendingStore AND a decision receipt found in
1458
+ // _localReceiptOids whose body.subject_oid === pending_oid:
1459
+ // - body.initiator.actor_oid === TIMEOUT_SENTINEL_OID -> status:"timeout"
1460
+ // - body.decision === 'allow' -> status:"allowed", decision, receipt_oid, verify_url
1461
+ // - body.decision === 'deny' (operator) -> status:"denied", decision, receipt_oid, verify_url
1462
+ //
1463
+ // 3. Neither (unknown OID: never submitted, or evicted with no receipt) -> 404.
1464
+ //
1465
+ // Loopback + Host + custom-header guards are inherited from the router-level
1466
+ // middlewares above (do NOT add auth here; this is a read-only agent observe).
1467
+ // challenge_nonce is deliberately NOT returned (ADR_014 Section 9.4 / re-panel #3).
1468
+ //
1469
+ // Status: DESIGN (ADR_014 Section 9.5 DESIGN). PARTIAL-against-test-keys.
1470
+ /** Maximum server-side long-poll window (mirrors /v1/risk/hitl/:id/wait 30s cap). */
1471
+ exports.MAX_OUTCOME_WAIT_MS = 30_000;
1472
+ /** Tick interval for the long-poll internal poller. */
1473
+ const OUTCOME_POLL_TICK_MS = 250;
1474
+ /**
1475
+ * Concurrency cap for the long-poll path only (wait_ms > 0).
1476
+ * Short-poll (wait_ms=0 or absent) is never subject to this cap.
1477
+ * Mirrors the MAX_PENDING_ACTIONS pattern: prevents unbounded open handles.
1478
+ */
1479
+ exports.MAX_CONCURRENT_OUTCOME_WAITS = 20;
1480
+ let _activeLongPolls = 0;
1481
+ /** TEST ONLY: reset the active long-poll counter to 0 (or to a given value). */
1482
+ function _resetActiveLongPolls(to = 0) {
1483
+ _activeLongPolls = to;
1484
+ }
1485
+ /**
1486
+ * Look up a decision receipt whose body.subject_oid === pending_oid from the
1487
+ * _localReceiptOids registry. Returns null if none found.
1488
+ */
1489
+ function _findDecisionReceiptForPendingOid(pending_oid) {
1490
+ for (const oid of _localReceiptOids) {
1491
+ const r = (0, store_1.getReceipt)(oid);
1492
+ if (r === null)
1493
+ continue;
1494
+ // The requested receipt has status='pending' (both shapes); skip it.
1495
+ // Decision receipts (allowed/denied) resolve to a verdict on EITHER shape
1496
+ // this router emits -- see _decisionVerdict for why body.decision may be
1497
+ // absent on the lite self-sign tier.
1498
+ if (r.body.subject_oid === pending_oid && _decisionVerdict(r) !== null) {
1499
+ return r;
1500
+ }
1501
+ }
1502
+ return null;
1503
+ }
1504
+ router.get('/gate/:pending_oid', (req, res) => {
1505
+ const pending_oid = typeof req.params['pending_oid'] === 'string'
1506
+ ? req.params['pending_oid']
1507
+ : '';
1508
+ if (!pending_oid) {
1509
+ res.status(400).json({ error: 'pending_oid required', type: 'invalid_request' });
1510
+ return;
1511
+ }
1512
+ const rawWaitMs = req.query['wait_ms'];
1513
+ const requestedWaitMs = typeof rawWaitMs === 'string' && /^\d+$/.test(rawWaitMs)
1514
+ ? Math.min(parseInt(rawWaitMs, 10), exports.MAX_OUTCOME_WAIT_MS)
1515
+ : 0;
1516
+ function resolveNow() {
1517
+ // Phase 1: still pending.
1518
+ if (exports._pendingStore.has(pending_oid)) {
1519
+ res.status(200).json({ pending_oid, status: 'pending' });
1520
+ return;
1521
+ }
1522
+ // Phase 2: look up decision receipt.
1523
+ const receipt = _findDecisionReceiptForPendingOid(pending_oid);
1524
+ if (receipt !== null) {
1525
+ const isSentinel = receipt.body.initiator.actor_oid === TIMEOUT_SENTINEL_OID;
1526
+ if (isSentinel) {
1527
+ res.status(200).json({
1528
+ pending_oid,
1529
+ status: 'timeout',
1530
+ receipt_oid: receipt.oid,
1531
+ verify_url: `/local/receipts/${receipt.oid}`,
1532
+ });
1533
+ }
1534
+ else if (_decisionVerdict(receipt) === 'allow') {
1535
+ res.status(200).json({
1536
+ pending_oid,
1537
+ status: 'allowed',
1538
+ decision: 'allow',
1539
+ receipt_oid: receipt.oid,
1540
+ verify_url: `/local/receipts/${receipt.oid}`,
1541
+ });
1542
+ }
1543
+ else {
1544
+ // deny (operator-decided, non-sentinel)
1545
+ res.status(200).json({
1546
+ pending_oid,
1547
+ status: 'denied',
1548
+ decision: 'deny',
1549
+ receipt_oid: receipt.oid,
1550
+ verify_url: `/local/receipts/${receipt.oid}`,
1551
+ });
1552
+ }
1553
+ return;
1554
+ }
1555
+ // Phase 3: unknown pending_oid (never existed, no pending, no receipt).
1556
+ res.status(404).json({ error: 'not_found', type: 'not_found' });
1557
+ }
1558
+ // No wait_ms: short-poll, return immediately.
1559
+ if (requestedWaitMs <= 0) {
1560
+ resolveNow();
1561
+ return;
1562
+ }
1563
+ // Long-poll: action is currently pending; park up to requestedWaitMs.
1564
+ // If the action is already decided (or unknown) return immediately.
1565
+ if (!exports._pendingStore.has(pending_oid)) {
1566
+ resolveNow();
1567
+ return;
1568
+ }
1569
+ // Concurrency cap: only long-poll paths (wait_ms > 0) count toward the cap.
1570
+ // Short-poll (requestedWaitMs <= 0) already returned above.
1571
+ if (_activeLongPolls >= exports.MAX_CONCURRENT_OUTCOME_WAITS) {
1572
+ res.status(429).json({
1573
+ error: `Too many concurrent long-polls (cap: ${exports.MAX_CONCURRENT_OUTCOME_WAITS}); use wait_ms=0 for immediate status`,
1574
+ type: 'too_many_long_polls',
1575
+ });
1576
+ return;
1577
+ }
1578
+ // Increment before entering the wait loop; decrement on ALL exit paths.
1579
+ _activeLongPolls++;
1580
+ // Park: poll _pendingStore every OUTCOME_POLL_TICK_MS until the action
1581
+ // leaves (decided/swept) or the window elapses.
1582
+ const deadline = Date.now() + requestedWaitMs;
1583
+ let timer = null;
1584
+ let settled = false;
1585
+ function cleanup() {
1586
+ if (settled)
1587
+ return;
1588
+ settled = true;
1589
+ _activeLongPolls--;
1590
+ if (timer !== null) {
1591
+ clearTimeout(timer);
1592
+ timer = null;
1593
+ }
1594
+ }
1595
+ // Handle early client disconnect (avoid writing to a closed socket).
1596
+ req.on('close', cleanup);
1597
+ function tick() {
1598
+ if (settled)
1599
+ return;
1600
+ if (!exports._pendingStore.has(pending_oid)) {
1601
+ // Action left the store (decided or swept); resolve now.
1602
+ cleanup();
1603
+ resolveNow();
1604
+ return;
1605
+ }
1606
+ const remaining = deadline - Date.now();
1607
+ if (remaining <= 0) {
1608
+ // Window elapsed; action still pending.
1609
+ cleanup();
1610
+ res.status(200).json({ pending_oid, status: 'pending' });
1611
+ return;
1612
+ }
1613
+ timer = setTimeout(tick, Math.min(OUTCOME_POLL_TICK_MS, remaining));
1614
+ }
1615
+ timer = setTimeout(tick, Math.min(OUTCOME_POLL_TICK_MS, requestedWaitMs));
1616
+ });
1617
+ // ── GET /local/receipts ───────────────────────────────────────────────────────
1618
+ //
1619
+ // Returns receipts from the store for this session. For v1 (single tenant),
1620
+ // we query the receipts store for receipts whose detail contains
1621
+ // "governed-action." subjects. In a future slice this uses receipt.query
1622
+ // over the bundle's three own subjects.
1623
+ router.get('/receipts', (_req, res) => {
1624
+ // Use the _listLocalReceipts helper (exported for tests).
1625
+ const receipts = _listLocalReceipts();
1626
+ res.json({ receipts, count: receipts.length });
1627
+ });
1628
+ // ── GET /local/receipts/:oid ──────────────────────────────────────────────────
1629
+ router.get('/receipts/:oid', (req, res) => {
1630
+ const oid = typeof req.params['oid'] === 'string' ? req.params['oid'] : '';
1631
+ if (!oid) {
1632
+ res.status(400).json({ error: 'oid required', type: 'invalid_request' });
1633
+ return;
1634
+ }
1635
+ const r = (0, store_1.getReceipt)(oid);
1636
+ if (r === null) {
1637
+ res.status(404).json({ error: 'receipt not found', type: 'not_found' });
1638
+ return;
1639
+ }
1640
+ res.json(r);
1641
+ });
1642
+ exports.default = router;