@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.
- package/LICENSE +195 -0
- package/README.md +185 -0
- package/bin/synoi-gateway-lite.js +22 -0
- package/dist/cdro-mirror.js +280 -0
- package/dist/daemon.js +194 -0
- package/dist/gap/cited-oracle-inputs.js +283 -0
- package/dist/gap/lite-dashboard.html +502 -0
- package/dist/gap/lite-keystore.js +351 -0
- package/dist/gap/lite-mode.js +31 -0
- package/dist/gap/lite-signing-key.js +362 -0
- package/dist/gap/local-ingest-router.js +1642 -0
- package/dist/gap/operator-enrollment.js +466 -0
- package/dist/gap/store.js +1063 -0
- package/dist/gap/types.js +15 -0
- package/dist/key-provider.js +275 -0
- package/dist/keys.js +250 -0
- package/dist/native-mldsa.js +82 -0
- package/dist/receipt-store.js +801 -0
- package/dist/shadow-mode.js +85 -0
- package/dist/state-drift/quantize.js +107 -0
- package/dist/tenant-store.js +366 -0
- package/dist/verify-router.js +1755 -0
- package/npm-shrinkwrap.json +1400 -0
- package/package.json +46 -0
|
@@ -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;
|