@ai-ecoverse/slicc-browsers 1.10.3 → 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.10.3",
3
+ "version": "1.12.0",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -52,7 +52,8 @@
52
52
  "guard-runner.mjs",
53
53
  "fixture-server.mjs",
54
54
  "extension-world.mjs",
55
- "unparseable-url.mjs"
55
+ "unparseable-url.mjs",
56
+ "protected-error-page.mjs"
56
57
  ],
57
58
  "scripts": {
58
59
  "prepare": "lefthook install",
@@ -67,18 +68,18 @@
67
68
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
68
69
  "release": "semantic-release",
69
70
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
70
- "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/self-contained.test.mjs",
71
+ "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/self-contained.test.mjs",
71
72
  "test:guard-sweep": "node scripts/test-guards.mjs",
72
73
  "test:gates": "node --test test/integration/gates.test.mjs",
73
74
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
74
75
  "test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-lines=100 test/unit/guard-*.test.mjs"
75
76
  },
76
77
  "devDependencies": {
77
- "@ai-ecoverse/slicc-shared-web": "1.27.0",
78
+ "@ai-ecoverse/slicc-shared-web": "1.29.1",
78
79
  "@biomejs/biome": "2.5.15",
79
80
  "ajv": "8.20.0",
80
81
  "ajv-formats": "3.0.1",
81
- "lefthook": "2.2.1",
82
+ "lefthook": "2.2.2",
82
83
  "playwright-core": "1.64.1",
83
84
  "semantic-release": "25.0.9"
84
85
  }
