@gate-forge/witness 0.12.0 → 0.13.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/dist/client/witness-client.d.ts +10 -1
- package/dist/client/witness-client.d.ts.map +1 -1
- package/dist/client/witness-client.js +11 -3
- package/dist/client/witness-client.js.map +1 -1
- package/dist/constants.d.ts +15 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +21 -0
- package/dist/constants.js.map +1 -1
- package/dist/witness/api-settlement.d.ts +86 -0
- package/dist/witness/api-settlement.d.ts.map +1 -0
- package/dist/witness/api-settlement.js +150 -0
- package/dist/witness/api-settlement.js.map +1 -0
- package/dist/witness/browser.d.ts +13 -3
- package/dist/witness/browser.d.ts.map +1 -1
- package/dist/witness/browser.js +168 -2
- package/dist/witness/browser.js.map +1 -1
- package/dist/witness/classifications.d.ts +4 -6
- package/dist/witness/classifications.d.ts.map +1 -1
- package/dist/witness/classifications.js.map +1 -1
- package/dist/witness/page-observation.d.ts +93 -0
- package/dist/witness/page-observation.d.ts.map +1 -0
- package/dist/witness/page-observation.js +117 -0
- package/dist/witness/page-observation.js.map +1 -0
- package/dist/witness/page-observer-registration.d.ts +49 -0
- package/dist/witness/page-observer-registration.d.ts.map +1 -0
- package/dist/witness/page-observer-registration.js +194 -0
- package/dist/witness/page-observer-registration.js.map +1 -0
- package/dist/witness/page-observer.d.ts +18 -0
- package/dist/witness/page-observer.d.ts.map +1 -0
- package/dist/witness/page-observer.js +462 -0
- package/dist/witness/page-observer.js.map +1 -0
- package/dist/witness/server.d.ts.map +1 -1
- package/dist/witness/server.js +188 -1
- package/dist/witness/server.js.map +1 -1
- package/dist/witness/types.d.ts +70 -0
- package/dist/witness/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/witness/server.js
CHANGED
|
@@ -155,6 +155,7 @@ import { join, resolve } from 'node:path';
|
|
|
155
155
|
import { ATTESTATION_VERSION, attestationMac, behaviorActionDigestOf, DEFAULT_QUEUE_POLL_INTERVAL_MS, DEFAULT_QUEUE_TERMINAL_TIMEOUT_MS, BehaviorCatalogSchema, BEHAVIOR_CASE_KIND, enumerationDigestOf, interpretObservedPath, pathMatchesShape, recordIdOf, resolveHttpRoute, } from '@gate-forge/core';
|
|
156
156
|
import { canonicalOf } from '../json.js';
|
|
157
157
|
import { DEFAULT_REQUEST_TIMEOUT_MS, HTTP_OBSERVED_KIND, KNOWN_RECORD_KINDS, LOOPBACK_HOSTNAME, OBSERVED_KIND, OBSERVED_EXCHANGES_CAP, PERSISTENCE_KIND, RUN_HEADER, VERIFIER_HEADER, } from '../constants.js';
|
|
158
|
+
import { PageObserverRegistrationError, registerPageObserver, sweepPageVisits } from './page-observer-registration.js';
|
|
158
159
|
import { loadAdapters, makeAdapterContext } from './adapter-registry.js';
|
|
159
160
|
import { AttestationError, assertLoopback, envFingerprintMismatch, probeEnvFingerprint, } from './env-attestation.js';
|
|
160
161
|
import { hostResolverRules, isLoopbackAddress, pinnedLoopbackIps, pinnedGet } from './loopback-pins.js';
|
|
@@ -777,6 +778,21 @@ async function stopSessionProxy(session) {
|
|
|
777
778
|
server.close(() => resolveClose());
|
|
778
779
|
});
|
|
779
780
|
}
|
|
781
|
+
/** Detaches the independent browser observer when its test session ends. */
|
|
782
|
+
async function stopPageObserver(state, sessionId) {
|
|
783
|
+
const observer = state.pageObservers.get(sessionId);
|
|
784
|
+
state.pageObservers.delete(sessionId);
|
|
785
|
+
await observer?.close();
|
|
786
|
+
}
|
|
787
|
+
/** Only a test session sealed as passing can retain its page observations. */
|
|
788
|
+
function sealPageObservationRecords(state, sessionId, outcome) {
|
|
789
|
+
const recordIds = state.pageObservationRecords.get(sessionId) ?? [];
|
|
790
|
+
if (outcome !== 'passed') {
|
|
791
|
+
for (const recordId of recordIds)
|
|
792
|
+
state.ledger.delete(recordId);
|
|
793
|
+
}
|
|
794
|
+
state.pageObservationRecords.delete(sessionId);
|
|
795
|
+
}
|
|
780
796
|
function isPlainObject(value) {
|
|
781
797
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
782
798
|
}
|
|
@@ -950,13 +966,16 @@ export async function startWitness(options) {
|
|
|
950
966
|
twinShapes: options.twinShapes === undefined || options.twinShapes === null ? null : options.twinShapes,
|
|
951
967
|
expectedTests: new Map(),
|
|
952
968
|
enumerationDigest: null,
|
|
969
|
+
pageObservation: null,
|
|
953
970
|
behaviorCatalog: null,
|
|
954
971
|
caseExecutions: new Map(),
|
|
955
972
|
tick: 0,
|
|
973
|
+
pageObservationRecords: new Map(),
|
|
956
974
|
sessions: new Map(),
|
|
957
975
|
workerSessions: new Map(),
|
|
958
976
|
sessionIdentities: new Map(),
|
|
959
977
|
sessionPageOrigins: new Map(),
|
|
978
|
+
pageObservers: new Map(),
|
|
960
979
|
// The engine browser is OPTIONAL here and required only by the
|
|
961
980
|
// ENGINE-BROWSER proof channel: this package is runner-neutral and
|
|
962
981
|
// must not assume a browser is installed. A witness started without
|
|
@@ -1583,6 +1602,37 @@ async function handleRequest(state, req, res) {
|
|
|
1583
1602
|
await handleRecords(state, res, (await readBody(req)));
|
|
1584
1603
|
return;
|
|
1585
1604
|
}
|
|
1605
|
+
if (req.method === 'POST' && path === '/runs/page-sweep') {
|
|
1606
|
+
requireSupervisor(state, req.headers[VERIFIER_HEADER]);
|
|
1607
|
+
const body = (await readBody(req));
|
|
1608
|
+
if (typeof body.audience !== 'string' || body.audience.length === 0 ||
|
|
1609
|
+
!Array.isArray(body.pages) ||
|
|
1610
|
+
body.pages.some((page) => !isPlainObject(page) ||
|
|
1611
|
+
typeof page['id'] !== 'string' || page['id'].length === 0 ||
|
|
1612
|
+
typeof page['path'] !== 'string' || !page['path'].startsWith('/') ||
|
|
1613
|
+
!(page['anonymous'] === undefined || typeof page['anonymous'] === 'boolean')) ||
|
|
1614
|
+
!Array.isArray(body.loginRoutes) ||
|
|
1615
|
+
body.loginRoutes.some((route) => typeof route !== 'string') ||
|
|
1616
|
+
!Array.isArray(body.errorMarkers) ||
|
|
1617
|
+
body.errorMarkers.some((marker) => typeof marker !== 'string')) {
|
|
1618
|
+
throw new HttpError(400, 'page sweep configuration is invalid');
|
|
1619
|
+
}
|
|
1620
|
+
if (state.engineBrowser === null)
|
|
1621
|
+
throw new HttpError(503, 'page sweep needs the engine browser');
|
|
1622
|
+
const visits = await sweepPageVisits({
|
|
1623
|
+
browser: state.engineBrowser,
|
|
1624
|
+
sessionId: `page-sweep-${state.options.runId}-${body.audience}`,
|
|
1625
|
+
testId: 'page-sweep',
|
|
1626
|
+
appBase: requireTrustedUiBase(state),
|
|
1627
|
+
routes: body.pages,
|
|
1628
|
+
loginRoutes: body.loginRoutes,
|
|
1629
|
+
errorMarkers: body.errorMarkers,
|
|
1630
|
+
storageState: body.storageState,
|
|
1631
|
+
issueRecord: (obligationId, testId, payload) => issueRecord(state, obligationId, 'page.observed', testId, payload, 'engine-observed').recordId,
|
|
1632
|
+
});
|
|
1633
|
+
sendJson(res, 200, { visits });
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1586
1636
|
if (req.method === 'POST' && path === '/runs/expected-set') {
|
|
1587
1637
|
await handleExpectedSet(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1588
1638
|
return;
|
|
@@ -1686,6 +1736,48 @@ async function handleRequest(state, req, res) {
|
|
|
1686
1736
|
await handleSessionPageOrigins(state, res, (await readBody(req)));
|
|
1687
1737
|
return;
|
|
1688
1738
|
}
|
|
1739
|
+
if (req.method === 'POST' && path === '/sessions/page-observer') {
|
|
1740
|
+
const body = (await readBody(req));
|
|
1741
|
+
try {
|
|
1742
|
+
await registerPageObserver({
|
|
1743
|
+
state,
|
|
1744
|
+
response: res,
|
|
1745
|
+
body,
|
|
1746
|
+
requireSession: (value) => {
|
|
1747
|
+
const session = requireOpenSession(state, value);
|
|
1748
|
+
return {
|
|
1749
|
+
sessionId: session.sessionId,
|
|
1750
|
+
testId: session.testId,
|
|
1751
|
+
activity: session.activity,
|
|
1752
|
+
// The supervisor-opened identity (0.13 authority cutover):
|
|
1753
|
+
// static tamper risks match against THIS file, never
|
|
1754
|
+
// suite-supplied metadata.
|
|
1755
|
+
registeredFile: session.registered === null ? null : session.registered.file,
|
|
1756
|
+
};
|
|
1757
|
+
},
|
|
1758
|
+
issueRecord: (obligationId, testId, payload) => issueRecord(state, obligationId, 'page.observed', testId, payload, 'engine-observed').recordId,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
catch (error) {
|
|
1762
|
+
if (error instanceof PageObserverRegistrationError)
|
|
1763
|
+
throw new HttpError(error.status, error.message);
|
|
1764
|
+
throw error;
|
|
1765
|
+
}
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (req.method === 'POST' && path === '/sessions/page-observer/flush') {
|
|
1769
|
+
const body = (await readBody(req));
|
|
1770
|
+
const session = requireOpenSession(state, body);
|
|
1771
|
+
if (body.testId !== session.testId)
|
|
1772
|
+
throw new HttpError(403, 'page observer flush test identity does not match its session');
|
|
1773
|
+
const observer = state.pageObservers.get(session.sessionId);
|
|
1774
|
+
if (observer === undefined)
|
|
1775
|
+
throw new HttpError(409, 'page observer is not registered for this session');
|
|
1776
|
+
await observer.flush();
|
|
1777
|
+
session.activity += 1;
|
|
1778
|
+
sendJson(res, 200, { flushed: true });
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1689
1781
|
if (req.method === 'POST' && path === '/sessions/intervals/open') {
|
|
1690
1782
|
await handleIntervalOpen(state, res, (await readBody(req)));
|
|
1691
1783
|
return;
|
|
@@ -1796,7 +1888,9 @@ function sendJson(res, status, body) {
|
|
|
1796
1888
|
* state: running witness state.
|
|
1797
1889
|
* res: response to answer.
|
|
1798
1890
|
* verifier: the `x-gateforge-verifier` header value.
|
|
1799
|
-
* body: the parsed request body ({tests: [...]},
|
|
1891
|
+
* body: the parsed request body ({tests: [...], pageObservation?},
|
|
1892
|
+
* identity-shaped; the optional controller-held page-observation
|
|
1893
|
+
* context binds once alongside the set and is never suite-supplied).
|
|
1800
1894
|
*/
|
|
1801
1895
|
async function handleExpectedSet(state, res, verifier, body) {
|
|
1802
1896
|
requireSupervisor(state, verifier);
|
|
@@ -1835,9 +1929,90 @@ async function handleExpectedSet(state, res, verifier, body) {
|
|
|
1835
1929
|
observationOnly,
|
|
1836
1930
|
});
|
|
1837
1931
|
}
|
|
1932
|
+
// Controller-held page-observation context (0.13 authority cutover):
|
|
1933
|
+
// validated HERE, once, supervisor-authenticated, and stored alongside
|
|
1934
|
+
// the expected set. The suite never supplies any of it — the registrar
|
|
1935
|
+
// grades with THIS context or issues no page proof at all.
|
|
1936
|
+
let pageObservation = null;
|
|
1937
|
+
const rawContext = body['pageObservation'];
|
|
1938
|
+
if (rawContext !== undefined) {
|
|
1939
|
+
if (!isPlainObject(rawContext)) {
|
|
1940
|
+
throw new HttpError(400, 'expected-set pageObservation must be an object');
|
|
1941
|
+
}
|
|
1942
|
+
const rawPages = rawContext['pages'];
|
|
1943
|
+
const rawLoginRoutes = rawContext['loginRoutes'];
|
|
1944
|
+
const rawErrorMarkers = rawContext['errorMarkers'];
|
|
1945
|
+
const rawAppOrigins = rawContext['appOrigins'];
|
|
1946
|
+
const rawTamperRisks = rawContext['tamperRisks'];
|
|
1947
|
+
const validPages = Array.isArray(rawPages) && rawPages.length > 0 &&
|
|
1948
|
+
rawPages.every((page) => typeof page === 'object' && page !== null &&
|
|
1949
|
+
typeof page['id'] === 'string' &&
|
|
1950
|
+
page['id'].length > 0 &&
|
|
1951
|
+
!page['id'].includes(':') &&
|
|
1952
|
+
typeof page['path'] === 'string' &&
|
|
1953
|
+
page['path'].startsWith('/') &&
|
|
1954
|
+
(page['anonymous'] === undefined ||
|
|
1955
|
+
typeof page['anonymous'] === 'boolean'));
|
|
1956
|
+
const validLoginRoutes = Array.isArray(rawLoginRoutes) &&
|
|
1957
|
+
rawLoginRoutes.every((path) => typeof path === 'string' && path.startsWith('/'));
|
|
1958
|
+
const validErrorMarkers = Array.isArray(rawErrorMarkers) &&
|
|
1959
|
+
rawErrorMarkers.every((marker) => typeof marker === 'string' && marker.length > 0);
|
|
1960
|
+
const validAppOrigins = Array.isArray(rawAppOrigins) &&
|
|
1961
|
+
rawAppOrigins.every((origin) => {
|
|
1962
|
+
if (typeof origin !== 'string')
|
|
1963
|
+
return false;
|
|
1964
|
+
try {
|
|
1965
|
+
return new URL(origin).origin === origin;
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
return false;
|
|
1969
|
+
}
|
|
1970
|
+
});
|
|
1971
|
+
const validTamperRisks = Array.isArray(rawTamperRisks) &&
|
|
1972
|
+
rawTamperRisks.every((risk) => {
|
|
1973
|
+
if (typeof risk !== 'object' || risk === null)
|
|
1974
|
+
return false;
|
|
1975
|
+
const row = risk;
|
|
1976
|
+
return ((row['testId'] === null || typeof row['testId'] === 'string') &&
|
|
1977
|
+
typeof row['file'] === 'string' && row['file'].length > 0 &&
|
|
1978
|
+
typeof row['locationFile'] === 'string' && row['locationFile'].length > 0 &&
|
|
1979
|
+
typeof row['line'] === 'number' && Number.isInteger(row['line']) && row['line'] >= 1);
|
|
1980
|
+
});
|
|
1981
|
+
if (!validPages || !validLoginRoutes || !validErrorMarkers || !validAppOrigins || !validTamperRisks) {
|
|
1982
|
+
throw new HttpError(400, 'expected-set pageObservation requires pages (non-empty {id, path, anonymous?} rows), loginRoutes ' +
|
|
1983
|
+
"('/'-prefixed strings), errorMarkers (non-empty strings), appOrigins (valid origin " +
|
|
1984
|
+
'strings), and tamperRisks ({testId: string|null, file, locationFile, line})');
|
|
1985
|
+
}
|
|
1986
|
+
pageObservation = {
|
|
1987
|
+
pages: rawPages.map((page) => ({
|
|
1988
|
+
id: page['id'],
|
|
1989
|
+
path: page['path'],
|
|
1990
|
+
...(page['anonymous'] === undefined ? {} : { anonymous: page['anonymous'] }),
|
|
1991
|
+
})),
|
|
1992
|
+
loginRoutes: rawLoginRoutes,
|
|
1993
|
+
errorMarkers: rawErrorMarkers,
|
|
1994
|
+
appOrigins: rawAppOrigins,
|
|
1995
|
+
tamperRisks: rawTamperRisks.map((risk) => ({
|
|
1996
|
+
testId: risk['testId'],
|
|
1997
|
+
file: risk['file'],
|
|
1998
|
+
locationFile: risk['locationFile'],
|
|
1999
|
+
line: risk['line'],
|
|
2000
|
+
})),
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
1838
2003
|
const digest = enumerationDigestOf(tests);
|
|
1839
2004
|
if (state.enumerationDigest !== null) {
|
|
1840
2005
|
if (state.enumerationDigest === digest) {
|
|
2006
|
+
// Identical tests with a DIFFERENT page-observation context are a
|
|
2007
|
+
// relabeling attempt, exactly like a changed test set: the context
|
|
2008
|
+
// is a PRE-run fact bound once with the set.
|
|
2009
|
+
if (canonicalOf(state.pageObservation) !== canonicalOf(pageObservation)) {
|
|
2010
|
+
sendJson(res, 409, {
|
|
2011
|
+
error: 'an expected set with the same tests is already bound to this run with a different ' +
|
|
2012
|
+
'page-observation context; the context is a PRE-run fact and is never relabeled',
|
|
2013
|
+
});
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
1841
2016
|
sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
|
|
1842
2017
|
return;
|
|
1843
2018
|
}
|
|
@@ -1859,6 +2034,7 @@ async function handleExpectedSet(state, res, verifier, body) {
|
|
|
1859
2034
|
state.expectedTests.set(expectedKey(test.project, test.file, test.titlePath), test);
|
|
1860
2035
|
}
|
|
1861
2036
|
state.enumerationDigest = digest;
|
|
2037
|
+
state.pageObservation = pageObservation;
|
|
1862
2038
|
sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
|
|
1863
2039
|
}
|
|
1864
2040
|
/**
|
|
@@ -2812,6 +2988,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2812
2988
|
}
|
|
2813
2989
|
session.outcome = outcome;
|
|
2814
2990
|
}
|
|
2991
|
+
sealPageObservationRecords(state, sessionId, session.outcome);
|
|
2815
2992
|
// The session's own evidence died with its proxy at release; the
|
|
2816
2993
|
// observe snapshots are dropped HERE, at the moment the outcome is
|
|
2817
2994
|
// finally recorded, so an unreleased session's before-state can
|
|
@@ -2819,6 +2996,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2819
2996
|
state.observeSnapshots.delete(sessionId);
|
|
2820
2997
|
state.sessionIdentities.delete(sessionId);
|
|
2821
2998
|
state.sessionPageOrigins.delete(sessionId);
|
|
2999
|
+
await stopPageObserver(state, sessionId);
|
|
2822
3000
|
sendJson(res, 200, { sealed: true });
|
|
2823
3001
|
return;
|
|
2824
3002
|
}
|
|
@@ -2826,6 +3004,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2826
3004
|
session.status = 'sealed';
|
|
2827
3005
|
session.sealedTick = (state.tick += 1);
|
|
2828
3006
|
session.outcome = typeof outcome === 'string' ? outcome : null;
|
|
3007
|
+
sealPageObservationRecords(state, sessionId, session.outcome);
|
|
2829
3008
|
state.workerSessions.delete(session.workerIndex);
|
|
2830
3009
|
// Observe snapshots die with the session: finalize runs BEFORE seal
|
|
2831
3010
|
// (the drain finalizes a passed test, then seals), so anything left
|
|
@@ -2840,6 +3019,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2840
3019
|
// (or submit) through it afterwards. The engine browser context dies
|
|
2841
3020
|
// too — a sealed session's pages are never driven again.
|
|
2842
3021
|
await stopSessionProxy(session);
|
|
3022
|
+
await stopPageObserver(state, sessionId);
|
|
2843
3023
|
await state.engineBrowser?.closeSession(session.sessionId);
|
|
2844
3024
|
}
|
|
2845
3025
|
sendJson(res, 200, { sealed: true });
|
|
@@ -3035,6 +3215,7 @@ async function handleSessionRelease(state, res, body) {
|
|
|
3035
3215
|
// The dedicated channel dies with the release: no exchange can be
|
|
3036
3216
|
// observed through this session after its test finished.
|
|
3037
3217
|
await stopSessionProxy(session);
|
|
3218
|
+
await stopPageObserver(state, sessionId);
|
|
3038
3219
|
await state.engineBrowser?.closeSession(session.sessionId);
|
|
3039
3220
|
}
|
|
3040
3221
|
sendJson(res, 200, { released: true });
|
|
@@ -5105,6 +5286,12 @@ async function stopWitness(state) {
|
|
|
5105
5286
|
for (const session of state.sessions.values()) {
|
|
5106
5287
|
await stopSessionProxy(session);
|
|
5107
5288
|
}
|
|
5289
|
+
for (const sessionId of state.pageObservers.keys()) {
|
|
5290
|
+
await stopPageObserver(state, sessionId);
|
|
5291
|
+
}
|
|
5292
|
+
for (const sessionId of state.pageObservationRecords.keys()) {
|
|
5293
|
+
sealPageObservationRecords(state, sessionId, null);
|
|
5294
|
+
}
|
|
5108
5295
|
// Phase 4 lifecycle shutdown: release every live fixture lease namespace.
|
|
5109
5296
|
// Timeouts/failures release only their own namespace and never flip a
|
|
5110
5297
|
// verdict — releases here are best-effort shutdown hygiene.
|