@volter/twin-standard 1.0.62 → 1.0.64

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.
@@ -109,10 +109,10 @@ export async function assessPack(packDir, options = {}) {
109
109
  const httpOnly = life.steps.every((step) => 'method' in step || 'wait' in step);
110
110
  if (options.browser && httpOnly) {
111
111
  const { chromiumServed } = await import("./browser-assessment.js");
112
- const transport = await chromiumServed(packDir, life.host ?? 'twin.local');
112
+ const transport = await chromiumServed(packDir, life.host ?? 'twin.local', 'http-journey');
113
113
  try {
114
114
  const a = await replay(transport.served), b = await replay(transport.served);
115
- browser = { measured: true, target: 'Chromium fetch, cookie jar and HTTP response behavior; no DOM interaction coverage', version: transport.version, answered: a.trace.length, failures: [...a.failures, ...b.failures], replay: { equal: a.trace.length > 0 && json(a.trace) === json(b.trace), firstSha256: hash(a.trace), secondSha256: hash(b.trace), runs: 2 }, operationCoverage: coverageOf(a), domCoverage: null, clock: transport.clock };
115
+ browser = { measured: true, target: 'HTTP customer journey replay through Chromium at each request origin; authored HTTP headers and observed wire responses', responseObservation: 'Transport observes status, headers and body, including redirects and Set-Cookie; these are not JavaScript-visible response claims', applicationOriginCorsCoverage: null, cookieJarCoverage: null, version: transport.version, answered: a.trace.length, failures: [...a.failures, ...b.failures], replay: { equal: a.trace.length > 0 && json(a.trace) === json(b.trace), firstSha256: hash(a.trace), secondSha256: hash(b.trace), runs: 2 }, operationCoverage: coverageOf(a), domCoverage: null, clock: transport.clock };
116
116
  }
117
117
  finally {
118
118
  await transport.stop();
@@ -1,5 +1,5 @@
1
1
  import type { ServedTwin } from './walk/behavior-journey.js';
2
- export declare function chromiumServed(packDir: string, host: string): Promise<{
2
+ export declare function chromiumServed(packDir: string, host: string, mode?: 'browser-fetch' | 'http-journey'): Promise<{
3
3
  served: ServedTwin;
4
4
  version: string;
5
5
  clock: {
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { worldNow } from '@volter/world-core/runtime';
14
- export async function chromiumServed(packDir, host) {
14
+ export async function chromiumServed(packDir, host, mode = 'browser-fetch') {
15
15
  const { chromium } = await import('playwright');
16
16
  const mod = await import(__rewriteRelativeImportExtension(join(packDir, 'src/index.ts')));
17
17
  const factory = Object.entries(mod).find(([name, value]) => /^create\w+Fetch$/.test(name) && !/TwinFetch$/.test(name) && typeof value === 'function')?.[1];
@@ -43,11 +43,12 @@ export async function chromiumServed(packDir, host) {
43
43
  syncClock();
44
44
  const fetchPack = factory({ root, ...options });
45
45
  const context = await browser.newContext({ serviceWorkers: 'block', locale: 'en-US', timezoneId: 'UTC' });
46
- const page = await context.newPage();
47
- const harness = `https://${host}/__catalog_assessment__`;
46
+ const pages = [];
47
+ const pending = new Map();
48
+ const harnesses = new Set();
48
49
  await context.route('**/*', async (route) => {
49
50
  const request = route.request();
50
- if (request.url() === harness) {
51
+ if (request.isNavigationRequest() && harnesses.has(request.url())) {
51
52
  await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Catalog assessment</title>' });
52
53
  return;
53
54
  }
@@ -55,8 +56,12 @@ export async function chromiumServed(packDir, host) {
55
56
  await route.abort('blockedbyclient');
56
57
  return;
57
58
  }
59
+ const observation = pending.get(request.frame().page());
58
60
  try {
59
- const response = await fetchPack(new Request(request.url(), { method: request.method(), headers: await request.allHeaders(), ...(request.postDataBuffer() ? { body: new Uint8Array(request.postDataBuffer()).buffer } : {}) }));
61
+ if (mode === 'http-journey' && !observation)
62
+ throw new Error('HTTP journey request has no authored input');
63
+ // HTTP transcripts explicitly name actors and headers; strict browser checks use the native wire instead.
64
+ const response = await fetchPack(new Request(request.url(), { method: request.method(), headers: mode === 'http-journey' ? observation.request.headers : await request.allHeaders(), ...(request.postDataBuffer() ? { body: new Uint8Array(request.postDataBuffer()).buffer } : {}) }));
60
65
  const headers = Object.fromEntries(response.headers);
61
66
  // Playwright otherwise adds CORS permission to cross-origin fulfillments. Absence means denial on the vendor wire.
62
67
  if (!Object.hasOwn(headers, 'access-control-allow-origin'))
@@ -64,31 +69,65 @@ export async function chromiumServed(packDir, host) {
64
69
  const cookies = response.headers.getSetCookie();
65
70
  if (cookies.length)
66
71
  headers['set-cookie'] = cookies.join('\n');
67
- await route.fulfill({ status: response.status, headers, body: Buffer.from(await response.arrayBuffer()) });
72
+ const bytes = Buffer.from(await response.arrayBuffer());
73
+ if (observation)
74
+ observation.response = new Response(request.method() === 'HEAD' || [204, 205, 304].includes(response.status) ? null : bytes, { status: response.status, headers: response.headers });
75
+ await route.fulfill({ status: response.status, headers, body: bytes });
68
76
  }
69
- catch {
77
+ catch (error) {
78
+ if (observation)
79
+ observation.error = error;
70
80
  await route.abort('failed');
71
81
  }
72
82
  });
73
- await page.goto(harness);
74
- await page.clock.setFixedTime(syncClock());
83
+ const acquire = async (origin) => {
84
+ const available = pages.find((entry) => entry.origin === origin && !entry.busy);
85
+ if (available) {
86
+ available.busy = true;
87
+ return available;
88
+ }
89
+ const page = await context.newPage();
90
+ const entry = { page, origin, busy: true };
91
+ pages.push(entry);
92
+ const harness = `${origin}/__catalog_assessment__`;
93
+ harnesses.add(harness);
94
+ await page.goto(harness);
95
+ return entry;
96
+ };
75
97
  return {
76
98
  scheme: 'https',
77
99
  fetch: async (request, options) => {
78
- await page.clock.setFixedTime(syncClock());
79
- const body = request.body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null;
80
- const result = await page.evaluate(async ({ url, method, headers, body, credentials }) => {
81
- const bytes = body === null ? undefined : Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
82
- const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials });
83
- const data = new Uint8Array(await response.arrayBuffer());
84
- let text = '';
85
- for (let i = 0; i < data.length; i += 32768)
86
- text += String.fromCharCode(...data.subarray(i, i + 32768));
87
- return { status: response.status, headers: [...response.headers], body: btoa(text) };
88
- }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body, credentials: options?.credentials ?? 'same-origin' });
89
- if (!result.status)
90
- throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
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 });
100
+ const entry = await acquire(mode === 'http-journey' ? new URL(request.url).origin : `https://${host}`);
101
+ const { page } = entry;
102
+ const observation = { request };
103
+ pending.set(page, observation);
104
+ try {
105
+ await page.clock.setFixedTime(syncClock());
106
+ const body = request.body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null;
107
+ const result = await page.evaluate(async ({ url, method, headers, body, credentials }) => {
108
+ const bytes = body === null ? undefined : Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
109
+ const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials });
110
+ const data = new Uint8Array(await response.arrayBuffer());
111
+ let text = '';
112
+ for (let i = 0; i < data.length; i += 32768)
113
+ text += String.fromCharCode(...data.subarray(i, i + 32768));
114
+ return { status: response.status, headers: [...response.headers], body: btoa(text) };
115
+ }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body, credentials: options?.credentials ?? 'same-origin' });
116
+ if (mode === 'http-journey') {
117
+ if (observation.error)
118
+ throw observation.error;
119
+ if (!observation.response)
120
+ throw new Error('Chromium request produced no observed HTTP response');
121
+ return observation.response;
122
+ }
123
+ if (!result.status)
124
+ throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
125
+ 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 });
126
+ }
127
+ finally {
128
+ pending.delete(page);
129
+ entry.busy = false;
130
+ }
92
131
  },
93
132
  stop: () => context.close(),
94
133
  };
@@ -16,5 +16,12 @@ export declare function verifyBrowserTransport(pack: string): Promise<{
16
16
  binaryBytes: number;
17
17
  corsEnforced: boolean;
18
18
  credentialModes: boolean;
19
+ httpJourney: {
20
+ perRequestOrigin: boolean;
21
+ authoredActorHeaders: boolean;
22
+ redirectCaptures: boolean;
23
+ setCookieCaptures: boolean;
24
+ dispatchExactlyOnce: boolean;
25
+ };
19
26
  domCoverage: null;
20
27
  }>;
@@ -26,9 +26,12 @@ export async function verifyBrowserTransport(pack) {
26
26
  { id: 'unused', method: 'get', path: '/unused', class: 'get' },
27
27
  { id: 'publicCrossOrigin', method: 'get', path: '/public', class: 'get' },
28
28
  { id: 'credentialedCrossOrigin', method: 'get', path: '/credentialed', class: 'get' },
29
+ { id: 'redirect', method: 'post', path: '/redirect', class: 'action' },
29
30
  ] }, handlers: {
30
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']] }),
31
32
  identity: async ({ request }) => {
33
+ const actor = request.headers.get('cookie')?.match(/actor=([^;]+)/)?.[1];
34
+ if (actor) return Response.json({ actor, origin: request.headers.get('origin') });
32
35
  const session = request.headers.get('cookie')?.includes('session=fixture') || false;
33
36
  const second = request.headers.get('cookie')?.includes('second=fixture') || false;
34
37
  return Response.json({ authenticated: session && second, session, second });
@@ -37,6 +40,7 @@ export async function verifyBrowserTransport(pack) {
37
40
  head: async () => new Response(null, { status: 204 }),
38
41
  publicCrossOrigin: async () => Response.json({ public: true }, { headers: { 'access-control-allow-origin': '*' } }),
39
42
  credentialedCrossOrigin: async () => Response.json({ credentialed: true }, { headers: { 'access-control-allow-origin': 'https://browser-fixture.test', 'access-control-allow-credentials': 'true' } }),
43
+ redirect: async () => new Response(null, { status: 302, headers: [['location', 'https://console-fixture.test/identity'], ['set-cookie', 'actor=second; Path=/; Secure; HttpOnly'], ['set-cookie', 'redirect=fixture; Path=/; Secure']] }),
40
44
  }, gap: async () => Response.json({ error: 'unsupported' }, { status: 404 }) });
41
45
  }
