@gate-forge/witness 0.12.0 → 0.13.1
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 +88 -0
- package/dist/witness/api-settlement.d.ts.map +1 -0
- package/dist/witness/api-settlement.js +160 -0
- package/dist/witness/api-settlement.js.map +1 -0
- package/dist/witness/browser.d.ts +14 -3
- package/dist/witness/browser.d.ts.map +1 -1
- package/dist/witness/browser.js +208 -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-live-channels.d.ts +46 -0
- package/dist/witness/page-live-channels.d.ts.map +1 -0
- package/dist/witness/page-live-channels.js +68 -0
- package/dist/witness/page-live-channels.js.map +1 -0
- package/dist/witness/page-observation.d.ts +108 -0
- package/dist/witness/page-observation.d.ts.map +1 -0
- package/dist/witness/page-observation.js +134 -0
- package/dist/witness/page-observation.js.map +1 -0
- package/dist/witness/page-observer-registration.d.ts +86 -0
- package/dist/witness/page-observer-registration.d.ts.map +1 -0
- package/dist/witness/page-observer-registration.js +258 -0
- package/dist/witness/page-observer-registration.js.map +1 -0
- package/dist/witness/page-observer.d.ts +20 -0
- package/dist/witness/page-observer.d.ts.map +1 -0
- package/dist/witness/page-observer.js +520 -0
- package/dist/witness/page-observer.js.map +1 -0
- package/dist/witness/server.d.ts.map +1 -1
- package/dist/witness/server.js +201 -1
- package/dist/witness/server.js.map +1 -1
- package/dist/witness/types.d.ts +82 -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,44 @@ 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.liveChannels) ||
|
|
1617
|
+
body.liveChannels.some((prefix) => typeof prefix !== 'string' || !prefix.startsWith('/')) ||
|
|
1618
|
+
!Array.isArray(body.errorMarkers) ||
|
|
1619
|
+
body.errorMarkers.some((marker) => typeof marker !== 'string')) {
|
|
1620
|
+
throw new HttpError(400, 'page sweep configuration is invalid');
|
|
1621
|
+
}
|
|
1622
|
+
if (state.engineBrowser === null)
|
|
1623
|
+
throw new HttpError(503, 'page sweep needs the engine browser');
|
|
1624
|
+
const sweep = await sweepPageVisits({
|
|
1625
|
+
browser: state.engineBrowser,
|
|
1626
|
+
sessionId: `page-sweep-${state.options.runId}-${body.audience}`,
|
|
1627
|
+
testId: 'page-sweep',
|
|
1628
|
+
appBase: requireTrustedUiBase(state),
|
|
1629
|
+
audience: body.audience,
|
|
1630
|
+
routes: body.pages,
|
|
1631
|
+
loginRoutes: body.loginRoutes,
|
|
1632
|
+
errorMarkers: body.errorMarkers,
|
|
1633
|
+
liveChannels: body.liveChannels,
|
|
1634
|
+
storageState: body.storageState,
|
|
1635
|
+
...(body.progress === true
|
|
1636
|
+
? { onProgress: (line) => process.stderr.write(`${line}\n`) }
|
|
1637
|
+
: {}),
|
|
1638
|
+
issueRecord: (obligationId, testId, payload) => issueRecord(state, obligationId, 'page.observed', testId, payload, 'engine-observed').recordId,
|
|
1639
|
+
});
|
|
1640
|
+
sendJson(res, 200, { visits: sweep.visits, sessionRejected: sweep.sessionRejected });
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1586
1643
|
if (req.method === 'POST' && path === '/runs/expected-set') {
|
|
1587
1644
|
await handleExpectedSet(state, res, req.headers[VERIFIER_HEADER], (await readBody(req)));
|
|
1588
1645
|
return;
|
|
@@ -1686,6 +1743,48 @@ async function handleRequest(state, req, res) {
|
|
|
1686
1743
|
await handleSessionPageOrigins(state, res, (await readBody(req)));
|
|
1687
1744
|
return;
|
|
1688
1745
|
}
|
|
1746
|
+
if (req.method === 'POST' && path === '/sessions/page-observer') {
|
|
1747
|
+
const body = (await readBody(req));
|
|
1748
|
+
try {
|
|
1749
|
+
await registerPageObserver({
|
|
1750
|
+
state,
|
|
1751
|
+
response: res,
|
|
1752
|
+
body,
|
|
1753
|
+
requireSession: (value) => {
|
|
1754
|
+
const session = requireOpenSession(state, value);
|
|
1755
|
+
return {
|
|
1756
|
+
sessionId: session.sessionId,
|
|
1757
|
+
testId: session.testId,
|
|
1758
|
+
activity: session.activity,
|
|
1759
|
+
// The supervisor-opened identity (0.13 authority cutover):
|
|
1760
|
+
// static tamper risks match against THIS file, never
|
|
1761
|
+
// suite-supplied metadata.
|
|
1762
|
+
registeredFile: session.registered === null ? null : session.registered.file,
|
|
1763
|
+
};
|
|
1764
|
+
},
|
|
1765
|
+
issueRecord: (obligationId, testId, payload) => issueRecord(state, obligationId, 'page.observed', testId, payload, 'engine-observed').recordId,
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
catch (error) {
|
|
1769
|
+
if (error instanceof PageObserverRegistrationError)
|
|
1770
|
+
throw new HttpError(error.status, error.message);
|
|
1771
|
+
throw error;
|
|
1772
|
+
}
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (req.method === 'POST' && path === '/sessions/page-observer/flush') {
|
|
1776
|
+
const body = (await readBody(req));
|
|
1777
|
+
const session = requireOpenSession(state, body);
|
|
1778
|
+
if (body.testId !== session.testId)
|
|
1779
|
+
throw new HttpError(403, 'page observer flush test identity does not match its session');
|
|
1780
|
+
const observer = state.pageObservers.get(session.sessionId);
|
|
1781
|
+
if (observer === undefined)
|
|
1782
|
+
throw new HttpError(409, 'page observer is not registered for this session');
|
|
1783
|
+
await observer.flush();
|
|
1784
|
+
session.activity += 1;
|
|
1785
|
+
sendJson(res, 200, { flushed: true });
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1689
1788
|
if (req.method === 'POST' && path === '/sessions/intervals/open') {
|
|
1690
1789
|
await handleIntervalOpen(state, res, (await readBody(req)));
|
|
1691
1790
|
return;
|
|
@@ -1796,7 +1895,9 @@ function sendJson(res, status, body) {
|
|
|
1796
1895
|
* state: running witness state.
|
|
1797
1896
|
* res: response to answer.
|
|
1798
1897
|
* verifier: the `x-gateforge-verifier` header value.
|
|
1799
|
-
* body: the parsed request body ({tests: [...]},
|
|
1898
|
+
* body: the parsed request body ({tests: [...], pageObservation?},
|
|
1899
|
+
* identity-shaped; the optional controller-held page-observation
|
|
1900
|
+
* context binds once alongside the set and is never suite-supplied).
|
|
1800
1901
|
*/
|
|
1801
1902
|
async function handleExpectedSet(state, res, verifier, body) {
|
|
1802
1903
|
requireSupervisor(state, verifier);
|
|
@@ -1835,9 +1936,96 @@ async function handleExpectedSet(state, res, verifier, body) {
|
|
|
1835
1936
|
observationOnly,
|
|
1836
1937
|
});
|
|
1837
1938
|
}
|
|
1939
|
+
// Controller-held page-observation context (0.13 authority cutover):
|
|
1940
|
+
// validated HERE, once, supervisor-authenticated, and stored alongside
|
|
1941
|
+
// the expected set. The suite never supplies any of it — the registrar
|
|
1942
|
+
// grades with THIS context or issues no page proof at all.
|
|
1943
|
+
let pageObservation = null;
|
|
1944
|
+
const rawContext = body['pageObservation'];
|
|
1945
|
+
if (rawContext !== undefined) {
|
|
1946
|
+
if (!isPlainObject(rawContext)) {
|
|
1947
|
+
throw new HttpError(400, 'expected-set pageObservation must be an object');
|
|
1948
|
+
}
|
|
1949
|
+
const rawPages = rawContext['pages'];
|
|
1950
|
+
const rawLoginRoutes = rawContext['loginRoutes'];
|
|
1951
|
+
const rawErrorMarkers = rawContext['errorMarkers'];
|
|
1952
|
+
const rawAppOrigins = rawContext['appOrigins'];
|
|
1953
|
+
const rawTamperRisks = rawContext['tamperRisks'];
|
|
1954
|
+
const validPages = Array.isArray(rawPages) && rawPages.length > 0 &&
|
|
1955
|
+
rawPages.every((page) => typeof page === 'object' && page !== null &&
|
|
1956
|
+
typeof page['id'] === 'string' &&
|
|
1957
|
+
page['id'].length > 0 &&
|
|
1958
|
+
!page['id'].includes(':') &&
|
|
1959
|
+
typeof page['path'] === 'string' &&
|
|
1960
|
+
page['path'].startsWith('/') &&
|
|
1961
|
+
(page['anonymous'] === undefined ||
|
|
1962
|
+
typeof page['anonymous'] === 'boolean'));
|
|
1963
|
+
const validLoginRoutes = Array.isArray(rawLoginRoutes) &&
|
|
1964
|
+
rawLoginRoutes.every((path) => typeof path === 'string' && path.startsWith('/'));
|
|
1965
|
+
const validErrorMarkers = Array.isArray(rawErrorMarkers) &&
|
|
1966
|
+
rawErrorMarkers.every((marker) => typeof marker === 'string' && marker.length > 0);
|
|
1967
|
+
const validAppOrigins = Array.isArray(rawAppOrigins) &&
|
|
1968
|
+
rawAppOrigins.every((origin) => {
|
|
1969
|
+
if (typeof origin !== 'string')
|
|
1970
|
+
return false;
|
|
1971
|
+
try {
|
|
1972
|
+
return new URL(origin).origin === origin;
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
const rawLiveChannels = rawContext['liveChannels'];
|
|
1979
|
+
const validLiveChannels = rawLiveChannels === undefined ||
|
|
1980
|
+
(Array.isArray(rawLiveChannels) &&
|
|
1981
|
+
rawLiveChannels.every((prefix) => typeof prefix === 'string' && prefix.startsWith('/')));
|
|
1982
|
+
const validTamperRisks = Array.isArray(rawTamperRisks) &&
|
|
1983
|
+
rawTamperRisks.every((risk) => {
|
|
1984
|
+
if (typeof risk !== 'object' || risk === null)
|
|
1985
|
+
return false;
|
|
1986
|
+
const row = risk;
|
|
1987
|
+
return ((row['testId'] === null || typeof row['testId'] === 'string') &&
|
|
1988
|
+
typeof row['file'] === 'string' && row['file'].length > 0 &&
|
|
1989
|
+
typeof row['locationFile'] === 'string' && row['locationFile'].length > 0 &&
|
|
1990
|
+
typeof row['line'] === 'number' && Number.isInteger(row['line']) && row['line'] >= 1);
|
|
1991
|
+
});
|
|
1992
|
+
if (!validPages || !validLoginRoutes || !validErrorMarkers || !validAppOrigins || !validLiveChannels || !validTamperRisks) {
|
|
1993
|
+
throw new HttpError(400, 'expected-set pageObservation requires pages (non-empty {id, path, anonymous?} rows), loginRoutes ' +
|
|
1994
|
+
"('/'-prefixed strings), errorMarkers (non-empty strings), appOrigins (valid origin " +
|
|
1995
|
+
'strings), liveChannels (optional \'/\'-prefixed prefixes), and tamperRisks ' +
|
|
1996
|
+
'({testId: string|null, file, locationFile, line})');
|
|
1997
|
+
}
|
|
1998
|
+
pageObservation = {
|
|
1999
|
+
pages: rawPages.map((page) => ({
|
|
2000
|
+
id: page['id'],
|
|
2001
|
+
path: page['path'],
|
|
2002
|
+
...(page['anonymous'] === undefined ? {} : { anonymous: page['anonymous'] }),
|
|
2003
|
+
})),
|
|
2004
|
+
loginRoutes: rawLoginRoutes,
|
|
2005
|
+
errorMarkers: rawErrorMarkers,
|
|
2006
|
+
appOrigins: rawAppOrigins,
|
|
2007
|
+
...(rawLiveChannels === undefined ? {} : { liveChannels: rawLiveChannels }),
|
|
2008
|
+
tamperRisks: rawTamperRisks.map((risk) => ({
|
|
2009
|
+
testId: risk['testId'],
|
|
2010
|
+
file: risk['file'],
|
|
2011
|
+
locationFile: risk['locationFile'],
|
|
2012
|
+
line: risk['line'],
|
|
2013
|
+
})),
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
1838
2016
|
const digest = enumerationDigestOf(tests);
|
|
1839
2017
|
if (state.enumerationDigest !== null) {
|
|
1840
2018
|
if (state.enumerationDigest === digest) {
|
|
2019
|
+
// Identical tests with a DIFFERENT page-observation context are a
|
|
2020
|
+
// relabeling attempt, exactly like a changed test set: the context
|
|
2021
|
+
// is a PRE-run fact bound once with the set.
|
|
2022
|
+
if (canonicalOf(state.pageObservation) !== canonicalOf(pageObservation)) {
|
|
2023
|
+
sendJson(res, 409, {
|
|
2024
|
+
error: 'an expected set with the same tests is already bound to this run with a different ' +
|
|
2025
|
+
'page-observation context; the context is a PRE-run fact and is never relabeled',
|
|
2026
|
+
});
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
1841
2029
|
sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
|
|
1842
2030
|
return;
|
|
1843
2031
|
}
|
|
@@ -1859,6 +2047,7 @@ async function handleExpectedSet(state, res, verifier, body) {
|
|
|
1859
2047
|
state.expectedTests.set(expectedKey(test.project, test.file, test.titlePath), test);
|
|
1860
2048
|
}
|
|
1861
2049
|
state.enumerationDigest = digest;
|
|
2050
|
+
state.pageObservation = pageObservation;
|
|
1862
2051
|
sendJson(res, 200, { bound: true, enumerationDigest: digest, count: state.expectedTests.size });
|
|
1863
2052
|
}
|
|
1864
2053
|
/**
|
|
@@ -2812,6 +3001,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2812
3001
|
}
|
|
2813
3002
|
session.outcome = outcome;
|
|
2814
3003
|
}
|
|
3004
|
+
sealPageObservationRecords(state, sessionId, session.outcome);
|
|
2815
3005
|
// The session's own evidence died with its proxy at release; the
|
|
2816
3006
|
// observe snapshots are dropped HERE, at the moment the outcome is
|
|
2817
3007
|
// finally recorded, so an unreleased session's before-state can
|
|
@@ -2819,6 +3009,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2819
3009
|
state.observeSnapshots.delete(sessionId);
|
|
2820
3010
|
state.sessionIdentities.delete(sessionId);
|
|
2821
3011
|
state.sessionPageOrigins.delete(sessionId);
|
|
3012
|
+
await stopPageObserver(state, sessionId);
|
|
2822
3013
|
sendJson(res, 200, { sealed: true });
|
|
2823
3014
|
return;
|
|
2824
3015
|
}
|
|
@@ -2826,6 +3017,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2826
3017
|
session.status = 'sealed';
|
|
2827
3018
|
session.sealedTick = (state.tick += 1);
|
|
2828
3019
|
session.outcome = typeof outcome === 'string' ? outcome : null;
|
|
3020
|
+
sealPageObservationRecords(state, sessionId, session.outcome);
|
|
2829
3021
|
state.workerSessions.delete(session.workerIndex);
|
|
2830
3022
|
// Observe snapshots die with the session: finalize runs BEFORE seal
|
|
2831
3023
|
// (the drain finalizes a passed test, then seals), so anything left
|
|
@@ -2840,6 +3032,7 @@ async function handleSessionClose(state, res, body) {
|
|
|
2840
3032
|
// (or submit) through it afterwards. The engine browser context dies
|
|
2841
3033
|
// too — a sealed session's pages are never driven again.
|
|
2842
3034
|
await stopSessionProxy(session);
|
|
3035
|
+
await stopPageObserver(state, sessionId);
|
|
2843
3036
|
await state.engineBrowser?.closeSession(session.sessionId);
|
|
2844
3037
|
}
|
|
2845
3038
|
sendJson(res, 200, { sealed: true });
|
|
@@ -3035,6 +3228,7 @@ async function handleSessionRelease(state, res, body) {
|
|
|
3035
3228
|
// The dedicated channel dies with the release: no exchange can be
|
|
3036
3229
|
// observed through this session after its test finished.
|
|
3037
3230
|
await stopSessionProxy(session);
|
|
3231
|
+
await stopPageObserver(state, sessionId);
|
|
3038
3232
|
await state.engineBrowser?.closeSession(session.sessionId);
|
|
3039
3233
|
}
|
|
3040
3234
|
sendJson(res, 200, { released: true });
|
|
@@ -5105,6 +5299,12 @@ async function stopWitness(state) {
|
|
|
5105
5299
|
for (const session of state.sessions.values()) {
|
|
5106
5300
|
await stopSessionProxy(session);
|
|
5107
5301
|
}
|
|
5302
|
+
for (const sessionId of state.pageObservers.keys()) {
|
|
5303
|
+
await stopPageObserver(state, sessionId);
|
|
5304
|
+
}
|
|
5305
|
+
for (const sessionId of state.pageObservationRecords.keys()) {
|
|
5306
|
+
sealPageObservationRecords(state, sessionId, null);
|
|
5307
|
+
}
|
|
5108
5308
|
// Phase 4 lifecycle shutdown: release every live fixture lease namespace.
|
|
5109
5309
|
// Timeouts/failures release only their own namespace and never flip a
|
|
5110
5310
|
// verdict — releases here are best-effort shutdown hygiene.
|