@synoi/gateway-lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,466 @@
1
+ "use strict";
2
+ /**
3
+ * gap/operator-enrollment.ts - operator identity enrollment record + auth helper.
4
+ *
5
+ * ADR_015 Fix #1: closes Attack A by requiring that the operator OID be
6
+ * enrolled via a signed-and-sealed record before any HITL decision is accepted.
7
+ *
8
+ * No em dashes. No AI attribution.
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.EnrollmentIntegrityError = exports.OperatorNotEnrolledForTenantError = exports.OperatorNotEnrolledError = exports.LITE_ENROLLMENT_PAYLOAD_TYPE = exports.ENROLLMENT_PAYLOAD_TYPE = void 0;
15
+ exports.getEnrollmentPath = getEnrollmentPath;
16
+ exports.verifyDsseEnrollment = verifyDsseEnrollment;
17
+ exports._testResetEnrollmentCache = _testResetEnrollmentCache;
18
+ exports.loadEnrollmentRecord = loadEnrollmentRecord;
19
+ exports.saveEnrollmentRecord = saveEnrollmentRecord;
20
+ exports.enrollOperator = enrollOperator;
21
+ exports.assertEnrolledOperatorAuth = assertEnrolledOperatorAuth;
22
+ exports.assertEnrolledOperator = assertEnrolledOperator;
23
+ exports._testClearEnrollment = _testClearEnrollment;
24
+ exports._testEnrollOperatorForTest = _testEnrollOperatorForTest;
25
+ const node_path_1 = __importDefault(require("node:path"));
26
+ const node_os_1 = __importDefault(require("node:os"));
27
+ const node_fs_1 = __importDefault(require("node:fs"));
28
+ const node_crypto_1 = require("node:crypto");
29
+ const ml_dsa_js_1 = require("@noble/post-quantum/ml-dsa.js");
30
+ const ed25519_1 = require("@noble/curves/ed25519");
31
+ const verify_router_1 = require("../verify-router");
32
+ const key_provider_1 = require("../key-provider");
33
+ const sraid_1 = require("@synoi/sraid");
34
+ const lite_mode_1 = require("./lite-mode");
35
+ const lite_signing_key_1 = require("./lite-signing-key");
36
+ exports.ENROLLMENT_PAYLOAD_TYPE = 'application/vnd.synoi.operator-enrollment+json';
37
+ /**
38
+ * Security F1 (2026-07-12 quality gate, SEVERE, ship-block): the lite
39
+ * enrollment seal tier. The full-gateway tier above (ENROLLMENT_PAYLOAD_TYPE,
40
+ * verifyDsseEnrollment below) seals with the gateway's signing bundle
41
+ * (verify-router.ts currentBundle()), which in production is a persisted,
42
+ * managed key -- correct there. In lite mode that same bundle is, by design,
43
+ * the SYNOI_ALLOW_EPHEMERAL_KEYS fallback: a fresh Ed25519+ML-DSA-65 pair
44
+ * generated in-process on EVERY boot and never written to disk. Sealing the
45
+ * enrollment record with it meant the seal was byte-correct at write time but
46
+ * unverifiable forever after, because the NEXT boot's key history starts
47
+ * empty and gets a DIFFERENT fresh bundle -- never the one that signed the
48
+ * file. loadEnrollmentRecord() then returns null, the operator reads
49
+ * not-enrolled, and a subsequent enrollOperator() call starts
50
+ * enrollment_seq back at 1 (the "already enrolled" guard and the audit
51
+ * trail's monotonic sequence both silently reset).
52
+ *
53
+ * FIX: in lite mode, seal with the PERSISTED operator key
54
+ * (lite-signing-key.ts), Ed25519-only, verified against the persisted lite
55
+ * public key -- the SAME key that survives a restart by construction (it is
56
+ * generated once and read from disk on every later boot). This removes
57
+ * lite's enrollment-seal dependence on SYNOI_ALLOW_EPHEMERAL_KEYS /
58
+ * initSigningKeys() entirely: loadEnrollmentRecord() for a lite-sealed
59
+ * record never touches the gateway key history.
60
+ *
61
+ * Dispatch is by the record's OWN attestation.payloadType, not by the
62
+ * CURRENT process's lite-mode flag: a lite-sealed record verifies correctly
63
+ * via the lite path regardless of what mode happens to be active when it is
64
+ * loaded (there is no legitimate cross-tier record today, but self-describing
65
+ * dispatch is strictly more robust than trusting ambient state).
66
+ *
67
+ * Uses @synoi/sraid's own `pae()` (Pre-Authentication Encoding) rather than
68
+ * hand-rolling a second copy of the DSSE prefix construction the way
69
+ * verifyDsseEnrollment below does (pre-existing code, out of scope for this
70
+ * fix): one canonical PAE implementation for new code.
71
+ */
72
+ exports.LITE_ENROLLMENT_PAYLOAD_TYPE = 'application/vnd.synoi.operator-enrollment.lite+json';
73
+ function signLiteEnrollmentSeal(payload) {
74
+ const key = (0, lite_signing_key_1.loadOrCreateLiteSigningKey)();
75
+ const paeBytes = (0, sraid_1.pae)(exports.LITE_ENROLLMENT_PAYLOAD_TYPE, payload);
76
+ const sigBytes = ed25519_1.ed25519.sign(paeBytes, key.privateKey);
77
+ return {
78
+ payloadType: exports.LITE_ENROLLMENT_PAYLOAD_TYPE,
79
+ payload,
80
+ signatures: [{ alg: 'ed25519', sig: Buffer.from(sigBytes).toString('base64') }],
81
+ };
82
+ }
83
+ /**
84
+ * Verify a lite-tier enrollment seal against the PERSISTED operator key.
85
+ * Unlike verifyDsseEnrollment (which walks the gateway's in-process key
86
+ * history), this checks against exactly one key: whatever
87
+ * loadOrCreateLiteSigningKey() currently loads from disk. That call is
88
+ * itself idempotent and restart-stable (same file, same key, every boot),
89
+ * which is the whole point of this fix. Never throws; returns false on any
90
+ * failure (malformed signature, wrong payloadType, key-load failure).
91
+ */
92
+ function verifyLiteEnrollmentSeal(attestation, payload) {
93
+ if (attestation.payloadType !== exports.LITE_ENROLLMENT_PAYLOAD_TYPE)
94
+ return false;
95
+ const edSig = attestation.signatures.find(s => s.alg === 'ed25519');
96
+ if (edSig === undefined)
97
+ return false;
98
+ try {
99
+ const key = (0, lite_signing_key_1.loadOrCreateLiteSigningKey)();
100
+ const paeBytes = (0, sraid_1.pae)(attestation.payloadType, payload);
101
+ return ed25519_1.ed25519.verify(Buffer.from(edSig.sig, 'base64'), paeBytes, key.publicKey);
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ class OperatorNotEnrolledError extends Error {
108
+ code;
109
+ constructor(code, message) {
110
+ super(message);
111
+ this.name = 'OperatorNotEnrolledError';
112
+ this.code = code;
113
+ }
114
+ }
115
+ exports.OperatorNotEnrolledError = OperatorNotEnrolledError;
116
+ /**
117
+ * Thrown when the operator IS enrolled but NOT for the requested tenant
118
+ * (ADR_015 Section 10.2.A). Distinct from OperatorNotEnrolledError so the
119
+ * failure mode is observable at the router level.
120
+ */
121
+ class OperatorNotEnrolledForTenantError extends Error {
122
+ code;
123
+ constructor(message) {
124
+ super(message);
125
+ this.name = 'OperatorNotEnrolledForTenantError';
126
+ this.code = 'operator_not_enrolled_for_tenant';
127
+ }
128
+ }
129
+ exports.OperatorNotEnrolledForTenantError = OperatorNotEnrolledForTenantError;
130
+ class EnrollmentIntegrityError extends Error {
131
+ constructor(message) {
132
+ super(message);
133
+ this.name = 'EnrollmentIntegrityError';
134
+ }
135
+ }
136
+ exports.EnrollmentIntegrityError = EnrollmentIntegrityError;
137
+ function getEnrollmentPath() {
138
+ return node_path_1.default.join(process.env['SYNOI_DATA_DIR'] ?? node_path_1.default.join(node_os_1.default.homedir(), '.synoi'), 'operator-enrollment.json');
139
+ }
140
+ // The enrollment-seal content-core is canonicalized with the SINGLE @synoi/sraid
141
+ // serializer (RFC 8785 JCS). The previous local `canonicalize` copy was
142
+ // byte-identical to @synoi/sraid for the seal content-core (schema_version,
143
+ // enrolled_operators[], enrollment_seq integer, updated_at) across all in-use
144
+ // records; removing it leaves exactly one canonicalization path.
145
+ /**
146
+ * Verify a DSSE enrollment attestation. Reconstructs PAE from the attestation
147
+ * and checks Ed25519 + ML-DSA-65 over those PAE bytes, against every key in history.
148
+ * Returns true only if both algorithms verify with the SAME bundle.
149
+ */
150
+ function verifyDsseEnrollment(attestation, payload) {
151
+ const enc = new TextEncoder();
152
+ const typeBytes = enc.encode(attestation.payloadType);
153
+ const bodyBytes = enc.encode(payload);
154
+ const prefix = enc.encode(`DSSEv1 ${typeBytes.length} ${attestation.payloadType} ${bodyBytes.length} `);
155
+ const paeBytes = new Uint8Array(prefix.length + bodyBytes.length);
156
+ paeBytes.set(prefix, 0);
157
+ paeBytes.set(bodyBytes, prefix.length);
158
+ const edSig = attestation.signatures.find(s => s.alg === 'ed25519');
159
+ const mlSig = attestation.signatures.find(s => s.alg === 'ml-dsa-65');
160
+ if (!edSig || !mlSig)
161
+ return false;
162
+ const edSigBytes = Buffer.from(edSig.sig, 'base64');
163
+ const mlSigBytes = Buffer.from(mlSig.sig, 'base64');
164
+ for (const bundle of (0, key_provider_1.getKeyHistory)()) {
165
+ let edOk = false;
166
+ let mlOk = false;
167
+ try {
168
+ const keyObj = (0, node_crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
169
+ edOk = (0, node_crypto_1.verify)(null, Buffer.from(paeBytes), keyObj, edSigBytes);
170
+ }
171
+ catch { /* fail-closed */ }
172
+ try {
173
+ const { publicKey: mlPub } = ml_dsa_js_1.ml_dsa65.keygen(bundle.mlDsa.seed);
174
+ mlOk = ml_dsa_js_1.ml_dsa65.verify(mlSigBytes, paeBytes, mlPub);
175
+ }
176
+ catch { /* fail-closed */ }
177
+ if (edOk && mlOk)
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+ let _enrollmentCache = null;
183
+ /** TEST ONLY: drop the verified-enrollment cache. */
184
+ function _testResetEnrollmentCache() {
185
+ _enrollmentCache = null;
186
+ }
187
+ /**
188
+ * Load the enrollment record from disk, verify the DSSE seal.
189
+ * Returns null on ANY failure (missing file, parse error, seal failure, schema mismatch).
190
+ * NEVER throws. NEVER fails-open.
191
+ */
192
+ function loadEnrollmentRecord() {
193
+ try {
194
+ const p = getEnrollmentPath();
195
+ if (!node_fs_1.default.existsSync(p)) {
196
+ _enrollmentCache = null;
197
+ return null;
198
+ }
199
+ // [2] mtime+size cache: skip the ML-DSA seal re-verify when the file is unchanged.
200
+ const st = node_fs_1.default.statSync(p);
201
+ if (_enrollmentCache &&
202
+ _enrollmentCache.mtimeMs === st.mtimeMs &&
203
+ _enrollmentCache.size === st.size) {
204
+ return _enrollmentCache.record;
205
+ }
206
+ const raw = node_fs_1.default.readFileSync(p, 'utf8');
207
+ const record = JSON.parse(raw);
208
+ if (record.schema_version !== 'operator-enrollment/1') {
209
+ process.stderr.write('[enrollment] schema_version mismatch\n');
210
+ return null;
211
+ }
212
+ // Reconstruct the content core (everything except attestation) and verify.
213
+ const contentCore = {
214
+ schema_version: record.schema_version,
215
+ enrolled_operators: record.enrolled_operators,
216
+ enrollment_seq: record.enrollment_seq,
217
+ updated_at: record.updated_at,
218
+ };
219
+ const payload = (0, sraid_1.canonicalize)(contentCore);
220
+ // Dispatch by the record's OWN payloadType (self-describing), not by the
221
+ // current process's lite-mode flag -- see the LITE_ENROLLMENT_PAYLOAD_TYPE
222
+ // doc comment above (Security F1 fix).
223
+ const verified = record.attestation.payloadType === exports.LITE_ENROLLMENT_PAYLOAD_TYPE
224
+ ? verifyLiteEnrollmentSeal(record.attestation, payload)
225
+ : verifyDsseEnrollment(record.attestation, payload);
226
+ if (!verified) {
227
+ process.stderr.write('[enrollment] attestation verification failed\n');
228
+ return null;
229
+ }
230
+ // Cache only the VERIFIED record, keyed by the exact file identity.
231
+ _enrollmentCache = { mtimeMs: st.mtimeMs, size: st.size, record };
232
+ return record;
233
+ }
234
+ catch (e) {
235
+ process.stderr.write(`[enrollment] load failed: ${e.message}\n`);
236
+ return null;
237
+ }
238
+ }
239
+ /**
240
+ * Build, seal, and atomically write the enrollment record.
241
+ */
242
+ function saveEnrollmentRecord(operators) {
243
+ const schema_version = 'operator-enrollment/1';
244
+ const enrollment_seq = (loadEnrollmentRecord()?.enrollment_seq ?? 0) + 1;
245
+ const updated_at = new Date().toISOString();
246
+ const contentCore = { schema_version, enrolled_operators: operators, enrollment_seq, updated_at };
247
+ const payload = (0, sraid_1.canonicalize)(contentCore);
248
+ // Security F1 fix: lite mode seals with the persisted operator key (which
249
+ // survives a restart by construction), never the gateway's ephemeral
250
+ // per-boot bundle. The full gateway's existing hybrid-DSSE tier is
251
+ // unchanged.
252
+ const attestation = (0, lite_mode_1.isLiteMode)()
253
+ ? signLiteEnrollmentSeal(payload)
254
+ : (0, verify_router_1.signDssePayload)(exports.ENROLLMENT_PAYLOAD_TYPE, payload);
255
+ const record = {
256
+ schema_version,
257
+ enrolled_operators: operators,
258
+ enrollment_seq,
259
+ updated_at,
260
+ attestation,
261
+ };
262
+ const p = getEnrollmentPath();
263
+ const tmp = p + '.tmp';
264
+ node_fs_1.default.writeFileSync(tmp, JSON.stringify(record, null, 2), 'utf8');
265
+ node_fs_1.default.renameSync(tmp, p);
266
+ // [2] Invalidate the verified-record cache explicitly: a fresh write normally
267
+ // bumps mtime/size, but a rename within the same coarse mtime tick with an equal
268
+ // size could otherwise read stale. Force the next load to re-verify.
269
+ _enrollmentCache = null;
270
+ }
271
+ /**
272
+ * Enroll a new operator, scoped to a specific tenant (ADR_015 Section 10.2.A).
273
+ *
274
+ * Signature: enrollOperator(tenant_id, pubkeyHex, deviceLabel, opts?)
275
+ * Default source: 'first-run-local'.
276
+ *
277
+ * Guard behavior (per Section 10.4):
278
+ * - For non-demo sources ('first-run-local' | 'magic-link'): refuses if an
279
+ * active non-demo entry already exists FOR THAT tenant_id.
280
+ * - For 'demo-session' source: idempotent -- may (re)enroll the same demo
281
+ * tenant freely without triggering the guard.
282
+ *
283
+ * The demo route must call with source 'demo-session' and the demo tenant_id only.
284
+ * Production-trusted enrollment must only be called over the loopback /local surface.
285
+ */
286
+ function enrollOperator(tenant_id, pubkeyHex, deviceLabel, opts) {
287
+ const source = opts?.source ?? 'first-run-local';
288
+ const existing = loadEnrollmentRecord();
289
+ const prior = existing?.enrolled_operators ?? [];
290
+ if (source !== 'demo-session') {
291
+ // Production sources: refuse if an active non-demo entry already exists for THIS tenant.
292
+ const conflict = prior.find(e => e.tenant_id === tenant_id &&
293
+ e.status === 'active' &&
294
+ e.enrollment_source !== 'demo-session');
295
+ if (conflict) {
296
+ throw new Error('operator already enrolled; use re-enrollment ceremony to change');
297
+ }
298
+ }
299
+ // Demo-session: idempotent on the same key -- deduplicate below.
300
+ const pubBytes = Buffer.from(pubkeyHex, 'hex');
301
+ const operator_oid = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
302
+ const entry = {
303
+ operator_oid,
304
+ operator_pubkey: pubkeyHex,
305
+ tenant_id,
306
+ enrollment_source: source,
307
+ device_label: deviceLabel,
308
+ enrolled_at: new Date().toISOString(),
309
+ status: 'active',
310
+ };
311
+ // Append; deduplicate by (tenant_id, operator_oid, enrollment_source) -- last write wins.
312
+ const merged = [
313
+ ...prior.filter(e => !(e.tenant_id === tenant_id && e.operator_oid === operator_oid && e.enrollment_source === source)),
314
+ entry,
315
+ ];
316
+ saveEnrollmentRecord(merged);
317
+ }
318
+ /**
319
+ * assertEnrolledOperatorAuth - steps 1-3 only (OID-binding + tenant-scoped
320
+ * enrolled-membership + Ed25519 signature). Does NOT check a nonce (step 4).
321
+ *
322
+ * Factored from assertEnrolledOperator per ADR_016 Section 4.5 so that both
323
+ * GET /local/decide-challenge (steps 1-3) and POST /local/decide (steps 1-4)
324
+ * share the same membership+signature core with no divergent comparator.
325
+ *
326
+ * Throws EnrollmentIntegrityError, OperatorNotEnrolledError, or
327
+ * OperatorNotEnrolledForTenantError on any failure.
328
+ *
329
+ * @param tenant_id The tenant the operator must be enrolled for.
330
+ * @param operator_oid Caller-declared OID; checked against sha256(pubkey).
331
+ * @param operator_pubkey_hex Raw 32-byte Ed25519 public key as 64-char lc hex.
332
+ * @param signature Ed25519 sig over canonical_signed_payload, 128-char lc hex.
333
+ * @param canonical_signed_payload Exact canonical string the caller signed.
334
+ */
335
+ function assertEnrolledOperatorAuth(tenant_id, operator_oid, operator_pubkey_hex, signature, canonical_signed_payload) {
336
+ // Step 1: OID-binding
337
+ try {
338
+ const pubBytes = Buffer.from(operator_pubkey_hex, 'hex');
339
+ const computed = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
340
+ if (computed !== operator_oid) {
341
+ throw new EnrollmentIntegrityError('oid_binding_mismatch');
342
+ }
343
+ }
344
+ catch (e) {
345
+ if (e instanceof EnrollmentIntegrityError)
346
+ throw e;
347
+ throw new EnrollmentIntegrityError('oid_binding_mismatch');
348
+ }
349
+ // Step 2: Enrolled-membership (tenant-scoped, source-gated per Section 10.2.A + 10.2.C).
350
+ const record = loadEnrollmentRecord();
351
+ if (record === null) {
352
+ throw new OperatorNotEnrolledError('enrollment_missing', 'no enrollment record found or record is corrupt');
353
+ }
354
+ const anyActiveEntry = record.enrolled_operators.find(e => e.operator_oid === operator_oid && e.status === 'active');
355
+ if (!anyActiveEntry) {
356
+ throw new OperatorNotEnrolledError('not_enrolled', `operator ${operator_oid} is not in the active enrolled set`);
357
+ }
358
+ const isDemoTenant = tenant_id.startsWith('tenant:demo-') && !tenant_id.startsWith('tenant:demo-mem-');
359
+ const tenantEntry = record.enrolled_operators.find(e => e.operator_oid === operator_oid &&
360
+ e.status === 'active' &&
361
+ e.tenant_id === tenant_id &&
362
+ (isDemoTenant || e.enrollment_source !== 'demo-session'));
363
+ if (!tenantEntry) {
364
+ throw new OperatorNotEnrolledForTenantError(`operator ${operator_oid} is not enrolled for tenant ${tenant_id} with a production-trusted source`);
365
+ }
366
+ // Step 3: Signature
367
+ try {
368
+ const sigBytes = Buffer.from(signature, 'hex');
369
+ const msgBytes = Buffer.from(canonical_signed_payload, 'utf8');
370
+ const pubBytes = Buffer.from(operator_pubkey_hex, 'hex');
371
+ const valid = ed25519_1.ed25519.verify(sigBytes, msgBytes, pubBytes);
372
+ if (!valid) {
373
+ throw new EnrollmentIntegrityError('signature_invalid');
374
+ }
375
+ }
376
+ catch (e) {
377
+ if (e instanceof EnrollmentIntegrityError)
378
+ throw e;
379
+ throw new EnrollmentIntegrityError('signature_invalid');
380
+ }
381
+ }
382
+ /**
383
+ * Validate that an operator is enrolled for the given tenant and their request is authentic.
384
+ * Throws EnrollmentIntegrityError, OperatorNotEnrolledError, or
385
+ * OperatorNotEnrolledForTenantError on any failure.
386
+ *
387
+ * Steps (in order, ADR_015 Sections 3 + 10.2.A):
388
+ * 1. OID-binding: sha256(pubkeyBytes) == operator_oid
389
+ * 2. Enrolled-membership (tenant-scoped): record exists + active entry for
390
+ * (operator_oid, tenant_id) with enrollment_source !== 'demo-session'.
391
+ * Entries lacking tenant_id are treated as not-enrolled-for-any-tenant
392
+ * (fail-CLOSED migration per Section 10.3).
393
+ * 3. Signature: Ed25519 sig over canonical_signed_payload with operator_pubkey_hex
394
+ * 4. Freshness: stored_nonce === challenge_nonce
395
+ *
396
+ * The new first arg tenant_id is the tenant the action authorizes against.
397
+ * Steps 1/3/4 are UNCHANGED from Sections 1-9.
398
+ *
399
+ * Delegates steps 1-3 to assertEnrolledOperatorAuth (ADR_016 Section 4.5).
400
+ */
401
+ function assertEnrolledOperator(tenant_id, operator_oid, operator_pubkey_hex, signature, canonical_signed_payload, challenge_nonce, stored_nonce) {
402
+ // Steps 1-3: delegate to the shared auth helper (ADR_016 Section 4.5).
403
+ assertEnrolledOperatorAuth(tenant_id, operator_oid, operator_pubkey_hex, signature, canonical_signed_payload);
404
+ // Step 4: Freshness
405
+ if (stored_nonce === undefined) {
406
+ throw new EnrollmentIntegrityError('nonce_missing');
407
+ }
408
+ if (stored_nonce !== challenge_nonce) {
409
+ throw new EnrollmentIntegrityError('stale_or_replayed');
410
+ }
411
+ }
412
+ // -- TEST-ONLY helpers ---------------------------------------------------------
413
+ /** TEST ONLY: delete the enrollment file if it exists. */
414
+ function _testClearEnrollment() {
415
+ // Hard production stop (re-panel F2, 2026-06-22): same pattern as
416
+ // __setSigningBundleForTest in verify-router.ts:179. NODE_ENV=production
417
+ // is a hard wall regardless of SYNOI_ALLOW_EPHEMERAL_KEYS.
418
+ if (process.env['NODE_ENV'] === 'production') {
419
+ throw new Error('[operator-enrollment] _testClearEnrollment is TEST-ONLY and must not be ' +
420
+ "called in production (NODE_ENV === 'production').");
421
+ }
422
+ if (process.env['SYNOI_ALLOW_EPHEMERAL_KEYS'] !== 'true') {
423
+ throw new Error('_testClearEnrollment is TEST-ONLY; set SYNOI_ALLOW_EPHEMERAL_KEYS=true');
424
+ }
425
+ const p = getEnrollmentPath();
426
+ if (node_fs_1.default.existsSync(p))
427
+ node_fs_1.default.unlinkSync(p);
428
+ }
429
+ /**
430
+ * TEST ONLY: enroll without the "already enrolled" guard.
431
+ * Now requires tenant_id and accepts optional source (ADR_015 Section 10.4).
432
+ * The NODE_ENV=production hard-wall (re-panel F2, 2026-06-22) is UNCHANGED.
433
+ *
434
+ * Callers that previously passed only pubkeyHex must be updated to supply
435
+ * tenant_id (and optionally source). Default source is 'first-run-local'.
436
+ */
437
+ function _testEnrollOperatorForTest(pubkeyHex, tenant_id = 'tenant:test', source = 'first-run-local') {
438
+ // Hard production stop (re-panel F2, 2026-06-22).
439
+ if (process.env['NODE_ENV'] === 'production') {
440
+ throw new Error('[operator-enrollment] _testEnrollOperatorForTest is TEST-ONLY and must not be ' +
441
+ "called in production (NODE_ENV === 'production').");
442
+ }
443
+ if (process.env['SYNOI_ALLOW_EPHEMERAL_KEYS'] !== 'true') {
444
+ throw new Error('_testEnrollOperatorForTest is TEST-ONLY; set SYNOI_ALLOW_EPHEMERAL_KEYS=true');
445
+ }
446
+ const pubBytes = Buffer.from(pubkeyHex, 'hex');
447
+ const operator_oid = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
448
+ const entry = {
449
+ operator_oid,
450
+ operator_pubkey: pubkeyHex,
451
+ tenant_id,
452
+ enrollment_source: source,
453
+ device_label: 'test-device',
454
+ enrolled_at: new Date().toISOString(),
455
+ status: 'active',
456
+ };
457
+ // Append to any existing test entries (do not wipe operators enrolled earlier
458
+ // in the same test run). Deduplicate by (tenant_id, operator_oid, enrollment_source).
459
+ const existing = loadEnrollmentRecord();
460
+ const prior = existing?.enrolled_operators ?? [];
461
+ const merged = [
462
+ ...prior.filter(e => !(e.tenant_id === tenant_id && e.operator_oid === operator_oid && e.enrollment_source === source)),
463
+ entry,
464
+ ];
465
+ saveEnrollmentRecord(merged);
466
+ }