@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.
Files changed (41) hide show
  1. package/dist/client/witness-client.d.ts +10 -1
  2. package/dist/client/witness-client.d.ts.map +1 -1
  3. package/dist/client/witness-client.js +11 -3
  4. package/dist/client/witness-client.js.map +1 -1
  5. package/dist/constants.d.ts +15 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/constants.js +21 -0
  8. package/dist/constants.js.map +1 -1
  9. package/dist/witness/api-settlement.d.ts +88 -0
  10. package/dist/witness/api-settlement.d.ts.map +1 -0
  11. package/dist/witness/api-settlement.js +160 -0
  12. package/dist/witness/api-settlement.js.map +1 -0
  13. package/dist/witness/browser.d.ts +14 -3
  14. package/dist/witness/browser.d.ts.map +1 -1
  15. package/dist/witness/browser.js +208 -2
  16. package/dist/witness/browser.js.map +1 -1
  17. package/dist/witness/classifications.d.ts +4 -6
  18. package/dist/witness/classifications.d.ts.map +1 -1
  19. package/dist/witness/classifications.js.map +1 -1
  20. package/dist/witness/page-live-channels.d.ts +46 -0
  21. package/dist/witness/page-live-channels.d.ts.map +1 -0
  22. package/dist/witness/page-live-channels.js +68 -0
  23. package/dist/witness/page-live-channels.js.map +1 -0
  24. package/dist/witness/page-observation.d.ts +108 -0
  25. package/dist/witness/page-observation.d.ts.map +1 -0
  26. package/dist/witness/page-observation.js +134 -0
  27. package/dist/witness/page-observation.js.map +1 -0
  28. package/dist/witness/page-observer-registration.d.ts +86 -0
  29. package/dist/witness/page-observer-registration.d.ts.map +1 -0
  30. package/dist/witness/page-observer-registration.js +258 -0
  31. package/dist/witness/page-observer-registration.js.map +1 -0
  32. package/dist/witness/page-observer.d.ts +20 -0
  33. package/dist/witness/page-observer.d.ts.map +1 -0
  34. package/dist/witness/page-observer.js +520 -0
  35. package/dist/witness/page-observer.js.map +1 -0
  36. package/dist/witness/server.d.ts.map +1 -1
  37. package/dist/witness/server.js +201 -1
  38. package/dist/witness/server.js.map +1 -1
  39. package/dist/witness/types.d.ts +82 -0
  40. package/dist/witness/types.d.ts.map +1 -1
  41. package/package.json +2 -2
@@ -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: [...]}, identity-shaped).
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.