@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.
Files changed (37) 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 +86 -0
  10. package/dist/witness/api-settlement.d.ts.map +1 -0
  11. package/dist/witness/api-settlement.js +150 -0
  12. package/dist/witness/api-settlement.js.map +1 -0
  13. package/dist/witness/browser.d.ts +13 -3
  14. package/dist/witness/browser.d.ts.map +1 -1
  15. package/dist/witness/browser.js +168 -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-observation.d.ts +93 -0
  21. package/dist/witness/page-observation.d.ts.map +1 -0
  22. package/dist/witness/page-observation.js +117 -0
  23. package/dist/witness/page-observation.js.map +1 -0
  24. package/dist/witness/page-observer-registration.d.ts +49 -0
  25. package/dist/witness/page-observer-registration.d.ts.map +1 -0
  26. package/dist/witness/page-observer-registration.js +194 -0
  27. package/dist/witness/page-observer-registration.js.map +1 -0
  28. package/dist/witness/page-observer.d.ts +18 -0
  29. package/dist/witness/page-observer.d.ts.map +1 -0
  30. package/dist/witness/page-observer.js +462 -0
  31. package/dist/witness/page-observer.js.map +1 -0
  32. package/dist/witness/server.d.ts.map +1 -1
  33. package/dist/witness/server.js +188 -1
  34. package/dist/witness/server.js.map +1 -1
  35. package/dist/witness/types.d.ts +70 -0
  36. package/dist/witness/types.d.ts.map +1 -1
  37. 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,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: [...]}, identity-shaped).
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.