@gate-forge/pack-playwright 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.
Files changed (140) hide show
  1. package/LICENSE +202 -0
  2. package/bin/gateforge-witness.js +40 -0
  3. package/dist/attestation/proxy.d.ts +40 -0
  4. package/dist/attestation/proxy.d.ts.map +1 -0
  5. package/dist/attestation/proxy.js +78 -0
  6. package/dist/attestation/proxy.js.map +1 -0
  7. package/dist/constants.d.ts +130 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +138 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/discovery/adapters.d.ts +167 -0
  12. package/dist/discovery/adapters.d.ts.map +1 -0
  13. package/dist/discovery/adapters.js +276 -0
  14. package/dist/discovery/adapters.js.map +1 -0
  15. package/dist/discovery/discover.d.ts +57 -0
  16. package/dist/discovery/discover.d.ts.map +1 -0
  17. package/dist/discovery/discover.js +544 -0
  18. package/dist/discovery/discover.js.map +1 -0
  19. package/dist/discovery/index.d.ts +15 -0
  20. package/dist/discovery/index.d.ts.map +1 -0
  21. package/dist/discovery/index.js +15 -0
  22. package/dist/discovery/index.js.map +1 -0
  23. package/dist/discovery/inference.d.ts +63 -0
  24. package/dist/discovery/inference.d.ts.map +1 -0
  25. package/dist/discovery/inference.js +173 -0
  26. package/dist/discovery/inference.js.map +1 -0
  27. package/dist/discovery/pytest-adapter.d.ts +205 -0
  28. package/dist/discovery/pytest-adapter.d.ts.map +1 -0
  29. package/dist/discovery/pytest-adapter.js +548 -0
  30. package/dist/discovery/pytest-adapter.js.map +1 -0
  31. package/dist/discovery/reconcile.d.ts +106 -0
  32. package/dist/discovery/reconcile.d.ts.map +1 -0
  33. package/dist/discovery/reconcile.js +268 -0
  34. package/dist/discovery/reconcile.js.map +1 -0
  35. package/dist/discovery/runner-env.d.ts +80 -0
  36. package/dist/discovery/runner-env.d.ts.map +1 -0
  37. package/dist/discovery/runner-env.js +139 -0
  38. package/dist/discovery/runner-env.js.map +1 -0
  39. package/dist/discovery/static-discovery.d.ts +121 -0
  40. package/dist/discovery/static-discovery.d.ts.map +1 -0
  41. package/dist/discovery/static-discovery.js +1028 -0
  42. package/dist/discovery/static-discovery.js.map +1 -0
  43. package/dist/discovery/supervised-run.d.ts +110 -0
  44. package/dist/discovery/supervised-run.d.ts.map +1 -0
  45. package/dist/discovery/supervised-run.js +328 -0
  46. package/dist/discovery/supervised-run.js.map +1 -0
  47. package/dist/discovery/trusted-config.d.ts +103 -0
  48. package/dist/discovery/trusted-config.d.ts.map +1 -0
  49. package/dist/discovery/trusted-config.js +117 -0
  50. package/dist/discovery/trusted-config.js.map +1 -0
  51. package/dist/fixture/evidence.d.ts +191 -0
  52. package/dist/fixture/evidence.d.ts.map +1 -0
  53. package/dist/fixture/evidence.js +366 -0
  54. package/dist/fixture/evidence.js.map +1 -0
  55. package/dist/fixture/fixture.d.ts +37 -0
  56. package/dist/fixture/fixture.d.ts.map +1 -0
  57. package/dist/fixture/fixture.js +38 -0
  58. package/dist/fixture/fixture.js.map +1 -0
  59. package/dist/fixture/witness-client.d.ts +164 -0
  60. package/dist/fixture/witness-client.d.ts.map +1 -0
  61. package/dist/fixture/witness-client.js +253 -0
  62. package/dist/fixture/witness-client.js.map +1 -0
  63. package/dist/index.d.ts +64 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +55 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/json.d.ts +16 -0
  68. package/dist/json.d.ts.map +1 -0
  69. package/dist/json.js +21 -0
  70. package/dist/json.js.map +1 -0
  71. package/dist/reporter/ledger.d.ts +105 -0
  72. package/dist/reporter/ledger.d.ts.map +1 -0
  73. package/dist/reporter/ledger.js +197 -0
  74. package/dist/reporter/ledger.js.map +1 -0
  75. package/dist/reporter/reporter.cjs +105 -0
  76. package/dist/reporter/reporter.cjs.map +1 -0
  77. package/dist/reporter/reporter.d.cts +35 -0
  78. package/dist/reporter/reporter.d.cts.map +1 -0
  79. package/dist/reporter/reporter.d.ts +167 -0
  80. package/dist/reporter/reporter.d.ts.map +1 -0
  81. package/dist/reporter/reporter.js +673 -0
  82. package/dist/reporter/reporter.js.map +1 -0
  83. package/dist/setup.d.ts +49 -0
  84. package/dist/setup.d.ts.map +1 -0
  85. package/dist/setup.js +167 -0
  86. package/dist/setup.js.map +1 -0
  87. package/dist/supervisor/client.d.ts +73 -0
  88. package/dist/supervisor/client.d.ts.map +1 -0
  89. package/dist/supervisor/client.js +164 -0
  90. package/dist/supervisor/client.js.map +1 -0
  91. package/dist/supervisor/drain.d.ts +45 -0
  92. package/dist/supervisor/drain.d.ts.map +1 -0
  93. package/dist/supervisor/drain.js +235 -0
  94. package/dist/supervisor/drain.js.map +1 -0
  95. package/dist/supervisor/index.d.ts +16 -0
  96. package/dist/supervisor/index.d.ts.map +1 -0
  97. package/dist/supervisor/index.js +13 -0
  98. package/dist/supervisor/index.js.map +1 -0
  99. package/dist/supervisor/spool.d.ts +137 -0
  100. package/dist/supervisor/spool.d.ts.map +1 -0
  101. package/dist/supervisor/spool.js +263 -0
  102. package/dist/supervisor/spool.js.map +1 -0
  103. package/dist/surface.d.ts +134 -0
  104. package/dist/surface.d.ts.map +1 -0
  105. package/dist/surface.js +139 -0
  106. package/dist/surface.js.map +1 -0
  107. package/dist/witness/adapter-registry.d.ts +38 -0
  108. package/dist/witness/adapter-registry.d.ts.map +1 -0
  109. package/dist/witness/adapter-registry.js +158 -0
  110. package/dist/witness/adapter-registry.js.map +1 -0
  111. package/dist/witness/bin.d.ts +21 -0
  112. package/dist/witness/bin.d.ts.map +1 -0
  113. package/dist/witness/bin.js +172 -0
  114. package/dist/witness/bin.js.map +1 -0
  115. package/dist/witness/browser.d.ts +173 -0
  116. package/dist/witness/browser.d.ts.map +1 -0
  117. package/dist/witness/browser.js +580 -0
  118. package/dist/witness/browser.js.map +1 -0
  119. package/dist/witness/classifications.d.ts +42 -0
  120. package/dist/witness/classifications.d.ts.map +1 -0
  121. package/dist/witness/classifications.js +87 -0
  122. package/dist/witness/classifications.js.map +1 -0
  123. package/dist/witness/env-attestation.d.ts +98 -0
  124. package/dist/witness/env-attestation.d.ts.map +1 -0
  125. package/dist/witness/env-attestation.js +202 -0
  126. package/dist/witness/env-attestation.js.map +1 -0
  127. package/dist/witness/loopback-pins.d.ts +79 -0
  128. package/dist/witness/loopback-pins.d.ts.map +1 -0
  129. package/dist/witness/loopback-pins.js +244 -0
  130. package/dist/witness/loopback-pins.js.map +1 -0
  131. package/dist/witness/server.d.ts +34 -0
  132. package/dist/witness/server.d.ts.map +1 -0
  133. package/dist/witness/server.js +2539 -0
  134. package/dist/witness/server.js.map +1 -0
  135. package/dist/witness/types.d.ts +643 -0
  136. package/dist/witness/types.d.ts.map +1 -0
  137. package/dist/witness/types.js +2 -0
  138. package/dist/witness/types.js.map +1 -0
  139. package/package.json +64 -0
  140. package/python/gateforge_persistence_intents.py +100 -0