42
46
  `);
@@ -46,6 +50,9 @@ export async function verifyBrowserTransport(pack) {
46
50
  const seen = new Set();
47
51
  const unsubscribe = observeDerivedDispatch(({ operation }) => seen.add(operation.id));
48
52
  const replays = [];
53
+ let redirects = 0;
54
+ const stopRedirects = observeDerivedDispatch(({ operation }) => { if (operation.id === 'redirect')
55
+ redirects++; });
49
56
  try {
50
57
  for (let run = 0; run < 2; run++) {
51
58
  progress(`replay-${run + 1}:start`);
@@ -79,6 +86,7 @@ export async function verifyBrowserTransport(pack) {
79
86
  await assert.rejects(() => served.fetch(new Request('https://another-origin.test/public'), { credentials: 'include' }), /Failed to fetch/, 'credentialed fetch refuses wildcard permission');
80
87
  progress(`replay-${run + 1}:credentialed-wildcard-denied`);
81
88
  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');
89
+ await assert.rejects(() => fetch('/redirect', 'POST'), /opaque response/, 'strict Fetch must not expose HTTP redirect captures');
82
90
  progress(`replay-${run + 1}:complete`);
83
91
  replays.push({ identity, expired, binaryBytes: binary.byteLength, headStatus: head.status, gapStatus: 404 });
84
92
  }
@@ -87,10 +95,42 @@ export async function verifyBrowserTransport(pack) {
87
95
  }
88
96
  }
89
97
  assert.deepEqual(replays[0], replays[1]);
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 };
98
+ setClock('2026-01-01T00:00:00.000Z');
99
+ progress('http-journey:launch');
100
+ const http = await chromiumServed(pack, 'browser-fixture.test', 'http-journey');
101
+ progress('http-journey:launched');
102
+ try {
103
+ for (let run = 0; run < 2; run++) {
104
+ progress(`http-journey-${run + 1}:start`);
105
+ setClock('2026-01-01T00:00:00.000Z');
106
+ const served = await http.served(world.root(), {});
107
+ try {
108
+ const redirect = await served.fetch(new Request('https://api-fixture.test/redirect', { method: 'POST' }));
109
+ progress(`http-journey-${run + 1}:redirect`);
110
+ assert.equal(redirect.status, 302);
111
+ assert.equal(redirect.headers.get('location'), 'https://console-fixture.test/identity');
112
+ assert.equal(redirect.headers.getSetCookie().length, 2, 'HTTP observations retain all Set-Cookie headers');
113
+ for (const actor of ['first', 'second', 'first']) {
114
+ const response = await served.fetch(new Request('https://console-fixture.test/identity', { headers: { cookie: `actor=${actor}`, origin: 'https://authored-app.test' } }));
115
+ assert.deepEqual(await response.json(), { actor, origin: 'https://authored-app.test' }, 'HTTP transcripts retain authored actor and origin headers');
116
+ }
117
+ assert.equal((await served.fetch(new Request('https://third-fixture.test/unknown'))).status, 404, 'changing hosts does not erase real HTTP gaps');
118
+ progress(`http-journey-${run + 1}:complete`);
119
+ }
120
+ finally {
121
+ await served.stop();
122
+ }
123
+ }
124
+ }
125
+ finally {
126
+ await http.stop();
127
+ }
128
+ assert.equal(redirects, 4, 'each strict or HTTP redirect request executes exactly once');
129
+ assert.deepEqual([...seen].sort(), ['binary', 'credentialedCrossOrigin', 'gap', 'head', 'identity', 'login', 'publicCrossOrigin', 'redirect']);
130
+ return { purpose: 'browser-transport-fixture', passed: true, chromium: browser.version, replays: 2, exercisedOperations: seen.size, declaredOperations: 9, cookieJar: true, cookieExpiry: ['Expires', 'Max-Age'], clock: browser.clock, binaryBytes: 100000, corsEnforced: true, credentialModes: true, httpJourney: { perRequestOrigin: true, authoredActorHeaders: true, redirectCaptures: true, setCookieCaptures: true, dispatchExactlyOnce: true }, domCoverage: null };
92
131
  }
93
132
  finally {
133
+ stopRedirects();
94
134
  unsubscribe();
95
135
  try {
96
136
  await browser.stop();
@@ -26,7 +26,7 @@ export declare function packIndexFile(dir: string, vendor: string): string;
26
26
  * itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
27
27
  * (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
28
28
  * the unit's proto is derived. */
29
- export declare function serverFile(vendor: string, grpc: boolean): string;
29
+ export declare function serverFile(vendor: string, grpc: boolean, wire?: boolean): string;
30
30
  /** A pack's tsconfig.json, a fixed file: what the grade's typecheck (soundness) compiles, the pack and its lanes. */
31
31
  export declare const TSCONFIG: string;
32
32
  /** The fixed files of a new pack, by path, for a vendor: what nobody should write by hand. */
@@ -336,10 +336,15 @@ export function packIndexFile(dir, vendor) {
336
336
  * itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
337
337
  * (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
338
338
  * the unit's proto is derived. */
339
- export function serverFile(vendor, grpc) {
339
+ export function serverFile(vendor, grpc, wire = false) {
340
340
  const Name = pascal(vendor);
341
341
  const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
342
342
  const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
343
+ if (grpc && wire) {
344
+ // a unit with a wire of its own beside its API (./stream.ts: the PlanetScale twin's MySQL protocol) serves it on a side
345
+ // port, `wirePort`, which the manifest's sidePorts declares
346
+ return `${head}import { grpc, serveStream, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\nimport { create${Name}TwinStream } from './stream.ts';\n\nexport async function create${Name}TwinServer(options: PackFetchOptions & { port?: number; wirePort?: number } = {}): Promise<{ port: number; wirePort: number; stop: () => void }> {\n const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n const server = await grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n // the unit's own wire beside its API, on its side port (the manifest's sidePorts: \`wirePort\`)\n const wire = await serveStream({ port: options.wirePort ?? 0, stream: create${Name}TwinStream(options) });\n return { port: server.port, wirePort: wire.port, stop: () => { wire.stop(); server.stop(); } };\n}\n`;
347
+ }
343
348
  if (grpc) {
344
349
  return `${head}import { grpc, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n return grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n}\n`;
345
350
  }
@@ -541,7 +546,7 @@ export async function createPack(args) {
541
546
  // a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form,
542
547
  // the one form a unit takes after --init (a server.ts of a wire of the pack's own, SMTP's, is never written over)
543
548
  if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
544
- writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true));
549
+ writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, existsSync(join(dir, 'src', 'stream.ts'))));
545
550
  console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
