@volter/twin-standard 1.0.37 → 1.0.39

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.
@@ -73,18 +73,19 @@ export async function chromiumServed(packDir, host) {
73
73
  await page.goto(harness);
74
74
  await page.clock.setFixedTime(syncClock());
75
75
  return {
76
- fetch: async (request) => {
76
+ scheme: 'https',
77
+ fetch: async (request, options) => {
77
78
  await page.clock.setFixedTime(syncClock());
78
79
  const body = request.body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null;
79
- const result = await page.evaluate(async ({ url, method, headers, body }) => {
80
+ const result = await page.evaluate(async ({ url, method, headers, body, credentials }) => {
80
81
  const bytes = body === null ? undefined : Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
81
- const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials: 'include' });
82
+ const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials });
82
83
  const data = new Uint8Array(await response.arrayBuffer());
83
84
  let text = '';
84
85
  for (let i = 0; i < data.length; i += 32768)
85
86
  text += String.fromCharCode(...data.subarray(i, i + 32768));
86
87
  return { status: response.status, headers: [...response.headers], body: btoa(text) };
87
- }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body });
88
+ }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body, credentials: options?.credentials ?? 'same-origin' });
88
89
  if (!result.status)
89
90
  throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
90
91
  return new Response([101, 204, 205, 304].includes(result.status) || request.method === 'HEAD' ? null : Buffer.from(result.body, 'base64'), { status: result.status, headers: result.headers });
@@ -15,5 +15,6 @@ export declare function verifyBrowserTransport(pack: string): Promise<{
15
15
  };
16
16
  binaryBytes: number;
17
17
  corsEnforced: boolean;
18
+ credentialModes: boolean;
18
19
  domCoverage: null;
19
20
  }>;
@@ -9,6 +9,8 @@ import { inMemoryWorld } from "./walk/behavior-journey.js";
9
9
  import { chromiumServed } from "./browser-assessment.js";
10
10
  const require = createRequire(import.meta.url);
11
11
  export async function verifyBrowserTransport(pack) {
12
+ const started = performance.now();
13
+ const progress = (phase) => console.log(JSON.stringify({ purpose: 'browser-transport-fixture', phase, elapsedMs: Math.round(performance.now() - started) }));
12
14
  assert.ok(process.env.VOLTER_WORLD, 'browser transport verification must run through a World');
13
15
  assert.ok(!existsSync(pack), 'browser fixture directory must be new and task-owned');
14
16
  mkdirSync(join(pack, 'src'), { recursive: true });
@@ -22,6 +24,8 @@ export async function verifyBrowserTransport(pack) {
22
24
  { id: 'head', method: 'head', path: '/head', class: 'get' },
23
25
  { id: 'gap', method: 'get', path: '/gap', class: 'get' },
24
26
  { id: 'unused', method: 'get', path: '/unused', class: 'get' },
27
+ { id: 'publicCrossOrigin', method: 'get', path: '/public', class: 'get' },
28
+ { id: 'credentialedCrossOrigin', method: 'get', path: '/credentialed', class: 'get' },
25
29
  ] }, handlers: {
26
30
  login: async () => new Response(null, { status: 204, headers: [['set-cookie', 'session=fixture; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=3600'], ['set-cookie', 'second=fixture; Path=/; Secure; SameSite=Strict; Expires=Thu, 01 Jan 2026 01:00:00 GMT']] }),
27
31
  identity: async ({ request }) => {
@@ -31,6 +35,8 @@ export async function verifyBrowserTransport(pack) {
31
35
  },
32
36
  binary: async () => new Response(new Uint8Array(100000).fill(197), { headers: { 'content-type': 'application/octet-stream' } }),
33
37
  head: async () => new Response(null, { status: 204 }),
38
+ publicCrossOrigin: async () => Response.json({ public: true }, { headers: { 'access-control-allow-origin': '*' } }),
39
+ credentialedCrossOrigin: async () => Response.json({ credentialed: true }, { headers: { 'access-control-allow-origin': 'https://browser-fixture.test', 'access-control-allow-credentials': 'true' } }),
34
40
  }, gap: async () => Response.json({ error: 'unsupported' }, { status: 404 }) });
35
41
  }