@@ -0,0 +1,2539 @@
1
+ /**
2
+ * The loopback witness service (pin #7, owner G6).
3
+ *
4
+ * A node:http server on an OS-assigned port, reachable ONLY on loopback.
5
+ * Every request must carry `x-gateforge-run: <token>` (per-run token);
6
+ * without it the witness answers 401. The run token is the SUITE's
7
+ * credential; a second, stronger capability — the verifier key
8
+ * (`x-gateforge-verifier`), which the tested suite NEVER receives —
9
+ * guards the supervisor surface. Endpoint authority (enforcement-review
10
+ * fix 3: the runner child holds NO supervisor rights):
11
+ *
12
+ * | Endpoint | Required authority |
13
+ * |-----------------------------------|-----------------------------------------|
14
+ * | `GET /health` | run token |
15
+ * | `GET /records` | run token |
16
+ * | `GET /classifications` | run token |
17
+ * | `POST /records` | run token + OPEN session credential |
18
+ * | `POST /witness/pre-observation` | run token + OPEN session credential |
19
+ * | `POST /witness/persistence` | run token + OPEN session credential |
20
+ * | `POST /witness/http-observation` | run token + OPEN session credential |
21
+ * | `POST /sessions/resolve` | run token (worker proves its identity; |
22
+ * | | answers only OPEN supervisor sessions) |
23
+ * | `POST /sessions/intervals/*` | run token + OPEN session credential |
24
+ * | `POST /browser/surface` | run token + OPEN session credential |
25
+ * | `POST /browser/action` | run token + OPEN session credential |
26
+ * | `POST /browser/visible` | run token + OPEN session credential |
27
+ * | `POST /run-context` | run token + verifier key (supervisor) |
28
+ * | `GET /ledger-attestation` | run token + verifier key (supervisor) |
29
+ * | `POST /runs/expected-set` | run token + verifier key (supervisor) |
30
+ * | `POST /runs/server-e2e-declarations` | run token + verifier key (supervisor)|
31
+ * | `POST /witness/server-persistence` | run token + verifier key (supervisor; |
32
+ * | | the drain forwards intents — the suite |
33
+ * | | can only WRITE spool lines) |
34
+ * | `GET /runs/execution-trace` | run token + verifier key (supervisor) |
35
+ * | `POST /sessions/open` | run token + verifier key (supervisor); |
36
+ * | | test must be in the registered set |
37
+ * | `POST /sessions/close` | run token + verifier key (supervisor) |
38
+ *
39
+ * Endpoints:
40
+ *
41
+ * - `POST /runs/expected-set` — SUPERVISOR ONLY: registers the expected
42
+ * test set BEFORE the run (enforcement-review fix 2a), bound to this
43
+ * run; identical re-registration is idempotent, any change or late
44
+ * registration is 409. Returns the domain-separated enumeration digest.
45
+ * - `POST /sessions/open` — SUPERVISOR ONLY: registers one started
46
+ * test as a session binding (runId, sessionId, testId, worker). One
47
+ * OPEN session per worker; an identical open (worker, testId)
48
+ * re-binds idempotently. When an expected set is registered, the test
49
+ * must belong to it (an invented testId is refused typed).
50
+ * - `POST /sessions/close` — SUPERVISOR ONLY: seals the session with
51
+ * the observed outcome. Closing SEALS: every later submission for the
52
+ * session is rejected (no post-hoc record injection).
53
+ * - `GET /runs/execution-trace` — SUPERVISOR ONLY: the witness-side
54
+ * session record (enforcement-review fix 2b) — per expected test,
55
+ * every session with its open/seal ticks and outcome. THE execution
56
+ * authority supervision grades completeness from.
57
+ * - `POST /sessions/resolve` — the worker-side fixture proves WHICH open
58
+ * session it runs under by the exact (workerIndex, testId) pair; only
59
+ * an open session answers, with the session credential.
60
+ * - `POST /sessions/intervals/{open,close}` — the fixture marks a
61
+ * witness-recorded observation interval per UI action (start/end ticks
62
+ * from the witness's monotonic clock). Proxy exchanges observed
63
+ * OUTSIDE every interval of a session are never consumable as that
64
+ * session's evidence — direct setup traffic cannot become browser
65
+ * evidence.
66
+ * - `POST /records` — test-side primitives submit UI evidence
67
+ * ({claimId, kind, payload, testId, sessionId, sessionToken}) → the
68
+ * witness ISSUES a record with service-computed provenance ONLY under
69
+ * a valid OPEN session (the record's testId is forced to the
70
+ * session's supervisor-registered value; a mismatching testId is
71
+ * refused). Unknown primitive kinds → 400 (GF-11, GF-14).
72
+ * - `POST /witness/persistence` — the fixture asks the witness to run
73
+ * the engine-side adapter (GET-only) for one entity → the witness
74
+ * executes the read, stamps a `persistence.entity` record from the
75
+ * ADAPTER RESPONSE, and returns {recordId, runId, verdictRelevant}.
76
+ * Raw adapter bodies never cross back into the test process. Wire is
77
+ * the pin-#7 shape extended with `testId` + `claimId` + the session
78
+ * credential so persistence records bind to the claim the engine
79
+ * grades, under the session the supervisor opened.
80
+ * - `POST /witness/server-persistence` — SUPERVISOR ONLY: the trusted
81
+ * drain forwards one persistence claim INTENT (drained from the
82
+ * runner-side `persistence-intents.jsonl` spool the supervised suite
83
+ * may only WRITE); the witness executes the resource's adapter SERVER
84
+ * PROBE itself (behind the same attestation chain as every adapter
85
+ * read) and stamps a WITNESSED `persistence.entity` record carrying
86
+ * `channel: 'server'` + `declaredKind: 'server-e2e'` — the
87
+ * server-witnessed channel for backend-only tables (a transactional
88
+ * outbox) that can never honestly appear in a UI. Probes run ONLY in
89
+ * this trusted process; missing adapter/probe/declaration and replayed
90
+ * sequences resolve to typed failures, never to satisfaction.
91
+ * - `POST /witness/http-observation` — consumes one engine-observed
92
+ * proxied exchange for an http:* claim. Phase 1: the caller must hold
93
+ * a valid OPEN session and the exchange must have been observed
94
+ * through THAT session's proxy prefix WITHIN one of its recorded
95
+ * action intervals — another test's/worker's request can never
96
+ * satisfy a claim (E11/E12 foundation).
97
+ * - `GET /records` — the issued ledger (the ONLY input the
98
+ * reporter copies into `records.json`; fabricated bundles never enter
99
+ * it — GF-23).
100
+ * - `GET /classifications` — per-resource primaryKey/exposure/plane/
101
+ * lifecycle projection for the reporter's per-claim ledger.
102
+ * - `GET /health` — readiness + attestation scope identity.
103
+ *
104
+ * Attestation (GF-10, GF-13): the attestation subject and every adapter
105
+ * read base must be loopback; adapter bases must present an
106
+ * `x-gateforge-env-fingerprint` marker matching the adapter's declared
107
+ * fingerprint (and the run's pinned target fingerprint when set).
108
+ * Mismatches REJECT the record — never `satisfied`.
109
+ *
110
+ * At shutdown the witness appends the record ids it issued to
111
+ * `manifest.json` in the run-state dir (pin #4/#7).
112
+ */
113
+ import { createServer, request } from 'node:http';
114
+ import { createHash, randomUUID } from 'node:crypto';
115
+ import { readFileSync, writeFileSync } from 'node:fs';
116
+ import { join, resolve } from 'node:path';
117
+ import { ATTESTATION_VERSION, attestationMac, enumerationDigestOf, recordIdOf, } from '@gate-forge/core';
118
+ import { canonicalOf } from '../json.js';
119
+ import { DEFAULT_REQUEST_TIMEOUT_MS, KNOWN_RECORD_KINDS, LOOPBACK_HOSTNAME, PERSISTENCE_KIND, RUN_HEADER, VERIFIER_HEADER, } from '../constants.js';
120
+ import { loadAdapters, makeAdapterContext } from './adapter-registry.js';
121
+ import { AttestationError, assertLoopback, envFingerprintMismatch, probeEnvFingerprint, } from './env-attestation.js';
122
+ import { hostResolverRules, pinnedLoopbackIps, pinnedGet } from './loopback-pins.js';
123
+ import { loadClassifications, toClassificationView } from './classifications.js';
124
+ import { EngineBrowserError, EngineBrowserManager, driveEngineAction, readEngineVisible, } from './browser.js';
125
+ import { SURFACE_DESCRIPTOR_VERSION, validateSurface, } from '../surface.js';
126
+ import { SERVER_CHANNEL, SERVER_E2E_TEST_KIND } from '../constants.js';
127
+ const MAX_BODY_BYTES = 1024 * 1024;
128
+ const OBLIGATION_ID_PATTERN = /^[^:]+:.+$/;
129
+ /**
130
+ * Bounded response snapshot the observation proxy keeps per forwarded
131
+ * exchange: at most this many body bytes are hashed into the snapshot,
132
+ * while the TOTAL byte count is tracked separately. The response still
133
+ * streams to the browser unbuffered — the snapshot is a tap, not a gate.
134
+ */
135
+ const OBSERVED_BODY_SNAPSHOT_BYTES = 16384;
136
+ /** Fail-closed witness configuration/startup error. */
137
+ export class WitnessStartupError extends Error {
138
+ constructor(message) {
139
+ super(message);
140
+ this.name = 'WitnessStartupError';
141
+ }
142
+ }
143
+ /** An HTTP JSON error the witness answers (status + {error, detail?}). */
144
+ class HttpError extends Error {
145
+ status;
146
+ detail;
147
+ constructor(status, message, detail = null) {
148
+ super(message);
149
+ this.name = 'HttpError';
150
+ this.status = status;
151
+ this.detail = detail;
152
+ }
153
+ }
154
+ /** Codepoint-wise comparison for deterministic ordering. */
155
+ function compareStrings(a, b) {
156
+ return a < b ? -1 : a > b ? 1 : 0;
157
+ }
158
+ /** The expected-set identity join key (project, file, titlePath). */
159
+ function expectedKey(project, file, titlePath) {
160
+ return `${project ?? '-'}\u0000${file}\u0000${titlePath.join('>')}`;
161
+ }
162
+ /**
163
+ * Normalizes the declared observation-proxy mount prefix (null when
164
+ * unset/empty). Fail-closed on values that can never be a plain path
165
+ * prefix — a malformed declaration would otherwise silently mismatch
166
+ * obligation identities, the exact failure the option exists to prevent.
167
+ */
168
+ function normalizeMountPath(raw) {
169
+ if (raw === null || raw === undefined || raw === '')
170
+ return null;
171
+ let path = raw.trim();
172
+ if (!path.startsWith('/'))
173
+ path = `/${path}`;
174
+ if (path.length > 1)
175
+ path = path.replace(/\/+$/, '');
176
+ if (path === '/' || /[\s?#]/.test(path)) {
177
+ throw new WitnessStartupError(`invalid mountPath '${raw}': declare the browser-facing mount prefix as a non-empty ` +
178
+ "absolute path like '/api'");
179
+ }
180
+ return path;
181
+ }
182
+ /**
183
+ * Strips the declared mount prefix from a proxied request URL (path
184
+ * plus possible query/fragment), returning the backend-facing URL the
185
+ * proxy forwards AND records. A request outside the prefix passes
186
+ * through untouched, and with no declared prefix the URL is returned
187
+ * byte-identical (the unmounted proxy's behavior).
188
+ */
189
+ function stripMountPath(rawUrl, mountPath) {
190
+ if (mountPath === null)
191
+ return rawUrl;
192
+ const queryStart = rawUrl.search(/[?#]/);
193
+ const pathPart = queryStart === -1 ? rawUrl : rawUrl.slice(0, queryStart);
194
+ const suffix = queryStart === -1 ? '' : rawUrl.slice(queryStart);
195
+ if (pathPart === mountPath)
196
+ return `/${suffix}`;
197
+ if (pathPart.startsWith(`${mountPath}/`)) {
198
+ return `${pathPart.slice(mountPath.length)}${suffix}`;
199
+ }
200
+ return rawUrl;
201
+ }
202
+ /**
203
+ * Starts one loopback reverse-proxy server forwarding to the run's
204
+ * attested proxy target. `sessionId` names the session the port belongs
205
+ * to (null = the shared unattributed proxy): every exchange completing
206
+ * on this port is recorded as an engine observation stamped with that
207
+ * session id and the witness-monotonic completion tick.
208
+ *
209
+ * Args:
210
+ * state: running witness state.
211
+ * sessionId: owning session id, or null for the shared proxy.
212
+ *
213
+ * Returns:
214
+ * Promise<Server>: the listening server (OS-assigned loopback port).
215
+ *
216
+ * Throws:
217
+ * Error: when the server fails to bind.
218
+ */
219
+ async function startObservedProxy(state, sessionId) {
220
+ const proxyTargetUrl = new URL(state.options.proxyTarget);
221
+ const server = createServer((req, res) => {
222
+ const chunks = [];
223
+ req.on('data', (chunk) => chunks.push(chunk));
224
+ req.on('end', () => {
225
+ const body = Buffer.concat(chunks);
226
+ // Mount-prefix handling: forward the backend-facing (STRIPPED)
227
+ // URL, and record the same STRIPPED path below, so observations
228
+ // match the backend-derived obligation identities the suite
229
+ // claims. With no declared mount path the URL is forwarded and
230
+ // recorded byte-identical to today.
231
+ const forwardUrl = stripMountPath(req.url ?? '/', state.options.mountPath);
232
+ // In-flight accounting (plan §11.4): a proxy exchange that starts
233
+ // before `/run-context` binds must refuse the bind — otherwise
234
+ // traffic from an older invocation could be signed under the new
235
+ // context.
236
+ state.proxyInFlight += 1;
237
+ let settledFlight = false;
238
+ const settleFlight = () => {
239
+ if (!settledFlight) {
240
+ settledFlight = true;
241
+ state.proxyInFlight -= 1;
242
+ }
243
+ };
244
+ // b59/b60 lesson (phase7-runtime e22ec24): `agent: false` is
245
+ // load-bearing. On Node >=19 the default global agent keeps sockets
246
+ // alive while dev servers close idle keep-alive sockets at their
247
+ // keepAliveTimeout — reusing a socket the target closed mid-handshake
248
+ // intermittently killed exactly one browser exchange per batch. A
249
+ // fresh loopback connection per forwarded exchange costs nothing and
250
+ // removes the reuse race.
251
+ const forward = request({
252
+ protocol: proxyTargetUrl.protocol,
253
+ hostname: proxyTargetUrl.hostname,
254
+ port: proxyTargetUrl.port,
255
+ method: req.method,
256
+ path: forwardUrl,
257
+ headers: { ...req.headers, host: proxyTargetUrl.host },
258
+ agent: false,
259
+ }, (upstream) => {
260
+ const status = upstream.statusCode ?? 0;
261
+ const observedPath = normalizeObservedPath(forwardUrl);
262
+ // Bounded response-body snapshot: the tap is attached BEFORE
263
+ // piping so both consumers receive the stream; forwarding to
264
+ // the browser stays unbuffered (the snapshot never gates the
265
+ // response). Total bytes are counted even beyond the snapshot
266
+ // limit; only the snapshot is hashed.
267
+ const snapshot = [];
268
+ let snapshotBytes = 0;
269
+ let totalBytes = 0;
270
+ upstream.on('data', (chunk) => {
271
+ totalBytes += chunk.length;
272
+ if (snapshotBytes < OBSERVED_BODY_SNAPSHOT_BYTES) {
273
+ const room = OBSERVED_BODY_SNAPSHOT_BYTES - snapshotBytes;
274
+ const taken = chunk.length > room ? chunk.subarray(0, room) : chunk;
275
+ snapshot.push(Buffer.from(taken)); // copy: detach from the stream pool
276
+ snapshotBytes += taken.length;
277
+ }
278
+ });
279
+ upstream.on('end', () => {
280
+ state.observed.push({
281
+ method: (req.method ?? 'GET').toUpperCase(),
282
+ path: observedPath,
283
+ status,
284
+ seq: (state.observedSeq += 1),
285
+ bodySha256: createHash('sha256').update(Buffer.concat(snapshot)).digest('hex'),
286
+ bodyBytes: totalBytes,
287
+ sessionId,
288
+ tick: (state.tick += 1),
289
+ });
290
+ settleFlight();
291
+ });
292
+ upstream.on('error', settleFlight);
293
+ res.writeHead(status, upstream.headers);
294
+ upstream.pipe(res);
295
+ });
296
+ forward.on('error', () => {
297
+ settleFlight();
298
+ if (!res.headersSent)
299
+ sendJson(res, 502, { error: 'observation proxy upstream failed' });
300
+ else
301
+ res.end();
302
+ });
303
+ if (body.length > 0)
304
+ forward.write(body);
305
+ forward.end();
306
+ });
307
+ });
308
+ await new Promise((resolveListen, rejectListen) => {
309
+ server.once('error', rejectListen);
310
+ server.listen(0, state.options.host, () => resolveListen());
311
+ });
312
+ server.removeAllListeners('error');
313
+ return server;
314
+ }
315
+ /** The base URL of a started observation-proxy server. */
316
+ function proxyUrlOf(state, server) {
317
+ const address = server.address();
318
+ if (address === null || typeof address === 'string') {
319
+ throw new WitnessStartupError('observation proxy failed to bind an OS-assigned port');
320
+ }
321
+ return `http://${formatHost(state.options.host)}:${address.port}`;
322
+ }
323
+ /**
324
+ * Starts the DEDICATED session proxy port (plan Phase 1) and stores it
325
+ * on the session. Only called when the run wires an observation proxy;
326
+ * the worker's browser uses this origin for the whole test, so all of
327
+ * its traffic — absolute paths included — is attributed to the session.
328
+ *
329
+ * Args:
330
+ * state: running witness state.
331
+ * session: the freshly opened session.
332
+ */
333
+ async function startSessionProxy(state, session) {
334
+ if (typeof state.options.proxyTarget !== 'string' ||
335
+ state.options.proxyTarget.length === 0) {
336
+ session.proxyUrl = null;
337
+ return;
338
+ }
339
+ const server = await startObservedProxy(state, session.sessionId);
340
+ session.proxyServer = server;
341
+ session.proxyUrl = proxyUrlOf(state, server);
342
+ }
343
+ /** Closes one session's dedicated proxy port (sealed = channel gone). */
344
+ async function stopSessionProxy(session) {
345
+ const server = session.proxyServer;
346
+ session.proxyServer = null;
347
+ if (server === null)
348
+ return;
349
+ await new Promise((resolveClose) => {
350
+ server.close(() => resolveClose());
351
+ });
352
+ }
353
+ function isPlainObject(value) {
354
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
355
+ }
356
+ /**
357
+ * The service-issued recordId comes from the frozen core primitive
358
+ * (`recordIdOf`, pin #1/#7): sha256 over GF-canonical JSON of the record
359
+ * identity. Sharing one implementation with the engine's provenance
360
+ * verifier guarantees the witness issues exactly what evaluation can
361
+ * recompute — an entry that never passed through the service has no
362
+ * matching hash, so shape-level fabrication (a hex string the service
363
+ * never issued) cannot line up with the ledger the reporter copies.
364
+ */
365
+ export { recordIdOf };
366
+ /** Reads the JSON request body (sized; malformed → HttpError). */
367
+ function readBody(req) {
368
+ return new Promise((resolveBody, rejectBody) => {
369
+ let raw = '';
370
+ req.setEncoding('utf8');
371
+ req.on('data', (chunk) => {
372
+ raw += chunk;
373
+ if (raw.length > MAX_BODY_BYTES) {
374
+ rejectBody(new HttpError(400, 'request body too large'));
375
+ req.destroy();
376
+ }
377
+ });
378
+ req.on('end', () => {
379
+ if (raw.length === 0) {
380
+ rejectBody(new HttpError(400, 'request body is required'));
381
+ return;
382
+ }
383
+ try {
384
+ resolveBody(JSON.parse(raw));
385
+ }
386
+ catch (error) {
387
+ rejectBody(new HttpError(400, `request body is not valid JSON: ${error.message}`));
388
+ }
389
+ });
390
+ req.on('error', rejectBody);
391
+ });
392
+ }
393
+ /**
394
+ * Starts the witness service.
395
+ *
396
+ * Args:
397
+ * options: run identity + token (required), state dir, adapters dir,
398
+ * classifications path, target/attestation config, timeout, clock.
399
+ *
400
+ * Returns:
401
+ * WitnessHandle: {url, stop} once the server listens.
402
+ *
403
+ * Throws:
404
+ * WitnessStartupError / AdapterRegistryError / AttestationError:
405
+ * fail-closed startup problems (GF-10 blocks non-loopback targets
406
+ * HERE, before any adapter request can be constructed).
407
+ */
408
+ export async function startWitness(options) {
409
+ if (typeof options.runId !== 'string' || options.runId.length === 0) {
410
+ throw new WitnessStartupError('witness requires a runId (GATEFORGE_RUN_ID)');
411
+ }
412
+ if (typeof options.token !== 'string' || options.token.length === 0) {
413
+ throw new WitnessStartupError('witness requires a token (GATEFORGE_RUN_TOKEN)');
414
+ }
415
+ const cwd = process.cwd();
416
+ const adaptersDir = options.adaptersDir === null || options.adaptersDir === undefined
417
+ ? null
418
+ : resolve(cwd, options.adaptersDir);
419
+ const classificationsPath = options.classificationsPath === null || options.classificationsPath === undefined
420
+ ? null
421
+ : resolve(cwd, options.classificationsPath);
422
+ const adapters = adaptersDir === null ? new Map() : await loadAdapters(adaptersDir);
423
+ const classifications = loadClassifications(classificationsPath);
424
+ const targetBaseUrl = options.targetBaseUrl ?? null;
425
+ // The mount prefix declares how the browser-facing deployment mounts
426
+ // the backend for the OBSERVATION PROXY; it is meaningless without one.
427
+ const mountPath = normalizeMountPath(options.mountPath);
428
+ if (mountPath !== null && (options.proxyTarget === undefined || options.proxyTarget === '')) {
429
+ throw new WitnessStartupError('witness option mountPath requires proxyTarget: the mount prefix declares how the ' +
430
+ 'observation proxy bridges the browser-facing deployment and the backend');
431
+ }
432
+ // GF-10: the attestation subject must be loopback — block at startup,
433
+ // before any mutation-capable request surface exists.
434
+ if (targetBaseUrl !== null) {
435
+ await assertLoopback(targetBaseUrl, 'attestation subject');
436
+ }
437
+ // GF-13 minimal v1: when the run pins a fingerprint, the subject's
438
+ // marker must match before the service opens for business.
439
+ if (targetBaseUrl !== null &&
440
+ options.targetFingerprint !== null &&
441
+ options.targetFingerprint !== undefined) {
442
+ const probe = await probeEnvFingerprint(targetBaseUrl, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
443
+ const mismatch = envFingerprintMismatch(probe, options.targetFingerprint, options.targetFingerprint);
444
+ if (mismatch !== null) {
445
+ throw new AttestationError(`attestation subject '${targetBaseUrl}' failed startup attestation: ${mismatch}`);
446
+ }
447
+ }
448
+ const state = {
449
+ options: {
450
+ ...options,
451
+ runId: options.runId,
452
+ token: options.token,
453
+ mountPath,
454
+ verifierKey: typeof options.verifierKey === 'string' && options.verifierKey.length > 0
455
+ ? options.verifierKey
456
+ : null,
457
+ requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
458
+ host: options.host ?? LOOPBACK_HOSTNAME,
459
+ },
460
+ adapters,
461
+ classifications,
462
+ ledger: new Map(),
463
+ preObservations: new Map(),
464
+ serverE2eDeclarations: null,
465
+ serverPreObservations: new Map(),
466
+ serverIntentSequences: new Map(),
467
+ observed: [],
468
+ observedSeq: 0,
469
+ runContext: null,
470
+ observedSeqAtBind: 0,
471
+ proxyInFlight: 0,
472
+ expectedTests: new Map(),
473
+ enumerationDigest: null,
474
+ tick: 0,
475
+ sessions: new Map(),
476
+ workerSessions: new Map(),
477
+ engineBrowser: new EngineBrowserManager(options.engineBrowserLauncher),
478
+ proxyServer: null,
479
+ server: undefined,
480
+ nowIso: options.now ?? (() => new Date().toISOString()),
481
+ stopped: false,
482
+ };
483
+ state.server = createServer((req, res) => {
484
+ void handleRequest(state, req, res);
485
+ });
486
+ await new Promise((resolveListen, rejectListen) => {
487
+ state.server.once('error', rejectListen);
488
+ state.server.listen(0, state.options.host, () => resolveListen());
489
+ });
490
+ state.server.removeAllListeners('error');
491
+ const address = state.server.address();
492
+ if (address === null || typeof address === 'string') {
493
+ await stopWitness(state);
494
+ throw new WitnessStartupError('witness failed to bind an OS-assigned port');
495
+ }
496
+ const url = `http://${formatHost(state.options.host)}:${address.port}`;
497
+ // ADR 0004 D7: the witness-owned loopback reverse proxy. Traffic
498
+ // aimed at a witness proxy is forwarded to the attested target and
499
+ // (method, path, status, bounded body snapshot, total body bytes)
500
+ // recorded as an ENGINE observation; a suite-callable endpoint consumes
501
+ // a matching observation to issue a witnessed record. A proxy never
502
+ // needs the run token: it serves the browser, holds no authority, and
503
+ // can only add observations the engine itself saw.
504
+ //
505
+ // Phase 1 session channels: the shared proxy (below) stays the
506
+ // UNATTRIBUTED channel — its exchanges carry sessionId null and are
507
+ // never consumable as a test's evidence. Each supervisor-opened
508
+ // session additionally gets a DEDICATED loopback proxy port (see
509
+ // `startSessionProxy`): the worker's browser uses that origin for the
510
+ // whole test, so every absolute-path form action, link, and fetch on
511
+ // it lands on the session's own channel — attribution by ORIGIN, not
512
+ // by URL rewriting.
513
+ let proxyUrl = null;
514
+ if (typeof state.options.proxyTarget === 'string' && state.options.proxyTarget.length > 0) {
515
+ await assertLoopback(state.options.proxyTarget, 'observation proxy target');
516
+ state.proxyServer = await startObservedProxy(state, null);
517
+ proxyUrl = proxyUrlOf(state, state.proxyServer);
518
+ }
519
+ // DNS binding (loopback-pins): the startup asserts above pinned every
520
+ // operator-provided hostname to its approved loopback IPs. Hand the
521
+ // resulting resolver rules to the engine browser BEFORE it can launch —
522
+ // its traffic for those names then cannot leave loopback even if DNS
523
+ // changes mid-run, while Host headers and origins (tenant routing)
524
+ // stay exactly as the suite addresses them.
525
+ state.engineBrowser.setDnsPinRules(hostResolverRules(pinnedLoopbackIps()));
526
+ const handle = Object.freeze({
527
+ url,
528
+ proxyUrl,
529
+ stop: () => stopWitness(state),
530
+ });
531
+ return handle;
532
+ }
533
+ /**
534
+ * Canonicalizes an observed request path (query/fragment stripped, one
535
+ * leading slash, trailing slashes dropped, root '/' stays '/').
536
+ * Lockstep with core's `interpretObservedPath` (plan §9 steps 1-3):
537
+ * duplicate slashes are NOT collapsed and percent-encodings are NEVER
538
+ * decoded on either side — noncanonical routing meaning stays visible
539
+ * so the verifier blocks instead of matching a different endpoint.
540
+ */
541
+ function normalizeObservedPath(rawPath) {
542
+ let path = rawPath.split('?')[0]?.split('#')[0] ?? '/';
543
+ if (!path.startsWith('/'))
544
+ path = `/${path}`;
545
+ if (path.length > 1)
546
+ path = path.replace(/\/+$/, '');
547
+ return path;
548
+ }
549
+ /**
550
+ * Consumes one engine-observed request matching (method, path) and
551
+ * issues witnessed `http.request` records bound to the declaring test's
552
+ * obligation claims (ADR 0004 D7, plan §8 / D1 transport-only semantics).
553
+ * The witness observes that an HTTP exchange traversed the proxy; WHICH
554
+ * browser, UI action, or test produced it is suite-claimed attribution,
555
+ * never independent proof. Single-use at the EXCHANGE level: an
556
+ * observation proves exactly one real request — it is consumed on first
557
+ * match and can never be re-claimed, replayed, or extended later. One
558
+ * genuine exchange genuinely instantiates every contract its endpoint
559
+ * declares of it (a compiler emits `http:frontend-request-observed` AND
560
+ * `http:response-status-ok` per consumed endpoint; ADR 0004 D8 calls the
561
+ * latter "the same witnessed record carrying a 2xx status"), so the
562
+ * consumed exchange issues one record PER claim id the declaring test
563
+ * itself declared — all carrying the identical engine-observed payload,
564
+ * each still independently provenance-verified and shape/status-checked
565
+ * by the verdict engine. The payload is
566
+ * `{method, url, status, bodySha256, bodyBytes}` — the bounded response
567
+ * snapshot hash and total byte count ride in the record, a tamper-evident
568
+ * trace of exactly what the engine observed.
569
+ *
570
+ * An optional `expectedStatus` narrows the consume match to exchanges
571
+ * the target answered with that exact status. This stays honest: the
572
+ * suite still cannot fabricate or mutate observations — it only selects
573
+ * WHICH real exchange it is accounting for. It exists because one
574
+ * (method, path) shape can legitimately fire several times per run with
575
+ * different statuses (e.g. the SPA's unauthenticated `/me` probe ahead of
576
+ * the authenticated one); FIFO-without-status would bind a `:response-
577
+ * status-ok` claim to an observed 401 the journey never intended.
578
+ */
579
+ async function handleHttpObservation(state, res, body) {
580
+ const testId = body['testId'];
581
+ const method = body['method'];
582
+ const path = body['path'];
583
+ const expectedStatus = body['expectedStatus'];
584
+ // A split legacy assignment (distinct `claimId` vs `obligationId`)
585
+ // is ambiguous caller intent — fail closed instead of silently
586
+ // picking one (plan §8 step 7: no silent wrong-obligation binding).
587
+ if (typeof body['claimId'] === 'string' &&
588
+ body['claimId'].length > 0 &&
589
+ typeof body['obligationId'] === 'string' &&
590
+ body['obligationId'].length > 0 &&
591
+ body['claimId'] !== body['obligationId']) {
592
+ sendJson(res, 400, {
593
+ error: 'http observation refuses a split claimId/obligationId assignment: supply one ' +
594
+ 'explicit obligation id (or a claimIds list)',
595
+ });
596
+ return;
597
+ }
598
+ // Claim binding: `claimIds` (the declaring test's claimed obligation
599
+ // ids for this endpoint) — with the singular legacy `claimId` /
600
+ // `obligationId` pair still accepted and folded in.
601
+ const rawClaimIds = Array.isArray(body['claimIds'])
602
+ ? [...body['claimIds'], body['claimId'], body['obligationId']]
603
+ : [body['claimIds'], body['claimId'], body['obligationId']];
604
+ const claimIds = [];
605
+ for (const entry of rawClaimIds) {
606
+ if (entry === undefined || entry === null)
607
+ continue;
608
+ if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
609
+ sendJson(res, 400, {
610
+ error: 'http observation requires claimIds as obligation-id strings ' +
611
+ "'<resourceId>:<contract>' (a singular legacy claimId/obligationId is still accepted)",
612
+ });
613
+ return;
614
+ }
615
+ if (!claimIds.includes(entry))
616
+ claimIds.push(entry);
617
+ }
618
+ if (claimIds.length === 0 ||
619
+ typeof testId !== 'string' ||
620
+ testId.length === 0 ||
621
+ typeof method !== 'string' ||
622
+ typeof path !== 'string' ||
623
+ path.length === 0 ||
624
+ (expectedStatus !== undefined &&
625
+ (typeof expectedStatus !== 'number' || !Number.isInteger(expectedStatus)))) {
626
+ sendJson(res, 400, {
627
+ error: 'http observation requires testId, method, and path strings plus at least one ' +
628
+ "claimed obligation id ('<resourceId>:<contract>'); expectedStatus, when present, " +
629
+ 'must be an integer status code',
630
+ });
631
+ return;
632
+ }
633
+ const wanted = normalizeObservedPath(path);
634
+ // Phase 1 (E11/E12 foundation): the consuming side must hold a valid
635
+ // OPEN session, and only an exchange observed through THAT session's
636
+ // proxy prefix WITHIN one of its recorded action intervals can be
637
+ // consumed — a request supplied by another test/worker (different
638
+ // session channel) or by setup traffic outside every interval is
639
+ // never credited to this test's claims.
640
+ const session = requireOpenSession(state, body);
641
+ requireSessionTestId(session, testId);
642
+ // Bind watermark (plan §11.4): observations that completed before the
643
+ // trusted context bound predate it and are never consumable under the
644
+ // new invocation — closing the proxy/bind race where a request started
645
+ // before binding but its response ends after it.
646
+ const watermark = state.runContext === null ? 0 : state.observedSeqAtBind;
647
+ const matchesShape = (entry) => entry.seq > watermark &&
648
+ entry.method === method.toUpperCase() &&
649
+ entry.path === wanted &&
650
+ (expectedStatus === undefined || entry.status === expectedStatus);
651
+ const index = state.observed.findIndex((entry) => entry.sessionId === session.sessionId &&
652
+ tickWithinSessionInterval(session, entry.tick) &&
653
+ matchesShape(entry));
654
+ if (index === -1) {
655
+ // Precise fail-closed diagnostics: distinguish "outside every
656
+ // interval" from "another session's channel" from "no such traffic".
657
+ const unattributed = state.observed.find((entry) => entry.sessionId === null && matchesShape(entry));
658
+ const foreign = state.observed.find((entry) => entry.sessionId !== null && entry.sessionId !== session.sessionId && matchesShape(entry));
659
+ const outsideInterval = state.observed.find((entry) => entry.sessionId === session.sessionId && matchesShape(entry));
660
+ if (outsideInterval !== undefined) {
661
+ sendJson(res, 409, {
662
+ error: `an engine-observed ${method.toUpperCase()} ${wanted} exists for this session but its ` +
663
+ 'completion tick falls outside every recorded UI-action observation interval — the ' +
664
+ 'fixture marks an interval per UI action, so traffic outside those windows (setup ' +
665
+ 'calls, stray navigation) is never browser evidence',
666
+ });
667
+ return;
668
+ }
669
+ if (foreign !== undefined) {
670
+ sendJson(res, 409, {
671
+ error: `the engine-observed ${method.toUpperCase()} ${wanted} traversed ANOTHER session's ` +
672
+ 'channel; exchanges are consumable only by the session whose proxy prefix they ' +
673
+ 'arrived through (a request supplied by another test/worker is never credited)',
674
+ });
675
+ return;
676
+ }
677
+ sendJson(res, 409, {
678
+ error: `no engine-observed request matches ${method.toUpperCase()} ${wanted}` +
679
+ `${expectedStatus === undefined ? '' : ` with status ${String(expectedStatus)}`}` +
680
+ `${unattributed === undefined ? '' : ' (traffic bypassed every session channel)'}; drive ` +
681
+ 'traffic through this session\'s observation-proxy prefix before claiming the obligation',
682
+ });
683
+ return;
684
+ }
685
+ const observedRequest = state.observed[index];
686
+ // Consume the exchange FIRST (single-use), then issue one record per
687
+ // distinct claimed obligation id — same payload, per-claim identity.
688
+ state.observed.splice(index, 1);
689
+ // Witness-side activity (review recheck fix 2026-09-14): consuming an
690
+ // engine-observed session exchange is an observation the witness made;
691
+ // count it.
692
+ session.activity += 1;
693
+ const payload = {
694
+ method: observedRequest.method,
695
+ url: observedRequest.path,
696
+ status: observedRequest.status,
697
+ bodySha256: observedRequest.bodySha256,
698
+ bodyBytes: observedRequest.bodyBytes,
699
+ sessionId: session.sessionId,
700
+ };
701
+ const issued = claimIds.map((claimId) => issueRecord(state, claimId, 'http.request', session.testId, payload, 'engine-observed'));
702
+ const first = issued[0];
703
+ sendJson(res, 200, {
704
+ recordId: first.recordId,
705
+ runId: first.runId,
706
+ trust: first.trust,
707
+ status: observedRequest.status,
708
+ records: issued.map((record) => ({ recordId: record.recordId, obligationId: record.obligationId })),
709
+ });
710
+ }
711
+ /**
712
+ * Resolves the engine browser's UI subject — the ONE attested
713
+ * application origin the engine may drive (fake-frontend fix
714
+ * 2026-09-14): `targetBaseUrl`, provisioned through trusted witness
715
+ * configuration (orchestrator env/flags), never from suite input. A
716
+ * copyable fingerprint header cannot authenticate application identity,
717
+ * so origin equality with this provisioned subject is the identity —
718
+ * loopback + fingerprint remain as attestation defense in depth, never
719
+ * as identity.
720
+ *
721
+ * Args:
722
+ * state: running witness state.
723
+ *
724
+ * Returns:
725
+ * string: the normalized trusted base (no trailing slash).
726
+ *
727
+ * Throws:
728
+ * HttpError: 409 when no attested subject is provisioned (browser
729
+ * proof without a provisioned subject fails closed — it never
730
+ * falls back to a suite-supplied origin).
731
+ */
732
+ function requireTrustedUiBase(state) {
733
+ const base = state.options.targetBaseUrl;
734
+ if (base === null || base === undefined || base === '') {
735
+ throw new HttpError(409, 'no attested UI subject is provisioned for this witness (targetBaseUrl): browser proof ' +
736
+ 'requires the orchestrator to provision the application origin through trusted ' +
737
+ 'configuration — the engine never drives a suite-supplied origin');
738
+ }
739
+ return base.replace(/\/+$/, '');
740
+ }
741
+ /**
742
+ * `POST /browser/surface` (plan Phase 1 item 4): registers the
743
+ * consumer-declared surface descriptor for one open session. The
744
+ * descriptor is validated structurally engine-side; selectors are
745
+ * locators only — registration proves nothing by itself.
746
+ *
747
+ * The driven origin is NOT negotiable here (fake-frontend fix
748
+ * 2026-09-14): a `appBaseUrl` field is rejected outright (400) — the
749
+ * engine drives exactly the provisioned attested subject
750
+ * (`requireTrustedUiBase`), which must be loopback (GF-10) and, when
751
+ * the run pins a target fingerprint, must present it (GF-13). A test
752
+ * that could name its own frontend could point the engine at a fake
753
+ * that replays the real API — origin equality with trusted
754
+ * configuration is the only application identity.
755
+ */
756
+ async function handleBrowserSurface(state, res, body) {
757
+ if (!isPlainObject(body)) {
758
+ throw new HttpError(400, 'browser surface body must be an object');
759
+ }
760
+ const { testId, surface } = body;
761
+ if (typeof testId !== 'string' || testId.length === 0) {
762
+ throw new HttpError(400, 'browser surface requires a non-empty testId');
763
+ }
764
+ if (body['appBaseUrl'] !== undefined) {
765
+ throw new HttpError(400, 'browser surface rejects appBaseUrl: the engine drives exactly the provisioned attested ' +
766
+ 'subject from trusted witness configuration — suite-supplied origins are never accepted ' +
767
+ '(a test-named frontend could replay the real API behind a copied fingerprint header)');
768
+ }
769
+ const session = requireOpenSession(state, body);
770
+ requireSessionTestId(session, testId);
771
+ let validated;
772
+ try {
773
+ validated = validateSurface(surface);
774
+ }
775
+ catch (error) {
776
+ throw new HttpError(400, `browser surface descriptor rejected: ${error.message}`);
777
+ }
778
+ // The trusted subject, resolved BEFORE any browser exists: loopback
779
+ // attestation (GF-10/GF-13) runs against the provisioned origin, never
780
+ // a suite URL.
781
+ const trustedBase = requireTrustedUiBase(state);
782
+ await assertLoopback(trustedBase, 'engine browser base');
783
+ const pinned = state.options.targetFingerprint ?? null;
784
+ if (pinned !== null) {
785
+ const probe = await probeEnvFingerprint(trustedBase, state.options.requestTimeoutMs);
786
+ const mismatch = envFingerprintMismatch(probe, pinned, pinned);
787
+ if (mismatch !== null) {
788
+ throw new HttpError(409, `engine browser subject rejected: ${mismatch}`);
789
+ }
790
+ }
791
+ session.engineSurface = {
792
+ surface: validated,
793
+ };
794
+ sendJson(res, 200, { registered: true });
795
+ }
796
+ /** Requires the session's registered engine surface (409 when absent). */
797
+ function requireEngineSurface(session) {
798
+ const registered = session.engineSurface;
799
+ if (registered === null) {
800
+ throw new HttpError(409, 'no engine surface is registered for this session: the fixture must register the ' +
801
+ 'consumer-declared surface descriptor first (POST /browser/surface) — the engine ' +
802
+ 'drives no browser without it');
803
+ }
804
+ return {
805
+ surface: registered.surface,
806
+ };
807
+ }
808
+ /** Validates browser claim ids: non-empty, obligation-shaped, one resource. */
809
+ function requireBrowserClaims(body) {
810
+ const raw = body['claimIds'];
811
+ if (!Array.isArray(raw) || raw.length === 0) {
812
+ throw new HttpError(400, 'browser calls require a non-empty claimIds array of obligation ids');
813
+ }
814
+ const claimIds = [];
815
+ for (const entry of raw) {
816
+ if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
817
+ throw new HttpError(400, `browser claimIds must be obligation ids '<resourceId>:<contract>' (got '${String(entry)}')`);
818
+ }
819
+ if (!claimIds.includes(entry))
820
+ claimIds.push(entry);
821
+ }
822
+ const resources = new Set(claimIds.map((id) => id.slice(0, id.indexOf(':'))));
823
+ if (resources.size !== 1) {
824
+ throw new HttpError(400, 'browser calls bind one resource per action: claimIds span several resources ' +
825
+ `(${[...resources].sort(compareStrings).join(', ')}) — drive one action per resource`);
826
+ }
827
+ return { claimIds, resourceId: [...resources][0] };
828
+ }
829
+ /**
830
+ * `POST /browser/action` (plan Phase 1 item 4): performs ONE constrained
831
+ * surface operation on the session's engine-owned page and issues
832
+ * engine-observed records for exactly what the engine did. The full
833
+ * binding in one call: the relevant rendered control + entered values,
834
+ * the actual action, the resulting application request + entity
835
+ * identity, the visible outcome — plus the engine-side pre-observation
836
+ * the persistence read later consumes (create/update). The captured
837
+ * exchanges join the witness observation log inside the engine's own
838
+ * action interval, so the existing http-observation consume path binds
839
+ * them with single-use semantics.
840
+ */
841
+ async function handleBrowserAction(state, res, body) {
842
+ if (!isPlainObject(body)) {
843
+ throw new HttpError(400, 'browser action body must be an object');
844
+ }
845
+ const { testId, operation, fields, entityId } = body;
846
+ if (typeof testId !== 'string' || testId.length === 0) {
847
+ throw new HttpError(400, 'browser action requires a non-empty testId');
848
+ }
849
+ if (operation !== 'create' && operation !== 'read' && operation !== 'update' && operation !== 'delete') {
850
+ throw new HttpError(400, "browser action operation must be one of 'create' | 'read' | 'update' | 'delete'");
851
+ }
852
+ if (fields !== undefined && !isPlainObject(fields)) {
853
+ throw new HttpError(400, 'browser action fields, when present, must be a string-valued object');
854
+ }
855
+ if (entityId !== undefined && typeof entityId !== 'string') {
856
+ throw new HttpError(400, 'browser action entityId, when present, must be a string');
857
+ }
858
+ const session = requireOpenSession(state, body);
859
+ const boundTestId = requireSessionTestId(session, testId);
860
+ const { claimIds, resourceId } = requireBrowserClaims(body);
861
+ const { surface } = requireEngineSurface(session);
862
+ // The driven origin comes from trusted configuration on EVERY call —
863
+ // never from stored suite input (there is none anymore).
864
+ const appBaseUrl = requireTrustedUiBase(state);
865
+ // The engine's own observation interval (witness clock, never suite
866
+ // time): exchanges captured during the drive land inside it.
867
+ const { intervalId } = openActionInterval(state, session, operation);
868
+ // Engine-side pre-observation BEFORE the drive (create: id-set
869
+ // absence; update: entity-fields delta) — the persistence read later
870
+ // consumes it under the same session.
871
+ let preObservationId = null;
872
+ if (operation === 'create' || operation === 'update') {
873
+ const pre = await takePreObservation(state, session, resourceId, operation === 'update' ? (entityId ?? '') : undefined);
874
+ preObservationId = pre.observationId;
875
+ }
876
+ try {
877
+ const page = await state.engineBrowser.pageFor(session.sessionId);
878
+ const observation = await driveEngineAction(page, appBaseUrl, surface, operation, {
879
+ ...(fields !== undefined ? { fields: fields } : {}),
880
+ ...(entityId !== undefined ? { entityId } : {}),
881
+ });
882
+ // Publish the captured exchanges into the witness observation log
883
+ // INSIDE the engine interval (ticks between open and close), so the
884
+ // existing single-use http-observation consume path binds them.
885
+ for (const exchange of observation.exchanges) {
886
+ state.observed.push({
887
+ method: exchange.method,
888
+ path: exchange.path,
889
+ status: exchange.status,
890
+ seq: (state.observedSeq += 1),
891
+ bodySha256: createHash('sha256').update(exchange.body).digest('hex'),
892
+ bodyBytes: exchange.body.length,
893
+ sessionId: session.sessionId,
894
+ tick: (state.tick += 1),
895
+ });
896
+ }
897
+ // Suite-submittable intervals/records stay open-submission; the
898
+ // ENGINE's own window closes here — late suite traffic after this
899
+ // tick is outside the engine interval and never credited.
900
+ closeActionInterval(state, session, intervalId);
901
+ // Witness-side activity: the engine drove a real browser action
902
+ // under the session; count it.
903
+ session.activity += 1;
904
+ // One engine-observed ui.action per claimed obligation (same
905
+ // payload, per-claim identity — the http-observation convention).
906
+ // The payload carries what the ENGINE observed: the operation, the
907
+ // rendered entity id, and the ENTERED input (exact-value echo
908
+ // source) — never suite-declared outcomes.
909
+ const issued = claimIds.map((claimId) => issueRecord(state, claimId, 'ui.action', boundTestId, {
910
+ operation,
911
+ entityId: observation.entityId,
912
+ fields: observation.enteredFields,
913
+ sessionId: session.sessionId,
914
+ }, 'engine-observed'));
915
+ const appStatus = operation === 'read'
916
+ ? (observation.exchanges[0]?.status ?? 0)
917
+ : (observation.exchanges.find((entry) => entry.method !== 'GET' && entry.method !== 'HEAD' && entry.status < 400)?.status ?? 0);
918
+ const response = {
919
+ entityId: observation.entityId,
920
+ enteredFields: observation.enteredFields,
921
+ renderedFields: observation.renderedFields,
922
+ appStatus,
923
+ preObservationId,
924
+ recordIds: issued.map((record) => record.recordId),
925
+ };
926
+ sendJson(res, 200, response);
927
+ }
928
+ catch (error) {
929
+ // The drive failed AFTER the interval opened: seal the window so a
930
+ // failed action never leaves a dangling interval for later traffic
931
+ // to borrow, then fail the call (the test fails; the gate blocks).
932
+ try {
933
+ closeActionInterval(state, session, intervalId);
934
+ }
935
+ catch {
936
+ // already closed — ignore
937
+ }
938
+ if (error instanceof EngineBrowserError) {
939
+ throw new HttpError(409, `engine browser action failed: ${error.message}`);
940
+ }
941
+ throw error;
942
+ }
943
+ }
944
+ /**
945
+ * `POST /browser/visible` (plan Phase 1 item 4): re-reads the rendered
946
+ * result for the engine-observed entity on the session's engine page
947
+ * and issues engine-observed visible-result records (row readback for
948
+ * mutations, form readback for reads).
949
+ */
950
+ async function handleBrowserVisible(state, res, body) {
951
+ if (!isPlainObject(body)) {
952
+ throw new HttpError(400, 'browser visible body must be an object');
953
+ }
954
+ const { testId, entityId, operation } = body;
955
+ if (typeof testId !== 'string' || testId.length === 0) {
956
+ throw new HttpError(400, 'browser visible requires a non-empty testId');
957
+ }
958
+ if (typeof entityId !== 'string' || entityId.length === 0) {
959
+ throw new HttpError(400, 'browser visible requires a non-empty entityId');
960
+ }
961
+ if (operation !== 'create' && operation !== 'read' && operation !== 'update' && operation !== 'delete') {
962
+ throw new HttpError(400, "browser visible operation must be one of 'create' | 'read' | 'update' | 'delete'");
963
+ }
964
+ const session = requireOpenSession(state, body);
965
+ const boundTestId = requireSessionTestId(session, testId);
966
+ const { claimIds } = requireBrowserClaims(body);
967
+ const { surface } = requireEngineSurface(session);
968
+ // The driven origin comes from trusted configuration on EVERY call —
969
+ // never from stored suite input (there is none anymore).
970
+ const appBaseUrl = requireTrustedUiBase(state);
971
+ try {
972
+ const page = await state.engineBrowser.pageFor(session.sessionId);
973
+ const fields = await readEngineVisible(page, appBaseUrl, surface, operation, entityId);
974
+ session.activity += 1;
975
+ const issued = claimIds.map((claimId) => issueRecord(state, claimId, 'ui.visible-result', boundTestId, { entityId, fields, sessionId: session.sessionId }, 'engine-observed'));
976
+ const response = {
977
+ entityId,
978
+ fields,
979
+ recordIds: issued.map((record) => record.recordId),
980
+ };
981
+ sendJson(res, 200, response);
982
+ }
983
+ catch (error) {
984
+ if (error instanceof EngineBrowserError) {
985
+ throw new HttpError(409, `engine browser visible read failed: ${error.message}`);
986
+ }
987
+ throw error;
988
+ }
989
+ }
990
+ /** Formats the bind host into a URL host (bracketing IPv6 literals). */
991
+ function formatHost(host) {
992
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
993
+ }
994
+ /** Routes one request through auth + body parse + dispatch. */
995
+ async function handleRequest(state, req, res) {
996
+ try {
997
+ const token = req.headers[RUN_HEADER];
998
+ if (typeof token !== 'string' || !timingSafeEqual(token, state.options.token)) {
999
+ sendJson(res, 401, { error: 'unauthorized: expected x-gateforge-run with the run token' });
1000
+ return;
1001
+ }
1002
+ const url = new URL(req.url ?? '/', 'http://witness');
1003
+ const path = url.pathname;
1004
+ if (req.method === 'GET' && path === '/health') {
1005
+ sendJson(res, 200, {
1006
+ ok: true,
1007
+ runId: state.options.runId,
1008
+ attestationScope: 'loopback+env-fingerprint',
1009
+ adapterCount: state.adapters.size,
1010
+ recordCount: state.ledger.size,
1011
+ });
1012
+ return;
1013
+ }
1014
+ if (req.method === 'GET' && path === '/records') {
1015
+ const records = [...state.ledger.values()].sort((a, b) => compareStrings(a.recordId, b.recordId));
1016
+ sendJson(res, 200, { records });
1017
+ return;
1018
+ }
1019
+ if (req.method === 'GET' && path === '/ledger-attestation') {
1020
+ handleLedgerAttestation(state, res, req.headers[VERIFIER_HEADER]);
1021
+ return;
1022
+ }
1023
+ if (req.method === 'POST' && path === '/run-context') {
1024
+ await handleRunContext(state, res, req.headers[VERIFIER_HEADER], await readBody(req));
1025
+ return;
1026
+ }
1027
+ if (req.method === 'GET' && path === '/classifications') {
1028
+ const resources = {};
1029
+ for (const key of Object.keys(state.classifications).sort(compareStrings)) {
1030
+ resources[key] = toClassificationView(state.classifications[key]);
1031
+ }
1032
+ sendJson(res, 200, { resources });
1033
+ return;
1034
+ }
1035
+ if (req.method === 'POST' && path === '/records') {
1036
+ await handleRecords(state, res, (await readBody(req)));
1037
+ return;
1038
+ }
1039
+ if (req.method === 'POST' && path === '/runs/expected-set') {
1040
+ await handleExpectedSet(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1041
+ return;
1042
+ }
1043
+ if (req.method === 'POST' && path === '/runs/server-e2e-declarations') {
1044
+ await handleServerE2eDeclarations(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1045
+ return;
1046
+ }
1047
+ if (req.method === 'GET' && path === '/runs/execution-trace') {
1048
+ requireSupervisor(state, req.headers[VERIFIER_HEADER]);
1049
+ sendJson(res, 200, executionTraceOf(state));
1050
+ return;
1051
+ }
1052
+ if (req.method === 'POST' && path === '/sessions/open') {
1053
+ requireSupervisor(state, req.headers[VERIFIER_HEADER]);
1054
+ await handleSessionOpen(state, res, (await readBody(req)));
1055
+ return;
1056
+ }
1057
+ if (req.method === 'POST' && path === '/sessions/close') {
1058
+ requireSupervisor(state, req.headers[VERIFIER_HEADER]);
1059
+ await handleSessionClose(state, res, (await readBody(req)));
1060
+ return;
1061
+ }
1062
+ if (req.method === 'POST' && path === '/sessions/resolve') {
1063
+ await handleSessionResolve(state, res, (await readBody(req)));
1064
+ return;
1065
+ }
1066
+ if (req.method === 'POST' && path === '/sessions/intervals/open') {
1067
+ await handleIntervalOpen(state, res, (await readBody(req)));
1068
+ return;
1069
+ }
1070
+ if (req.method === 'POST' && path === '/sessions/intervals/close') {
1071
+ await handleIntervalClose(state, res, (await readBody(req)));
1072
+ return;
1073
+ }
1074
+ if (req.method === 'POST' && path === '/witness/pre-observation') {
1075
+ await handlePreObservation(state, res, (await readBody(req)));
1076
+ return;
1077
+ }
1078
+ if (req.method === 'POST' && path === '/witness/persistence') {
1079
+ await handlePersistence(state, res, (await readBody(req)));
1080
+ return;
1081
+ }
1082
+ if (req.method === 'POST' && path === '/witness/server-persistence') {
1083
+ await handleServerPersistence(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
1084
+ return;
1085
+ }
1086
+ if (req.method === 'POST' && path === '/witness/http-observation') {
1087
+ await handleHttpObservation(state, res, (await readBody(req)));
1088
+ return;
1089
+ }
1090
+ if (req.method === 'POST' && path === '/browser/surface') {
1091
+ await handleBrowserSurface(state, res, (await readBody(req)));
1092
+ return;
1093
+ }
1094
+ if (req.method === 'POST' && path === '/browser/action') {
1095
+ await handleBrowserAction(state, res, (await readBody(req)));
1096
+ return;
1097
+ }
1098
+ if (req.method === 'POST' && path === '/browser/visible') {
1099
+ await handleBrowserVisible(state, res, (await readBody(req)));
1100
+ return;
1101
+ }
1102
+ sendJson(res, 404, { error: `no witness endpoint at ${req.method} ${path}` });
1103
+ }
1104
+ catch (error) {
1105
+ if (error instanceof HttpError) {
1106
+ const detail = error.detail;
1107
+ sendJson(res, error.status, detail === null ? { error: error.message } : { error: error.message, detail });
1108
+ return;
1109
+ }
1110
+ if (error instanceof AttestationError) {
1111
+ sendJson(res, 409, { error: 'attestation blocked', detail: error.message });
1112
+ return;
1113
+ }
1114
+ sendJson(res, 500, { error: `witness internal error: ${error instanceof Error ? error.message : String(error)}` });
1115
+ }
1116
+ }
1117
+ /** Constant-time token comparison. */
1118
+ function timingSafeEqual(a, b) {
1119
+ if (a.length !== b.length)
1120
+ return false;
1121
+ let diff = 0;
1122
+ for (let i = 0; i < a.length; i++) {
1123
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1124
+ }
1125
+ return diff === 0;
1126
+ }
1127
+ /**
1128
+ * Enforces the SUPERVISOR capability (enforcement-review fix 3): the
1129
+ * verifier key — the secret the tested suite never receives. The suite's
1130
+ * run token authorizes evidence submission and session resolution, never
1131
+ * session lifecycle, expected-set registration, traces, or attestation.
1132
+ * A witness without a configured verifier key has NO supervisor
1133
+ * capability at all: every supervisor endpoint answers 403 (fail closed)
1134
+ * rather than degrading to run-token authority.
1135
+ *
1136
+ * Args:
1137
+ * state: running witness state.
1138
+ * verifier: the `x-gateforge-verifier` header value.
1139
+ *
1140
+ * Throws:
1141
+ * HttpError: 403 when the witness holds no verifier key (typed
1142
+ * 'supervisor authorization required') or 401 on a key mismatch.
1143
+ */
1144
+ function requireSupervisor(state, verifier) {
1145
+ const verifierKey = state.options.verifierKey;
1146
+ if (verifierKey === null || verifierKey === undefined) {
1147
+ throw new HttpError(403, 'supervisor authorization required: this witness runs without a verifier key, so the ' +
1148
+ 'supervisor surface (expected set, session open/close, execution trace) is unavailable — ' +
1149
+ 'start the witness with GATEFORGE_WITNESS_VERIFIER_KEY from the orchestrating CLI');
1150
+ }
1151
+ if (typeof verifier !== 'string' || !timingSafeEqual(verifier, verifierKey)) {
1152
+ throw new HttpError(401, 'unauthorized: supervisor endpoints require x-gateforge-verifier with the verifier key ' +
1153
+ '(the run token never authorizes session lifecycle)');
1154
+ }
1155
+ }
1156
+ /** Sends a GF-canonical JSON response. */
1157
+ function sendJson(res, status, body) {
1158
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
1159
+ res.end(canonicalOf(body));
1160
+ }
1161
+ /**
1162
+ * `POST /runs/expected-set` (enforcement-review fix 2a) — SUPERVISOR
1163
+ * ONLY: registers the expected test set BEFORE the run, bound to this
1164
+ * run in witness memory. Once registered, `/sessions/open` accepts only
1165
+ * tests in this set (an invented testId is refused), and the execution
1166
+ * trace groups sessions by these registered identities. Identical
1167
+ * re-registration is idempotent (200); any change is 409; registration
1168
+ * after any session was opened is 409 (the expected set is a PRE-run
1169
+ * fact). The response carries the domain-separated enumeration digest
1170
+ * the sealed execution result binds.
1171
+ *
1172
+ * Args:
1173
+ * state: running witness state.
1174
+ * res: response to answer.
1175
+ * verifier: the `x-gateforge-verifier` header value.
1176
+ * body: the parsed request body ({tests: [...]}, identity-shaped).
1177
+ */
1178
+ async function handleExpectedSet(state, res, verifier, body) {
1179
+ requireSupervisor(state, verifier);
1180
+ if (!isPlainObject(body) || !Array.isArray(body['tests'])) {
1181
+ throw new HttpError(400, 'expected-set body must be {tests: [...]}');
1182
+ }
1183
+ const tests = [];
1184
+ for (const entry of body['tests']) {
1185
+ if (typeof entry !== 'object' || entry === null) {
1186
+ throw new HttpError(400, 'expected-set tests must be objects');
1187
+ }
1188
+ const row = entry;
1189
+ const testId = row['testId'] === undefined || row['testId'] === null ? null : row['testId'];
1190
+ const project = row['project'] === undefined || row['project'] === null ? null : row['project'];
1191
+ if ((testId !== null && typeof testId !== 'string') ||
1192
+ (project !== null && typeof project !== 'string') ||
1193
+ typeof row['file'] !== 'string' ||
1194
+ row['file'].length === 0 ||
1195
+ !Array.isArray(row['titlePath']) ||
1196
+ row['titlePath'].length === 0 ||
1197
+ !row['titlePath'].every((part) => typeof part === 'string' && part.length > 0)) {
1198
+ throw new HttpError(400, 'expected-set tests require file (non-empty string), titlePath (non-empty string array), ' +
1199
+ 'and optional testId/project strings');
1200
+ }
1201
+ tests.push({
1202
+ testId: testId,
1203
+ project: project,
1204
+ file: row['file'],
1205
+ titlePath: row['titlePath'],
1206
+ });
1207
+ }
1208
+ const digest = enumerationDigestOf(tests);
1209
+ if (state.enumerationDigest !== null) {
1210
+ if (state.enumerationDigest === digest) {
1211
+ sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
1212
+ return;
1213
+ }
1214
+ sendJson(res, 409, {
1215
+ error: 'an expected set is already bound to this run and differs; the expected set is a PRE-run ' +
1216
+ 'fact and is never relabeled — start a fresh witness for a new invocation',
1217
+ });
1218
+ return;
1219
+ }
1220
+ if (state.sessions.size > 0) {
1221
+ sendJson(res, 409, {
1222
+ error: 'sessions were already opened on this witness; the expected set must be registered ' +
1223
+ 'BEFORE the run — start a fresh witness for a new invocation',
1224
+ });
1225
+ return;
1226
+ }
1227
+ state.expectedTests.clear();
1228
+ for (const test of tests) {
1229
+ state.expectedTests.set(expectedKey(test.project, test.file, test.titlePath), test);
1230
+ }
1231
+ state.enumerationDigest = digest;
1232
+ sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
1233
+ }
1234
+ /**
1235
+ * Builds the witness-side execution trace (enforcement-review fix 2b):
1236
+ * per registered expected test, every recorded session with its open/
1237
+ * seal ticks and outcome. When no expected set is registered, sessions
1238
+ * are grouped by their own open identity (the legacy/standalone shape).
1239
+ * This record lives ONLY in witness memory — the suite cannot mint,
1240
+ * alter, or replay it — and is the authority supervision grades
1241
+ * completeness from.
1242
+ */
1243
+ function executionTraceOf(state) {
1244
+ const byKey = new Map();
1245
+ for (const session of state.sessions.values()) {
1246
+ const key = session.registered !== null
1247
+ ? expectedKey(session.registered.project, session.registered.file, session.registered.titlePath)
1248
+ : `runtime\u0000${session.sessionId}`;
1249
+ let entry = byKey.get(key);
1250
+ if (entry === undefined) {
1251
+ entry = {
1252
+ testId: session.registered?.testId ?? session.testId,
1253
+ project: session.registered?.project ?? null,
1254
+ file: session.registered?.file ?? '',
1255
+ titlePath: session.registered?.titlePath ?? [],
1256
+ sessions: [],
1257
+ };
1258
+ byKey.set(key, entry);
1259
+ }
1260
+ const traced = {
1261
+ sessionId: session.sessionId,
1262
+ openedTick: session.openedTick,
1263
+ sealedTick: session.sealedTick,
1264
+ outcome: session.outcome,
1265
+ // Witness-side activity (review recheck fix 2026-09-14): the count
1266
+ // of session-bound observations the witness itself made. A sealed
1267
+ // 'passed' session with zero activity blocks supervision — the
1268
+ // runner-reported outcome alone proves nothing.
1269
+ activity: session.activity,
1270
+ };
1271
+ entry.sessions.push(traced);
1272
+ }
1273
+ return {
1274
+ enumerationDigest: state.enumerationDigest,
1275
+ tests: [...byKey.entries()]
1276
+ .sort((a, b) => compareStrings(a[0], b[0]))
1277
+ .map(([, entry]) => ({ ...entry, sessions: [...entry.sessions].sort((a, b) => a.openedTick - b.openedTick) })),
1278
+ };
1279
+ }
1280
+ /**
1281
+ * `POST /sessions/open` (plan Phase 1; enforcement-review fix 3) —
1282
+ * SUPERVISOR ONLY: the trusted CLI (which owns the verifier key) registers
1283
+ * one started test. The witness issues the session binding — (runId,
1284
+ * sessionId, testId, worker) — and, when an observation proxy is active,
1285
+ * a per-session browser mount segment. One session may be OPEN per worker
1286
+ * at a time (a worker runs one test at a time; overlapping sessions would
1287
+ * make interval attribution ambiguous — fail closed instead). Re-opening
1288
+ * the identical (worker, testId) pair while it is still open is
1289
+ * idempotent (double `testBegin` dispatch safety).
1290
+ *
1291
+ * When an expected set is registered (fix 2a), the opened test must
1292
+ * belong to it — matched by the registered (project, file, titlePath)
1293
+ * identity the supervisor carries from the runner's lifecycle spool — so
1294
+ * a suite-invented testId can never mint a session.
1295
+ *
1296
+ * The suite holds the run token but CANNOT mint sessions with it: the
1297
+ * run token alone answers 403 typed.
1298
+ */
1299
+ async function handleSessionOpen(state, res, body) {
1300
+ if (!isPlainObject(body)) {
1301
+ throw new HttpError(400, 'session open body must be an object');
1302
+ }
1303
+ const { testId, workerIndex } = body;
1304
+ if (typeof testId !== 'string' || testId.length === 0) {
1305
+ throw new HttpError(400, 'session open requires a non-empty testId (the runner-assigned test id)');
1306
+ }
1307
+ if (typeof workerIndex !== 'number' || !Number.isInteger(workerIndex) || workerIndex < 0) {
1308
+ throw new HttpError(400, 'session open requires a non-negative integer workerIndex');
1309
+ }
1310
+ // Expected-set membership (fix 2a): once the supervisor registered the
1311
+ // expected tests, sessions exist only for them. Identity comes from the
1312
+ // supervisor's spool drain (file + titlePath + project).
1313
+ let registered = null;
1314
+ if (state.enumerationDigest !== null) {
1315
+ const file = typeof body['file'] === 'string' ? body['file'] : null;
1316
+ const rawTitlePath = Array.isArray(body['titlePath'])
1317
+ ? body['titlePath'].filter((part) => typeof part === 'string')
1318
+ : null;
1319
+ const project = typeof body['project'] === 'string' ? body['project'] : null;
1320
+ registered =
1321
+ file !== null && rawTitlePath !== null && rawTitlePath.length > 0
1322
+ ? state.expectedTests.get(expectedKey(project, file, rawTitlePath)) ?? null
1323
+ : null;
1324
+ if (registered === null) {
1325
+ throw new HttpError(403, `session open refused: test '${testId}' is not in the registered expected set — ` +
1326
+ 'sessions are minted only for the tests the supervisor registered before the run');
1327
+ }
1328
+ }
1329
+ const existingWorkerSession = state.workerSessions.get(workerIndex);
1330
+ if (existingWorkerSession !== undefined) {
1331
+ const open = state.sessions.get(existingWorkerSession);
1332
+ if (open !== undefined && open.testId === testId) {
1333
+ // Idempotent re-open of the identical (worker, testId) session.
1334
+ sendJson(res, 200, sessionView(state, open));
1335
+ return;
1336
+ }
1337
+ sendJson(res, 409, {
1338
+ error: `worker ${String(workerIndex)} already carries an open session for testId ` +
1339
+ `'${open === undefined ? existingWorkerSession : open.testId}'; a worker runs one test ` +
1340
+ 'at a time — close the session before opening another',
1341
+ });
1342
+ return;
1343
+ }
1344
+ // Phase 4 claim injection: the supervisor carries the mapped obligation
1345
+ // claims for this test on the session-open path. Claims stay
1346
+ // DECLARATIONS (they never satisfy anything by themselves), but the
1347
+ // witness normalizes them — obligation-id-shaped, sorted, deduplicated
1348
+ // — and refuses malformed values so a typo cannot silently redirect
1349
+ // evidence to a nonexistent claim identity.
1350
+ const rawClaims = body['claims'];
1351
+ let claims = [];
1352
+ if (rawClaims !== undefined) {
1353
+ if (!Array.isArray(rawClaims)) {
1354
+ throw new HttpError(400, 'session open claims, when present, must be an array of obligation ids');
1355
+ }
1356
+ const seen = new Set();
1357
+ for (const claim of rawClaims) {
1358
+ if (typeof claim !== 'string' || !OBLIGATION_ID_PATTERN.test(claim)) {
1359
+ throw new HttpError(400, `session open claims must be obligation ids '<resourceId>:<contract>' (got '${String(claim)}')`);
1360
+ }
1361
+ seen.add(claim);
1362
+ }
1363
+ claims = [...seen].sort(compareStrings);
1364
+ }
1365
+ const sessionId = randomUUID();
1366
+ const session = {
1367
+ sessionId,
1368
+ token: randomUUID(),
1369
+ testId,
1370
+ workerIndex,
1371
+ status: 'open',
1372
+ openedTick: (state.tick += 1),
1373
+ sealedTick: null,
1374
+ outcome: null,
1375
+ intervals: new Map(),
1376
+ // Filled below: the session's DEDICATED observation-proxy port.
1377
+ proxyUrl: null,
1378
+ proxyServer: null,
1379
+ claims,
1380
+ // The registered expected-set identity this session was minted for
1381
+ // (enforcement-review fix 2b); null when no expected set is bound.
1382
+ registered: registered !== null
1383
+ ? {
1384
+ testId: registered.testId,
1385
+ project: registered.project,
1386
+ file: registered.file,
1387
+ titlePath: [...registered.titlePath],
1388
+ }
1389
+ : null,
1390
+ // Witness-side activity counter: incremented at every observation
1391
+ // the witness itself makes under this session (records, intervals,
1392
+ // exchanges, pre-observations). Diagnostic corroboration only —
1393
+ // execution authority is the supervisor-observed trusted lifecycle,
1394
+ // not this count.
1395
+ activity: 0,
1396
+ // Engine-browser surface registration (plan Phase 1 item 4): the
1397
+ // validated consumer descriptor + loopback app base the engine
1398
+ // drives for this session. Null until the fixture registers it.
1399
+ engineSurface: null,
1400
+ };
1401
+ // Session attribution channel (plan Phase 1): a dedicated loopback
1402
+ // proxy port whose traffic is attributed to THIS session. Exists only
1403
+ // when an observation proxy is wired; otherwise nothing browser-side
1404
+ // is attributable.
1405
+ await startSessionProxy(state, session);
1406
+ state.sessions.set(sessionId, session);
1407
+ state.workerSessions.set(workerIndex, sessionId);
1408
+ sendJson(res, 200, sessionView(state, session));
1409
+ }
1410
+ /** The session view returned to supervisor and worker (the credential). */
1411
+ function sessionView(state, session) {
1412
+ void state;
1413
+ return {
1414
+ sessionId: session.sessionId,
1415
+ sessionToken: session.token,
1416
+ testId: session.testId,
1417
+ workerIndex: session.workerIndex,
1418
+ openedTick: session.openedTick,
1419
+ proxyUrl: session.proxyUrl,
1420
+ claims: [...session.claims],
1421
+ };
1422
+ }
1423
+ /**
1424
+ * `POST /sessions/close` (plan Phase 1; enforcement-review fix 3) —
1425
+ * SUPERVISOR ONLY (enforced at dispatch): the supervisor seals the
1426
+ * session with the observed outcome. Sealing is FINAL — every later
1427
+ * submission, interval, or resolve for the session is rejected, so
1428
+ * records cannot be injected after the test ended. Closing an unknown
1429
+ * session fails (400); closing an already-sealed session is idempotent
1430
+ * (safe re-delivery). The suite has NO reachable close path: the run
1431
+ * token alone answers 403 before this handler runs.
1432
+ */
1433
+ async function handleSessionClose(state, res, body) {
1434
+ if (!isPlainObject(body)) {
1435
+ throw new HttpError(400, 'session close body must be an object');
1436
+ }
1437
+ const { sessionId, outcome } = body;
1438
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
1439
+ throw new HttpError(400, 'session close requires a sessionId');
1440
+ }
1441
+ if (outcome !== undefined && typeof outcome !== 'string') {
1442
+ throw new HttpError(400, 'session close outcome, when present, must be a string');
1443
+ }
1444
+ const session = state.sessions.get(sessionId);
1445
+ if (session === undefined) {
1446
+ throw new HttpError(400, `session '${sessionId}' is unknown (never opened on this witness)`);
1447
+ }
1448
+ if (session.status === 'open') {
1449
+ session.status = 'sealed';
1450
+ session.sealedTick = (state.tick += 1);
1451
+ session.outcome = typeof outcome === 'string' ? outcome : null;
1452
+ state.workerSessions.delete(session.workerIndex);
1453
+ // The dedicated channel dies with the session: nothing can observe
1454
+ // (or submit) through it afterwards. The engine browser context dies
1455
+ // too — a sealed session's pages are never driven again.
1456
+ await stopSessionProxy(session);
1457
+ await state.engineBrowser.closeSession(session.sessionId);
1458
+ }
1459
+ sendJson(res, 200, { sealed: true });
1460
+ }
1461
+ /**
1462
+ * `POST /sessions/resolve` (plan Phase 1): the worker-side fixture asks
1463
+ * for the session credential of the OPEN session bound to its exact
1464
+ * (workerIndex, testId) pair. Only an open session answers — a sealed
1465
+ * or never-opened session 404s — so the fixture can never obtain a
1466
+ * credential for a session the supervisor did not open for exactly this
1467
+ * test instance.
1468
+ */
1469
+ async function handleSessionResolve(state, res, body) {
1470
+ if (!isPlainObject(body)) {
1471
+ throw new HttpError(400, 'session resolve body must be an object');
1472
+ }
1473
+ const { testId, workerIndex } = body;
1474
+ if (typeof testId !== 'string' || testId.length === 0) {
1475
+ throw new HttpError(400, 'session resolve requires a non-empty testId');
1476
+ }
1477
+ if (typeof workerIndex !== 'number' || !Number.isInteger(workerIndex) || workerIndex < 0) {
1478
+ throw new HttpError(400, 'session resolve requires a non-negative integer workerIndex');
1479
+ }
1480
+ const sessionId = state.workerSessions.get(workerIndex);
1481
+ const session = sessionId === undefined ? undefined : state.sessions.get(sessionId);
1482
+ if (session === undefined || session.status !== 'open' || session.testId !== testId) {
1483
+ sendJson(res, 404, {
1484
+ error: `no open session for (workerIndex ${String(workerIndex)}, testId '${testId}'); the ` +
1485
+ 'supervisor (the gateforge reporter) opens a session per started test — run under ' +
1486
+ 'the pack reporter so evidence primitives can resolve their session',
1487
+ });
1488
+ return;
1489
+ }
1490
+ sendJson(res, 200, sessionView(state, session));
1491
+ }
1492
+ /**
1493
+ * Enforces the supervisor-issued session credential on a submission:
1494
+ * the session must EXIST (403 otherwise), carry a token that matches
1495
+ * (timing-safe), and still be OPEN (409 once sealed — late submissions
1496
+ * after close are rejected fail closed).
1497
+ */
1498
+ function requireOpenSession(state, body) {
1499
+ const sessionId = body['sessionId'];
1500
+ const sessionToken = body['sessionToken'];
1501
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof sessionToken !== 'string') {
1502
+ throw new HttpError(400, 'submissions require the supervisor-issued session credential (sessionId + sessionToken); ' +
1503
+ 'run under the gateforge reporter so the test session is opened and resolved');
1504
+ }
1505
+ const session = state.sessions.get(sessionId);
1506
+ if (session === undefined || !timingSafeEqual(sessionToken, session.token)) {
1507
+ throw new HttpError(403, 'session credential is unknown to this witness: sessions are issued only by the ' +
1508
+ 'supervisor channel (/sessions/open) and cannot be minted from the run token');
1509
+ }
1510
+ if (session.status !== 'open') {
1511
+ throw new HttpError(409, `session for testId '${session.testId}' was sealed at tick ${String(session.sealedTick)}: ` +
1512
+ 'late submissions after session close are rejected (no post-hoc record injection)');
1513
+ }
1514
+ return session;
1515
+ }
1516
+ /**
1517
+ * Forces the submission's testId onto the session: the record's test
1518
+ * identity is the supervisor-registered one, never a caller-declared
1519
+ * value — a suite-supplied testId cannot assign evidence to another
1520
+ * test (plan Phase 1 work item 2).
1521
+ */
1522
+ function requireSessionTestId(session, testId) {
1523
+ if (testId !== session.testId) {
1524
+ throw new HttpError(403, `submission testId '${String(testId)}' does not match the open session's ` +
1525
+ `supervisor-registered testId '${session.testId}' — records bind to the session ` +
1526
+ 'the supervisor opened, never to a caller-declared test id');
1527
+ }
1528
+ return session.testId;
1529
+ }
1530
+ /**
1531
+ * Opens a UI-action observation interval on the witness clock (at most
1532
+ * one open per session — opening auto-closes the previous). Shared by
1533
+ * the suite-callable endpoint and the engine browser driver: both
1534
+ * produce witness-stamped windows, never suite-supplied times.
1535
+ */
1536
+ function openActionInterval(state, session, operation) {
1537
+ for (const interval of session.intervals.values()) {
1538
+ if (interval.endTick === null)
1539
+ interval.endTick = (state.tick += 1);
1540
+ }
1541
+ const intervalId = randomUUID();
1542
+ const startTick = (state.tick += 1);
1543
+ session.intervals.set(intervalId, { startTick, endTick: null, operation });
1544
+ // Witness-side activity: a recorded UI-action interval is a
1545
+ // witness-kept window; count it.
1546
+ session.activity += 1;
1547
+ return { intervalId, startTick };
1548
+ }
1549
+ /** Seals one UI-action observation interval (unknown/duplicate → throw). */
1550
+ function closeActionInterval(state, session, intervalId) {
1551
+ const interval = session.intervals.get(intervalId);
1552
+ if (interval === undefined) {
1553
+ throw new HttpError(400, `interval '${intervalId}' is unknown for this session`);
1554
+ }
1555
+ if (interval.endTick !== null) {
1556
+ throw new HttpError(409, `interval '${intervalId}' is already closed (endTick ${String(interval.endTick)})`);
1557
+ }
1558
+ interval.endTick = (state.tick += 1);
1559
+ return { startTick: interval.startTick, endTick: interval.endTick };
1560
+ }
1561
+ /**
1562
+ * `POST /sessions/intervals/open`: the fixture marks the START of a
1563
+ * UI-action observation interval on the witness's monotonic clock. At
1564
+ * most one interval is open per session — opening a new one auto-closes
1565
+ * the previous (a dangling interval must not silently widen the
1566
+ * evidence window).
1567
+ */
1568
+ async function handleIntervalOpen(state, res, body) {
1569
+ const session = requireOpenSession(state, body);
1570
+ const operation = body['operation'];
1571
+ if (typeof operation !== 'string' || operation.length === 0) {
1572
+ throw new HttpError(400, 'interval open requires a non-empty operation label');
1573
+ }
1574
+ const { intervalId, startTick } = openActionInterval(state, session, operation);
1575
+ sendJson(res, 200, { intervalId, startTick });
1576
+ }
1577
+ /**
1578
+ * `POST /sessions/intervals/close`: seals a UI-action observation
1579
+ * interval. Closing an unknown interval fails (400); closing twice is
1580
+ * refused (409) — a sealed window must not be stretched after the fact.
1581
+ */
1582
+ async function handleIntervalClose(state, res, body) {
1583
+ const session = requireOpenSession(state, body);
1584
+ const intervalId = body['intervalId'];
1585
+ if (typeof intervalId !== 'string' || intervalId.length === 0) {
1586
+ throw new HttpError(400, 'interval close requires an intervalId');
1587
+ }
1588
+ const { startTick, endTick } = closeActionInterval(state, session, intervalId);
1589
+ sendJson(res, 200, { startTick, endTick });
1590
+ }
1591
+ /**
1592
+ * Whether an exchange observed at `tick` falls inside one of the
1593
+ * session's recorded UI-action intervals (open intervals extend to the
1594
+ * current moment — an observe arriving between action end and interval
1595
+ * close still sees the window). Exchanges outside every interval are
1596
+ * NEVER credited: that is setup traffic, not the browser action
1597
+ * (plan Phase 1 item 6).
1598
+ */
1599
+ function tickWithinSessionInterval(session, tick) {
1600
+ for (const interval of session.intervals.values()) {
1601
+ if (tick >= interval.startTick && (interval.endTick === null || tick <= interval.endTick)) {
1602
+ return true;
1603
+ }
1604
+ }
1605
+ return false;
1606
+ }
1607
+ /**
1608
+ * `POST /records`: validates and issues a submitted evidence record.
1609
+ * Unknown primitive kinds → 400 (GF-11: an unregistered primitive name
1610
+ * has no registration path; GF-14: the audit-event primitive is
1611
+ * implementation-gated and does not exist yet). Only the two UI
1612
+ * primitives are suite-submittable; `http.request` and persistence
1613
+ * records are witness-issued only (engine-side observation), so no
1614
+ * claimed-side path can mint them. Phase 1: issuance requires a valid
1615
+ * OPEN session — the record's testId is forced onto the session's
1616
+ * supervisor-registered value and the payload carries the session id,
1617
+ * so every record self-describes the channel it was minted through.
1618
+ */
1619
+ async function handleRecords(state, res, body) {
1620
+ if (!isPlainObject(body)) {
1621
+ throw new HttpError(400, 'request body must be an object');
1622
+ }
1623
+ const { claimId, kind, payload, testId } = body;
1624
+ if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
1625
+ throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
1626
+ }
1627
+ if (typeof kind !== 'string' || !KNOWN_RECORD_KINDS.includes(kind)) {
1628
+ throw new HttpError(400, `unknown evidence primitive '${String(kind)}'; accepted kinds: ${KNOWN_RECORD_KINDS.join(', ')} ` +
1629
+ '(http.request and persistence records are witness-issued only: /witness/http-observation ' +
1630
+ 'and /witness/persistence)');
1631
+ }
1632
+ if (typeof testId !== 'string' || testId.length === 0) {
1633
+ throw new HttpError(400, 'testId must be a non-empty string');
1634
+ }
1635
+ if (!isPlainObject(payload)) {
1636
+ throw new HttpError(400, 'payload must be a JSON object');
1637
+ }
1638
+ const session = requireOpenSession(state, body);
1639
+ requireSessionTestId(session, testId);
1640
+ const record = issueRecord(state, claimId, kind, session.testId, { ...payload, sessionId: session.sessionId }, 'suite-submitted');
1641
+ // Witness-side activity (review recheck fix 2026-09-14): a submitted
1642
+ // record under an open session is a session-bound event the witness
1643
+ // issued; count it. (Content trust stays claimed — the count only
1644
+ // corroborates session liveness for supervision, never evidence
1645
+ // strength.)
1646
+ session.activity += 1;
1647
+ const response = {
1648
+ recordId: record.recordId,
1649
+ trust: record.trust,
1650
+ runId: record.runId,
1651
+ };
1652
+ sendJson(res, 200, response);
1653
+ }
1654
+ /**
1655
+ * `POST /witness/persistence`: runs the engine-side adapter (GET-only)
1656
+ * for one entity, stamps a persistence record from the ADAPTER RESPONSE,
1657
+ * and returns the verdict-relevant comparison. Attestation failures
1658
+ * (GF-10 non-loopback base, GF-13 fingerprint mismatch) REJECT the
1659
+ * record with 409 — raw adapter responses never leave this process.
1660
+ *
1661
+ * The issued record's payload is the ENGINE OBSERVATION the verdict
1662
+ * engine grades postconditions against (audit rounds 4-5): `{resourceId,
1663
+ * entityId, found, fields?, before?}`. Expectations NEVER come from the
1664
+ * tested suite; `before` links a consumed pre-observation (id-set
1665
+ * absence for create, entity-fields snapshot for update).
1666
+ */
1667
+ async function handlePersistence(state, res, body) {
1668
+ if (!isPlainObject(body)) {
1669
+ throw new HttpError(400, 'request body must be an object');
1670
+ }
1671
+ const { resourceId, entityId, testId, claimId, preObservationId } = body;
1672
+ if (typeof resourceId !== 'string' || resourceId.length === 0) {
1673
+ throw new HttpError(400, 'resourceId must be a non-empty string');
1674
+ }
1675
+ if (typeof testId !== 'string' || testId.length === 0) {
1676
+ throw new HttpError(400, 'testId must be a non-empty string');
1677
+ }
1678
+ if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
1679
+ throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
1680
+ }
1681
+ if (preObservationId !== undefined &&
1682
+ (typeof preObservationId !== 'string' || preObservationId.length === 0)) {
1683
+ throw new HttpError(400, 'preObservationId must be a non-empty string when present');
1684
+ }
1685
+ // Phase 1: engine-side reads run under the supervisor-opened session,
1686
+ // so the persistence record binds to the same channel the UI action
1687
+ // used (and the record's testId is the session's, never caller-declared).
1688
+ const session = requireOpenSession(state, body);
1689
+ const boundTestId = requireSessionTestId(session, testId);
1690
+ const boundClaimId = String(claimId);
1691
+ const { adapterName, adapter, baseUrl } = await adapterReadContext(state, resourceId);
1692
+ // Consume the referenced pre-observation, if any (single-use). Its
1693
+ // contents — never suite-declared expectations — are what the engine
1694
+ // grades create/update postconditions against.
1695
+ let before;
1696
+ let consumed;
1697
+ if (typeof preObservationId === 'string') {
1698
+ const observation = state.preObservations.get(preObservationId);
1699
+ if (observation === undefined || observation.resourceId !== resourceId) {
1700
+ throw new HttpError(400, `pre-observation '${preObservationId}' is unknown, already consumed, or belongs to another resource`);
1701
+ }
1702
+ state.preObservations.delete(preObservationId);
1703
+ consumed = observation;
1704
+ before = { entityAbsent: true }; // refined below for ids-kind snapshots
1705
+ }
1706
+ // Execute the adapter's GET-only read through the mediated transport.
1707
+ const adapterHeaders = state.options.adapterReadAuthorization
1708
+ ? { authorization: state.options.adapterReadAuthorization }
1709
+ : undefined;
1710
+ const ctx = makeAdapterContext(baseUrl, resourceId, (path) => adapterGet(baseUrl, state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), adapterHeaders);
1711
+ let bodyRaw;
1712
+ try {
1713
+ bodyRaw = await adapter.read(ctx, entityId);
1714
+ }
1715
+ catch (error) {
1716
+ throw new HttpError(409, `adapter '${adapterName}' read failed: ${error instanceof Error ? error.message : String(error)}`);
1717
+ }
1718
+ const found = bodyRaw !== null && bodyRaw !== undefined;
1719
+ let normalized = null;
1720
+ if (found) {
1721
+ try {
1722
+ const candidate = adapter.normalize(bodyRaw);
1723
+ if (!isPlainObject(candidate) || !('entityId' in candidate) || !('fields' in candidate)) {
1724
+ throw new Error('normalize must return {entityId, fields}');
1725
+ }
1726
+ normalized = { entityId: candidate['entityId'], fields: candidate['fields'] };
1727
+ }
1728
+ catch (error) {
1729
+ throw new HttpError(409, `adapter '${adapterName}' normalize failed: ${error instanceof Error ? error.message : String(error)}`);
1730
+ }
1731
+ }
1732
+ // Report-only observations; the ENGINE judges identity binding (GF-05).
1733
+ const mismatches = [];
1734
+ let entityAgrees = true;
1735
+ if (found && entityId !== undefined && normalized !== null) {
1736
+ try {
1737
+ if (canonicalOf(entityId) !== canonicalOf(normalized.entityId)) {
1738
+ entityAgrees = false;
1739
+ mismatches.push(`entityId mismatch: adapter returned ${canonicalOf(normalized.entityId)} for requested ${canonicalOf(entityId)}`);
1740
+ }
1741
+ }
1742
+ catch {
1743
+ entityAgrees = false;
1744
+ }
1745
+ }
1746
+ // Build `before` from the consumed pre-observation's OWN contents —
1747
+ // never from anything the suite declared.
1748
+ if (consumed !== undefined && before !== undefined) {
1749
+ if (consumed.kind === 'ids') {
1750
+ const observedId = normalized !== null && normalized.entityId !== undefined ? normalized.entityId : entityId;
1751
+ let absent = true;
1752
+ try {
1753
+ absent = !consumed.ids.includes(canonicalOf(observedId));
1754
+ }
1755
+ catch {
1756
+ absent = true; // unrepresentable id: treat as not previously observed
1757
+ }
1758
+ before = { entityAbsent: absent };
1759
+ }
1760
+ else {
1761
+ before = {
1762
+ found: consumed.found,
1763
+ ...(consumed.found ? { fields: consumed.fields } : {}),
1764
+ };
1765
+ }
1766
+ }
1767
+ // The payload IS the engine observation (hashed into the record id).
1768
+ const payload = {
1769
+ resourceId,
1770
+ entityId: found && normalized !== null ? normalized.entityId : entityId ?? null,
1771
+ found,
1772
+ ...(found && normalized !== null ? { fields: normalized.fields } : {}),
1773
+ ...(before !== undefined ? { before } : {}),
1774
+ sessionId: session.sessionId,
1775
+ };
1776
+ const issued = issuePersistenceRecord(state, boundClaimId, boundTestId, payload);
1777
+ // Witness-side activity (review recheck fix 2026-09-14): an
1778
+ // engine-observed persistence read under the session is real work the
1779
+ // witness performed; count it so supervision can corroborate execution.
1780
+ session.activity += 1;
1781
+ const response = {
1782
+ recordId: issued.recordId,
1783
+ runId: issued.runId,
1784
+ verdictRelevant: {
1785
+ found,
1786
+ fieldsMatch: entityAgrees,
1787
+ ...(mismatches.length > 0 ? { mismatches } : {}),
1788
+ },
1789
+ };
1790
+ sendJson(res, 200, response);
1791
+ }
1792
+ /**
1793
+ * `POST /witness/pre-observation` (audit rounds 4-5): takes an
1794
+ * engine-side snapshot BEFORE a claimed action, stored in witness
1795
+ * memory and consumed single-use by the paired persistence read. Two
1796
+ * modes:
1797
+ * - with `entityId`: snapshots that entity's observed fields (for
1798
+ * update postconditions — the engine grades the before/after delta);
1799
+ * - without: snapshots the resource's observed id set via the adapter's
1800
+ * optional `list` (for create postconditions — the engine grades
1801
+ * absence-before).
1802
+ * A suite can reference a real observation but cannot fabricate,
1803
+ * replay, or mutate its contents.
1804
+ */
1805
+ async function handlePreObservation(state, res, body) {
1806
+ if (!isPlainObject(body)) {
1807
+ throw new HttpError(400, 'request body must be an object');
1808
+ }
1809
+ const { resourceId, testId, claimId, entityId } = body;
1810
+ if (typeof resourceId !== 'string' || resourceId.length === 0) {
1811
+ throw new HttpError(400, 'resourceId must be a non-empty string');
1812
+ }
1813
+ if (typeof testId !== 'string' || testId.length === 0) {
1814
+ throw new HttpError(400, 'testId must be a non-empty string');
1815
+ }
1816
+ if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
1817
+ throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
1818
+ }
1819
+ // Phase 1: pre-observations belong to the supervisor-opened session of
1820
+ // the claiming test (the persistence read later consumes them under
1821
+ // the same session).
1822
+ const session = requireOpenSession(state, body);
1823
+ requireSessionTestId(session, testId);
1824
+ const { observationId, observed } = await takePreObservation(state, session, resourceId, entityId);
1825
+ const response = { observationId, observed };
1826
+ sendJson(res, 200, response);
1827
+ }
1828
+ /**
1829
+ * Takes an engine-side pre-observation snapshot (shared by the
1830
+ * suite-callable endpoint and the engine browser driver): with
1831
+ * `entityId` an entity-fields snapshot (update postconditions),
1832
+ * without it the resource id-set snapshot (create postconditions).
1833
+ * Snapshots live in witness memory, single-use, and their contents —
1834
+ * never suite-declared expectations — are what the engine grades
1835
+ * against. A suite can reference a real observation but cannot
1836
+ * fabricate, replay, or mutate its contents.
1837
+ */
1838
+ async function takePreObservation(state, session, resourceId, entityId) {
1839
+ const { adapterName, adapter, baseUrl } = await adapterReadContext(state, resourceId);
1840
+ const adapterHeaders = state.options.adapterReadAuthorization
1841
+ ? { authorization: state.options.adapterReadAuthorization }
1842
+ : undefined;
1843
+ const ctx = makeAdapterContext(baseUrl, resourceId, (path) => adapterGet(baseUrl, state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), adapterHeaders);
1844
+ const observationId = randomUUID();
1845
+ if (entityId !== undefined) {
1846
+ // Entity-fields snapshot (update postconditions).
1847
+ let bodyRaw;
1848
+ try {
1849
+ bodyRaw = await adapter.read(ctx, entityId);
1850
+ }
1851
+ catch (error) {
1852
+ throw new HttpError(409, `adapter '${adapterName}' read failed: ${error instanceof Error ? error.message : String(error)}`);
1853
+ }
1854
+ const found = bodyRaw !== null && bodyRaw !== undefined;
1855
+ let fields = undefined;
1856
+ if (found) {
1857
+ try {
1858
+ const candidate = adapter.normalize(bodyRaw);
1859
+ if (!isPlainObject(candidate) || !('fields' in candidate)) {
1860
+ throw new Error('normalize must return {entityId, fields}');
1861
+ }
1862
+ fields = candidate['fields'];
1863
+ }
1864
+ catch (error) {
1865
+ throw new HttpError(409, `adapter '${adapterName}' normalize failed during pre-observation: ${error instanceof Error ? error.message : String(error)}`);
1866
+ }
1867
+ }
1868
+ state.preObservations.set(observationId, {
1869
+ resourceId,
1870
+ kind: 'entity',
1871
+ entityId: canonicalOf(entityId),
1872
+ found,
1873
+ ...(found ? { fields } : {}),
1874
+ });
1875
+ // Witness-side activity: the engine-side snapshot ran under the
1876
+ // session; count it.
1877
+ session.activity += 1;
1878
+ return { observationId, observed: found ? 1 : 0 };
1879
+ }
1880
+ // Resource id-set snapshot (create postconditions).
1881
+ if (typeof adapter.list !== 'function') {
1882
+ throw new HttpError(409, `adapter '${adapterName}' does not support resource-level pre-observation ` +
1883
+ '(no list export); create postconditions cannot be observed for this resource');
1884
+ }
1885
+ let raw;
1886
+ try {
1887
+ raw = await adapter.list(ctx);
1888
+ }
1889
+ catch (error) {
1890
+ throw new HttpError(409, `adapter '${adapterName}' list failed: ${error instanceof Error ? error.message : String(error)}`);
1891
+ }
1892
+ if (!Array.isArray(raw)) {
1893
+ throw new HttpError(409, `adapter '${adapterName}' list must return an array of entities`);
1894
+ }
1895
+ const ids = [];
1896
+ for (const entity of raw) {
1897
+ try {
1898
+ const candidate = adapter.normalize(entity);
1899
+ if (!isPlainObject(candidate) || !('entityId' in candidate)) {
1900
+ throw new Error('normalize must return {entityId, fields}');
1901
+ }
1902
+ ids.push(canonicalOf(candidate['entityId']));
1903
+ }
1904
+ catch (error) {
1905
+ throw new HttpError(409, `adapter '${adapterName}' normalize failed during pre-observation: ${error instanceof Error ? error.message : String(error)}`);
1906
+ }
1907
+ }
1908
+ state.preObservations.set(observationId, { resourceId, kind: 'ids', ids: ids.sort(compareStrings) });
1909
+ // Witness-side activity: the engine-side id-set snapshot ran under
1910
+ // the session; count it.
1911
+ session.activity += 1;
1912
+ return { observationId, observed: ids.length };
1913
+ }
1914
+ /**
1915
+ * `POST /runs/server-e2e-declarations` — SUPERVISOR ONLY (verifier key;
1916
+ * the same authority as `POST /runs/expected-set`): registers the
1917
+ * obligation ids the trusted mapping layer declared kind `server-e2e`.
1918
+ * This is the gate that makes the server-witnessed channel kind-honest:
1919
+ * the witness refuses (`409`) any server intent whose claimId is not in
1920
+ * this set, so a browser-kind claim can never be satisfied through the
1921
+ * channel and a suite-written intent can never self-declare its kind
1922
+ * (the kind resolves in the trusted CLI mapping layer, which is exactly
1923
+ * why the fact enters through a verifier-key surface, never through the
1924
+ * suite-writable spool). Bound once BEFORE any issuance — identical
1925
+ * re-registration is idempotent, any change or late registration is 409.
1926
+ */
1927
+ async function handleServerE2eDeclarations(state, res, verifier, body) {
1928
+ requireSupervisor(state, verifier);
1929
+ if (!isPlainObject(body) || !Array.isArray(body['obligations'])) {
1930
+ throw new HttpError(400, 'server-e2e declarations body must be {obligations: [...]}');
1931
+ }
1932
+ const obligations = new Set();
1933
+ for (const entry of body['obligations']) {
1934
+ if (typeof entry !== 'string' || !OBLIGATION_ID_PATTERN.test(entry)) {
1935
+ throw new HttpError(400, `server-e2e declarations must be obligation ids '<resourceId>:<contract>' (got '${String(entry)}')`);
1936
+ }
1937
+ obligations.add(entry);
1938
+ }
1939
+ if (state.serverE2eDeclarations !== null) {
1940
+ const identical = state.serverE2eDeclarations.size === obligations.size &&
1941
+ [...obligations].every((id) => state.serverE2eDeclarations?.has(id));
1942
+ if (identical) {
1943
+ sendJson(res, 200, {
1944
+ bound: true,
1945
+ count: state.serverE2eDeclarations.size,
1946
+ obligations: [...state.serverE2eDeclarations].sort(compareStrings),
1947
+ });
1948
+ return;
1949
+ }
1950
+ sendJson(res, 409, {
1951
+ error: 'server-e2e declarations are already bound to this run and differ; the declaration set ' +
1952
+ 'is a PRE-run fact and is never relabeled — start a fresh witness for a new invocation',
1953
+ });
1954
+ return;
1955
+ }
1956
+ if (state.ledger.size > 0 || state.sessions.size > 0 || state.serverPreObservations.size > 0) {
1957
+ sendJson(res, 409, {
1958
+ error: 'witness already issued evidence or holds open sessions; server-e2e declarations must be ' +
1959
+ 'registered BEFORE the run — start a fresh witness for a new invocation',
1960
+ });
1961
+ return;
1962
+ }
1963
+ state.serverE2eDeclarations = obligations;
1964
+ sendJson(res, 200, {
1965
+ bound: true,
1966
+ count: obligations.size,
1967
+ obligations: [...obligations].sort(compareStrings),
1968
+ });
1969
+ }
1970
+ /**
1971
+ * `POST /witness/server-persistence` — SUPERVISOR ONLY (run token +
1972
+ * verifier key; the trusted CLI drain forwards intents the supervised
1973
+ * suite could only WRITE to the spool): one persistence claim intent
1974
+ * resolved against the app's real state. The WITNESS — never the test
1975
+ * process — executes the resource's adapter SERVER PROBE (witness-side,
1976
+ * behind the same attestation chain as every adapter read: GF-10
1977
+ * loopback + GF-13 fingerprint) and, only on a successful observation,
1978
+ * stamps a WITNESSED `persistence.entity` record carrying
1979
+ * `channel: 'server'` + `declaredKind: 'server-e2e'`, bound to
1980
+ * runId/claimId/testId and covered by the ledger attestation exactly
1981
+ * like every witnessed record.
1982
+ *
1983
+ * Fail-closed resolution (typed causes on the error `detail`):
1984
+ * - obligation not registered `server-e2e` → 409, detail
1985
+ * `TEST_KIND_UNKNOWN` (declare `kind: server-e2e` in the test map);
1986
+ * - replayed/out-of-order intent sequence → 409 (no stale re-drive);
1987
+ * - create/update post intent without the paired pre intent → 409
1988
+ * (the engine grades before/after; without a witness-side before
1989
+ * observation there is nothing to stamp);
1990
+ * - missing adapter / missing `probeServer` export / probe throw or
1991
+ * malformed probe result → 409 with detail `SERVER_PROBE_UNAVAILABLE`
1992
+ * — the intent NEVER resolves to satisfaction on probe trouble.
1993
+ *
1994
+ * The intent line itself is suite-writable and proves nothing; it only
1995
+ * selects WHICH entity the witness probes and what the suite expects.
1996
+ * Expectation CONTRADICTION (e.g. create-post but the entity is still
1997
+ * absent) is not a probe failure: the record stamps what the witness
1998
+ * observed and the verdict engine grades the postcondition — one
1999
+ * grading site, engine-owned.
2000
+ */
2001
+ async function handleServerPersistence(state, res, verifier, body) {
2002
+ requireSupervisor(state, verifier);
2003
+ if (!isPlainObject(body)) {
2004
+ throw new HttpError(400, 'server persistence body must be an object');
2005
+ }
2006
+ const { resourceId, claimId, operation, phase, intent, key, sequence, testId } = body;
2007
+ if (typeof resourceId !== 'string' || resourceId.length === 0) {
2008
+ throw new HttpError(400, 'resourceId must be a non-empty string');
2009
+ }
2010
+ if (typeof claimId !== 'string' || !OBLIGATION_ID_PATTERN.test(claimId)) {
2011
+ throw new HttpError(400, "claimId must be an obligation id '<resourceId>:<contract>'");
2012
+ }
2013
+ if (operation !== 'create' && operation !== 'read' && operation !== 'update' && operation !== 'delete') {
2014
+ throw new HttpError(400, "operation must be one of 'create' | 'read' | 'update' | 'delete'");
2015
+ }
2016
+ // Claim/operation/resource agreement: an intent never steers evidence
2017
+ // onto a different obligation identity than the one it names.
2018
+ if (claimId !== `${resourceId}:persistence:${operation}`) {
2019
+ throw new HttpError(400, `claimId '${claimId}' must equal '<resourceId>:persistence:${operation}' for this intent ` +
2020
+ '(claim, resource, and operation must agree — an intent never redirects evidence)');
2021
+ }
2022
+ if (phase !== 'pre' && phase !== 'post') {
2023
+ throw new HttpError(400, "phase must be 'pre' or 'post'");
2024
+ }
2025
+ if (intent !== 'expect-present' && intent !== 'expect-absent') {
2026
+ throw new HttpError(400, "intent must be 'expect-present' or 'expect-absent'");
2027
+ }
2028
+ if (typeof sequence !== 'number' || !Number.isInteger(sequence) || sequence < 1) {
2029
+ throw new HttpError(400, 'sequence must be an integer >= 1');
2030
+ }
2031
+ if (typeof testId !== 'string' || testId.length === 0) {
2032
+ throw new HttpError(400, 'testId must be a non-empty string');
2033
+ }
2034
+ // The entity key is the probe subject: scalar or column-keyed object,
2035
+ // always GF-canonical-JSON-representable (it hashes into the record).
2036
+ let entityKey;
2037
+ try {
2038
+ entityKey = canonicalOf(key);
2039
+ }
2040
+ catch {
2041
+ throw new HttpError(400, 'key must be a JSON scalar or a column-keyed JSON object');
2042
+ }
2043
+ // Kind gate (trusted mapping layer, never the suite): the witness
2044
+ // stamps the server channel ONLY for obligations the supervisor
2045
+ // registered as mapping kind 'server-e2e'.
2046
+ if (state.serverE2eDeclarations === null ||
2047
+ !state.serverE2eDeclarations.has(claimId)) {
2048
+ throw new HttpError(409, `server persistence intent refused: obligation '${claimId}' is not registered kind ` +
2049
+ `'${SERVER_E2E_TEST_KIND}' on this witness — declare 'kind: ${SERVER_E2E_TEST_KIND}' in the ` +
2050
+ 'test-map sidecar and pass the resolved obligations to the supervisor drain ' +
2051
+ '(a browser-kind claim is never satisfied through the server channel)', 'TEST_KIND_UNKNOWN');
2052
+ }
2053
+ // Replay gate: strictly increasing per claimId. A duplicate line (drain
2054
+ // restart, spool replay, forged re-append) resolves to a typed failure
2055
+ // — an intent is resolved at most once per sequence.
2056
+ const lastSequence = state.serverIntentSequences.get(claimId);
2057
+ if (lastSequence !== undefined && sequence <= lastSequence) {
2058
+ throw new HttpError(409, `server persistence intent refused: sequence ${String(sequence)} for '${claimId}' does not ` +
2059
+ `exceed the last accepted (${String(lastSequence)}) — intents are strictly increasing per ` +
2060
+ 'claim and a replayed line is never re-driven');
2061
+ }
2062
+ state.serverIntentSequences.set(claimId, sequence);
2063
+ // WITNESS-SIDE probe: the same attestation chain as every adapter read
2064
+ // (reviewed adapter, GF-10 loopback, GF-13 fingerprint), then the
2065
+ // adapter's own probeServer against the app database. Any trouble here
2066
+ // is a typed SERVER_PROBE_UNAVAILABLE failure — never satisfaction.
2067
+ const { adapterName, adapter } = await serverProbeContext(state, resourceId, claimId);
2068
+ const observation = await runServerProbe(state, adapterName, adapter, resourceId, key);
2069
+ if (phase === 'pre') {
2070
+ // Pre intents STORE the witness observation; they stamp no record.
2071
+ if (operation === 'create') {
2072
+ if (intent !== 'expect-absent') {
2073
+ throw new HttpError(400, "create pre intents must declare intent 'expect-absent'");
2074
+ }
2075
+ }
2076
+ else if (operation === 'update') {
2077
+ if (intent !== 'expect-present') {
2078
+ throw new HttpError(400, "update pre intents must declare intent 'expect-present'");
2079
+ }
2080
+ }
2081
+ else {
2082
+ throw new HttpError(400, `pre intents apply only to create/update (operation '${operation}' postconditions need no before-state)`);
2083
+ }
2084
+ const preKey = `${claimId}\u0000${entityKey}`;
2085
+ if (state.serverPreObservations.has(preKey)) {
2086
+ throw new HttpError(409, `server persistence intent refused: claim '${claimId}' already holds a pending ` +
2087
+ 'pre-observation for this entity — advance the sequence and post the mutation first');
2088
+ }
2089
+ state.serverPreObservations.set(preKey, {
2090
+ resourceId,
2091
+ kind: operation === 'create' ? 'absence' : 'entity',
2092
+ found: observation.found,
2093
+ ...(observation.found ? { fields: observation.fields ?? {} } : {}),
2094
+ });
2095
+ const response = { resolved: 'pre', found: observation.found };
2096
+ sendJson(res, 200, response);
2097
+ return;
2098
+ }
2099
+ // Post intents consume the paired pre-observation (create/update) and
2100
+ // stamp ONE self-contained witnessed record — the same `before` shapes
2101
+ // the browser path's persistence reads carry, so the verdict engine
2102
+ // grades BOTH channels with the same postcondition code.
2103
+ let before;
2104
+ if (operation === 'create' || operation === 'update') {
2105
+ const preKey = `${claimId}\u0000${entityKey}`;
2106
+ const pre = state.serverPreObservations.get(preKey);
2107
+ const wantedKind = operation === 'create' ? 'absence' : 'entity';
2108
+ if (pre === undefined || pre.resourceId !== resourceId || pre.kind !== wantedKind) {
2109
+ throw new HttpError(409, `server persistence intent refused: no witness-side pre-observation for '${claimId}' on ` +
2110
+ `entity ${entityKey} — write the pre intent (before the mutation) so the engine can ` +
2111
+ 'grade the before/after postcondition from its OWN observations');
2112
+ }
2113
+ state.serverPreObservations.delete(preKey);
2114
+ before =
2115
+ pre.kind === 'absence'
2116
+ ? { entityAbsent: !pre.found }
2117
+ : pre.found
2118
+ ? { found: true, fields: pre.fields }
2119
+ : { found: false };
2120
+ }
2121
+ const payload = {
2122
+ resourceId,
2123
+ entityId: key,
2124
+ found: observation.found,
2125
+ ...(observation.found ? { fields: observation.fields ?? {} } : {}),
2126
+ ...(before !== undefined ? { before } : {}),
2127
+ channel: SERVER_CHANNEL,
2128
+ declaredKind: SERVER_E2E_TEST_KIND,
2129
+ intent: { phase: 'post', expectation: intent, sequence },
2130
+ };
2131
+ // The record binds runId/claimId/testId (hashed into its provenance id)
2132
+ // and rides the same ledger attestation MAC as every witnessed record.
2133
+ const issued = issuePersistenceRecord(state, claimId, testId, payload);
2134
+ const response = {
2135
+ recordId: issued.recordId,
2136
+ runId: issued.runId,
2137
+ trust: issued.trust,
2138
+ channel: SERVER_CHANNEL,
2139
+ verdictRelevant: { found: observation.found },
2140
+ };
2141
+ sendJson(res, 200, response);
2142
+ }
2143
+ /**
2144
+ * Resolves the reviewed adapter for a server probe, enforcing the FULL
2145
+ * browser-path attestation chain (ADR 0001 reviewed adapter, GF-10
2146
+ * loopback, GF-13 fingerprint) so a server observation is exactly as
2147
+ * trustworthy as an engine-side adapter read. A missing adapter or a
2148
+ * missing `probeServer` export resolves typed SERVER_PROBE_UNAVAILABLE
2149
+ * (actionable; the intent never resolves to satisfaction).
2150
+ */
2151
+ async function serverProbeContext(state, resourceId, claimId) {
2152
+ let adapterName;
2153
+ let adapter;
2154
+ try {
2155
+ const context = await adapterReadContext(state, resourceId);
2156
+ adapterName = context.adapterName;
2157
+ adapter = context.adapter;
2158
+ }
2159
+ catch (error) {
2160
+ if (error instanceof HttpError) {
2161
+ throw new HttpError(error.status, `server persistence intent for '${claimId}' failed: ${error.message}`, 'SERVER_PROBE_UNAVAILABLE');
2162
+ }
2163
+ throw error;
2164
+ }
2165
+ if (typeof adapter.probeServer !== 'function') {
2166
+ throw new HttpError(409, `adapter '${adapterName}' for resource '${resourceId}' exports no server probe ` +
2167
+ `(add 'async probeServer(ctx, subject) => ({found, fields})' to ` +
2168
+ `'.gateforge/adapters/${adapterName}.mjs'); server-witnessed persistence intents ` +
2169
+ 'fail closed without one', 'SERVER_PROBE_UNAVAILABLE');
2170
+ }
2171
+ return { adapterName, adapter };
2172
+ }
2173
+ /**
2174
+ * Executes one adapter server probe (witness process ONLY) and validates
2175
+ * the result shape. A throw or a malformed return is a typed
2176
+ * SERVER_PROBE_UNAVAILABLE failure; a well-shaped result — even one that
2177
+ * contradicts the suite's expectation — is an honest observation the
2178
+ * verdict engine grades.
2179
+ */
2180
+ async function runServerProbe(state, adapterName, adapter, resourceId, key) {
2181
+ const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl;
2182
+ const ctx = makeAdapterContext(baseUrl ?? '', resourceId, (path) => adapterGet(baseUrl ?? '', state.options.requestTimeoutMs, path, state.options.adapterReadAuthorization), state.options.adapterReadAuthorization
2183
+ ? { authorization: state.options.adapterReadAuthorization }
2184
+ : undefined);
2185
+ let raw;
2186
+ try {
2187
+ raw = await adapter.probeServer?.(ctx, key);
2188
+ }
2189
+ catch (error) {
2190
+ throw new HttpError(409, `adapter '${adapterName}' server probe failed for resource '${resourceId}': ` +
2191
+ `${error instanceof Error ? error.message : String(error)}`, 'SERVER_PROBE_UNAVAILABLE');
2192
+ }
2193
+ if (!isPlainObject(raw) ||
2194
+ typeof raw['found'] !== 'boolean' ||
2195
+ !(raw['fields'] === null || raw['fields'] === undefined || isPlainObject(raw['fields']))) {
2196
+ throw new HttpError(409, `adapter '${adapterName}' server probe must return {found: boolean, fields: object|null} ` +
2197
+ `for resource '${resourceId}' (got ${(() => {
2198
+ try {
2199
+ return canonicalOf(raw);
2200
+ }
2201
+ catch {
2202
+ return '<non-JSON>';
2203
+ }
2204
+ })()})`, 'SERVER_PROBE_UNAVAILABLE');
2205
+ }
2206
+ return {
2207
+ found: raw['found'],
2208
+ fields: raw['fields'] ?? null,
2209
+ };
2210
+ }
2211
+ /**
2212
+ * Resolves the reviewed adapter + mediated read base for one resource,
2213
+ * enforcing the full attestation chain (ADR 0001 adapter, GF-10
2214
+ * loopback, GF-13 fingerprint). Shared by persistence reads and
2215
+ * pre-observations.
2216
+ */
2217
+ async function adapterReadContext(state, resourceId) {
2218
+ const classification = state.classifications[resourceId];
2219
+ const adapterName = classification?.evidenceAdapter ?? resourceId;
2220
+ const adapter = state.adapters.get(adapterName);
2221
+ if (adapter === undefined) {
2222
+ throw new HttpError(400, `no reviewed evidence adapter registered for resource '${resourceId}' ` +
2223
+ `(looked for '.gateforge/adapters/${adapterName}.mjs'); ` +
2224
+ 'user-facing resources cannot be proven without a trusted adapter (ADR 0001)');
2225
+ }
2226
+ const baseUrl = adapter.baseUrl ?? state.options.adapterBaseUrl ?? state.options.targetBaseUrl;
2227
+ if (baseUrl === null || baseUrl === undefined || baseUrl === '') {
2228
+ throw new HttpError(400, `adapter '${adapterName}' has no read base (set GATEFORGE_ADAPTER_BASE_URL, ` +
2229
+ 'the adapter baseUrl export, or the attestation target)');
2230
+ }
2231
+ // GF-10 (per-read mediation): never build a request against a
2232
+ // non-loopback base.
2233
+ await assertLoopback(baseUrl, `adapter '${adapterName}'`);
2234
+ // GF-13 minimal v1 attestation: the adapter target must present the
2235
+ // marker the adapter declares, and match the run's attested env.
2236
+ const probe = await probeEnvFingerprint(baseUrl, state.options.requestTimeoutMs);
2237
+ const mismatch = envFingerprintMismatch(probe, adapter.environmentFingerprint, state.options.targetFingerprint ?? null);
2238
+ if (mismatch !== null) {
2239
+ throw new HttpError(409, `persistence record for resource '${resourceId}' rejected: ${mismatch}`, mismatch);
2240
+ }
2241
+ return { adapterName, adapter, baseUrl };
2242
+ }
2243
+ /**
2244
+ * The GET-only transport adapters use. `path` may be absolute
2245
+ * (http(s)://…) or relative to the adapter base. Timeout is enforced by
2246
+ * aborting the underlying fetch.
2247
+ */
2248
+ async function adapterGet(baseUrl, timeoutMs, path, readAuthorization) {
2249
+ const target = /^https?:\/\//.test(path) ? path : `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
2250
+ const controller = new AbortController();
2251
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2252
+ try {
2253
+ // Pinned egress: attested hostnames connect to their
2254
+ // startup-approved loopback IPs (Host preserved for tenant
2255
+ // routing); unpinned names behave exactly as before.
2256
+ const response = await pinnedGet(target, {
2257
+ timeoutMs,
2258
+ headers: {
2259
+ // Operator-issued read-only service credential for the ENGINE's own
2260
+ // adapter reads (see WitnessOptions.adapterReadAuthorization); never
2261
+ // forwarded to the suite and never attached to browser traffic.
2262
+ ...(readAuthorization ? { authorization: readAuthorization } : {}),
2263
+ },
2264
+ signal: controller.signal,
2265
+ });
2266
+ return {
2267
+ status: response.status,
2268
+ headers: response.headers,
2269
+ json: () => response.json(),
2270
+ text: () => response.text(),
2271
+ };
2272
+ }
2273
+ finally {
2274
+ clearTimeout(timer);
2275
+ }
2276
+ }
2277
+ /**
2278
+ * Issues one witness-stamped record into the ledger.
2279
+ *
2280
+ * Trust follows ORIGIN, not channel (GF-23, audit round 3): the tested
2281
+ * suite owns the browser and holds the run token, so a submitted
2282
+ * `ui.action`/`ui.visible-result` payload proves only that the suite
2283
+ * asserted it — those records are stamped `trust: 'claimed'` with
2284
+ * `origin: 'suite-submitted'`. Records whose contents the witness
2285
+ * itself observed engine-side (the adapter read behind
2286
+ * `persistence.entity`) are stamped `trust: 'witnessed'` with
2287
+ * `origin: 'engine-observed'`. Attestation (the ledger MAC) proves the
2288
+ * witness issued a record; it can never prove a UI event happened.
2289
+ */
2290
+ function issueRecord(state, obligationId, kind, testId, payload, origin) {
2291
+ const issuedAt = state.nowIso();
2292
+ const recordId = recordIdOf({
2293
+ runId: state.options.runId,
2294
+ obligationId,
2295
+ kind,
2296
+ testId,
2297
+ origin,
2298
+ payload,
2299
+ });
2300
+ const record = {
2301
+ schemaVersion: 1,
2302
+ recordId,
2303
+ runId: state.options.runId,
2304
+ trust: origin === 'engine-observed' ? 'witnessed' : 'claimed',
2305
+ obligationId,
2306
+ kind,
2307
+ testId,
2308
+ origin,
2309
+ payload,
2310
+ issuedAt,
2311
+ };
2312
+ state.ledger.set(recordId, record);
2313
+ return record;
2314
+ }
2315
+ /**
2316
+ * Issues a persistence record bound to the claim that requested the
2317
+ * adapter read (same testId/obligationId as the claim). The payload is
2318
+ * the ENGINE OBSERVATION assembled by the caller — entityId + fields
2319
+ * from the ADAPTER RESPONSE (never from caller args), plus the
2320
+ * presence/expectation/before data the engine grades postconditions
2321
+ * against.
2322
+ */
2323
+ function issuePersistenceRecord(state, claimId, testId, payload) {
2324
+ return issueRecord(state, claimId, PERSISTENCE_KIND, testId, payload, 'engine-observed');
2325
+ }
2326
+ /** UUID shape for run/invocation identities (validated, never compared across runs). */
2327
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2328
+ /** 64-char lowercase hex shape for input digests. */
2329
+ const INPUT_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
2330
+ /**
2331
+ * Binds the trusted run context (plan §11.4): the validated current
2332
+ * `{runId, invocationId, inputDigest}` from the trusted CLI/orchestrator
2333
+ * is frozen in witness memory. Requires BOTH the run token (outer gate)
2334
+ * and the verifier key header — a suite holding only the run token gets
2335
+ * 401 and the context is unchanged. Binding is allowed only before any
2336
+ * proxy exchange, pre-observation, or evidence issuance, and while no
2337
+ * proxy exchange is in flight; a used witness answers 409. Repeating the
2338
+ * identical binding is idempotent (200); any change to a bound value is
2339
+ * 409 — bound state is never relabeled.
2340
+ *
2341
+ * Args:
2342
+ * state: running witness state.
2343
+ * res: response to answer.
2344
+ * verifier: the `x-gateforge-verifier` header value.
2345
+ * body: parsed request body (must carry runId/invocationId/inputDigest).
2346
+ */
2347
+ async function handleRunContext(state, res, verifier, body) {
2348
+ const verifierKey = state.options.verifierKey;
2349
+ if (verifierKey === null || verifierKey === undefined) {
2350
+ sendJson(res, 409, { error: 'witness has no verifier key; run-context binding is unavailable' });
2351
+ return;
2352
+ }
2353
+ if (typeof verifier !== 'string' || !timingSafeEqual(verifier, verifierKey)) {
2354
+ sendJson(res, 401, { error: 'unauthorized: expected x-gateforge-verifier with the verifier key' });
2355
+ return;
2356
+ }
2357
+ if (!isPlainObject(body)) {
2358
+ throw new HttpError(400, 'run-context body must be an object');
2359
+ }
2360
+ const record = body;
2361
+ const runId = record['runId'];
2362
+ const invocationId = record['invocationId'];
2363
+ const inputDigest = record['inputDigest'];
2364
+ if (typeof runId !== 'string' ||
2365
+ !UUID_PATTERN.test(runId) ||
2366
+ typeof invocationId !== 'string' ||
2367
+ !UUID_PATTERN.test(invocationId) ||
2368
+ typeof inputDigest !== 'string' ||
2369
+ !INPUT_DIGEST_PATTERN.test(inputDigest)) {
2370
+ throw new HttpError(400, 'run-context requires runId (UUID), invocationId (UUID), and inputDigest (64-char lowercase hex)');
2371
+ }
2372
+ if (runId !== state.options.runId) {
2373
+ sendJson(res, 409, {
2374
+ error: `run-context runId '${runId}' does not match this witness run '${state.options.runId}'; ` +
2375
+ 'adopt the witness run id first, then bind — a witness already used by an older ' +
2376
+ 'invocation is rejected, start a fresh witness for a new invocation',
2377
+ });
2378
+ return;
2379
+ }
2380
+ const existing = state.runContext;
2381
+ if (existing !== null) {
2382
+ if (existing.runId === runId &&
2383
+ existing.invocationId === invocationId &&
2384
+ existing.inputDigest === inputDigest) {
2385
+ sendJson(res, 200, { bound: true, ...existing });
2386
+ return;
2387
+ }
2388
+ sendJson(res, 409, {
2389
+ error: 'run context is already bound and differs; bound state is never relabeled — ' +
2390
+ 'start a fresh witness for a new invocation',
2391
+ });
2392
+ return;
2393
+ }
2394
+ if (state.ledger.size > 0 ||
2395
+ state.observed.length > 0 ||
2396
+ state.preObservations.size > 0 ||
2397
+ state.serverPreObservations.size > 0 ||
2398
+ state.serverIntentSequences.size > 0 ||
2399
+ state.sessions.size > 0 ||
2400
+ state.proxyInFlight > 0) {
2401
+ sendJson(res, 409, {
2402
+ error: 'witness already observed or issued evidence (or holds an open test session); ' +
2403
+ 'run-context binding is allowed only before any proxy exchange, pre-observation, ' +
2404
+ 'server probe, session, or issuance — start a fresh witness for a new invocation',
2405
+ });
2406
+ return;
2407
+ }
2408
+ state.runContext = { runId, invocationId, inputDigest };
2409
+ state.observedSeqAtBind = state.observedSeq;
2410
+ sendJson(res, 200, { bound: true, runId, invocationId, inputDigest });
2411
+ }
2412
+ /**
2413
+ * Serves the authenticated live attestation (pin #7, GF-23, plan §11.3):
2414
+ * the SAME v2 signed envelope object the shutdown append writes —
2415
+ * `{attestationVersion: 2, runId, invocationId, inputDigest, recordIds,
2416
+ * mac}` with the MAC over the domain-tagged body. Requires the verifier
2417
+ * key — a secret the tested suite never receives — so only an
2418
+ * orchestrator-grade caller (the evaluating CLI) can certify issuance;
2419
+ * the suite's run token authorizes submissions, never attestation.
2420
+ * Without a configured verifier key the witness answers 409, and an
2421
+ * unbound witness answers 409 as well: it must not sign whatever digest
2422
+ * a suite-writable manifest happens to carry.
2423
+ */
2424
+ function handleLedgerAttestation(state, res, verifier) {
2425
+ const verifierKey = state.options.verifierKey;
2426
+ if (verifierKey === null || verifierKey === undefined) {
2427
+ sendJson(res, 409, { error: 'witness has no verifier key; attestation is unavailable' });
2428
+ return;
2429
+ }
2430
+ if (typeof verifier !== 'string' || !timingSafeEqual(verifier, verifierKey)) {
2431
+ sendJson(res, 401, { error: 'unauthorized: expected x-gateforge-verifier with the verifier key' });
2432
+ return;
2433
+ }
2434
+ const bound = state.runContext;
2435
+ if (bound === null) {
2436
+ sendJson(res, 409, {
2437
+ error: 'witness has no bound run context; bind POST /run-context before observation — ' +
2438
+ 'an unbound witness issues no authenticated attestation',
2439
+ });
2440
+ return;
2441
+ }
2442
+ const recordIds = [...state.ledger.keys()].sort(compareStrings);
2443
+ sendJson(res, 200, {
2444
+ attestationVersion: ATTESTATION_VERSION,
2445
+ runId: bound.runId,
2446
+ invocationId: bound.invocationId,
2447
+ inputDigest: bound.inputDigest,
2448
+ recordIds,
2449
+ mac: attestationMac(verifierKey, {
2450
+ runId: bound.runId,
2451
+ invocationId: bound.invocationId,
2452
+ inputDigest: bound.inputDigest,
2453
+ recordIds,
2454
+ }),
2455
+ });
2456
+ }
2457
+ /** Stops the server and appends issued recordIds to the run manifest. */
2458
+ async function stopWitness(state) {
2459
+ if (state.stopped)
2460
+ return;
2461
+ state.stopped = true;
2462
+ if (state.proxyServer !== null) {
2463
+ const proxy = state.proxyServer;
2464
+ state.proxyServer = null;
2465
+ await new Promise((resolveClose) => {
2466
+ proxy.close(() => resolveClose());
2467
+ });
2468
+ }
2469
+ for (const session of state.sessions.values()) {
2470
+ await stopSessionProxy(session);
2471
+ }
2472
+ await state.engineBrowser.closeAll();
2473
+ await new Promise((resolveClose) => {
2474
+ state.server.close(() => resolveClose());
2475
+ });
2476
+ appendRecordIdsToManifest(state);
2477
+ }
2478
+ /**
2479
+ * Pin #4/#7, plan §11.3–§11.4: at shutdown, append the issued recordIds
2480
+ * to the run manifest in the state dir (sorted, deduplicated; preserves
2481
+ * every other field). Absent manifest → no-op (standalone witness).
2482
+ *
2483
+ * The append NEVER reads a digest from the suite-writable manifest: the
2484
+ * v2 `attestation` envelope is built from the FROZEN bound context
2485
+ * (bound via authenticated `POST /run-context` before any observation)
2486
+ * plus EXACTLY the ids this witness issued — nothing more. The
2487
+ * pre-existing `recordIds` in the manifest came from the suite-writable
2488
+ * file, so merging them in would let a hostile suite have its forged
2489
+ * computed ids signed as issued (audit round 3). They are discarded,
2490
+ * not merged. An unbound witness appends the bare ids for reporting
2491
+ * only — no attestation, so downstream evaluation fails closed.
2492
+ * No legacy `recordIdsMac` is written: v1 MACs never authorize evidence.
2493
+ */
2494
+ function appendRecordIdsToManifest(state) {
2495
+ const stateDir = state.options.stateDir;
2496
+ if (stateDir === null || stateDir === undefined || stateDir === '')
2497
+ return;
2498
+ const manifestPath = join(resolve(process.cwd(), stateDir), 'manifest.json');
2499
+ let raw;
2500
+ try {
2501
+ raw = readFileSync(manifestPath, 'utf8');
2502
+ }
2503
+ catch {
2504
+ return;
2505
+ }
2506
+ let manifest;
2507
+ try {
2508
+ manifest = JSON.parse(raw);
2509
+ }
2510
+ catch {
2511
+ return; // malformed manifest: never corrupt it; evaluation reads it leniently
2512
+ }
2513
+ const issued = [...state.ledger.keys()].sort(compareStrings);
2514
+ const updated = { ...manifest, recordIds: issued };
2515
+ // Drop any legacy v1 MAC the suite (or an older writer) left behind:
2516
+ // it must never authorize evidence, not even when it verifies.
2517
+ delete updated['recordIdsMac'];
2518
+ const verifierKey = state.options.verifierKey;
2519
+ const bound = state.runContext;
2520
+ if (verifierKey !== null && verifierKey !== undefined && bound !== null) {
2521
+ updated['invocationId'] = bound.invocationId;
2522
+ updated['inputDigest'] = bound.inputDigest;
2523
+ updated['attestation'] = {
2524
+ attestationVersion: ATTESTATION_VERSION,
2525
+ runId: bound.runId,
2526
+ invocationId: bound.invocationId,
2527
+ inputDigest: bound.inputDigest,
2528
+ recordIds: issued,
2529
+ mac: attestationMac(verifierKey, {
2530
+ runId: bound.runId,
2531
+ invocationId: bound.invocationId,
2532
+ inputDigest: bound.inputDigest,
2533
+ recordIds: issued,
2534
+ }),
2535
+ };
2536
+ }
2537
+ writeFileSync(manifestPath, `${canonicalOf(updated)}\n`, 'utf8');
2538
+ }
2539
+ //# sourceMappingURL=server.js.map