546
551
  }
547
552
  }
@@ -8,7 +8,7 @@ import { attributionName } from "../verdict.js";
8
8
  // score-pack walks the life through it twice, on two fresh roots, and compares what they answer.
9
9
  //
10
10
  // The caller names the pack repository first (usePacks).
11
- import { redis } from '@volter/world-core';
11
+ import { mysql, redis } from '@volter/world-core';
12
12
  import { gzipSync } from 'node:zlib';
13
13
  import { parse, visit } from 'graphql';
14
14
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
@@ -16,7 +16,7 @@ import { join } from 'node:path';
16
16
  import { databaseSession, getActiveWorldStore, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActiveWorldStore, setDeliveryObserver, setInProcessTwins } from '@volter/world-core/runtime';
17
17
  import { createHostTable } from '@volter/world-core/host-routing';
18
18
  import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
19
- import { isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
19
+ import { isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
20
20
  import { lanesOf, lineShape, shapeChecker } from "./shape.js";
21
21
  import { xmlMembers } from "./xml-view.js";
22
22
  /** The pack repository the walk finds a vendor's pack in, set by the caller before its first walk. */
@@ -472,6 +472,79 @@ export async function walk(journey, root, collect, opts) {
472
472
  opts.hooks?.after?.(step, step.connection, answered, collect.slice(before));
473
473
  }
474
474
  };
475
+ // A MySQL step speaks the pack's MySQL wire as an application's driver does (@volter/world-core/mysql's client), on the
476
+ // same byte-stream adapter the owned TCP listener serves.
477
+ const mysqlConnections = new Map();
478
+ const mysqlStep = async (step) => {
479
+ const before = collect.length;
480
+ let answered = { status: 0, body: undefined };
481
+ const link = step.link ?? 'mysql';
482
+ opts.hooks?.before?.(step, link, step.mysql ?? '(sign in)');
483
+ try {
484
+ let body;
485
+ if (step.signIn) {
486
+ if (!served?.stream)
487
+ streams ??= await vendorStream(packsDir(), journey.vendor);
488
+ const factory = served?.stream ?? streams?.(root, {});
489
+ if (!factory)
490
+ throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
491
+ let out = [];
492
+ const conn = factory({ write: (b) => { out.push(...b); }, end: () => { } }, '127.0.0.1');
493
+ await conn.settled();
494
+ const greeting = Uint8Array.from(out);
495
+ out = [];
496
+ const exchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
497
+ const signed = await mysql.mysqlSignIn(exchange, greeting, fill(step.signIn.user, captured), fill(step.signIn.password, captured), step.signIn.database ? fill(step.signIn.database, captured) : null);
498
+ if ('error' in signed)
499
+ body = { ...signed.error };
500
+ else
501
+ mysqlConnections.set(link, { exchange, close: () => conn.close() });
502
+ }
503
+ if (!body && step.mysql !== undefined) {
504
+ const c = mysqlConnections.get(link);
505
+ if (!c)
506
+ throw new Error(`no MySQL connection "${link}" is open`);
507
+ const sql = fill(step.mysql, captured);
508
+ const result = step.values ? await mysql.mysqlExecute(c.exchange, sql, step.values.map((v) => (v === null ? null : fill(v, captured)))) : await mysql.mysqlQuery(c.exchange, sql);
509
+ body = 'error' in result ? { ...result.error }
510
+ : 'columns' in result ? { columns: result.columns.map((col) => col.name), rows: result.rows.map((row) => Object.fromEntries(result.columns.map((col, i) => [col.name, row[i] ?? null]))) }
511
+ : { ...result };
512
+ }
513
+ body ??= { signedIn: true };
514
+ const errno = typeof body.errno === 'number' ? body.errno : undefined;
515
+ answered = { status: errno ?? 0, body };
516
+ if (opts.verbose)
517
+ console.log(` ${String(errno ?? 'OK').padEnd(4)} ${link.padEnd(6)} ${(step.mysql ?? '(sign in)').slice(0, 70).padEnd(70)} ${step.name}`);
518
+ if (step.refused !== undefined ? errno !== step.refused : errno !== undefined) {
519
+ collect.push({ step: step.name, beat: 'STATUS', detail: errno !== undefined ? `MySQL refused it: ${errno} ${String(body.message)}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MySQL completed it, wanted ${step.refused}` });
520
+ return;
521
+ }
522
+ for (const [name, dot] of Object.entries(step.capture ?? {})) {
523
+ const value = captureAt(body, dot);
524
+ if (value === undefined || value === null)
525
+ collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
526
+ else
527
+ remember(name, value);
528
+ }
529
+ try {
530
+ for (const detail of expectFailures(step.expect ?? [], body, captured))
531
+ collect.push({ step: step.name, beat: 'EXPECT', detail });
532
+ }
533
+ catch (e) {
534
+ collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
535
+ }
536
+ if (step.close) {
537
+ mysqlConnections.get(link)?.close();
538
+ mysqlConnections.delete(link);
539
+ }
540
+ }
541
+ catch (e) {
542
+ collect.push({ step: step.name, beat: 'MYSQL', detail: e.message });
543
+ }
544
+ finally {
545
+ opts.hooks?.after?.(step, link, answered, collect.slice(before));
546
+ }
547
+ };
475
548
  // A git step is a git client's clone or push on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts); its answer is
476
549
  // checked as a request's is (status, captures, expectations).
477
550
  const gitStep = async (step) => {
@@ -824,6 +897,10 @@ export async function walk(journey, root, collect, opts) {
824
897
  try {
825
898
  for (const step of journey.steps) {
826
899
  stepNow = step.name;
900
+ if (isMysql(step)) {
901
+ await mysqlStep(step);
902
+ continue;
903
+ }
827
904
  if (isResp(step)) {
828
905
  await respStep(step);
829
906
  continue;
@@ -226,7 +226,29 @@ export type Journey = {
226
226
  }) => boolean;
227
227
  steps: Step[];
228
228
  };
229
- export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep;
229
+ export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
230
+ /** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
231
+ * application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
232
+ * password (mysql_native_password); `mysql` sends a statement on it: a text query, or a prepared statement when
233
+ * `values` binds its `?` placeholders (binary parameters and rows, as Prisma sends one). Its answer is the rows, each
234
+ * its columns by name in MySQL's text format (`rows`, `columns`), or `{ affectedRows, insertId }`, or MySQL's refusal
235
+ * `{ errno, sqlstate, message }`, which fails the step unless `refused` names its errno. */
236
+ export type MysqlStep = {
237
+ name: string;
238
+ mysql?: string;
239
+ values?: Array<string | null>;
240
+ link?: string;
241
+ signIn?: {
242
+ user: string;
243
+ password: string;
244
+ database?: string;
245
+ };
246
+ close?: boolean;
247
+ refused?: number;
248
+ capture?: JourneyStep['capture'];
249
+ expect?: JourneyStep['expect'];
250
+ };
251
+ export declare const isMysql: (step: Step) => step is MysqlStep;
230
252
  /** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
231
253
  * Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
232
254
  * `read` drains pushes or a blocking command's answer without sending bytes. */
@@ -197,6 +197,7 @@ export function expandFills(journey) {
197
197
  });
198
198
  return steps.some((x, i) => x !== journey.steps[i]) ? { ...journey, steps } : journey;
199
199
  }
200
+ export const isMysql = (step) => 'mysql' in step || 'signIn' in step;
200
201
  export const isResp = (step) => 'connection' in step;
201
202
  export const isLine = (step) => 'on' in step;
202
203
  export const isSocket = (step) => 'socket' in step;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.62",
3
+ "version": "1.0.64",
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.70",
78
+ "@volter/world-core": "3.0.72",
79
79
  "@volter/world-runtime": "*",
80
80
  "playwright": "^1.63.0"
81
81
  },
package/src/assessment.ts CHANGED
@@ -81,10 +81,10 @@ export async function assessPack(packDir: string, options: AssessmentOptions = {
81
81
  const httpOnly = life.steps.every((step) => 'method' in step || 'wait' in step);
82
82
  if (options.browser && httpOnly) {
83
83
  const { chromiumServed } = await import('./browser-assessment.ts');
84
- const transport = await chromiumServed(packDir, life.host ?? 'twin.local');
84
+ const transport = await chromiumServed(packDir, life.host ?? 'twin.local', 'http-journey');
85
85
  try {
86
86
  const a = await replay(transport.served), b = await replay(transport.served);
87
- browser = { measured: true, target: 'Chromium fetch, cookie jar and HTTP response behavior; no DOM interaction coverage', version: transport.version, answered: a.trace.length, failures: [...a.failures, ...b.failures], replay: { equal: a.trace.length > 0 && json(a.trace) === json(b.trace), firstSha256: hash(a.trace), secondSha256: hash(b.trace), runs: 2 }, operationCoverage: coverageOf(a), domCoverage: null, clock: transport.clock };
87
+ browser = { measured: true, target: 'HTTP customer journey replay through Chromium at each request origin; authored HTTP headers and observed wire responses', responseObservation: 'Transport observes status, headers and body, including redirects and Set-Cookie; these are not JavaScript-visible response claims', applicationOriginCorsCoverage: null, cookieJarCoverage: null, version: transport.version, answered: a.trace.length, failures: [...a.failures, ...b.failures], replay: { equal: a.trace.length > 0 && json(a.trace) === json(b.trace), firstSha256: hash(a.trace), secondSha256: hash(b.trace), runs: 2 }, operationCoverage: coverageOf(a), domCoverage: null, clock: transport.clock };
88
88
  } finally { await transport.stop(); }
89
89
  }
90
90
  const conform = options.conformance === false ? null : await conformance(packDir);
@@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { worldNow } from '@volter/world-core/runtime';
6
6
  import type { ServedTwin } from './walk/behavior-journey.ts';
7
- export async function chromiumServed(packDir: string, host: string) {
7
+ export async function chromiumServed(packDir: string, host: string, mode: 'browser-fetch' | 'http-journey' = 'browser-fetch') {
8
8
  const { chromium } = await import('playwright');
9
9
  const mod = await import(join(packDir, 'src/index.ts')) as Record<string, unknown>;
10
10
  const factory = Object.entries(mod).find(([name, value]) => /^create\w+Fetch$/.test(name) && !/TwinFetch$/.test(name) && typeof value === 'function')?.[1] as ((options: import('@volter/world-core').PackFetchOptions) => (request: Request) => Promise<Response>) | undefined;
@@ -29,39 +29,66 @@ export async function chromiumServed(packDir: string, host: string) {
29
29
  syncClock();
30
30
  const fetchPack = factory({ root, ...options });
31
31
  const context = await browser.newContext({ serviceWorkers: 'block', locale: 'en-US', timezoneId: 'UTC' });
32
- const page = await context.newPage();
33
- const harness = `https://${host}/__catalog_assessment__`;
32
+ type Page = Awaited<ReturnType<typeof context.newPage>>;
33
+ const pages: Array<{ page: Page; origin: string; busy: boolean }> = [];
34
+ const pending = new Map<Page, { request: Request; response?: Response; error?: unknown }>();
35
+ const harnesses = new Set<string>();
34
36
  await context.route('**/*', async (route) => {
35
37
  const request = route.request();
36
- if (request.url() === harness) { await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Catalog assessment</title>' }); return; }
38
+ if (request.isNavigationRequest() && harnesses.has(request.url())) { await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Catalog assessment</title>' }); return; }
37
39
  if (!['xhr', 'fetch'].includes(request.resourceType())) { await route.abort('blockedbyclient'); return; }
40
+ const observation = pending.get(request.frame().page());
38
41
  try {
39
- const response = await fetchPack(new Request(request.url(), { method: request.method(), headers: await request.allHeaders(), ...(request.postDataBuffer() ? { body: new Uint8Array(request.postDataBuffer()!).buffer } : {}) }));
42
+ if (mode === 'http-journey' && !observation) throw new Error('HTTP journey request has no authored input');
43
+ // HTTP transcripts explicitly name actors and headers; strict browser checks use the native wire instead.
44
+ const response = await fetchPack(new Request(request.url(), { method: request.method(), headers: mode === 'http-journey' ? observation!.request.headers : await request.allHeaders(), ...(request.postDataBuffer() ? { body: new Uint8Array(request.postDataBuffer()!).buffer } : {}) }));
40
45
  const headers = Object.fromEntries(response.headers);
41
46
  // Playwright otherwise adds CORS permission to cross-origin fulfillments. Absence means denial on the vendor wire.
42
47
  if (!Object.hasOwn(headers, 'access-control-allow-origin')) headers['access-control-allow-origin'] = '';
43
48
  const cookies = response.headers.getSetCookie();
44
49
  if (cookies.length) headers['set-cookie'] = cookies.join('\n');
45
- await route.fulfill({ status: response.status, headers, body: Buffer.from(await response.arrayBuffer()) });
46
- } catch { await route.abort('failed'); }
50
+ const bytes = Buffer.from(await response.arrayBuffer());
51
+ if (observation) observation.response = new Response(request.method() === 'HEAD' || [204, 205, 304].includes(response.status) ? null : bytes, { status: response.status, headers: response.headers });
52
+ await route.fulfill({ status: response.status, headers, body: bytes });
53
+ } catch (error) { if (observation) observation.error = error; await route.abort('failed'); }
47
54
  });
48
- await page.goto(harness);
49
- await page.clock.setFixedTime(syncClock());
55
+ const acquire = async (origin: string) => {
56
+ const available = pages.find((entry) => entry.origin === origin && !entry.busy);
57
+ if (available) { available.busy = true; return available; }
58
+ const page = await context.newPage();
59
+ const entry = { page, origin, busy: true };
60
+ pages.push(entry);
61
+ const harness = `${origin}/__catalog_assessment__`;
62
+ harnesses.add(harness);
63
+ await page.goto(harness);
64
+ return entry;
65
+ };
50
66
  return {
51
67
  scheme: 'https',
52
68
  fetch: async (request: Request, options?: { credentials?: RequestCredentials }) => {
53
- await page.clock.setFixedTime(syncClock());
54
- const body = request.body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null;
55
- const result = await page.evaluate(async ({ url, method, headers, body, credentials }) => {
56
- const bytes = body === null ? undefined : Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
57
- const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials });
58
- const data = new Uint8Array(await response.arrayBuffer());
59
- let text = '';
60
- for (let i = 0; i < data.length; i += 32768) text += String.fromCharCode(...data.subarray(i, i + 32768));
61
- return { status: response.status, headers: [...response.headers], body: btoa(text) };
62
- }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body, credentials: options?.credentials ?? 'same-origin' });
63
- if (!result.status) throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
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 });
69
+ const entry = await acquire(mode === 'http-journey' ? new URL(request.url).origin : `https://${host}`);
70
+ const { page } = entry;
71
+ const observation = { request } as { request: Request; response?: Response; error?: unknown };
72
+ pending.set(page, observation);
73
+ try {
74
+ await page.clock.setFixedTime(syncClock());
75
+ const body = request.body ? Buffer.from(await request.arrayBuffer()).toString('base64') : null;
76
+ const result = await page.evaluate(async ({ url, method, headers, body, credentials }) => {
77
+ const bytes = body === null ? undefined : Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
78
+ const response = await fetch(url, { method, headers, body: bytes, redirect: 'manual', credentials });
79
+ const data = new Uint8Array(await response.arrayBuffer());
80
+ let text = '';
81
+ for (let i = 0; i < data.length; i += 32768) text += String.fromCharCode(...data.subarray(i, i + 32768));
82
+ return { status: response.status, headers: [...response.headers], body: btoa(text) };
83
+ }, { url: request.url, method: request.method, headers: Object.fromEntries(request.headers), body, credentials: options?.credentials ?? 'same-origin' });
84
+ if (mode === 'http-journey') {
85
+ if (observation.error) throw observation.error;
86
+ if (!observation.response) throw new Error('Chromium request produced no observed HTTP response');
87
+ return observation.response;
88
+ }
89
+ if (!result.status) throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
90
+ 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 });
91
+ } finally { pending.delete(page); entry.busy = false; }
65
92
  },
66
93
  stop: () => context.close(),
67
94
  };