36
42
  `);
@@ -42,6 +48,7 @@ export async function verifyBrowserTransport(pack) {
42
48
  const replays = [];
43
49
  try {
44
50
  for (let run = 0; run < 2; run++) {
51
+ progress(`replay-${run + 1}:start`);
45
52
  setClock('2026-01-01T00:00:00.000Z');
46
53
  const served = await browser.served(world.root(), {});
47
54
  try {
@@ -55,6 +62,7 @@ export async function verifyBrowserTransport(pack) {
55
62
  setClock('2026-01-01T01:00:01.000Z');
56
63
  const expired = await (await fetch('/identity')).json();
57
64
  assert.deepEqual(expired, { authenticated: false, session: false, second: false }, 'native Expires and Max-Age must follow World time');
65
+ progress(`replay-${run + 1}:cookies`);
58
66
  const binary = new Uint8Array(await (await fetch('/binary')).arrayBuffer());
59
67
  assert.equal(binary.byteLength, 100000);
60
68
  assert.ok(binary.every((byte) => byte === 197));
@@ -63,7 +71,15 @@ export async function verifyBrowserTransport(pack) {
63
71
  assert.equal(await head.text(), '');
64
72
  assert.equal((await fetch('/gap')).status, 404);
65
73
  assert.equal((await fetch('/unknown')).status, 404);
74
+ progress(`replay-${run + 1}:responses`);
66
75
  await assert.rejects(() => served.fetch(new Request('https://another-origin.test/identity')), /Failed to fetch/);
76
+ progress(`replay-${run + 1}:cors-denied`);
77
+ assert.deepEqual(await (await served.fetch(new Request('https://another-origin.test/public'))).json(), { public: true }, 'anonymous cross-origin fetch accepts a wildcard permission');
78
+ progress(`replay-${run + 1}:anonymous-cors`);
79
+ await assert.rejects(() => served.fetch(new Request('https://another-origin.test/public'), { credentials: 'include' }), /Failed to fetch/, 'credentialed fetch refuses wildcard permission');
80
+ progress(`replay-${run + 1}:credentialed-wildcard-denied`);
81
+ assert.deepEqual(await (await served.fetch(new Request('https://another-origin.test/credentialed'), { credentials: 'include' })).json(), { credentialed: true }, 'credentialed fetch requires matching origin and credential permission');
82
+ progress(`replay-${run + 1}:complete`);
67
83
  replays.push({ identity, expired, binaryBytes: binary.byteLength, headStatus: head.status, gapStatus: 404 });
68
84
  }
69
85
  finally {
@@ -71,8 +87,8 @@ export async function verifyBrowserTransport(pack) {
71
87
  }
72
88
  }
73
89
  assert.deepEqual(replays[0], replays[1]);
74
- assert.deepEqual([...seen].sort(), ['binary', 'gap', 'head', 'identity', 'login']);
75
- return { purpose: 'browser-transport-fixture', passed: true, chromium: browser.version, replays: 2, exercisedOperations: seen.size, declaredOperations: 6, cookieJar: true, cookieExpiry: ['Expires', 'Max-Age'], clock: browser.clock, binaryBytes: 100000, corsEnforced: true, domCoverage: null };
90
+ assert.deepEqual([...seen].sort(), ['binary', 'credentialedCrossOrigin', 'gap', 'head', 'identity', 'login', 'publicCrossOrigin']);
91
+ return { purpose: 'browser-transport-fixture', passed: true, chromium: browser.version, replays: 2, exercisedOperations: seen.size, declaredOperations: 8, cookieJar: true, cookieExpiry: ['Expires', 'Max-Age'], clock: browser.clock, binaryBytes: 100000, corsEnforced: true, credentialModes: true, domCoverage: null };
76
92
  }
77
93
  finally {
78
94
  unsubscribe();
@@ -33,7 +33,10 @@ export type ServedTwin = (root: string, options: {
33
33
  scenarioPath?: string;
34
34
  application?: import('@volter/world-core').PackFetchOptions['application'];
35
35
  }) => Promise<{
36
- fetch: (request: Request) => Promise<Response>;
36
+ scheme?: 'http' | 'https';
37
+ fetch: (request: Request, options?: {
38
+ credentials?: RequestCredentials;
39
+ }) => Promise<Response>;
37
40
  stop: () => void | Promise<void>;
38
41
  socket?: (url: string, headers: Record<string, string>) => WebSocket;
39
42
  stream?: TwinStream;
@@ -239,6 +239,7 @@ export async function walk(journey, root, collect, opts) {
239
239
  const twin = served ? served.fetch : factory(root, { ...(scenarioPath ? { scenarioPath } : {}), application });
240
240
  const shape = await shapeOf(journey.vendor);
241
241
  const host = journey.host ?? 'twin.local';
242
+ const origin = `${served?.scheme ?? 'http'}://${host}`;
242
243
  // what an earlier walk captured, when this one continues it (the branch round-trip sends a life's write again)
243
244
  const captured = { ...(opts.captured ?? {}) };
244
245
  // a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
