@volter/twin-standard 1.0.158 → 1.0.160
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/client-sdks/package.json +1 -0
- package/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/client-sdks/package.json +1 -0
- package/dist/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/src/assessment.d.ts +13 -3
- package/dist/src/assessment.js +10 -6
- package/dist/src/browser-assessment.js +16 -13
- package/dist/src/check-sources.js +2 -2
- package/dist/src/clients/harness.d.ts +25 -4
- package/dist/src/clients/harness.js +184 -22
- package/dist/src/clients/jira.js +11 -2
- package/dist/src/clients/resend.first-use.json +2 -2
- package/dist/src/clients/run.js +19 -14
- package/dist/src/conformance.d.ts +3 -4
- package/dist/src/conformance.js +396 -203
- package/dist/src/consumer.d.ts +22 -1
- package/dist/src/consumer.js +91 -26
- package/dist/src/create-pack.d.ts +1 -1
- package/dist/src/create-pack.js +13 -12
- package/dist/src/credential-issuer-import.d.ts +3 -0
- package/dist/src/credential-issuer-import.js +42 -0
- package/dist/src/derive.js +6 -3
- package/dist/src/gate.js +4 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/pack-facts.d.ts +3 -0
- package/dist/src/pack-facts.js +36 -1
- package/dist/src/protocol-3.js +13 -8
- package/dist/src/spec-documents.d.ts +4 -0
- package/dist/src/spec-documents.js +110 -3
- package/dist/src/spec-ir-client.js +1 -1
- package/dist/src/spec-ir-commands.js +1 -1
- package/dist/src/spec-ir-lines.js +1 -1
- package/dist/src/spec-ir-proto.d.ts +1 -1
- package/dist/src/spec-patches.js +2 -2
- package/dist/src/tools/judge-brief.js +1 -2
- package/dist/src/tools/life-coverage-walk.js +38 -26
- package/dist/src/tools/life-coverage.js +2 -2
- package/dist/src/tools/packs-root.js +3 -3
- package/dist/src/tools/score-pack.js +7 -7
- package/dist/src/tools/vendor-examples.js +95 -85
- package/dist/src/verdict.js +1 -1
- package/dist/src/walk/behavior-journey.d.ts +22 -14
- package/dist/src/walk/behavior-journey.js +199 -133
- package/dist/src/walk/journey-kit.d.ts +5 -4
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/replay.d.ts +23 -0
- package/dist/src/walk/replay.js +118 -0
- package/dist/src/walk/serve-pack.d.ts +2 -2
- package/dist/src/walk/serve-pack.js +44 -2
- package/dist/src/walk/shape.d.ts +26 -5
- package/dist/src/walk/shape.js +167 -57
- package/dist/src/walk/vendor-fetch.d.ts +7 -2
- package/dist/src/walk/vendor-fetch.js +13 -2
- package/dist/src/walk/walk-database.d.ts +9 -1
- package/dist/src/walk/walk-database.js +35 -13
- package/dist/src/walk/xml-view.d.ts +3 -2
- package/dist/src/walk/xml-view.js +5 -4
- package/package.json +6 -4
- package/src/assessment.ts +12 -8
- package/src/browser-assessment.ts +12 -5
- package/src/check-sources.ts +2 -2
- package/src/clients/harness.ts +142 -19
- package/src/clients/jira.ts +4 -2
- package/src/clients/resend.first-use.json +2 -2
- package/src/clients/run.ts +17 -15
- package/src/conformance.ts +317 -147
- package/src/consumer.ts +83 -21
- package/src/create-pack.ts +13 -12
- package/src/credential-issuer-import.ts +38 -0
- package/src/derive.ts +6 -4
- package/src/gate.ts +3 -0
- package/src/index.ts +1 -1
- package/src/lanes.ts +7 -0
- package/src/pack-facts.ts +25 -2
- package/src/protocol-3.ts +12 -8
- package/src/spec-documents.ts +92 -3
- package/src/spec-ir-client.ts +1 -1
- package/src/spec-ir-commands.ts +1 -1
- package/src/spec-ir-lines.ts +1 -1
- package/src/spec-ir-proto.ts +1 -1
- package/src/spec-patches.ts +2 -2
- package/src/tools/judge-brief.ts +1 -2
- package/src/tools/life-coverage-walk.ts +32 -22
- package/src/tools/life-coverage.ts +2 -2
- package/src/tools/packs-root.ts +3 -3
- package/src/tools/score-pack.ts +8 -8
- package/src/tools/vendor-examples.ts +92 -80
- package/src/verdict.ts +1 -1
- package/src/walk/behavior-journey.ts +158 -109
- package/src/walk/journey-kit.ts +9 -7
- package/src/walk/replay.ts +103 -0
- package/src/walk/serve-pack.ts +30 -5
- package/src/walk/shape.ts +157 -55
- package/src/walk/vendor-fetch.ts +16 -4
- package/src/walk/walk-database.ts +41 -15
- package/src/walk/xml-view.ts +5 -4
package/dist/src/conformance.js
CHANGED
|
@@ -6,7 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
import { hasOwnSurface } from "./lanes.js";
|
|
9
|
+
import { hasOwnSurface, unitsIn } from "./lanes.js";
|
|
10
10
|
// CONFORMANCE — the deterministic half of the Protocol 3 standard that executes the pack: the contract it serves, run the
|
|
11
11
|
// way an application calls it, and every rule a pack's author follows that a machine can decide (docs/contributing/
|
|
12
12
|
// architecture.md, "Conformance"). One command, the same for a contributor, the catalog's admission and its regular
|
|
@@ -19,28 +19,52 @@ import { hasOwnSurface } from "./lanes.js";
|
|
|
19
19
|
// judges (the life's plausibility) is the proof section's. Each check names the defect it was written against: one
|
|
20
20
|
// that escaped review once fails here when reintroduced.
|
|
21
21
|
import { spawn, spawnSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { createRequire } from 'node:module';
|
|
24
24
|
import { connect } from 'node:net';
|
|
25
25
|
import { tmpdir } from 'node:os';
|
|
26
26
|
import { basename, dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path';
|
|
27
27
|
// A path relative to another with '/' between its parts on every platform (Windows' relative() joins with '\').
|
|
28
28
|
const rel = (from, to) => relative(from, to).split(sep).join('/');
|
|
29
|
+
import { hostRules } from '@volter/world-core/host-routing';
|
|
30
|
+
import { twinEnvStem } from '@volter/world-core/vendor-hosts';
|
|
29
31
|
import { citations, sourceHeld } from "./check-sources.js";
|
|
30
32
|
import { fileURLToPath } from 'node:url';
|
|
31
|
-
import {
|
|
33
|
+
import { parseEnv } from 'node:util';
|
|
34
|
+
import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from "./clients/harness.js";
|
|
32
35
|
import { servePack as serve } from "./walk/serve-pack.js";
|
|
36
|
+
import { walkPackDirectory } from "./walk/pack-directory.js";
|
|
37
|
+
import { formBody, sigv4Headers } from "./walk/journey-kit.js";
|
|
38
|
+
import { consumerClientProof, consumerRecipe, runConsumerCommand } from "./consumer.js";
|
|
39
|
+
import { declaredResponseStatuses } from "./spec-documents.js";
|
|
33
40
|
const pascal = (s) => s.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
34
41
|
const readJson = (p) => (existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : undefined);
|
|
35
|
-
/**
|
|
36
|
-
function
|
|
37
|
-
return
|
|
42
|
+
/** Exercise the door's declared organization and deployment, as the World renderer does. */
|
|
43
|
+
function credentialDoorBody(door) {
|
|
44
|
+
return JSON.stringify({ ...door.body, ...(door.account ? { [door.account.field]: 'default' } : {}), ...(door.deployment ? { [door.deployment.field]: 'conformance' } : {}) });
|
|
38
45
|
}
|
|
39
46
|
const nameOf = (pack, unit) => rel(resolve(pack, '..'), unit);
|
|
47
|
+
/** A credential door's native successful object and nonempty string outputs, through the served HTTP owner. */
|
|
48
|
+
async function credentialFields(server, door, host) {
|
|
49
|
+
await server.managedDatabaseUrl?.();
|
|
50
|
+
const port = server.inspectPort ?? server.port;
|
|
51
|
+
const answer = await readHttpResponse(servedHttpFetch(port), new Request(`http://${host ?? `127.0.0.1:${port}`}${door.path}`, {
|
|
52
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'x-volter-world-activity': 'provisioning' }, body: credentialDoorBody(door),
|
|
53
|
+
}));
|
|
54
|
+
if (answer.bodyError)
|
|
55
|
+
throw new Error(`credential door ${door.path}: ${answer.bodyError.message}`);
|
|
56
|
+
if (answer.status < 200 || answer.status >= 300)
|
|
57
|
+
throw new Error(`credential door ${door.path} answered ${answer.status}`);
|
|
58
|
+
const { decodeResponse } = await import("./walk/shape.js");
|
|
59
|
+
const values = decodeResponse(answer.text, undefined);
|
|
60
|
+
if (!values || typeof values !== 'object' || Array.isArray(values))
|
|
61
|
+
throw new Error(`credential door ${door.path} supplies no JSON object`);
|
|
62
|
+
const fields = values;
|
|
63
|
+
const outputs = Object.fromEntries(Object.entries(door.fill).flatMap(([name, field]) => typeof fields[field] === 'string' && fields[field] ? [[name, fields[field]]] : []));
|
|
64
|
+
return { values: fields, outputs };
|
|
65
|
+
}
|
|
40
66
|
/** The files conformance reads from a unit: its manifest, its spec's record, its vendor's life and its decision table
|
|
41
|
-
* (a lane's journeys are its own `journeys/` or else its vendor's). A release publishes each (published())
|
|
42
|
-
* pack's standing counts it in the form only when every unit holds them (twin-world's scripts/pack-standing.ts, by
|
|
43
|
-
* unitFilesMissing): the one list of both. */
|
|
67
|
+
* (a lane's journeys are its own `journeys/` or else its vendor's). A release publishes each (published()). */
|
|
44
68
|
export const UNIT_FILES = ['src/manifest.ts', 'spec/SOURCE.md', 'journeys/customer-life.json', 'journeys/decisions.json'];
|
|
45
69
|
/** The published package must carry every conformance input its units hold, including lanes. */
|
|
46
70
|
export function unpublishedUnitFiles(unit) {
|
|
@@ -54,8 +78,6 @@ export function unpublishedUnitFiles(unit) {
|
|
|
54
78
|
return !patterns.some((p) => !p.exclude && matches(p.pattern)) || patterns.some((p) => p.exclude && matches(p.pattern));
|
|
55
79
|
}).map((f) => `${f} is absent from package.json files (conformance reads it)`);
|
|
56
80
|
}
|
|
57
|
-
/** Each of UNIT_FILES a unit lacks where conformance reads it: the manifest of every unit, the life at its vendor's root
|
|
58
|
-
* (lifeServed), the spec's record and the decision table of a unit with a spec (decided()). */
|
|
59
81
|
const trackedJourneySources = new Map();
|
|
60
82
|
/** Table ownership follows one index snapshot per checkout/run. */
|
|
61
83
|
function hasJourneySource(unit) {
|
|
@@ -91,17 +113,6 @@ function hasJourneySource(unit) {
|
|
|
91
113
|
const prefix = relative(checkout, dir).replaceAll('\\', '/') + '/';
|
|
92
114
|
return source.some(path => path.startsWith(prefix));
|
|
93
115
|
}
|
|
94
|
-
export function unitFilesMissing(unit) {
|
|
95
|
-
const vendor = existsSync(join(unit, 'package.json')) ? unit : dirname(unit);
|
|
96
|
-
const spec = hasOwnSurface(unit);
|
|
97
|
-
const at = {
|
|
98
|
-
'src/manifest.ts': unit,
|
|
99
|
-
'spec/SOURCE.md': spec ? unit : undefined,
|
|
100
|
-
'journeys/customer-life.json': vendor,
|
|
101
|
-
'journeys/decisions.json': spec ? (hasJourneySource(unit) ? unit : vendor) : undefined,
|
|
102
|
-
};
|
|
103
|
-
return UNIT_FILES.filter((f) => at[f] !== undefined && !existsSync(join(at[f], f))).map((f) => `${relative(unit, join(at[f], f))} is missing (conformance reads it)`);
|
|
104
|
-
}
|
|
105
116
|
/** A unit's fetch export: a pack's `create<Vendor>Fetch`, a lane's `create<Lane>LaneFetch`. */
|
|
106
117
|
const fetchName = (dir) => (existsSync(join(dir, 'package.json')) ? `create${pascal(basename(dir))}Fetch` : `create${pascal(basename(dir))}LaneFetch`);
|
|
107
118
|
const manifestOf = async (unit) => (await import(__rewriteRelativeImportExtension(join(unit, 'src', 'manifest.ts')))).manifest;
|
|
@@ -247,7 +258,7 @@ async function cited(pack) {
|
|
|
247
258
|
for (const [url, quotes] of await citations(unit)) {
|
|
248
259
|
const r = record[url];
|
|
249
260
|
if (!r) {
|
|
250
|
-
out.push(`${nameOf(pack, unit)}: ${url} is cited and not recorded (
|
|
261
|
+
out.push(`${nameOf(pack, unit)}: ${url} is cited and not recorded (twin-standard check-sources ${nameOf(pack, unit)})`);
|
|
251
262
|
continue;
|
|
252
263
|
}
|
|
253
264
|
if (!sourceHeld(url, r))
|
|
@@ -308,7 +319,6 @@ async function allowance(pack, runtime) {
|
|
|
308
319
|
}
|
|
309
320
|
return out;
|
|
310
321
|
}
|
|
311
|
-
const within = (ms, p) => Promise.race([p, new Promise((r) => setTimeout(() => r('timeout'), ms))]);
|
|
312
322
|
/** Served by its server.ts as a World runs it (its boot id set), the pack answers HTTP and the boot probe, and every socket
|
|
313
323
|
* its manifests declare takes an upgrade. Escaped twice: serveHttp never took the upgrade a pack's fetch carries, then
|
|
314
324
|
* read it off the boot probe's wrapper of the fetch, so OpenAI Realtime's /v1/realtime answered 404, first everywhere
|
|
@@ -320,35 +330,33 @@ async function standalone(pack) {
|
|
|
320
330
|
for (const s of (await manifestOf(unit)).sockets ?? [])
|
|
321
331
|
sockets.push({ unit: nameOf(pack, unit), path: s.path, ...(s.host ? { host: s.host } : {}) });
|
|
322
332
|
// served the way a World runs it: the boot id set, so the seam wraps the fetch with the World's boot probe
|
|
323
|
-
const
|
|
324
|
-
const before = env.VOLTER_WORLD_BOOT_ID;
|
|
325
|
-
env.VOLTER_WORLD_BOOT_ID = 'conformance';
|
|
326
|
-
let server;
|
|
333
|
+
const server = await serve(pack, {});
|
|
327
334
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
335
|
+
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
336
|
+
const probe = (path) => readHttpResponse(http, new Request(`http://127.0.0.1:${server.inspectPort ?? server.port}${path}`, { signal: AbortSignal.timeout(5000) }));
|
|
337
|
+
try {
|
|
338
|
+
const answer = await probe('/');
|
|
339
|
+
if (answer.bodyError)
|
|
340
|
+
out.push(`served as a World runs it, its HTTP response failed (${answer.bodyError.message})`);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
out.push(`served as a World runs it, it answers no HTTP request (${String(error)})`);
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const boot = await probe('/__volter/world-boot');
|
|
347
|
+
if (boot.bodyError || JSON.parse(boot.text).boot !== 'conformance')
|
|
348
|
+
throw new Error('missing boot identity');
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
342
351
|
out.push("served as a World runs it, it does not answer the World's boot probe (/__volter/world-boot)");
|
|
352
|
+
}
|
|
343
353
|
for (const s of sockets) {
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
const timer = setTimeout(() => { ws.close(); done('no upgrade in 5 s'); }, 5000);
|
|
347
|
-
ws.onopen = () => { clearTimeout(timer); ws.close(); done(undefined); };
|
|
348
|
-
ws.onerror = () => { clearTimeout(timer); done('the upgrade was refused'); };
|
|
349
|
-
});
|
|
354
|
+
const ws = new WebSocket(`ws://127.0.0.1:${server.port}${s.path}`, { headers: s.host ? { host: s.host } : {} });
|
|
355
|
+
const upgraded = await openWebSocket(ws);
|
|
350
356
|
if (upgraded)
|
|
351
|
-
|
|
357
|
+
ws.close();
|
|
358
|
+
else
|
|
359
|
+
out.push(`${s.unit}: the socket at ${s.host ?? ''}${s.path}: no upgrade in 5 s or the upgrade was refused`);
|
|
352
360
|
}
|
|
353
361
|
}
|
|
354
362
|
finally {
|
|
@@ -382,163 +390,392 @@ function sweepRequest(op, basePath, host, credential) {
|
|
|
382
390
|
headers.set('content-type', 'application/xml');
|
|
383
391
|
body = '';
|
|
384
392
|
}
|
|
385
|
-
else if (op.bodyEncoding === 'json'
|
|
393
|
+
else if (op.bodyEncoding === 'json') {
|
|
386
394
|
headers.set('content-type', 'application/json');
|
|
387
395
|
body = '{}';
|
|
388
396
|
}
|
|
397
|
+
else if (op.bodyEncoding === 'multipart') {
|
|
398
|
+
const formed = formBody({ parts: [] }, {});
|
|
399
|
+
headers.set('content-type', formed.contentType);
|
|
400
|
+
body = formed.bytes;
|
|
401
|
+
}
|
|
389
402
|
return new Request(url, { method, headers, ...(body !== undefined && !['GET', 'HEAD'].includes(method) ? { body } : {}) });
|
|
390
403
|
}
|
|
404
|
+
/** Obtain authorization through the existing customer recipe, with any declared issuer on the same World as the API. */
|
|
405
|
+
async function consumerAuthorization(pack, scratch, root, server, descriptor, outputs, claimsAuthorization, consumer) {
|
|
406
|
+
const vendor = (await manifestOf(pack)).vendor ?? basename(pack);
|
|
407
|
+
const issuer = descriptor.credentialIssuer;
|
|
408
|
+
const companion = issuer ? walkPackDirectory(dirname(pack), issuer, pack) : undefined;
|
|
409
|
+
if (issuer && !companion)
|
|
410
|
+
throw new Error(`${vendor}: its declared credential issuer ${issuer} is not installed`);
|
|
411
|
+
const issuerDoor = companion ? (await manifestOf(companion)).descriptor?.credentialDoor : undefined;
|
|
412
|
+
if (companion && !issuerDoor)
|
|
413
|
+
throw new Error(`${issuer}: its declared credential door is missing`);
|
|
414
|
+
const recipe = consumerRecipe(vendor, pack);
|
|
415
|
+
if (!consumer)
|
|
416
|
+
throw new Error(`${vendor}: native authorization requires its prepared installed customer application`);
|
|
417
|
+
mkdirSync(scratch, { recursive: true });
|
|
418
|
+
const appDir = consumer.appDir;
|
|
419
|
+
for (const [path, contents] of Object.entries(recipe.files))
|
|
420
|
+
if (path !== 'package.json' && path !== '.gitignore' && readFileSync(join(appDir, path), 'utf8') !== contents)
|
|
421
|
+
throw new Error(`${vendor}: its prepared customer file ${path} differs from its native first-use recipe`);
|
|
422
|
+
const proxies = [];
|
|
423
|
+
let servedIssuer;
|
|
424
|
+
let granted;
|
|
425
|
+
const { forwardableHeaders } = await import('@volter/world-runtime');
|
|
426
|
+
try {
|
|
427
|
+
const env = { ...outputs };
|
|
428
|
+
if (companion && issuerDoor) {
|
|
429
|
+
servedIssuer = await serve(companion, { root });
|
|
430
|
+
Object.assign(env, (await credentialFields(servedIssuer, issuerDoor)).outputs);
|
|
431
|
+
}
|
|
432
|
+
const credentialNames = new Set([...Object.keys(descriptor.credentialDoor?.fill ?? {}), ...Object.keys(issuerDoor?.fill ?? {})]);
|
|
433
|
+
for (const name of Object.keys(parseEnv(recipe.files['.env.example'])))
|
|
434
|
+
if (credentialNames.has(name) && !env[name])
|
|
435
|
+
throw new Error(`${vendor}: its native credential doors supplied no ${name}`);
|
|
436
|
+
const selectedProxy = await worldProxy(server.inspectPort ?? server.port, (request, response) => {
|
|
437
|
+
const url = new URL(request.url);
|
|
438
|
+
if (response.status >= 200 && response.status < 300 && !url.pathname.startsWith('/_twin/') && claimsAuthorization(url.hostname, url.pathname)) {
|
|
439
|
+
const headers = forwardableHeaders(request.headers);
|
|
440
|
+
for (const name of [...headers.keys()])
|
|
441
|
+
if (/^(host|forwarded|x-forwarded-.*|x-volter-.*|x-twins-key|proxy-.*|via|content-.*|digest)$/i.test(name))
|
|
442
|
+
headers.delete(name);
|
|
443
|
+
granted = { headers: Object.fromEntries(headers), url };
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
proxies.push(selectedProxy);
|
|
447
|
+
const selected = [{ vendor, dir: pack, port: selectedProxy.port }];
|
|
448
|
+
if (issuer && companion && servedIssuer) {
|
|
449
|
+
const issuerProxy = await worldProxy(servedIssuer.inspectPort ?? servedIssuer.port);
|
|
450
|
+
proxies.push(issuerProxy);
|
|
451
|
+
selected.push({ vendor: issuer, dir: companion, port: issuerProxy.port });
|
|
452
|
+
}
|
|
453
|
+
const { compilePackFacts, renderPackFacts } = await import("./pack-facts.js");
|
|
454
|
+
const facts = await compilePackFacts(selected.map(({ vendor, dir }) => ({ vendor, dir })));
|
|
455
|
+
if (facts.undeclared.length)
|
|
456
|
+
throw new Error('Customer authorization requires declared pack descriptors');
|
|
457
|
+
const packFacts = join(scratch, 'pack-facts.json');
|
|
458
|
+
writeFileSync(packFacts, renderPackFacts(facts));
|
|
459
|
+
const instance = join(scratch, 'instance.json');
|
|
460
|
+
writeFileSync(instance, JSON.stringify({ packFacts: [packFacts] }));
|
|
461
|
+
const routes = Object.fromEntries(selected.flatMap(({ vendor, port }) => (facts.packs[vendor]?.hosts ?? []).map((rule) => [rule.key ?? vendor, `http://127.0.0.1:${port}`])));
|
|
462
|
+
const endpoints = Object.fromEntries(Object.entries(routes).map(([key, url]) => [`${twinEnvStem(key)}_TWIN_URL`, url]));
|
|
463
|
+
const require = createRequire(join(pack, 'package.json'));
|
|
464
|
+
const inject = require.resolve('@volter/world-core/inject');
|
|
465
|
+
const result = await runConsumerCommand(recipe.run, appDir, {
|
|
466
|
+
...process.env, ...endpoints, ...env, VOLTER_WORLD_INSTANCE: instance,
|
|
467
|
+
TWIN_INJECT: Object.entries(routes).map(([name, url]) => `${name}=${url}`).join(','),
|
|
468
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(inject)}`.trim(),
|
|
469
|
+
VOLTER_TWIN_STRICT_EGRESS: '1',
|
|
470
|
+
}, AbortSignal.timeout(10_000));
|
|
471
|
+
if (result.exitCode !== 0)
|
|
472
|
+
throw new Error(`${vendor}: its native first-use command exited ${result.exitCode}`);
|
|
473
|
+
if (!granted)
|
|
474
|
+
throw new Error(`${vendor}: its native first-use command made no successful authorized request to the selected API`);
|
|
475
|
+
return granted;
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
const cleanup = await Promise.allSettled([...proxies.map((proxy) => proxy.stop()), ...(servedIssuer ? [Promise.resolve().then(() => servedIssuer.stop())] : [])]);
|
|
479
|
+
const failed = cleanup.find((result) => result.status === 'rejected');
|
|
480
|
+
if (failed?.status === 'rejected')
|
|
481
|
+
throw failed.reason;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
391
484
|
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
392
485
|
* runs it: once plainly and once with a scenario loaded (an empty one, as a World that scripts nothing loads it). Each
|
|
393
486
|
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
394
487
|
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
395
|
-
* refresh list a fresh account can read (one with no parameter in its path)
|
|
396
|
-
*
|
|
488
|
+
* refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
|
|
489
|
+
* native 403 contract while another read succeeds, or matches the pack's own removal at the World clock.
|
|
490
|
+
* Escaped before: the scenario hang (a served pack given a scenario answered
|
|
397
491
|
* nothing), leaked kernel fields in answers no life step read, and refresh scopes on operations the pack answered with
|
|
398
492
|
* the gap. The life walks what a customer does; this calls what the pack declares it serves. */
|
|
399
|
-
async function surfaceSwept(pack, scratch) {
|
|
493
|
+
async function surfaceSwept(pack, scratch, runtime, consumer) {
|
|
400
494
|
const out = [];
|
|
401
495
|
const descriptor = (await manifestOf(pack)).descriptor;
|
|
402
496
|
const fetchOf = (await import(__rewriteRelativeImportExtension(join(pack, 'src', 'fetch.ts'))))[fetchName(pack)];
|
|
403
497
|
const root = fetchOf?.();
|
|
404
498
|
const byUnit = root?.ownersByUnit?.() ?? { '': root?.owners?.() ?? {} };
|
|
405
|
-
const { shapeChecker } = await import("./walk/shape.js");
|
|
406
|
-
const { leaked } = await import("./walk/behavior-journey.js");
|
|
499
|
+
const { decodeResponse, responseProblems, shapeChecker, vendorFieldsOf } = await import("./walk/shape.js");
|
|
407
500
|
const shape = await shapeChecker(pack);
|
|
408
501
|
const units = [];
|
|
409
|
-
const vendorFields =
|
|
502
|
+
const vendorFields = await vendorFieldsOf(pack);
|
|
410
503
|
for (const unit of unitsIn(pack).filter(hasOwnSurface)) {
|
|
411
504
|
const surface = readJson(join(unit, 'src', 'generated', 'surface.gen.json'));
|
|
412
|
-
|
|
413
|
-
for (const f of r.fields)
|
|
414
|
-
vendorFields.add(f.name);
|
|
505
|
+
const m = (await manifestOf(unit));
|
|
415
506
|
// a line protocol and a command table ride in no path of their own: the life's line steps and the examples read them
|
|
416
507
|
if (!surface?.operations || ['command-reply', 'command-table'].includes(String(surface.format)))
|
|
417
508
|
continue;
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
out.push(`${nameOf(pack, unit)}: no host is named (its manifest's hosts or the descriptor's) for an application to call it at`);
|
|
509
|
+
const hosts = m.hosts ?? m.descriptor?.hosts ?? descriptor?.hosts ?? [];
|
|
510
|
+
if (!hosts.length) {
|
|
511
|
+
out.push(`${nameOf(pack, unit)}: no host is claimed (its manifest's hosts or the descriptor's) for an application to call it at`);
|
|
422
512
|
continue;
|
|
423
513
|
}
|
|
424
514
|
const owners = byUnit[unit === pack ? '' : basename(unit)] ?? {};
|
|
425
515
|
const refreshLists = Object.entries(m.resources ?? {}).flatMap(([resource, decl]) => (decl.refresh?.list ? [{ resource, id: decl.refresh.list }] : []));
|
|
426
|
-
|
|
516
|
+
const refreshReads = Object.values(m.resources ?? {}).flatMap((decl) => [decl.refresh?.list, decl.refresh?.get, decl.refresh?.detail].filter((id) => !!id));
|
|
517
|
+
const declaredAuth = m.descriptor?.auth ?? descriptor?.auth;
|
|
518
|
+
const strategy = declaredAuth?.in === 'lanes' ? declaredAuth.lanes[unit === pack ? '' : basename(unit)] : declaredAuth;
|
|
519
|
+
const shared = join(unit, 'src/semantics/shared.ts');
|
|
520
|
+
const removedAt = existsSync(shared) ? (await import(__rewriteRelativeImportExtension(shared))).removedAt : undefined;
|
|
521
|
+
units.push({ name: nameOf(pack, unit), lane: unit === pack ? '' : basename(unit), hosts, basePath: surface.basePath ?? '', manifest: m, ops: surface.operations.filter((o) => owners[o.id] !== undefined && owners[o.id] !== 'gap'), responseStatuses: declaredResponseStatuses(join(unit, 'spec')), refreshLists, refreshReads, ...(m.auth ? { auth: m.auth } : {}), ...(strategy ? { strategy } : {}), ...(typeof removedAt === 'function' ? { removedAt } : {}) });
|
|
427
522
|
}
|
|
428
523
|
if (!units.length)
|
|
429
524
|
return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
430
|
-
const tally = { calls: 0, ok: 0, refused: 0 };
|
|
431
|
-
const
|
|
525
|
+
const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
|
|
526
|
+
const replayedHeaders = new Set();
|
|
432
527
|
const scenarioPath = join(scratch, 'surface-scenario.json');
|
|
433
528
|
writeFileSync(scenarioPath, '{"handlers":[]}');
|
|
434
529
|
for (const mode of ['plain', 'scenario']) {
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
let server;
|
|
438
|
-
try {
|
|
439
|
-
server = await serve(pack, { root: join(scratch, `surface-${mode}`), ...(mode === 'scenario' ? { scenarioPath } : {}) });
|
|
440
|
-
}
|
|
441
|
-
finally {
|
|
442
|
-
if (before === undefined)
|
|
443
|
-
delete env.VOLTER_WORLD_BOOT_ID;
|
|
444
|
-
else
|
|
445
|
-
env.VOLTER_WORLD_BOOT_ID = before;
|
|
446
|
-
}
|
|
530
|
+
const root = join(scratch, `surface-${mode}`);
|
|
531
|
+
const server = await serve(pack, { root, ...(mode === 'scenario' ? { scenarioPath } : {}) });
|
|
447
532
|
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
448
533
|
const send = async (request) => {
|
|
449
534
|
await server.managedDatabaseUrl?.();
|
|
450
|
-
const abort = new AbortController();
|
|
451
535
|
try {
|
|
452
|
-
return await
|
|
536
|
+
return await readHttpResponse(http, request);
|
|
453
537
|
}
|
|
454
538
|
catch (error) {
|
|
455
|
-
return error;
|
|
456
|
-
}
|
|
457
|
-
finally {
|
|
458
|
-
abort.abort();
|
|
539
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
459
540
|
}
|
|
460
541
|
};
|
|
461
542
|
try {
|
|
543
|
+
const said = mode === 'scenario' ? ' with a scenario loaded' : '';
|
|
462
544
|
// the account an application holds: the key the credential door issues, sent as the vendor reads its credential
|
|
463
545
|
let issued;
|
|
546
|
+
let nativeHeaders;
|
|
547
|
+
let recipeAuthorized = false;
|
|
548
|
+
let nativeUrl;
|
|
549
|
+
const outputs = {};
|
|
464
550
|
if (descriptor?.credentialDoor) {
|
|
465
551
|
const door = descriptor.credentialDoor;
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
|
|
552
|
+
const fields = await credentialFields(server, door);
|
|
553
|
+
const values = fields.values;
|
|
554
|
+
Object.assign(outputs, fields.outputs);
|
|
555
|
+
issued = Object.values(door.fill).map((f) => values[f]).find((v) => typeof v === 'string' && v.length > 0);
|
|
556
|
+
if (typeof values.authorization === 'string' && values.authorization) {
|
|
557
|
+
nativeHeaders = { authorization: values.authorization };
|
|
558
|
+
if (typeof values.url === 'string') {
|
|
559
|
+
try {
|
|
560
|
+
nativeUrl = new URL(values.url);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
out.push(`${nameOf(pack, pack)}: credential door supplies an invalid native url`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (nativeUrl.protocol !== 'https:' || nativeUrl.username || nativeUrl.password) {
|
|
567
|
+
out.push(`${nameOf(pack, pack)}: credential door supplies a native url without a vendor HTTPS origin`);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const headerUnits = units.filter((unit) => !unit.strategy);
|
|
574
|
+
const ambiguousHeaderCredential = !nativeHeaders && new Set(Object.values(outputs)).size > 1 && headerUnits.length > 0
|
|
575
|
+
&& Object.keys(parseEnv(consumerRecipe((await manifestOf(pack)).vendor ?? basename(pack), pack).files['.env.example'])).some((name) => outputs[name]);
|
|
576
|
+
if (descriptor && (descriptor.credentialIssuer || ambiguousHeaderCredential)) {
|
|
577
|
+
const headerClaims = headerUnits.map((unit) => hostRules(unit.hosts));
|
|
578
|
+
const otherClaims = units.filter((unit) => unit.strategy).map((unit) => hostRules(unit.hosts));
|
|
579
|
+
const claimsAuthorization = descriptor.credentialIssuer ? hostRules(descriptor.hosts ?? []).takes
|
|
580
|
+
: (host, path) => headerClaims.some((claims) => claims.takes(host, path)) && !otherClaims.some((claims) => claims.takes(host, path));
|
|
581
|
+
const granted = await consumerAuthorization(pack, join(scratch, `authorization-${mode}`), root, server, descriptor, outputs, claimsAuthorization, consumer);
|
|
582
|
+
nativeHeaders = granted.headers;
|
|
583
|
+
recipeAuthorized = true;
|
|
584
|
+
for (const name of Object.keys(nativeHeaders))
|
|
585
|
+
replayedHeaders.add(name);
|
|
586
|
+
nativeUrl = granted.url;
|
|
587
|
+
issued = undefined;
|
|
588
|
+
}
|
|
589
|
+
const endpoints = Object.values(outputs).flatMap((value) => {
|
|
590
|
+
try {
|
|
591
|
+
const url = new URL(value);
|
|
592
|
+
return url.protocol === 'https:' && !url.username && !url.password ? [url] : [];
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
const claimsNativeUrl = (u) => {
|
|
599
|
+
return nativeUrl !== undefined && hostRules(u.hosts).takes(nativeUrl.hostname, nativeUrl.pathname);
|
|
600
|
+
};
|
|
601
|
+
if (nativeUrl && !units.some(claimsNativeUrl)) {
|
|
602
|
+
out.push(`${units.map((u) => u.name).join(', ')}: credential door native url ${nativeUrl.origin} matches no selected unit's declared host rules${said}`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const hostCandidates = (u) => {
|
|
606
|
+
const claims = hostRules(u.hosts);
|
|
607
|
+
const remote = u.manifest.remoteHost;
|
|
608
|
+
const remoteHost = remote?.template.replace(/\{([^}]+)\}/g, (_match, name) => Object.values(remote.values?.[name] ?? {})[0] ?? 'conformance');
|
|
609
|
+
if (remoteHost && !claims.names(new URL(`https://${remoteHost}`).hostname))
|
|
610
|
+
throw new Error(`${u.name}: its remote host ${remoteHost} matches none of its declared host rules`);
|
|
611
|
+
return [...new Set([
|
|
612
|
+
...(nativeUrl && claimsNativeUrl(u) ? [nativeUrl.host] : []),
|
|
613
|
+
...endpoints.map((url) => url.host),
|
|
614
|
+
...(remoteHost ? [remoteHost] : []),
|
|
615
|
+
...u.hosts.flatMap((rule) => 'host' in rule && rule.exclude !== true ? [rule.host] : []),
|
|
616
|
+
])];
|
|
617
|
+
};
|
|
618
|
+
const answers = new Map(units.map((u) => [u, new Map()]));
|
|
619
|
+
const removed = new Set();
|
|
620
|
+
const callOperation = async (u, op, request) => {
|
|
621
|
+
const now = Date.parse(runtime.worldNow());
|
|
622
|
+
const signing = u.strategy?.in === 'signature' && u.strategy.algorithm === 'aws-sigv4' ? u.strategy : undefined;
|
|
623
|
+
if (signing) {
|
|
624
|
+
const keyPart = (suffix) => {
|
|
625
|
+
const values = [...new Set(Object.entries(outputs).filter(([name]) => name === suffix || name.endsWith(`_${suffix}`)).map(([, value]) => value))];
|
|
626
|
+
return values.length === 1 && values[0] ? values[0] : undefined;
|
|
627
|
+
};
|
|
628
|
+
const accessKeyId = keyPart('ACCESS_KEY_ID');
|
|
629
|
+
const secretAccessKey = keyPart('SECRET_ACCESS_KEY');
|
|
630
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
631
|
+
out.push(`${u.name}: its SigV4 credential door supplies no unambiguous access-key-id and secret-access-key pair${said}`);
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
const url = new URL(request.url);
|
|
635
|
+
const headers = Object.fromEntries(request.headers);
|
|
636
|
+
const body = request.body === null ? undefined : await request.clone().text();
|
|
637
|
+
const scope = typeof signing.scope === 'function' ? signing.scope({ method: request.method, path: url.pathname + url.search, host: url.host, headers, ...(body === undefined ? {} : { body }) }) : signing.scope;
|
|
638
|
+
const signed = sigv4Headers({ accessKeyId, secretAccessKey, ...scope }, {}, request.method, request.url, headers, body, now);
|
|
639
|
+
request = new Request(request, { headers: { ...headers, ...signed } });
|
|
640
|
+
}
|
|
641
|
+
const where = `${u.name}: ${op?.id ?? 'GraphQL refresh'} (${request.method} ${new URL(request.url).pathname})`;
|
|
642
|
+
const graphqlBody = request.method === 'POST' && u.manifest.graphql?.paths.includes(new URL(request.url).pathname)
|
|
643
|
+
? await request.clone().json().catch(() => undefined) : undefined;
|
|
644
|
+
const res = await send(request);
|
|
645
|
+
if (res instanceof Error) {
|
|
646
|
+
out.push(`${where}: the connection failed${said} (${res.message})`);
|
|
647
|
+
return undefined;
|
|
648
|
+
}
|
|
649
|
+
if (op)
|
|
650
|
+
answers.get(u).set(op.id, res.status);
|
|
651
|
+
if (mode === 'plain') {
|
|
652
|
+
tally.calls++;
|
|
653
|
+
if (res.status < 300)
|
|
654
|
+
tally.ok++;
|
|
655
|
+
else
|
|
656
|
+
tally.refused++;
|
|
657
|
+
}
|
|
658
|
+
if (res.bodyError) {
|
|
659
|
+
out.push(`${where}: response body failed${said} (${res.bodyError.message})`);
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
const text = res.text;
|
|
663
|
+
const response = { status: res.status, headers: res.headers, body: text, bodyBytes: res.bodyBytes };
|
|
664
|
+
const removal = op ? u.removedAt?.(op.id, Math.floor(now / 1000)) : undefined;
|
|
665
|
+
if (removal) {
|
|
666
|
+
const expected = runtime.vendorError(u.manifest, removal);
|
|
667
|
+
if (res.status === expected.status && text === await expected.text())
|
|
668
|
+
removed.add(`${u.name}:${op.id}`);
|
|
669
|
+
else
|
|
670
|
+
out.push(`${where} does not match its declared native removal at ${new Date(now).toISOString()}${said}: expected ${removal.status} and its exact native body, received ${res.status}: ${text.slice(0, 160)}`);
|
|
671
|
+
}
|
|
672
|
+
const statuses = op ? u.responseStatuses.get(op.id) : undefined;
|
|
673
|
+
if (res.status === 500 && !statuses?.has('500') && !statuses?.has('5XX') && !statuses?.has('default'))
|
|
674
|
+
out.push(`${where} answered 500${said}, which the spec does not declare: ${text.slice(0, 160)}`);
|
|
675
|
+
const type = res.headers['content-type'];
|
|
676
|
+
const judged = responseProblems(shape, vendorFields, { method: request.method, path: request.url, requestHeaders: Object.fromEntries(request.headers), graphqlBody,
|
|
677
|
+
status: res.status, mediaType: type, body: decodeResponse(text, type), text });
|
|
678
|
+
for (const key of judged.leaks)
|
|
679
|
+
out.push(`${where} answers the twin's own field ${key}${said}`);
|
|
680
|
+
for (const detail of judged.shape)
|
|
681
|
+
out.push(`${where}: ${detail}${said}`);
|
|
682
|
+
return response;
|
|
683
|
+
};
|
|
684
|
+
const refreshed = new Set();
|
|
685
|
+
const nativeRefreshOwnsReads = recipeAuthorized && nativeHeaders !== undefined && units.some((unit) => unit.refreshReads.length > 0);
|
|
686
|
+
if (nativeRefreshOwnsReads) {
|
|
687
|
+
const vendor = (await manifestOf(pack)).vendor ?? basename(pack);
|
|
688
|
+
const refresh = runtime.getPack(vendor)?.stateSystem?.refresh;
|
|
689
|
+
if (!refresh)
|
|
690
|
+
throw new Error(`${vendor}: its registered native refresh adapter is missing`);
|
|
691
|
+
try {
|
|
692
|
+
await refresh(async (call) => {
|
|
693
|
+
const u = units.find((unit) => unit.lane === (call.lane ?? ''));
|
|
694
|
+
if (!u)
|
|
695
|
+
throw new Error(`${vendor}: its native refresh names no selected unit`);
|
|
696
|
+
const url = hostCandidates(u).map((host) => new URL(call.path, `https://${host}`)).find((candidate) => hostRules(u.hosts).takes(candidate.hostname, candidate.pathname));
|
|
697
|
+
if (!url)
|
|
698
|
+
throw new Error(`${u.name}: its native refresh request matches no declared host`);
|
|
699
|
+
const headers = { ...call.headers, ...(claimsNativeUrl(u) ? nativeHeaders : {}) };
|
|
700
|
+
const request = new Request(url, { method: call.method, headers, ...(call.body === undefined ? {} : { body: call.body }) });
|
|
701
|
+
const operation = shape?.operationOf(call.method, url.href, Object.fromEntries(request.headers));
|
|
702
|
+
const op = (operation?.lane ?? '') === u.lane ? u.ops.find((candidate) => candidate.id === operation?.op.id) : undefined;
|
|
703
|
+
const graphql = call.method.toUpperCase() === 'POST' && u.manifest.graphql?.paths.includes(url.pathname);
|
|
704
|
+
if (!op && !graphql)
|
|
705
|
+
throw new Error(`${u.name}: its native refresh matches no served operation or declared GraphQL endpoint`);
|
|
706
|
+
if (op)
|
|
707
|
+
refreshed.add(`${u.name}:${op.id}`);
|
|
708
|
+
const response = await callOperation(u, op, request);
|
|
709
|
+
if (!response)
|
|
710
|
+
throw new Error(`${u.name}: its native refresh request failed`);
|
|
711
|
+
return response;
|
|
712
|
+
}, { root });
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
716
|
+
const secrets = Object.values(nativeHeaders).flatMap((value) => [value, value.split(/\s+/).slice(1).join(' ')]).filter(Boolean);
|
|
717
|
+
out.push(`${vendor}: its native refresh adapter failed${said}: ${secrets.reduce((message, secret) => message.replaceAll(secret, '[redacted]'), detail)}`);
|
|
718
|
+
}
|
|
469
719
|
}
|
|
470
|
-
const said = mode === 'scenario' ? ' with a scenario loaded' : '';
|
|
471
720
|
for (const u of units) {
|
|
472
|
-
//
|
|
473
|
-
// one, a bearer token, and a refusal of it is no finding of the refresh below
|
|
721
|
+
// A front that checks its own key receives the door's token as a bearer.
|
|
474
722
|
const auth = u.auth ?? { header: 'authorization', scheme: 'Bearer' };
|
|
475
|
-
const
|
|
476
|
-
const
|
|
723
|
+
const native = nativeHeaders !== undefined && (!nativeUrl || claimsNativeUrl(u));
|
|
724
|
+
const credential = native ? nativeHeaders : issued ? { [auth.header]: auth.scheme ? `${auth.scheme} ${issued}` : issued } : {};
|
|
725
|
+
const signing = u.strategy?.in === 'signature' && u.strategy.algorithm === 'aws-sigv4' ? u.strategy : undefined;
|
|
726
|
+
if (!signing && u.auth && !native && !issued) {
|
|
727
|
+
out.push(`${u.name}: its credential door supplies no nonempty credential for ${auth.header}${said}`);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const claims = hostRules(u.hosts);
|
|
731
|
+
let hosts;
|
|
732
|
+
try {
|
|
733
|
+
hosts = hostCandidates(u);
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
out.push(`${error.message}${said}`);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
477
739
|
for (const op of u.ops) {
|
|
478
|
-
|
|
479
|
-
const where = `${u.name}: ${op.id} (${request.method} ${new URL(request.url).pathname})`;
|
|
480
|
-
const res = await send(request);
|
|
481
|
-
if (res === 'timeout') {
|
|
482
|
-
out.push(`${where} answered nothing in 10 s${said}`);
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
if (res instanceof Error) {
|
|
486
|
-
out.push(`${where}: the connection failed${said} (${res.message})`);
|
|
740
|
+
if (refreshed.has(`${u.name}:${op.id}`))
|
|
487
741
|
continue;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
else
|
|
495
|
-
tally.refused++;
|
|
496
|
-
}
|
|
497
|
-
const text = await res.text();
|
|
498
|
-
if (res.status === 500 && !op.responseMedia?.['500'] && !op.responseMedia?.['5XX']) {
|
|
499
|
-
out.push(`${where} answered 500${said}, which the spec does not declare: ${text.slice(0, 160)}`);
|
|
742
|
+
const request = hosts.map((host) => sweepRequest(op, u.basePath, host, signing ? {} : credential)).find((candidate) => {
|
|
743
|
+
const url = new URL(candidate.url);
|
|
744
|
+
return claims.takes(url.hostname, url.pathname);
|
|
745
|
+
});
|
|
746
|
+
if (!request) {
|
|
747
|
+
out.push(`${u.name}: ${op.id} has no concrete declared host or issued HTTPS endpoint matching its host rules${said}`);
|
|
500
748
|
continue;
|
|
501
749
|
}
|
|
502
|
-
|
|
503
|
-
continue;
|
|
504
|
-
const type = res.headers.get('content-type') ?? '';
|
|
505
|
-
let body;
|
|
506
|
-
if (/json/.test(type)) {
|
|
507
|
-
try {
|
|
508
|
-
body = JSON.parse(text);
|
|
509
|
-
}
|
|
510
|
-
catch {
|
|
511
|
-
body = undefined;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
for (const key of leaked(body, vendorFields, '', !op.answers || res.status >= 300))
|
|
515
|
-
out.push(`${where} answers the twin's own field ${key}`);
|
|
516
|
-
if (res.status >= 200 && res.status < 300 && shape) {
|
|
517
|
-
const hit = shape.operationOf(request.method, request.url, Object.fromEntries(request.headers));
|
|
518
|
-
if (hit)
|
|
519
|
-
for (const detail of shape.failures(hit.op, body, type, request.headers.get('accept') ?? undefined, res.status, { method: request.method, responseText: text }))
|
|
520
|
-
out.push(`${where}: ${detail}`);
|
|
521
|
-
}
|
|
750
|
+
await callOperation(u, op, request);
|
|
522
751
|
}
|
|
523
|
-
|
|
752
|
+
}
|
|
753
|
+
const readSomething = units.some((u) => u.refreshReads.some((id) => { const status = answers.get(u)?.get(id); return status !== undefined && status >= 200 && status < 300; }));
|
|
754
|
+
if (mode === 'plain' && !nativeRefreshOwnsReads)
|
|
755
|
+
for (const u of units)
|
|
524
756
|
for (const r of u.refreshLists) {
|
|
525
757
|
const op = u.ops.find((o) => o.id === r.id);
|
|
526
758
|
if (!op || op.path.includes('{') || (op.basePath ?? '').includes('{'))
|
|
527
759
|
continue;
|
|
528
|
-
const status =
|
|
529
|
-
if (
|
|
760
|
+
const status = answers.get(u)?.get(r.id);
|
|
761
|
+
if (removed.has(`${u.name}:${r.id}`)) {
|
|
762
|
+
tally.removed++;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (status === 403 && readSomething) {
|
|
766
|
+
tally.unread++;
|
|
530
767
|
continue;
|
|
768
|
+
}
|
|
531
769
|
if (status !== undefined && (status < 200 || status >= 300))
|
|
532
770
|
out.push(`${u.name}: ${r.resource}'s refresh lists by ${r.id}, which answered a fresh account ${status}: a World rooted at this twin reads nothing back`);
|
|
533
771
|
}
|
|
534
|
-
}
|
|
535
772
|
}
|
|
536
773
|
finally {
|
|
537
774
|
await server.stop();
|
|
538
775
|
}
|
|
539
776
|
}
|
|
540
777
|
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
541
|
-
return { failures: out.length > 40 ? [...out.slice(0, 40), `… ${out.length - 40} more`] : out, note: `${tally.calls} served operations called, twice each: ${tally.ok} answered successfully, ${tally.refused} refused, unscripted` };
|
|
778
|
+
return { failures: out.length > 40 ? [...out.slice(0, 40), `… ${out.length - 40} more`] : out, note: `${tally.calls} served operations called, twice each: ${tally.ok} answered successfully, ${tally.refused} refused, unscripted; ${tally.unread} refresh resources unread (403), ${tally.removed} match declared native removals${replayedHeaders.size ? `; replayed application headers (values redacted): ${[...replayedHeaders].sort().join(', ')}` : ''}` };
|
|
542
779
|
}
|
|
543
780
|
/** A line protocol's connection over TCP, in the walk's stream shape. A line drew its replies when bytes arrived and then
|
|
544
781
|
* 50 ms passed with no more (a multi-line reply arrives in pieces), or when 500 ms passed with none (a line that draws no
|
|
@@ -580,42 +817,22 @@ async function lifeServed(pack) {
|
|
|
580
817
|
return ['journeys/customer-life.json is missing'];
|
|
581
818
|
const { inMemoryWorld, usePacks, walk } = await import("./walk/behavior-journey.js");
|
|
582
819
|
usePacks(dirname(pack), { dependenciesFrom: pack });
|
|
583
|
-
const env = globalThis.process.env;
|
|
584
820
|
const served = async (root, options) => {
|
|
585
|
-
const
|
|
586
|
-
env.VOLTER_WORLD_BOOT_ID = 'conformance';
|
|
587
|
-
let server;
|
|
588
|
-
try {
|
|
589
|
-
server = await serve(pack, { root, ...options });
|
|
590
|
-
}
|
|
591
|
-
finally {
|
|
592
|
-
if (before === undefined)
|
|
593
|
-
delete env.VOLTER_WORLD_BOOT_ID;
|
|
594
|
-
else
|
|
595
|
-
env.VOLTER_WORLD_BOOT_ID = before;
|
|
596
|
-
}
|
|
821
|
+
const server = await serve(pack, { root, ...options });
|
|
597
822
|
const port = server.inspectPort ?? server.port;
|
|
598
823
|
const http = servedHttpFetch(port);
|
|
599
824
|
return {
|
|
600
825
|
// the vendor's URL as the application called it, sent to the served pack: its host carried in the Host header
|
|
601
|
-
fetch: Object.assign(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return answer === 'timeout' ? new Response(`no answer in 10 s from the served pack (${request.method} ${new URL(request.url).pathname})`, { status: 599 }) : answer;
|
|
607
|
-
}
|
|
608
|
-
finally {
|
|
609
|
-
abort.abort();
|
|
610
|
-
}
|
|
611
|
-
}, server.managedDatabaseUrl ? { managedDatabaseUrl: server.managedDatabaseUrl } : {}),
|
|
826
|
+
fetch: Object.assign(http, {
|
|
827
|
+
managedDatabaseUrl: server.managedDatabaseUrl,
|
|
828
|
+
managedDatabaseClock: server.managedDatabaseClock,
|
|
829
|
+
managedDatabaseConnection: server.managedDatabaseConnection,
|
|
830
|
+
}),
|
|
612
831
|
// a socket opened as an application opens it: a WebSocket to the served port, the vendor's host in its Host header
|
|
613
832
|
socket: (url, headers) => new WebSocket(`ws://127.0.0.1:${port}${url}`, { headers }),
|
|
614
833
|
// TCP to the native wire: a separate listener beside HTTP, or the primary protocol port.
|
|
615
834
|
stream: lineConnection(server.wirePort ?? server.port),
|
|
616
|
-
|
|
617
|
-
// 1.3.11: the walk ended and the stop spun at full CPU for 45 minutes), so the check waits 5 s and goes on
|
|
618
|
-
stop: async () => { await within(5000, Promise.resolve(server.stop())); },
|
|
835
|
+
stop: () => server.stop(),
|
|
619
836
|
};
|
|
620
837
|
};
|
|
621
838
|
const world = inMemoryWorld(`conform-${basename(pack)}`);
|
|
@@ -630,67 +847,43 @@ async function lifeServed(pack) {
|
|
|
630
847
|
}
|
|
631
848
|
/** The pack driven by the vendor's own client (clients/<vendor>.ts): served as a World runs it, its credentials issued
|
|
632
849
|
* by its credential door, the cases run in Node as an application runs the vendor's SDK (clients/run.ts), each the
|
|
633
|
-
* vendor's documented behaviour.
|
|
634
|
-
*
|
|
635
|
-
async function clientCases(pack, scratch) {
|
|
850
|
+
* vendor's documented behaviour. Without a built-in module, the current installed workflow can supply that proof;
|
|
851
|
+
* otherwise a vendor naming npm clients needs cases or clientCasesNone saying why none is owed. */
|
|
852
|
+
async function clientCases(pack, scratch, consumer) {
|
|
636
853
|
const vendor = (await manifestOf(pack)).vendor ?? basename(pack);
|
|
637
854
|
const clients = join(dirname(fileURLToPath(import.meta.url)), 'clients');
|
|
638
855
|
const extension = extname(fileURLToPath(import.meta.url));
|
|
639
856
|
if (!existsSync(join(clients, `${vendor}${extension}`))) {
|
|
640
|
-
// a vendor whose descriptor names npm clients (adoption.sdks) is held to them: no case is no proof the SDK works,
|
|
641
|
-
// unless the descriptor says why none is owed (clientCasesNone: no official SDK, or none the demand's applications
|
|
642
|
-
// use); one an application calls with no SDK of the vendor's (a raw HTTP catalog) has no client to drive
|
|
643
857
|
const descriptor = (await manifestOf(pack)).descriptor;
|
|
644
858
|
const sdks = descriptor?.adoption?.sdks ?? [];
|
|
645
859
|
if (descriptor?.clientCasesNone)
|
|
646
860
|
return { failures: [], note: `${vendor} owes no client case: ${descriptor.clientCasesNone}` };
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
861
|
+
if (!sdks.length)
|
|
862
|
+
return { failures: [], note: `${vendor} declares no SDK of its own` };
|
|
863
|
+
const proof = consumerClientProof(pack, vendor, sdks, consumer);
|
|
864
|
+
return { ...proof, failures: proof.failures.map((failure) => `${vendor} names npm clients (${sdks.join(', ')}) and has no built-in client cases: ${failure}`) };
|
|
650
865
|
}
|
|
651
866
|
const descriptor = (await manifestOf(pack)).descriptor;
|
|
652
867
|
// the first host that names one: a descriptor may lead with a suffix (a region's hosts under one domain), which no
|
|
653
868
|
// request addresses; the case's client reaches whatever host its issued credentials name
|
|
654
869
|
// a raw protocol's pack (hostsNone: SMTP, whose relay is whatever host the operator names) is reached by its TCP
|
|
655
870
|
// connection alone; its client names a stand-in host the connection never resolves
|
|
656
|
-
const host = descriptor?.hosts?.
|
|
871
|
+
const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
657
872
|
if (!host)
|
|
658
873
|
return { failures: ['the descriptor names no host for its client to address'] };
|
|
659
874
|
const scenarioPath = join(scratch, 'client-scenario.json');
|
|
660
875
|
writeFileSync(scenarioPath, '{"handlers":[]}');
|
|
661
|
-
const
|
|
662
|
-
const before = env.VOLTER_WORLD_BOOT_ID;
|
|
663
|
-
env.VOLTER_WORLD_BOOT_ID = 'conformance';
|
|
664
|
-
let server;
|
|
665
|
-
try {
|
|
666
|
-
server = await serve(pack, { root: join(scratch, 'client-world'), scenarioPath });
|
|
667
|
-
}
|
|
668
|
-
finally {
|
|
669
|
-
if (before === undefined)
|
|
670
|
-
delete env.VOLTER_WORLD_BOOT_ID;
|
|
671
|
-
else
|
|
672
|
-
env.VOLTER_WORLD_BOOT_ID = before;
|
|
673
|
-
}
|
|
876
|
+
const server = await serve(pack, { root: join(scratch, 'client-world'), scenarioPath });
|
|
674
877
|
try {
|
|
675
878
|
// the World's credentials: what the pack's credential door issues, by the variable a World sets each in
|
|
676
879
|
const issued = {};
|
|
677
880
|
const door = descriptor?.credentialDoor;
|
|
678
881
|
if (door) {
|
|
679
|
-
|
|
680
|
-
// nothing once held this request open for good)
|
|
681
|
-
const abort = new AbortController();
|
|
682
|
-
const asked = await within(10_000, fetch(`http://127.0.0.1:${server.port}${door.path}`, { method: 'POST', headers: { host, 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: abort.signal }).then((r) => r.json().catch(() => ({}))));
|
|
683
|
-
abort.abort();
|
|
684
|
-
if (asked === 'timeout')
|
|
685
|
-
return { failures: [`the credential door (POST ${door.path}) answered nothing in 10 s, served with a scenario loaded`] };
|
|
686
|
-
const answer = asked;
|
|
687
|
-
for (const [name, field] of Object.entries(door.fill))
|
|
688
|
-
if (answer[field] !== undefined)
|
|
689
|
-
issued[name] = String(answer[field]);
|
|
882
|
+
Object.assign(issued, (await credentialFields(server, door, host)).outputs);
|
|
690
883
|
}
|
|
691
884
|
const job = join(scratch, 'client-job.json');
|
|
692
|
-
// a
|
|
693
|
-
//
|
|
885
|
+
// a pack carries its facts (generated/pack-facts.json, `twin-standard facts`), in source and release alike; one that
|
|
886
|
+
// has none yet has them compiled here, as that command compiles them, for the client the case runs
|
|
694
887
|
let packFacts = join(pack, 'generated', 'pack-facts.json');
|
|
695
888
|
if (!existsSync(packFacts)) {
|
|
696
889
|
const { compilePackFacts, renderPackFacts } = await import("./pack-facts.js");
|
|
@@ -790,7 +983,7 @@ export async function conformance(dir, options = {}) {
|
|
|
790
983
|
options.onCheck?.('client');
|
|
791
984
|
const asks = "driven by the vendor's own client (its official SDK), served as a World runs it, the vendor's documented behaviour holds";
|
|
792
985
|
try {
|
|
793
|
-
const r = await clientCases(pack, scratch);
|
|
986
|
+
const r = await clientCases(pack, scratch, options.consumer);
|
|
794
987
|
return { id: 'client', asks, failures: r.failures, ...(r.note ? { note: r.note } : {}) };
|
|
795
988
|
}
|
|
796
989
|
catch (error) {
|
|
@@ -799,7 +992,7 @@ export async function conformance(dir, options = {}) {
|
|
|
799
992
|
})(),
|
|
800
993
|
await run('life', 'the life walked over HTTP against the pack served as a World runs it, with its scenario: every check held', () => lifeServed(pack)),
|
|
801
994
|
await run('standalone', 'served by its server.ts as a World runs it, the pack answers HTTP and the boot probe, and each declared socket upgrades', () => standalone(pack)),
|
|
802
|
-
await run('surface', 'every served operation called over HTTP as an application calls it, plainly and with a scenario loaded: answered within 10 s, no undeclared 500, no field of the twin\'s own, a success in the spec\'s shape, and every refresh list
|
|
995
|
+
await run('surface', 'every served operation called over HTTP as an application calls it, plainly and with a scenario loaded: answered within 10 s, no undeclared 500, no field of the twin\'s own, a success in the spec\'s shape, and every refresh list answering, remaining unread under the native 403 contract, or matching its declared removal at the World clock', () => surfaceSwept(pack, scratch, runtime, options.consumer)),
|
|
803
996
|
await run('records', 'each demand row cites the line it was read at, the spec\'s record names a vendored file that is there, the life names its author, the package has its README', () => records(pack)),
|
|
804
997
|
await run('form', 'every unit passes the form section of the grade (the gate): the layout, the handler and kernel contracts, the manifest as data, the rules an author\'s code holds', () => formOf(pack)),
|
|
805
998
|
];
|