@@ -27,9 +27,12 @@ export async function verifyBrowserTransport(pack: string) {
27
27
  { id: 'unused', method: 'get', path: '/unused', class: 'get' },
28
28
  { id: 'publicCrossOrigin', method: 'get', path: '/public', class: 'get' },
29
29
  { id: 'credentialedCrossOrigin', method: 'get', path: '/credentialed', class: 'get' },
30
+ { id: 'redirect', method: 'post', path: '/redirect', class: 'action' },
30
31
  ] }, handlers: {
31
32
  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']] }),
32
33
  identity: async ({ request }) => {
34
+ const actor = request.headers.get('cookie')?.match(/actor=([^;]+)/)?.[1];
35
+ if (actor) return Response.json({ actor, origin: request.headers.get('origin') });
33
36
  const session = request.headers.get('cookie')?.includes('session=fixture') || false;
34
37
  const second = request.headers.get('cookie')?.includes('second=fixture') || false;
35
38
  return Response.json({ authenticated: session && second, session, second });
@@ -38,6 +41,7 @@ export async function verifyBrowserTransport(pack: string) {
38
41
  head: async () => new Response(null, { status: 204 }),
39
42
  publicCrossOrigin: async () => Response.json({ public: true }, { headers: { 'access-control-allow-origin': '*' } }),
40
43
  credentialedCrossOrigin: async () => Response.json({ credentialed: true }, { headers: { 'access-control-allow-origin': 'https://browser-fixture.test', 'access-control-allow-credentials': 'true' } }),
44
+ redirect: async () => new Response(null, { status: 302, headers: [['location', 'https://console-fixture.test/identity'], ['set-cookie', 'actor=second; Path=/; Secure; HttpOnly'], ['set-cookie', 'redirect=fixture; Path=/; Secure']] }),
41
45
  }, gap: async () => Response.json({ error: 'unsupported' }, { status: 404 }) });