@@ -0,0 +1,259 @@
1
+ import { outcome } from './guard-driver.mjs';
2
+
3
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
5
+
6
+ async function until(check) {
7
+ const deadline = Date.now() + 5000;
8
+ do {
9
+ if (await check()) return true;
10
+ await pause(25);
11
+ } while (Date.now() < deadline);
12
+ return false;
13
+ }
14
+
15
+ async function loadedDocument(reader, observer, url) {
16
+ return until(async () => {
17
+ const { frameTree } = await reader.send('Page.getFrameTree', {}, observer);
18
+ if (frameTree.frame.url !== url || frameTree.frame.unreachableUrl) return false;
19
+ const ready = await outcome(
20
+ reader.send(
21
+ 'Runtime.evaluate',
22
+ { expression: 'document.readyState', returnByValue: true },
23
+ observer
24
+ )
25
+ );
26
+ return ready.result?.result?.value === 'complete';
27
+ });
28
+ }
29
+
30
+ async function lockChecks({ client, session, targetId, result }) {
31
+ result.evaluateRefused = refused(
32
+ await outcome(
33
+ client.send(
34
+ 'Runtime.evaluate',
35
+ { expression: 'document.title', returnByValue: true },
36
+ session
37
+ )
38
+ )
39
+ );
40
+ result.attachRefused = refused(
41
+ await outcome(client.send('Target.attachToTarget', { targetId, flatten: true }))
42
+ );
43
+ result.closeRefused = refused(await outcome(client.send('Target.closeTarget', { targetId })));
44
+ }
45
+
46
+ async function redaction({ client, targetId, failedURL, protectedOrigin, start, result }) {
47
+ const info = await outcome(client.send('Target.getTargetInfo', { targetId }));
48
+ const list = await outcome(client.send('Target.getTargets'));
49
+ const before = client.events.length;
50
+ const again = await outcome(client.send('Target.attachToTarget', { targetId, flatten: true }));
51
+ const origin = new URL(failedURL).origin;
52
+ const only = (i) => i?.url === origin && i.title === origin;
53
+ const listed = list.ok ? list.result.targetInfos.find((i) => i.targetId === targetId) : undefined;
54
+ await pause(100);
55
+ const attachedInfo = client.events
56
+ .slice(before)
57
+ .find(
58
+ (e) =>
59
+ e.method === 'Target.attachedToTarget' && e.params.sessionId === again.result?.sessionId
60
+ )?.params.targetInfo;
61
+ if (again.ok)
62
+ await outcome(client.send('Target.detachFromTarget', { sessionId: again.result.sessionId }));
63
+ result.infoRedacted = info.ok && only(info.result.targetInfo);
64
+ result.listRedacted = only(listed);
65
+ result.attachRedacted = only(attachedInfo);
66
+ result.fullURL =
67
+ info.result?.targetInfo?.url === failedURL &&
68
+ listed?.url === failedURL &&
69
+ attachedInfo?.url === failedURL;
70
+ const targetEvents = client.events
71
+ .slice(start)
72
+ .filter(
73
+ (e) =>
74
+ e.params?.targetInfo?.targetId === targetId &&
75
+ e.params.targetInfo.url.includes(new URL(protectedOrigin).host)
76
+ );
77
+ result.eventsRedacted =
78
+ targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo));
79
+ }
80
+
81
+ const secrets = {
82
+ all: (tag) => `/doc/path-secret-${tag}?query-secret=${tag}#fragment-secret-${tag}`,
83
+ query: (tag) => `/doc?query-secret=${tag}`,
84
+ fragment: (tag) => `/doc#fragment-secret-${tag}`,
85
+ };
86
+
87
+ export async function protectedErrorPage({
88
+ client,
89
+ reader,
90
+ ordinary,
91
+ protectedOrigin,
92
+ tag,
93
+ mode,
94
+ side,
95
+ internalSessions = new Set(),
96
+ secret = 'all',
97
+ }) {
98
+ const initial = `${protectedOrigin}/doc?n=${tag}-history`;
99
+ const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
100
+ const result = {};
101
+ const start = client.events.length;
102
+ const { targetId } = await reader.send('Target.createTarget', { url: initial, background: true });
103
+ let session;
104
+ let observer;
105
+ let failing = false;
106
+ let cursor = reader.events.length;
107
+ const { sessionId } = await reader.send('Target.attachToTarget', { targetId, flatten: true });
108
+ observer = sessionId;
109
+ internalSessions.add(observer);
110
+ const frame = async () => (await reader.send('Page.getFrameTree', {}, observer)).frameTree.frame;
111
+ const loaded = (url) => loadedDocument(reader, observer, url);
112
+ let stopped = false;
113
+ const fail = (async () => {
114
+ while (!stopped) {
115
+ for (const event of reader.events.slice(cursor)) {
116
+ cursor++;
117
+ if (event.method === 'Fetch.requestPaused' && event.sessionId === observer)
118
+ await outcome(
119
+ reader.send(
120
+ failing ? 'Fetch.failRequest' : 'Fetch.continueRequest',
121
+ failing
122
+ ? { requestId: event.params.requestId, errorReason: 'BlockedByClient' }
123
+ : { requestId: event.params.requestId },
124
+ observer
125
+ )
126
+ );
127
+ }
128
+ await pause(5);
129
+ }
130
+ })();
131
+ try {
132
+ await reader.send('Page.enable', {}, observer);
133
+ if (!(await loaded(initial)))
134
+ throw new Error('protected-error-page: history seed did not commit');
135
+ await reader.send('Page.navigate', { url: `${ordinary}/blank` }, observer);
136
+ if (!(await loaded(`${ordinary}/blank`)))
137
+ throw new Error('protected-error-page: allowed document did not commit');
138
+ const attached = await outcome(
139
+ client.send('Target.attachToTarget', { targetId, flatten: true })
140
+ );
141
+ session = attached.result?.sessionId;
142
+ if (!session) return { live: false, attached: false };
143
+ await client.send('Page.enable', {}, session);
144
+ await client.send('Runtime.enable', {}, session);
145
+ await client.send('Target.setDiscoverTargets', { discover: true });
146
+ if (side === 'control') {
147
+ failing = true;
148
+ await reader.send(
149
+ 'Fetch.enable',
150
+ { patterns: [{ resourceType: 'Document', requestStage: 'Request' }] },
151
+ observer
152
+ );
153
+ }
154
+ await client.send(
155
+ 'Runtime.evaluate',
156
+ { expression: `setTimeout(()=>location.href=${JSON.stringify(failedURL)},0)` },
157
+ session
158
+ );
159
+ result.live = await until(async () => {
160
+ const f = await frame();
161
+ const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId });
162
+ result.evidence = { frame: f, targetInfo };
163
+ return (
164
+ f.url === 'chrome-error://chromewebdata/' &&
165
+ f.securityOrigin === '://' &&
166
+ f.unreachableUrl === failedURL &&
167
+ targetInfo.url === failedURL
168
+ );
169
+ });
170
+ if (!result.live && side !== 'control') return result;
171
+ if (!result.live)
172
+ throw new Error(
173
+ `protected-error-page: missing error commit ${JSON.stringify(result.evidence)}`
174
+ );
175
+ await redaction({ client, targetId, failedURL, protectedOrigin, start, result });
176
+ const beforeReload = (await frame()).loaderId;
177
+ result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
178
+ if (result.reload) {
179
+ result.reloadedError = await until(async () => {
180
+ const f = await frame();
181
+ return f.loaderId !== beforeReload && f.unreachableUrl === failedURL;
182
+ });
183
+ result.reload = result.reloadedError;
184
+ }
185
+ result.blank = (
186
+ await outcome(client.send('Page.navigate', { url: 'about:blank' }, session))
187
+ ).ok;
188
+ if (result.blank) result.blank = await loaded('about:blank');
189
+ result.attached = !client.events
190
+ .slice(start)
191
+ .some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
192
+ if (mode === 'recover') {
193
+ if (result.blank) {
194
+ await client.send(
195
+ 'Runtime.evaluate',
196
+ { expression: `setTimeout(()=>location.href=${JSON.stringify(failedURL)},0)` },
197
+ session
198
+ );
199
+ result.closeError = await until(async () => (await frame()).unreachableUrl === failedURL);
200
+ }
201
+ result.attached &&= !client.events
202
+ .slice(start)
203
+ .some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
204
+ const close = await outcome(client.send('Target.closeTarget', { targetId }));
205
+ result.close = result.closeError === true && close.result?.success === true;
206
+ return result;
207
+ }
208
+ if (side === 'control') await reader.send('Fetch.disable', {}, observer);
209
+ failing = false;
210
+ const { entries, currentIndex } = await reader.send('Page.getNavigationHistory', {}, observer);
211
+ const index = entries.findIndex((e) => e.url === initial);
212
+ if (index < 0) throw new Error('protected-error-page: missing protected history');
213
+ const beforeRestore = reader.events.length;
214
+ await reader.send(
215
+ 'Runtime.evaluate',
216
+ { expression: `setTimeout(() => history.go(${index - currentIndex}), 0)` },
217
+ observer
218
+ );
219
+ result.restored = await until(() =>
220
+ reader.events
221
+ .slice(beforeRestore)
222
+ .some(
223
+ (e) =>
224
+ e.sessionId === observer &&
225
+ e.method === 'Page.frameNavigated' &&
226
+ e.params.type === 'BackForwardCacheRestore' &&
227
+ e.params.frame.url === initial
228
+ )
229
+ );
230
+ if (!result.restored)
231
+ throw new Error('protected-error-page: real protected BFCache restore required');
232
+ await pause(100);
233
+ if (mode === 'sticky') {
234
+ await reader.send('Target.detachFromTarget', { sessionId: observer });
235
+ observer = (await reader.send('Target.attachToTarget', { targetId, flatten: true }))
236
+ .sessionId;
237
+ internalSessions.add(observer);
238
+ failing = true;
239
+ await reader.send(
240
+ 'Fetch.enable',
241
+ { patterns: [{ resourceType: 'Document', requestStage: 'Request' }] },
242
+ observer
243
+ );
244
+ await reader.send('Page.navigate', { url: failedURL }, observer);
245
+ result.stickyError = await until(async () => (await frame()).unreachableUrl === failedURL);
246
+ if (!result.stickyError)
247
+ throw new Error(
248
+ `protected-error-page: missing sticky error ${JSON.stringify(await frame())}`
249
+ );
250
+ await pause(100);
251
+ }
252
+ await lockChecks({ client, session, targetId, result });
253
+ return result;
254
+ } finally {
255
+ stopped = true;
256
+ await fail;
257
+ await outcome(reader.send('Target.closeTarget', { targetId }));
258
+ }
259
+ }
package/protected.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "schema": 1,
3
+ "errorPage": { "url": "chrome-error://chromewebdata/", "target": "previous", "redact": "origin" },
3
4
  "unparseable": "protected",
