@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.
- package/LICENSE +202 -0
- package/bin/gateforge-witness.js +40 -0
- package/dist/attestation/proxy.d.ts +40 -0
- package/dist/attestation/proxy.d.ts.map +1 -0
- package/dist/attestation/proxy.js +78 -0
- package/dist/attestation/proxy.js.map +1 -0
- package/dist/constants.d.ts +130 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +138 -0
- package/dist/constants.js.map +1 -0
- package/dist/discovery/adapters.d.ts +167 -0
- package/dist/discovery/adapters.d.ts.map +1 -0
- package/dist/discovery/adapters.js +276 -0
- package/dist/discovery/adapters.js.map +1 -0
- package/dist/discovery/discover.d.ts +57 -0
- package/dist/discovery/discover.d.ts.map +1 -0
- package/dist/discovery/discover.js +544 -0
- package/dist/discovery/discover.js.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +15 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/inference.d.ts +63 -0
- package/dist/discovery/inference.d.ts.map +1 -0
- package/dist/discovery/inference.js +173 -0
- package/dist/discovery/inference.js.map +1 -0
- package/dist/discovery/pytest-adapter.d.ts +205 -0
- package/dist/discovery/pytest-adapter.d.ts.map +1 -0
- package/dist/discovery/pytest-adapter.js +548 -0
- package/dist/discovery/pytest-adapter.js.map +1 -0
- package/dist/discovery/reconcile.d.ts +106 -0
- package/dist/discovery/reconcile.d.ts.map +1 -0
- package/dist/discovery/reconcile.js +268 -0
- package/dist/discovery/reconcile.js.map +1 -0
- package/dist/discovery/runner-env.d.ts +80 -0
- package/dist/discovery/runner-env.d.ts.map +1 -0
- package/dist/discovery/runner-env.js +139 -0
- package/dist/discovery/runner-env.js.map +1 -0
- package/dist/discovery/static-discovery.d.ts +121 -0
- package/dist/discovery/static-discovery.d.ts.map +1 -0
- package/dist/discovery/static-discovery.js +1028 -0
- package/dist/discovery/static-discovery.js.map +1 -0
- package/dist/discovery/supervised-run.d.ts +110 -0
- package/dist/discovery/supervised-run.d.ts.map +1 -0
- package/dist/discovery/supervised-run.js +328 -0
- package/dist/discovery/supervised-run.js.map +1 -0
- package/dist/discovery/trusted-config.d.ts +103 -0
- package/dist/discovery/trusted-config.d.ts.map +1 -0
- package/dist/discovery/trusted-config.js +117 -0
- package/dist/discovery/trusted-config.js.map +1 -0
- package/dist/fixture/evidence.d.ts +191 -0
- package/dist/fixture/evidence.d.ts.map +1 -0
- package/dist/fixture/evidence.js +366 -0
- package/dist/fixture/evidence.js.map +1 -0
- package/dist/fixture/fixture.d.ts +37 -0
- package/dist/fixture/fixture.d.ts.map +1 -0
- package/dist/fixture/fixture.js +38 -0
- package/dist/fixture/fixture.js.map +1 -0
- package/dist/fixture/witness-client.d.ts +164 -0
- package/dist/fixture/witness-client.d.ts.map +1 -0
- package/dist/fixture/witness-client.js +253 -0
- package/dist/fixture/witness-client.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +16 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +21 -0
- package/dist/json.js.map +1 -0
- package/dist/reporter/ledger.d.ts +105 -0
- package/dist/reporter/ledger.d.ts.map +1 -0
- package/dist/reporter/ledger.js +197 -0
- package/dist/reporter/ledger.js.map +1 -0
- package/dist/reporter/reporter.cjs +105 -0
- package/dist/reporter/reporter.cjs.map +1 -0
- package/dist/reporter/reporter.d.cts +35 -0
- package/dist/reporter/reporter.d.cts.map +1 -0
- package/dist/reporter/reporter.d.ts +167 -0
- package/dist/reporter/reporter.d.ts.map +1 -0
- package/dist/reporter/reporter.js +673 -0
- package/dist/reporter/reporter.js.map +1 -0
- package/dist/setup.d.ts +49 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +167 -0
- package/dist/setup.js.map +1 -0
- package/dist/supervisor/client.d.ts +73 -0
- package/dist/supervisor/client.d.ts.map +1 -0
- package/dist/supervisor/client.js +164 -0
- package/dist/supervisor/client.js.map +1 -0
- package/dist/supervisor/drain.d.ts +45 -0
- package/dist/supervisor/drain.d.ts.map +1 -0
- package/dist/supervisor/drain.js +235 -0
- package/dist/supervisor/drain.js.map +1 -0
- package/dist/supervisor/index.d.ts +16 -0
- package/dist/supervisor/index.d.ts.map +1 -0
- package/dist/supervisor/index.js +13 -0
- package/dist/supervisor/index.js.map +1 -0
- package/dist/supervisor/spool.d.ts +137 -0
- package/dist/supervisor/spool.d.ts.map +1 -0
- package/dist/supervisor/spool.js +263 -0
- package/dist/supervisor/spool.js.map +1 -0
- package/dist/surface.d.ts +134 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +139 -0
- package/dist/surface.js.map +1 -0
- package/dist/witness/adapter-registry.d.ts +38 -0
- package/dist/witness/adapter-registry.d.ts.map +1 -0
- package/dist/witness/adapter-registry.js +158 -0
- package/dist/witness/adapter-registry.js.map +1 -0
- package/dist/witness/bin.d.ts +21 -0
- package/dist/witness/bin.d.ts.map +1 -0
- package/dist/witness/bin.js +172 -0
- package/dist/witness/bin.js.map +1 -0
- package/dist/witness/browser.d.ts +173 -0
- package/dist/witness/browser.d.ts.map +1 -0
- package/dist/witness/browser.js +580 -0
- package/dist/witness/browser.js.map +1 -0
- package/dist/witness/classifications.d.ts +42 -0
- package/dist/witness/classifications.d.ts.map +1 -0
- package/dist/witness/classifications.js +87 -0
- package/dist/witness/classifications.js.map +1 -0
- package/dist/witness/env-attestation.d.ts +98 -0
- package/dist/witness/env-attestation.d.ts.map +1 -0
- package/dist/witness/env-attestation.js +202 -0
- package/dist/witness/env-attestation.js.map +1 -0
- package/dist/witness/loopback-pins.d.ts +79 -0
- package/dist/witness/loopback-pins.d.ts.map +1 -0
- package/dist/witness/loopback-pins.js +244 -0
- package/dist/witness/loopback-pins.js.map +1 -0
- package/dist/witness/server.d.ts +34 -0
- package/dist/witness/server.d.ts.map +1 -0
- package/dist/witness/server.js +2539 -0
- package/dist/witness/server.js.map +1 -0
- package/dist/witness/types.d.ts +643 -0
- package/dist/witness/types.d.ts.map +1 -0
- package/dist/witness/types.js +2 -0
- package/dist/witness/types.js.map +1 -0
- package/package.json +64 -0
- 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
|