42
46
  }
43
47
  `);
@@ -47,6 +51,8 @@ export async function verifyBrowserTransport(pack: string) {
47
51
  const seen = new Set<string>();
48
52
  const unsubscribe = observeDerivedDispatch(({ operation }) => seen.add(operation.id));
49
53
  const replays = [];
54
+ let redirects = 0;
55
+ const stopRedirects = observeDerivedDispatch(({ operation }) => { if (operation.id === 'redirect') redirects++; });
50
56
  try {
51
57
  for (let run = 0; run < 2; run++) {
52
58
  progress(`replay-${run + 1}:start`);
@@ -79,12 +85,38 @@ export async function verifyBrowserTransport(pack: string) {
79
85
  await assert.rejects(() => served.fetch(new Request('https://another-origin.test/public'), { credentials: 'include' }), /Failed to fetch/, 'credentialed fetch refuses wildcard permission');
80
86
  progress(`replay-${run + 1}:credentialed-wildcard-denied`);
81
87
  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');
88
+ await assert.rejects(() => fetch('/redirect', 'POST'), /opaque response/, 'strict Fetch must not expose HTTP redirect captures');
82
89
  progress(`replay-${run + 1}:complete`);
83
90
  replays.push({ identity, expired, binaryBytes: binary.byteLength, headStatus: head.status, gapStatus: 404 });
84
91
  } finally { await served.stop(); }
85
92
  }
86
93
  assert.deepEqual(replays[0], replays[1]);
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 };
89
- } finally { unsubscribe(); try { await browser.stop(); } finally { world.restore(); } }
94
+ setClock('2026-01-01T00:00:00.000Z');
95
+ progress('http-journey:launch');
96
+ const http = await chromiumServed(pack, 'browser-fixture.test', 'http-journey');
97
+ progress('http-journey:launched');
98
+ try {
99
+ for (let run = 0; run < 2; run++) {
100
+ progress(`http-journey-${run + 1}:start`);
101
+ setClock('2026-01-01T00:00:00.000Z');
102
+ const served = await http.served(world.root(), {});
103
+ try {
104
+ const redirect = await served.fetch(new Request('https://api-fixture.test/redirect', { method: 'POST' }));
105
+ progress(`http-journey-${run + 1}:redirect`);
106
+ assert.equal(redirect.status, 302);
107
+ assert.equal(redirect.headers.get('location'), 'https://console-fixture.test/identity');
108
+ assert.equal(redirect.headers.getSetCookie().length, 2, 'HTTP observations retain all Set-Cookie headers');
109
+ for (const actor of ['first', 'second', 'first']) {
110
+ const response = await served.fetch(new Request('https://console-fixture.test/identity', { headers: { cookie: `actor=${actor}`, origin: 'https://authored-app.test' } }));
111
+ assert.deepEqual(await response.json(), { actor, origin: 'https://authored-app.test' }, 'HTTP transcripts retain authored actor and origin headers');
112
+ }
113
+ assert.equal((await served.fetch(new Request('https://third-fixture.test/unknown'))).status, 404, 'changing hosts does not erase real HTTP gaps');
114
+ progress(`http-journey-${run + 1}:complete`);
115
+ } finally { await served.stop(); }
116
+ }
117
+ } finally { await http.stop(); }
118
+ assert.equal(redirects, 4, 'each strict or HTTP redirect request executes exactly once');
119
+ assert.deepEqual([...seen].sort(), ['binary', 'credentialedCrossOrigin', 'gap', 'head', 'identity', 'login', 'publicCrossOrigin', 'redirect']);
120
+ return { purpose: 'browser-transport-fixture', passed: true, chromium: browser.version, replays: 2, exercisedOperations: seen.size, declaredOperations: 9, cookieJar: true, cookieExpiry: ['Expires', 'Max-Age'], clock: browser.clock, binaryBytes: 100000, corsEnforced: true, credentialModes: true, httpJourney: { perRequestOrigin: true, authoredActorHeaders: true, redirectCaptures: true, setCookieCaptures: true, dispatchExactlyOnce: true }, domCoverage: null };
121
+ } finally { stopRedirects(); unsubscribe(); try { await browser.stop(); } finally { world.restore(); } }
90
122
  }
@@ -324,10 +324,15 @@ export function packIndexFile(dir: string, vendor: string): string {
324
324
  * itself: "Other wires"), or, for a unit whose spec is a proto, the gRPC seam over the schema derive-pack generated
325
325
  * (src/generated/proto.gen.json, imported by its literal path). `--init` writes the plain one, `--index` the gRPC one once
326
326
  * the unit's proto is derived. */
327
- export function serverFile(vendor: string, grpc: boolean): string {
327
+ export function serverFile(vendor: string, grpc: boolean, wire = false): string {
328
328
  const Name = pascal(vendor);
329
329
  const head = `// FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).\n`;