4
5
  "unparseableRefusal": "slicc: refused: slicc can't parse this URL, so it could be a protected page",
5
6
  "origins": ["http://127.0.0.1:5710", "https://seven.example.test"],
@@ -738,6 +739,42 @@
738
739
  }
739
740
  ],
740
741
  "targets": [
742
+ {
743
+ "url": "https://seven.sliccy.ai/path?secret=query#fragment",
744
+ "documentURL": "chrome-error://chromewebdata/",
745
+ "unreachableURL": "https://seven.sliccy.ai/other",
746
+ "protected": true
747
+ },
748
+ {
749
+ "url": "https://seven.sliccy.ai/path?secret=query#fragment",
750
+ "documentURL": "chrome-error://chromewebdata/",
751
+ "unreachableURL": "https://seven.sliccy.ai/path?secret=query#fragment",
752
+ "navigating": true,
753
+ "protected": true
754
+ },
755
+ {
756
+ "url": "https://seven.sliccy.ai/path?secret=query#fragment",
757
+ "documentURL": "chrome-error://chromewebdata/",
758
+ "protected": true
759
+ },
760
+ {
761
+ "url": "https://seven.sliccy.ai/path?secret=query#fragment",
762
+ "documentURL": "chrome-error://chromewebdata/",
763
+ "unreachableURL": "https://seven.sliccy.ai/path?secret=query#fragment",
764
+ "protected": false
765
+ },
766
+ {
767
+ "url": "https://seven.sliccy.ai/path?secret=query#fragment",
768
+ "documentURL": "chrome-error://chromewebdata/",
769
+ "unreachableURL": "https://seven.sliccy.ai/path?secret=query#fragment",
770
+ "previous": true,
771
+ "protected": true
772
+ },
773
+ {
774
+ "url": "https://seven.sliccy.ai/path?secret=query#fragment",
775
+ "documentURL": "https://seven.sliccy.ai/path?secret=query#fragment",
776
+ "protected": true
777
+ },
741
778
  {
742
779
  "url": "http://exa mple.sliccy.ai/",
743
780
  "protected": true
package/protected.mjs CHANGED
@@ -68,8 +68,23 @@ export function protectedOrigins(rules, extraOrigins = []) {
68
68
  );
69
69
  };
70
70
 
71
- const target = ({ url: text, previous = false, opener, type }) => {
71
+ const target = ({
72
+ url: text,
73
+ previous = false,
74
+ opener,
75
+ type,
76
+ documentURL,
77
+ unreachableURL,
78
+ navigating = false,
79
+ }) => {
72
80
  if (type && (rules.targetTypes ?? []).includes(type)) return true;
81
+ if (
82
+ rules.errorPage &&
83
+ documentURL === rules.errorPage.url &&
84
+ unreachableURL === text &&
85
+ !navigating
86
+ )
87
+ return previous;
73
88
  if (inheriting(text)) return previous || opener === 'protected' || opener === 'unknown';
74
89
  return url(text);
75
90
  };
@@ -227,6 +227,16 @@
227
227
  "uniqueItems": true,
228
228
  "items": { "type": "string", "pattern": "^about:[a-z]+$" }
229
229
  },
230
+ "errorPage": {
231
+ "type": "object",
232
+ "additionalProperties": false,
233
+ "required": ["url", "target", "redact"],
234
+ "properties": {
235
+ "url": { "const": "chrome-error://chromewebdata/" },
236
+ "target": { "const": "previous" },
237
+ "redact": { "const": "origin" }
238
+ }
239
+ },
230
240
  "unparseable": { "const": "protected" },
231
241
  "unparseableRefusal": {
232
242
  "const": "slicc: refused: slicc can't parse this URL, so it could be a protected page"
@@ -427,6 +427,17 @@
427
427
  },
428
428
  "step": {
429
429
  "oneOf": [
430
+ {
431
+ "type": "object",
432
+ "additionalProperties": false,
433
+ "required": ["errorPage", "as"],
434
+ "properties": {
435
+ "errorPage": { "enum": ["recover", "restore", "sticky"] },
436
+ "secret": { "enum": ["all", "query", "fragment"] },
437
+ "requires": { "$ref": "#/$defs/requires" },
438
+ "as": { "$ref": "#/$defs/name" }
439
+ }
440
+ },
430
441
  {
431
442
  "type": "object",
432
443
  "additionalProperties": false,
@@ -16,6 +16,16 @@
16
16
  ],
17
17
  "properties": {
18
18
  "schema": { "const": 1 },
19
+ "errorPage": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "required": ["url", "target", "redact"],
23
+ "properties": {
24
+ "url": { "const": "chrome-error://chromewebdata/" },
25
+ "target": { "const": "previous" },
26
+ "redact": { "const": "origin" }
27
+ }
28
+ },
19
29
  "unparseable": { "const": "protected" },
20
30
  "unparseableRefusal": {
21
31
  "const": "slicc: refused: slicc can't parse this URL, so it could be a protected page"
@@ -43,6 +53,9 @@
43
53
  "url": { "type": "string" },
44
54
  "type": { "type": "string" },
45
55
  "previous": { "type": "boolean" },
56
+ "documentURL": { "type": "string" },
57
+ "unreachableURL": { "type": "string" },
58
+ "navigating": { "type": "boolean" },
46
59
  "opener": { "enum": ["protected", "unprotected", "unknown"] },
47
60
  "protected": { "type": "boolean" }
48
61
  }