@@ -268,7 +269,9 @@ export async function walk(journey, root, collect, opts) {
268
269
  // a twin that throws answers 500, as a server would: the walk reports the step and goes on to the next
269
270
  let res;
270
271
  try {
271
- res = await (companionOf(path)?.fetch ?? twin)(new Request(/^https?:\/\//.test(path) ? path : `http://${host}${path}`, init));
272
+ const request = new Request(/^https?:\/\//.test(path) ? path : `${origin}${path}`, init);
273
+ const companion = companionOf(path);
274
+ res = companion ? await companion.fetch(request) : served ? await served.fetch(request, { credentials: step.credentials ?? 'same-origin' }) : await twin(request);
272
275
  }
273
276
  catch (error) {
274
277
  res = new Response(`twin threw: ${error instanceof Error ? error.message : String(error)}`, { status: 500 });
@@ -287,7 +290,7 @@ export async function walk(journey, root, collect, opts) {
287
290
  // a hosted flow answers with a redirect: its target and query are what a step captures (redirect.code)
288
291
  const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
289
292
  if (location)
290
- body = { location, redirect: Object.fromEntries(new URL(location, `http://${host}`).searchParams) };
293
+ body = { location, redirect: Object.fromEntries(new URL(location, origin).searchParams) };
291
294
  // every cookie an answer sets, joined as one header: a sign-in sets several, and a map keeps only the last
292
295
  const cookies = res.headers.getSetCookie();
293
296
  return { status: res.status, body, text, headers: { ...Object.fromEntries(res.headers), ...(cookies.length ? { 'set-cookie': cookies.join(', ') } : {}) } };
@@ -488,7 +491,7 @@ export async function walk(journey, root, collect, opts) {
488
491
  opts.hooks?.before?.(step, where, files);
489
492
  let answered = { status: 0, body: undefined };
490
493
  try {
491
- const base = `http://${host}${where}`;
494
+ const base = `${origin}${where}`;
492
495
  const res = step.git === 'clone'
493
496
  ? await gitClone(twin, base, { ...(step.ref ? { ref: step.ref } : {}) }, headers)
494
497
  : await gitPush(twin, base, { ref: step.ref ?? 'refs/heads/main', ...(files ? { files } : {}), ...(step.message ? { message: step.message } : {}), ...(step.author ? { author: step.author } : {}), ...(step.amend ? { amend: true } : {}), ...(step.delete ? { delete: true } : {}) }, headers);
@@ -887,7 +890,7 @@ export async function walk(journey, root, collect, opts) {
887
890
  // an OAuth 1.0a step is signed as its client signs it, over the URL it sends to, at the World clock's instant
888
891
  if (step.oauth1) {
889
892
  const o = parseExactJson(fill(JSON.stringify(step.oauth1), captured));
890
- const url = /^https?:\/\//.test(path) ? path : `http://${host}${path}`;
893
+ const url = /^https?:\/\//.test(path) ? path : `${origin}${path}`;
891
894
  // a form is signed as it is sent: its multipart type, whose parts OAuth 1.0a's base string never carries
892
895
  const sent = formed ? undefined : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
893
896
  const contentType = Object.entries(headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1] ?? (formed ? formed.contentType : step.bodyBase64 === undefined ? defaultType(body) : undefined);
@@ -898,7 +901,7 @@ export async function walk(journey, root, collect, opts) {
898
901
  headers = { ...(headers ?? {}), authorization: jwtAuthorization(step.jwt, captured) };
899
902
  // an AWS SDK's request is signed as it sends it, at the World clock's instant
900
903
  if (step.sigv4) {
901
- const url = /^https?:\/\//.test(path) ? path : `http://${host}${path}`;
904
+ const url = /^https?:\/\//.test(path) ? path : `${origin}${path}`;
902
905
  // the bytes and the type sent: a form's multipart body and boundary, raw bytes, or the body's text
903
906
  const sent = formed ? formed.bytes : step.bodyBase64 !== undefined ? Uint8Array.from(Buffer.from(step.bodyBase64, 'base64')) : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
904
907
  const instant = clockFile && store.exists(clockFile) ? Date.parse((store.read(clockFile) ?? '').trim()) : Number.NaN;
@@ -950,7 +953,7 @@ export async function walk(journey, root, collect, opts) {
950
953
  collect.push({ step: step.name, beat: 'LANDED', detail: e.message });
951
954
  }
952
955
  if (changesPath)
953
- beforeChange = (await send({ method: 'GET', headers: stepHeaders(step) }, changesPath)).text;
956
+ beforeChange = (await send({ method: 'GET', headers: stepHeaders(step), credentials: step.credentials }, changesPath)).text;
954
957
  }
955
958
  const failuresBefore = collect.length;
956
959
  opts.hooks?.before?.(step, path, body);
@@ -1031,7 +1034,7 @@ export async function walk(journey, root, collect, opts) {
1031
1034
  }
1032
1035
  // LANDED beat, part two: a write that answered success must have moved something.
1033
1036
  if (changesPath !== undefined && beforeChange !== undefined) {
1034
- const after = await send({ method: 'GET', headers: stepHeaders(step) }, changesPath);
1037
+ const after = await send({ method: 'GET', headers: stepHeaders(step), credentials: step.credentials }, changesPath);
1035
1038
  if (after.text === beforeChange) {
1036
1039
  collect.push({ step: step.name, beat: 'LANDED', detail: `${step.method} ${path} answered ${res.status} and GET ${changesPath} is byte-identical to what it was before — the write reported success and stored nothing` });
1037
1040
  }
@@ -1051,13 +1054,13 @@ export async function walk(journey, root, collect, opts) {
1051
1054
  collect.push({ step: step.name, beat: 'ECHO (read back)', detail: e.message });
1052
1055
  continue;
1053
1056
  }
1054
- const back = await send({ method: 'GET', headers: stepHeaders(step) }, readPath);
1057
+ const back = await send({ method: 'GET', headers: stepHeaders(step), credentials: step.credentials }, readPath);
1055
1058
  if (back.status < 200 || back.status >= 300)
1056
1059
  collect.push({ step: step.name, beat: 'ECHO (read back)', detail: `GET ${readPath} answered ${back.status} right after the create` });
1057
1060
  else
1058
1061
  for (const f of echoFailures(body, payload(back.body), except))
1059
1062
  collect.push({ step: step.name, beat: 'ECHO (read back)', detail: `${f} — reading it back at ${readPath}` });
1060
- created.push({ step: step.name, readBack: readPath, sent: body, except, at: step.creates.at });
1063
+ created.push({ step: step.name, readBack: readPath, sent: body, except, at: step.creates.at, credentials: step.credentials });
1061
1064
  }
1062
1065
  }
1063
1066
  finally {
@@ -1067,7 +1070,7 @@ export async function walk(journey, root, collect, opts) {
1067
1070
  // PERSISTS — after the whole walk, everything created early must still be there.
1068
1071
  if (opts.beats) {
1069
1072
  for (const c of created) {
1070
- const back = await send({ method: 'GET' }, c.readBack);
1073
+ const back = await send({ method: 'GET', credentials: c.credentials }, c.readBack);
1071
1074
  if (back.status < 200 || back.status >= 300) {
1072
1075
  collect.push({ step: c.step, beat: 'PERSISTS', detail: `GET ${c.readBack} answered ${back.status} at the END of the walk — it was created and did not survive` });
1073
1076
  continue;
@@ -1118,7 +1121,7 @@ export async function withPinnedWorldDraws(fn) {
1118
1121
  let n = 0;
1119
1122
  globalThis.crypto.getRandomValues = ((array) => {
1120
1123
  const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
1121
- if (!/world-core\/src\//.test(caller))
1124
+ if (!/world-core[\\/](?:src|dist)[\\/]/.test(caller))
1122
1125
  return real(array);
1123
1126
  if (array) {
1124
1127
  const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
@@ -1129,7 +1132,7 @@ export async function withPinnedWorldDraws(fn) {
1129
1132
  });
1130
1133
  globalThis.crypto.subtle.generateKey = ((algorithm, extractable, usages) => {
1131
1134
  const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
1132
- if (!/world-core\/src\//.test(caller))
1135
+ if (!/world-core[\\/](?:src|dist)[\\/]/.test(caller))
1133
1136
  return generate(algorithm, extractable, usages);
1134
1137
  const shape = JSON.stringify([algorithm, extractable, usages]);
1135
1138
  const draw = keyDraws.get(shape) ?? 0;
@@ -33,6 +33,8 @@ export type JourneyStep = {
33
33
  * one of `body`, `bodyBase64` and `form`. */
34
34
  form?: JourneyForm;
35
35
  headers?: Record<string, string>;
36
+ /** Browser Fetch cookie mode; defaults to same-origin. Passed explicitly, independent of server Request getters. */
37
+ credentials?: 'omit' | 'same-origin' | 'include';
36
38
  /** The status this step must answer. Defaults to "any 2xx". */
37
39
  status?: number;
38
40
  /** Values for later steps: name → a dot path into the answer, `text:<pattern>` for a value read off a page's or mail's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
5
5
  "keywords": [
6
6
  "twin",
@@ -75,7 +75,7 @@
75
75
  "yaml": "2.9.0"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/world-core": "3.0.45",
78
+ "@volter/world-core": "3.0.47",
79
79
  "@volter/world-runtime": "*",
80
80
  "playwright": "^1.63.0"
81
81
  },
@@ -48,17 +48,18 @@ export async function chromiumServed(packDir: string, host: string) {
48
48
  await page.goto(harness);
49
49
  await page.clock.setFixedTime(syncClock());
50
50
  return {
51
- fetch: async (request: Request) => {
51
+ scheme: 'https',
52
+ fetch: async (request: Request, options?: { credentials?: RequestCredentials }) => {
52
53
  await page.clock.setFixedTime(syncClock());
53
54
  const body = request.body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null;
54
- const result = await page.evaluate(async ({ url, method, headers, body }) => {
55
+ const result = await page.evaluate(async ({ url, method, headers, body, credentials }) => {
55
56
  const bytes = body === null ? undefined : Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
56
- const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials: 'include' });
57
+ const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials });
57
58
  const data = new Uint8Array(await response.arrayBuffer());
58
59
  let text = '';
59
60
  for (let i = 0; i < data.length; i += 32768) text += String.fromCharCode(...data.subarray(i, i + 32768));
60
61
  return { status: response.status, headers: [...response.headers], body: btoa(text) };
61
- }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body });
62
+ }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body, credentials: options?.credentials ?? 'same-origin' });
62
63
  if (!result.status) throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
63
64
  return new Response([101, 204, 205, 304].includes(result.status) || request.method === 'HEAD' ? null : Buffer.from(result.body, 'base64'), { status: result.status, headers: result.headers });
64
65
  },
@@ -9,6 +9,8 @@ import { inMemoryWorld } from './walk/behavior-journey.ts';
9
9
  import { chromiumServed } from './browser-assessment.ts';
10
10
  const require = createRequire(import.meta.url);
11
11
  export async function verifyBrowserTransport(pack: string) {
12
+ const started = performance.now();
13
+ const progress = (phase: string) => console.log(JSON.stringify({ purpose: 'browser-transport-fixture', phase, elapsedMs: Math.round(performance.now() - started) }));
12
14
  assert.ok(process.env.VOLTER_WORLD, 'browser transport verification must run through a World');
13
15
  assert.ok(!existsSync(pack), 'browser fixture directory must be new and task-owned');
14
16
 
@@ -23,6 +25,8 @@ export async function verifyBrowserTransport(pack: string) {
23
25
  { id: 'head', method: 'head', path: '/head', class: 'get' },
24
26
  { id: 'gap', method: 'get', path: '/gap', class: 'get' },
25
27
  { id: 'unused', method: 'get', path: '/unused', class: 'get' },
28
+ { id: 'publicCrossOrigin', method: 'get', path: '/public', class: 'get' },
29
+ { id: 'credentialedCrossOrigin', method: 'get', path: '/credentialed', class: 'get' },
26
30
  ] }, handlers: {
27
31
  login: async () => new Response(null, { status: 204, headers: [['set-cookie', 'session=fixture; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=3600'], ['set-cookie', 'second=fixture; Path=/; Secure; SameSite=Strict; Expires=Thu, 01 Jan 2026 01:00:00 GMT']] }),
28
32
  identity: async ({ request }) => {
@@ -32,6 +36,8 @@ export async function verifyBrowserTransport(pack: string) {
32
36
  },
33
37
  binary: async () => new Response(new Uint8Array(100000).fill(197), { headers: { 'content-type': 'application/octet-stream' } }),
34
38
  head: async () => new Response(null, { status: 204 }),
39
+ publicCrossOrigin: async () => Response.json({ public: true }, { headers: { 'access-control-allow-origin': '*' } }),
40
+ credentialedCrossOrigin: async () => Response.json({ credentialed: true }, { headers: { 'access-control-allow-origin': 'https://browser-fixture.test', 'access-control-allow-credentials': 'true' } }),
35
41
  }, gap: async () => Response.json({ error: 'unsupported' }, { status: 404 }) });
36
42
  }
37
43
  `);
@@ -43,6 +49,7 @@ export async function verifyBrowserTransport(pack: string) {
43
49
  const replays = [];
44
50
  try {
45
51
  for (let run = 0; run < 2; run++) {
52
+ progress(`replay-${run + 1}:start`);
46
53
  setClock('2026-01-01T00:00:00.000Z');
47
54
  const served = await browser.served(world.root(), {});
48
55
  try {
@@ -56,6 +63,7 @@ export async function verifyBrowserTransport(pack: string) {
56
63
  setClock('2026-01-01T01:00:01.000Z');
57
64
  const expired = await (await fetch('/identity')).json();
58
65
  assert.deepEqual(expired, { authenticated: false, session: false, second: false }, 'native Expires and Max-Age must follow World time');
66
+ progress(`replay-${run + 1}:cookies`);
59
67
  const binary = new Uint8Array(await (await fetch('/binary')).arrayBuffer());
60
68
  assert.equal(binary.byteLength, 100000);
61
69
  assert.ok(binary.every((byte) => byte === 197));
@@ -63,12 +71,20 @@ export async function verifyBrowserTransport(pack: string) {
63
71
  assert.equal(head.status, 204); assert.equal(await head.text(), '');
64
72
  assert.equal((await fetch('/gap')).status, 404);
65
73
  assert.equal((await fetch('/unknown')).status, 404);
74
+ progress(`replay-${run + 1}:responses`);
66
75
  await assert.rejects(() => served.fetch(new Request('https://another-origin.test/identity')), /Failed to fetch/);
76
+ progress(`replay-${run + 1}:cors-denied`);
77
+ assert.deepEqual(await (await served.fetch(new Request('https://another-origin.test/public'))).json(), { public: true }, 'anonymous cross-origin fetch accepts a wildcard permission');
78
+ progress(`replay-${run + 1}:anonymous-cors`);
79
+ await assert.rejects(() => served.fetch(new Request('https://another-origin.test/public'), { credentials: 'include' }), /Failed to fetch/, 'credentialed fetch refuses wildcard permission');
80
+ progress(`replay-${run + 1}:credentialed-wildcard-denied`);
81
+ assert.deepEqual(await (await served.fetch(new Request('https://another-origin.test/credentialed'), { credentials: 'include' })).json(), { credentialed: true }, 'credentialed fetch requires matching origin and credential permission');
82
+ progress(`replay-${run + 1}:complete`);
67
83
  replays.push({ identity, expired, binaryBytes: binary.byteLength, headStatus: head.status, gapStatus: 404 });
68
84
  } finally { await served.stop(); }
69
85
  }
70
86
  assert.deepEqual(replays[0], replays[1]);
71
- assert.deepEqual([...seen].sort(), ['binary', 'gap', 'head', 'identity', 'login']);
72
- return { purpose: 'browser-transport-fixture', passed: true, chromium: browser.version, replays: 2, exercisedOperations: seen.size, declaredOperations: 6, cookieJar: true, cookieExpiry: ['Expires', 'Max-Age'], clock: browser.clock, binaryBytes: 100000, corsEnforced: true, domCoverage: null };
87
+ assert.deepEqual([...seen].sort(), ['binary', 'credentialedCrossOrigin', 'gap', 'head', 'identity', 'login', 'publicCrossOrigin']);
88
+ return { purpose: 'browser-transport-fixture', passed: true, chromium: browser.version, replays: 2, exercisedOperations: seen.size, declaredOperations: 8, cookieJar: true, cookieExpiry: ['Expires', 'Max-Age'], clock: browser.clock, binaryBytes: 100000, corsEnforced: true, credentialModes: true, domCoverage: null };
73
89
  } finally { unsubscribe(); try { await browser.stop(); } finally { world.restore(); } }
74
90
  }
@@ -168,7 +168,7 @@ function takeReplies(c: Connection): LineReply[] {
168
168
  /** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root and scenario its options):
169
169
  * the walk's requests go to it over HTTP, the vendor's host in their Host header, instead of to the fetch in process;
170
170
  * its sockets are WebSockets to that port, and a line connection (`stream`) is a TCP connection to its protocol port. */
171
- export type ServedTwin = (root: string, options: { scenarioPath?: string; application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ fetch: (request: Request) => Promise<Response>; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
171
+ export type ServedTwin = (root: string, options: { scenarioPath?: string; application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ scheme?: 'http' | 'https'; fetch: (request: Request, options?: { credentials?: RequestCredentials }) => Promise<Response>; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
172
172
 
173
173
  export async function walk(journey: Journey, root: string, collect: Failure[], opts: { beats: boolean; verbose?: boolean; hooks?: StepHooks; captured?: Captured; served?: ServedTwin }): Promise<Captured> {
174
174
  attributionName(journey.author);
@@ -223,6 +223,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
223
223
  const twin = served ? served.fetch : factory(root, { ...(scenarioPath ? { scenarioPath } : {}), application });
224
224
  const shape = await shapeOf(journey.vendor);
225
225
  const host = journey.host ?? 'twin.local';
226
+ const origin = `${served?.scheme ?? 'http'}://${host}`;
226
227
  // what an earlier walk captured, when this one continues it (the branch round-trip sends a life's write again)
227
228
  const captured: Captured = { ...(opts.captured ?? {}) };
228
229
  // a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
@@ -231,9 +232,9 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
231
232
  captured[name] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v);
232
233
  typedValues[name] = v;
233
234
  };
234
- const created: Array<{ step: string; readBack: string; sent: unknown; except: string[]; at?: string }> = [];
235
+ const created: Array<{ step: string; readBack: string; sent: unknown; except: string[]; at?: string; credentials?: JourneyStep['credentials'] }> = [];
235
236
 
236
- const send = async (step: Pick<JourneyStep, 'method' | 'body' | 'bodyBase64' | 'headers' | 'encode'> & { formed?: { contentType: string; bytes: Uint8Array<ArrayBuffer> } }, path: string, signal?: AbortSignal): Promise<Answer> => {
237
+ const send = async (step: Pick<JourneyStep, 'method' | 'body' | 'bodyBase64' | 'headers' | 'encode' | 'credentials'> & { formed?: { contentType: string; bytes: Uint8Array<ArrayBuffer> } }, path: string, signal?: AbortSignal): Promise<Answer> => {
237
238
  // raw bytes carry only the type the step names (a client that sends none, Dub's base64 image upload, sends none); a
238
239
  // form its own multipart type and boundary
239
240
  const headers = { ...(step.formed ? { 'content-type': step.formed.contentType } : step.bodyBase64 === undefined ? { 'content-type': defaultType(step.body) } : {}), ...(journey.headers ?? {}), ...(step.headers ?? {}) };
@@ -247,7 +248,11 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
247
248
  // a step may follow an absolute url an answer gave it (a hosted page's), as the application does
248
249
  // a twin that throws answers 500, as a server would: the walk reports the step and goes on to the next
249
250
  let res: Response;
250
- try { res = await (companionOf(path)?.fetch ?? twin)(new Request(/^https?:\/\//.test(path) ? path : `http://${host}${path}`, init)); }
251
+ try {
252
+ const request = new Request(/^https?:\/\//.test(path) ? path : `${origin}${path}`, init);
253
+ const companion = companionOf(path);
254
+ res = companion ? await companion.fetch(request) : served ? await served.fetch(request, { credentials: step.credentials ?? 'same-origin' }) : await twin(request);
255
+ }
251
256
  catch (error) { res = new Response(`twin threw: ${error instanceof Error ? error.message : String(error)}`, { status: 500 }); }
252
257
  const text = await res.text();
253
258
  let body: unknown; try { body = parseExactJson(text); } catch { body = text; }
@@ -255,7 +260,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
255
260
  if (typeof body === 'string' && /xml/.test(res.headers.get('content-type') ?? '')) body = xmlMembers(text) ?? text;
256
261
  // a hosted flow answers with a redirect: its target and query are what a step captures (redirect.code)
257
262
  const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
258
- if (location) body = { location, redirect: Object.fromEntries(new URL(location, `http://${host}`).searchParams) };
263
+ if (location) body = { location, redirect: Object.fromEntries(new URL(location, origin).searchParams) };
259
264
  // every cookie an answer sets, joined as one header: a sign-in sets several, and a map keeps only the last
260
265
  const cookies = res.headers.getSetCookie();
261
266
  return { status: res.status, body, text, headers: { ...Object.fromEntries(res.headers), ...(cookies.length ? { 'set-cookie': cookies.join(', ') } : {}) } };
@@ -379,7 +384,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
379
384
  opts.hooks?.before?.(step, where, files);
380
385
  let answered: { status: number; body: unknown } = { status: 0, body: undefined };
381
386
  try {
382
- const base = `http://${host}${where}`;
387
+ const base = `${origin}${where}`;
383
388
  const res = step.git === 'clone'
384
389
  ? await gitClone(twin as Parameters<typeof gitClone>[0], base, { ...(step.ref ? { ref: step.ref } : {}) }, headers)
385
390
  : await gitPush(twin as Parameters<typeof gitPush>[0], base, { ref: step.ref ?? 'refs/heads/main', ...(files ? { files } : {}), ...(step.message ? { message: step.message } : {}), ...(step.author ? { author: step.author } : {}), ...(step.amend ? { amend: true } : {}), ...(step.delete ? { delete: true } : {}) }, headers);
@@ -625,7 +630,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
625
630
  // an OAuth 1.0a step is signed as its client signs it, over the URL it sends to, at the World clock's instant
626
631
  if (step.oauth1) {
627
632
  const o = parseExactJson(fill(JSON.stringify(step.oauth1), captured)) as NonNullable<JourneyStep['oauth1']>;
628
- const url = /^https?:\/\//.test(path) ? path : `http://${host}${path}`;
633
+ const url = /^https?:\/\//.test(path) ? path : `${origin}${path}`;
629
634
  // a form is signed as it is sent: its multipart type, whose parts OAuth 1.0a's base string never carries
630
635
  const sent = formed ? undefined : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
631
636
  const contentType = Object.entries(headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1] ?? (formed ? formed.contentType : step.bodyBase64 === undefined ? defaultType(body) : undefined);
@@ -635,7 +640,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
635
640
  if (step.jwt) headers = { ...(headers ?? {}), authorization: jwtAuthorization(step.jwt, captured) };
636
641
  // an AWS SDK's request is signed as it sends it, at the World clock's instant
637
642
  if (step.sigv4) {
638
- const url = /^https?:\/\//.test(path) ? path : `http://${host}${path}`;
643
+ const url = /^https?:\/\//.test(path) ? path : `${origin}${path}`;
639
644
  // the bytes and the type sent: a form's multipart body and boundary, raw bytes, or the body's text
640
645
  const sent = formed ? formed.bytes : step.bodyBase64 !== undefined ? Uint8Array.from(Buffer.from(step.bodyBase64, 'base64')) : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
641
646
  const instant = clockFile && store.exists(clockFile) ? Date.parse((store.read(clockFile) ?? '').trim()) : Number.NaN;
@@ -673,7 +678,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
673
678
  let changesPath: string | undefined;
674
679
  if (step.changes && opts.beats) {
675
680
  try { changesPath = fill(step.changes, captured); } catch (e) { collect.push({ step: step.name, beat: 'LANDED', detail: (e as Error).message }); }
676
- if (changesPath) beforeChange = (await send({ method: 'GET', headers: stepHeaders(step) }, changesPath)).text;
681
+ if (changesPath) beforeChange = (await send({ method: 'GET', headers: stepHeaders(step), credentials: step.credentials }, changesPath)).text;
677
682
  }
678
683
  const failuresBefore = collect.length;
679
684
  opts.hooks?.before?.(step, path, body);
@@ -727,7 +732,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
727
732
 
728
733
  // LANDED beat, part two: a write that answered success must have moved something.
729
734
  if (changesPath !== undefined && beforeChange !== undefined) {
730
- const after = await send({ method: 'GET', headers: stepHeaders(step) }, changesPath);
735
+ const after = await send({ method: 'GET', headers: stepHeaders(step), credentials: step.credentials }, changesPath);
731
736
  if (after.text === beforeChange) {
732
737
  collect.push({ step: step.name, beat: 'LANDED', detail: `${step.method} ${path} answered ${res.status} and GET ${changesPath} is byte-identical to what it was before — the write reported success and stored nothing` });
733
738
  }
@@ -741,10 +746,10 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
741
746
  }
742
747
  let readPath: string;
743
748
  try { readPath = fill(step.creates.readBack, captured); } catch (e) { collect.push({ step: step.name, beat: 'ECHO (read back)', detail: (e as Error).message }); continue; }
744
- const back = await send({ method: 'GET', headers: stepHeaders(step) }, readPath);
749
+ const back = await send({ method: 'GET', headers: stepHeaders(step), credentials: step.credentials }, readPath);
745
750
  if (back.status < 200 || back.status >= 300) collect.push({ step: step.name, beat: 'ECHO (read back)', detail: `GET ${readPath} answered ${back.status} right after the create` });
746
751
  else for (const f of echoFailures(body, payload(back.body), except)) collect.push({ step: step.name, beat: 'ECHO (read back)', detail: `${f} — reading it back at ${readPath}` });
747
- created.push({ step: step.name, readBack: readPath, sent: body, except, at: step.creates.at });
752
+ created.push({ step: step.name, readBack: readPath, sent: body, except, at: step.creates.at, credentials: step.credentials });
748
753
  }
749
754
  } finally {
750
755
  opts.hooks?.after?.(step, path, answered, collect.slice(failuresBefore));
@@ -754,7 +759,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
754
759
  // PERSISTS — after the whole walk, everything created early must still be there.
755
760
  if (opts.beats) {
756
761
  for (const c of created) {
757
- const back = await send({ method: 'GET' }, c.readBack);
762
+ const back = await send({ method: 'GET', credentials: c.credentials }, c.readBack);
758
763
  if (back.status < 200 || back.status >= 300) { collect.push({ step: c.step, beat: 'PERSISTS', detail: `GET ${c.readBack} answered ${back.status} at the END of the walk — it was created and did not survive` }); continue; }
759
764
  for (const f of echoFailures(c.sent, c.at ? at(back.body, c.at) : back.body, c.except)) collect.push({ step: c.step, beat: 'PERSISTS', detail: `${f} — at the END of the walk` });
760
765
  }
@@ -793,13 +798,13 @@ export async function withPinnedWorldDraws<T>(fn: () => Promise<T>): Promise<T>
793
798
  let n = 0;
794
799
  globalThis.crypto.getRandomValues = (<A extends ArrayBufferView | null>(array: A): A => {
795
800
  const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
796
- if (!/world-core\/src\//.test(caller)) return real(array as ArrayBufferView) as A;
801
+ if (!/world-core[\\/](?:src|dist)[\\/]/.test(caller)) return real(array as ArrayBufferView) as A;
797
802
  if (array) { const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength); for (let i = 0; i < bytes.length; i++) bytes[i] = (n++ * 2654435761 + 97) >>> 24 & 0xff; }
798
803
  return array;
799
804
  }) as typeof globalThis.crypto.getRandomValues;
800
805
  globalThis.crypto.subtle.generateKey = ((algorithm: AlgorithmIdentifier, extractable: boolean, usages: KeyUsage[]) => {
801
806
  const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
802
- if (!/world-core\/src\//.test(caller)) return generate(algorithm as RsaHashedKeyGenParams, extractable, usages);
807
+ if (!/world-core[\\/](?:src|dist)[\\/]/.test(caller)) return generate(algorithm as RsaHashedKeyGenParams, extractable, usages);
803
808
  const shape = JSON.stringify([algorithm, extractable, usages]);
804
809
  const draw = keyDraws.get(shape) ?? 0;
805
810
  keyDraws.set(shape, draw + 1);
@@ -72,6 +72,8 @@ export type JourneyStep = {
72
72
  * one of `body`, `bodyBase64` and `form`. */
73
73
  form?: JourneyForm;
74
74
  headers?: Record<string, string>;
75
+ /** Browser Fetch cookie mode; defaults to same-origin. Passed explicitly, independent of server Request getters. */
76
+ credentials?: 'omit' | 'same-origin' | 'include';
75
77
  /** The status this step must answer. Defaults to "any 2xx". */
76
78
  status?: number;
77
79
  /** Values for later steps: name → a dot path into the answer, `text:<pattern>` for a value read off a page's or mail's