330
330
  const signature = `export async function create${Name}TwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {\n`;
331
+ if (grpc && wire) {
332
+ // a unit with a wire of its own beside its API (./stream.ts: the PlanetScale twin's MySQL protocol) serves it on a side
333
+ // port, `wirePort`, which the manifest's sidePorts declares
334
+ return `${head}import { grpc, serveStream, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\nimport { create${Name}TwinStream } from './stream.ts';\n\nexport async function create${Name}TwinServer(options: PackFetchOptions & { port?: number; wirePort?: number } = {}): Promise<{ port: number; wirePort: number; stop: () => void }> {\n const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n const server = await grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n // the unit's own wire beside its API, on its side port (the manifest's sidePorts: \`wirePort\`)\n const wire = await serveStream({ port: options.wirePort ?? 0, stream: create${Name}TwinStream(options) });\n return { port: server.port, wirePort: wire.port, stop: () => { wire.stop(); server.stop(); } };\n}\n`;
335
+ }
331
336
  if (grpc) {
332
337
  return `${head}import { grpc, type PackFetchOptions } from '@volter/world-core';\nimport schema from './generated/proto.gen.json' with { type: 'json' };\nimport { create${Name}Fetch } from './fetch.ts';\n\n${signature} const fetch = create${Name}Fetch(options);\n // the vendor speaks gRPC (its manifest's \`grpc\`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch\n return grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema: schema as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'] });\n}\n`;
333
338
  }
@@ -499,7 +504,7 @@ export async function createPack(args: string[]): Promise<void> {
499
504
  // a unit whose spec is a proto (derive-pack generated its schema) is served over gRPC: its serve seam in that form,
500
505
  // the one form a unit takes after --init (a server.ts of a wire of the pack's own, SMTP's, is never written over)
501
506
  if (!lanesOnly && existsSync(join(dir, 'src', 'generated', 'proto.gen.json'))) {
502
- writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true));
507
+ writeFileSync(join(dir, 'src', 'server.ts'), serverFile(vendor, true, existsSync(join(dir, 'src', 'stream.ts'))));
503
508
  console.log(`create-pack: wrote ${join(dir, 'src', 'server.ts')}`);
504
509
  }
505
510
  }
@@ -9,7 +9,7 @@ import { attributionName } from '../verdict.ts';
9
9
  //
10
10
  // The caller names the pack repository first (usePacks).
11
11
 
12
- import { redis } from '@volter/world-core';
12
+ import { mysql, redis } from '@volter/world-core';
13
13
  import { gzipSync } from 'node:zlib';
14
14
  import { parse, visit } from 'graphql';
15
15
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
@@ -17,7 +17,7 @@ import { join } from 'node:path';
17
17
  import { databaseSession, type DatabaseSession, getActiveWorldStore, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActiveWorldStore, setDeliveryObserver, setInProcessTwins, type InProcessTwin, type TwinStream, type TwinStreamConnection } from '@volter/world-core/runtime';
18
18
  import { createHostTable, type HostFacts } from '@volter/world-core/host-routing';
19
19
  import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
20
- import { type Captured, type GitStep, type Journey, type JourneyStep, type LineStep, type SocketStep, type SqlStep, type RespStep, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
20
+ import { type Captured, type GitStep, type Journey, type JourneyStep, type LineStep, type SocketStep, type SqlStep, type RespStep, type MysqlStep, isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
21
21
  import { lanesOf, lineShape, shapeChecker, type LineConnection, type ShapeChecker } from './shape.ts';
22
22
  import { xmlMembers } from './xml-view.ts';
23
23
 
@@ -369,6 +369,50 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
369
369
  finally { opts.hooks?.after?.(step, step.connection, answered, collect.slice(before)); }
370
370
  };
371
371
 
372
+ // A MySQL step speaks the pack's MySQL wire as an application's driver does (@volter/world-core/mysql's client), on the
373
+ // same byte-stream adapter the owned TCP listener serves.
374
+ const mysqlConnections = new Map<string, { exchange: mysql.MysqlExchange; close: () => void }>();
375
+ const mysqlStep = async (step: MysqlStep): Promise<void> => {
376
+ const before = collect.length; let answered: { status: number; body: unknown } = { status: 0, body: undefined };
377
+ const link = step.link ?? 'mysql';
378
+ opts.hooks?.before?.(step as never, link, step.mysql ?? '(sign in)');
379
+ try {
380
+ let body: Record<string, unknown> | undefined;
381
+ if (step.signIn) {
382
+ if (!served?.stream) streams ??= await vendorStream(packsDir(), journey.vendor);
383
+ const factory = served?.stream ?? streams?.(root, {}); if (!factory) throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
384
+ let out: number[] = [];
385
+ const conn = factory({ write: (b) => { out.push(...b); }, end: () => {} }, '127.0.0.1');
386
+ await conn.settled();
387
+ const greeting = Uint8Array.from(out); out = [];
388
+ const exchange: mysql.MysqlExchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
389
+ const signed = await mysql.mysqlSignIn(exchange, greeting, fill(step.signIn.user, captured), fill(step.signIn.password, captured), step.signIn.database ? fill(step.signIn.database, captured) : null);
390
+ if ('error' in signed) body = { ...signed.error }; else mysqlConnections.set(link, { exchange, close: () => conn.close() });
391
+ }
392
+ if (!body && step.mysql !== undefined) {
393
+ const c = mysqlConnections.get(link); if (!c) throw new Error(`no MySQL connection "${link}" is open`);
394
+ const sql = fill(step.mysql, captured);
395
+ const result = step.values ? await mysql.mysqlExecute(c.exchange, sql, step.values.map((v) => (v === null ? null : fill(v, captured)))) : await mysql.mysqlQuery(c.exchange, sql);
396
+ body = 'error' in result ? { ...result.error }
397
+ : 'columns' in result ? { columns: result.columns.map((col) => col.name), rows: result.rows.map((row) => Object.fromEntries(result.columns.map((col, i) => [col.name, row[i] ?? null]))) }
398
+ : { ...result };
399
+ }
400
+ body ??= { signedIn: true };
401
+ const errno = typeof body.errno === 'number' ? body.errno : undefined;
402
+ answered = { status: errno ?? 0, body };
403
+ if (opts.verbose) console.log(` ${String(errno ?? 'OK').padEnd(4)} ${link.padEnd(6)} ${(step.mysql ?? '(sign in)').slice(0, 70).padEnd(70)} ${step.name}`);
404
+ if (step.refused !== undefined ? errno !== step.refused : errno !== undefined) {
405
+ collect.push({ step: step.name, beat: 'STATUS', detail: errno !== undefined ? `MySQL refused it: ${errno} ${String(body.message)}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MySQL completed it, wanted ${step.refused}` });
406
+ return;
407
+ }
408
+ for (const [name, dot] of Object.entries(step.capture ?? {})) { const value = captureAt(body, dot); if (value === undefined || value === null) collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` }); else remember(name, value); }
409
+ try { for (const detail of expectFailures(step.expect ?? [], body, captured)) collect.push({ step: step.name, beat: 'EXPECT', detail }); }
410
+ catch (e) { collect.push({ step: step.name, beat: 'CAPTURE', detail: (e as Error).message }); }
411
+ if (step.close) { mysqlConnections.get(link)?.close(); mysqlConnections.delete(link); }
412
+ } catch (e) { collect.push({ step: step.name, beat: 'MYSQL', detail: (e as Error).message }); }
413
+ finally { opts.hooks?.after?.(step as never, link, answered, collect.slice(before)); }
414
+ };
415
+
372
416
  // A git step is a git client's clone or push on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts); its answer is
373
417
  // checked as a request's is (status, captures, expectations).
374
418
  const gitStep = async (step: GitStep): Promise<void> => {
@@ -602,6 +646,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
602
646
  try {
603
647
  for (const step of journey.steps) {
604
648
  stepNow = step.name;
649
+ if (isMysql(step)) { await mysqlStep(step); continue; }
605
650
  if (isResp(step)) { await respStep(step); continue; }
606
651
  if (isLine(step)) { await speak(step); continue; }
607
652
  if (isGit(step)) { await gitStep(step); continue; }
@@ -327,7 +327,20 @@ export type Journey = {
327
327
  steps: Step[];
328
328
  };
329
329
 
330
- export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep;
330
+ export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
331
+
332
+ /** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
333
+ * application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
334
+ * password (mysql_native_password); `mysql` sends a statement on it: a text query, or a prepared statement when
335
+ * `values` binds its `?` placeholders (binary parameters and rows, as Prisma sends one). Its answer is the rows, each
336
+ * its columns by name in MySQL's text format (`rows`, `columns`), or `{ affectedRows, insertId }`, or MySQL's refusal
337
+ * `{ errno, sqlstate, message }`, which fails the step unless `refused` names its errno. */
338
+ export type MysqlStep = {
339
+ name: string; mysql?: string; values?: Array<string | null>; link?: string;
340
+ signIn?: { user: string; password: string; database?: string }; close?: boolean;
341
+ refused?: number; capture?: JourneyStep['capture']; expect?: JourneyStep['expect'];
342
+ };
343
+ export const isMysql = (step: Step): step is MysqlStep => 'mysql' in step || 'signIn' in step;
331
344
 
332
345
  /** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
333
346
  * Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.