@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2
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 +2 -2
- package/dist/cli.js +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2744 -4
- package/scenario-majors.json +155 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A.5 — `fork-a-v1-run` (suite 2.0.0, target major 2; seam-gated on
|
|
3
|
+
* `openwop-conformance-seams-v2` and gated on `replay`).
|
|
4
|
+
*
|
|
5
|
+
* A v2 host MUST fork a run created before the cut (era `2`). The fork's
|
|
6
|
+
* prefix MUST be byte-equivalent to the TRANSLATED parent — the parent as read
|
|
7
|
+
* through the codemap, not its stored bytes — under RFC 0041 §C (observable
|
|
8
|
+
* output modulo clock fields and freshly minted ids), and `run.started` on the
|
|
9
|
+
* fork MUST carry the legacy Subject (`issuer: urn:openwop:legacy`,
|
|
10
|
+
* identity.md) where the parent had none (`spec/v2/core/replay.md` §Forking a
|
|
11
|
+
* v1 run; persistence.md §Forking a v1 run; RFC 0170 §A.3).
|
|
12
|
+
*
|
|
13
|
+
* The era-2 parent is seeded in v1 vocabulary with a `run.started` that
|
|
14
|
+
* carries no `owner` (a v1 payload) through the event-log seed seam
|
|
15
|
+
* (lib/era2-seed.ts); the fork is `{ mode: replay, fromSeq: <last> }` so the
|
|
16
|
+
* whole log below the terminal row is inherited history.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/replay.md §Forking a v1 run, §Byte-equivalence of the prefix
|
|
19
|
+
* @see spec/v2/core/identity.md §1.2 (the legacy subject)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
import { era2Gate, eventsOf, forkRun, observable, pollEvents, seedEra2Log, v1FixtureLog, type ReadEvent } from '../lib/era2-seed.js';
|
|
27
|
+
|
|
28
|
+
const DOC = 'spec/v2/core/replay.md §Forking a v1 run';
|
|
29
|
+
const LEGACY_ISSUER = 'urn:openwop:legacy';
|
|
30
|
+
|
|
31
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
32
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Forked =
|
|
36
|
+
| { readonly ok: true; readonly parent: ReadEvent[]; readonly fork: ReadEvent[]; readonly fromSeq: number }
|
|
37
|
+
| { readonly ok: false; readonly kind: 'blocked' | 'inapplicable' | 'skipped'; readonly reason: string };
|
|
38
|
+
|
|
39
|
+
async function forkedPair(): Promise<Forked> {
|
|
40
|
+
const doc = await discovery();
|
|
41
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'discovery unreachable' };
|
|
42
|
+
const gate = era2Gate(doc);
|
|
43
|
+
if (gate !== null && !gate.ok) return { ok: false, kind: gate.kind, reason: gate.reason };
|
|
44
|
+
if (!(await gateFamily('replay'))) return { ok: false, kind: 'inapplicable', reason: 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun has no surface' };
|
|
45
|
+
const log = v1FixtureLog();
|
|
46
|
+
const seeded = await seedEra2Log(log, 'completed');
|
|
47
|
+
if (!seeded.ok) return { ok: false, kind: seeded.kind, reason: seeded.reason };
|
|
48
|
+
const parentRead = await pollEvents(seeded.runId);
|
|
49
|
+
if (parentRead === null || parentRead.status !== 200) return { ok: false, kind: 'blocked', reason: `the translated parent could not be read — GET /runs/{runId}/events/poll answered ${parentRead?.status ?? 'no response'}` };
|
|
50
|
+
const fromSeq = Math.max(...log.map((e) => e.sequence));
|
|
51
|
+
const fork = await forkRun(seeded.runId, { mode: 'replay', fromSeq });
|
|
52
|
+
if (fork === null) return { ok: false, kind: 'blocked', reason: 'POST /runs/{runId}:fork unreachable (fetch failed)' };
|
|
53
|
+
if (fork.status !== 201) return { ok: false, kind: 'blocked', reason: `POST /runs/{runId}:fork on the era-2 parent answered ${fork.status} — the fork-a-v1-run obligation is asserted by openwop.requirement.0176.fork-a-v1-run.prefix, which records this refusal` };
|
|
54
|
+
const forkId = (fork.json as { runId?: unknown } | undefined)?.runId;
|
|
55
|
+
if (typeof forkId !== 'string') return { ok: false, kind: 'blocked', reason: 'the fork answered 201 without a runId' };
|
|
56
|
+
const forkRead = await pollEvents(forkId);
|
|
57
|
+
if (forkRead === null || forkRead.status !== 200) return { ok: false, kind: 'blocked', reason: `the fork's log could not be read — GET /runs/{forkId}/events/poll answered ${forkRead?.status ?? 'no response'}` };
|
|
58
|
+
return { ok: true, parent: eventsOf(parentRead.json), fork: eventsOf(forkRead.json), fromSeq };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('RFC 0176 §A.5 — fork-a-v1-run (seam-gated, replay)', () => {
|
|
62
|
+
it('the fork of an era-2 run is accepted and its prefix is byte-equivalent to the translated parent', async () => {
|
|
63
|
+
const doc = await discovery();
|
|
64
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
65
|
+
const pair = await forkedPair();
|
|
66
|
+
if (!pair.ok) {
|
|
67
|
+
// A refusal of the fork itself is the failure this leg exists to catch.
|
|
68
|
+
if (pair.reason.includes(':fork on the era-2 parent answered')) {
|
|
69
|
+
expect(pair.reason, req('openwop.requirement.0176.fork-a-v1-run.prefix', DOC, `a v2 host MUST fork a run created before the cut — ${pair.reason}`)).toBe('');
|
|
70
|
+
}
|
|
71
|
+
return softSkip(pair.kind, pair.reason);
|
|
72
|
+
}
|
|
73
|
+
const prefixOf = (events: ReadEvent[]) => events.filter((e) => typeof e.sequence === 'number' && e.sequence < pair.fromSeq).sort((a, b) => (a.sequence as number) - (b.sequence as number));
|
|
74
|
+
const parent = prefixOf(pair.parent);
|
|
75
|
+
const fork = prefixOf(pair.fork);
|
|
76
|
+
expect(parent.length, req('openwop.requirement.0176.fork-a-v1-run.prefix', DOC, `the translated parent MUST expose its prefix [0, ${pair.fromSeq}) — got ${parent.length} rows`)).toBe(pair.fromSeq);
|
|
77
|
+
expect(fork.length, req('openwop.requirement.0176.fork-a-v1-run.prefix', DOC, `the fork MUST inherit the whole prefix [0, ${pair.fromSeq}) as fixed history — got ${fork.length} rows`)).toBe(pair.fromSeq);
|
|
78
|
+
for (let i = 0; i < parent.length; i++) {
|
|
79
|
+
expect(
|
|
80
|
+
fork[i] === undefined ? null : observable(fork[i]!),
|
|
81
|
+
req('openwop.requirement.0176.fork-a-v1-run.prefix', 'spec/v2/core/replay.md §Byte-equivalence of the prefix', `sequence ${String(parent[i]!.sequence)}: the fork's row MUST be byte-equivalent (type, sequence, payload) to the TRANSLATED parent row — the parent as read through the codemap, never its stored v1 bytes (RFC 0041 §C)`),
|
|
82
|
+
).toBe(observable(parent[i]!));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('run.started on the fork carries the legacy Subject where the parent had none', async () => {
|
|
87
|
+
const doc = await discovery();
|
|
88
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
|
+
const pair = await forkedPair();
|
|
90
|
+
if (!pair.ok) return softSkip(pair.kind, pair.reason);
|
|
91
|
+
const started = pair.fork.find((e) => e.type === 'run.started');
|
|
92
|
+
expect(started, req('openwop.requirement.0176.fork-a-v1-run.legacy-subject', DOC, 'the fork\'s log MUST carry run.started at its head')).toBeDefined();
|
|
93
|
+
const owner = (started?.payload as { owner?: { subject?: { issuer?: unknown } } } | undefined)?.owner;
|
|
94
|
+
expect(
|
|
95
|
+
owner?.subject,
|
|
96
|
+
req('openwop.requirement.0176.fork-a-v1-run.legacy-subject', 'spec/v2/core/identity.md §1.2', 'run.started on the fork MUST carry owner.subject — the seeded parent had none, so the host stamps the legacy Subject at first v2 read (RFC 0170 §A.3) and the fork copies it verbatim (replay.md §The surface)'),
|
|
97
|
+
).toBeDefined();
|
|
98
|
+
expect(
|
|
99
|
+
owner?.subject?.issuer,
|
|
100
|
+
req('openwop.requirement.0176.fork-a-v1-run.legacy-subject', 'spec/v2/core/identity.md §1.2', `the stamped subject's issuer MUST be ${LEGACY_ISSUER} — nothing can attest the issuer of a historical principal, so a real issuer here is a forged provenance`),
|
|
101
|
+
).toBe(LEGACY_ISSUER);
|
|
102
|
+
const parentStarted = pair.parent.find((e) => e.type === 'run.started');
|
|
103
|
+
const parentIssuer = (parentStarted?.payload as { owner?: { subject?: { issuer?: unknown } } } | undefined)?.owner?.subject?.issuer;
|
|
104
|
+
expect(
|
|
105
|
+
parentIssuer,
|
|
106
|
+
req('openwop.requirement.0176.fork-a-v1-run.legacy-subject', 'spec/v2/core/persistence.md §Everything else a v1 host persisted', `the parent read through v2 MUST carry the same legacy stamp (legacy-stamped at first v2 read, never rewritten) — got ${String(parentIssuer)}`),
|
|
107
|
+
).toBe(LEGACY_ISSUER);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-form-when-reuses-edge-conditions — RFC 0177 §E.4;
|
|
3
|
+
* `spec/v2/core/form-content-packs.md` §"Conditional visibility".
|
|
4
|
+
*
|
|
5
|
+
* Suite 2.0.0, target major 2. A form field MAY carry `when: <EdgeCondition>`;
|
|
6
|
+
* the grammar is the `WorkflowEdge.condition` object `{ type, left, right }` of
|
|
7
|
+
* `schemas/v2/workflow-definition.schema.json` with the operator set
|
|
8
|
+
* `expression | equals | notEquals | contains | regex | truthy | falsy`, and no
|
|
9
|
+
* second expression language is accepted.
|
|
10
|
+
*
|
|
11
|
+
* Witnessed against `schemas/v2/form-content-pack-manifest.schema.json` (no
|
|
12
|
+
* reference host: `host-pending`), gated on the `forms` family: a form-content
|
|
13
|
+
* pack whose field carries `when: { type: "equals", left, right }` validates,
|
|
14
|
+
* and a field whose `when` names an operator outside the set is rejected. If
|
|
15
|
+
* the schema does not carry `when` yet (RFC 0177 §E.4 schema follow-up) the
|
|
16
|
+
* leg records `blocked` naming the gap rather than a pass.
|
|
17
|
+
*
|
|
18
|
+
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §E.4
|
|
19
|
+
* @see spec/v2/core/form-content-packs.md §"Conditional visibility"
|
|
20
|
+
* @see spec/v2/core/workflow-chain-packs.md §"Edge conditions"
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
|
+
import { targetMajor } from '../lib/seams.js';
|
|
27
|
+
import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
|
|
28
|
+
|
|
29
|
+
const SECTION = 'form-content-packs.md §"Conditional visibility" (RFC 0177 §E.4)';
|
|
30
|
+
|
|
31
|
+
function formPack(when: Record<string, unknown>): Record<string, unknown> {
|
|
32
|
+
return {
|
|
33
|
+
name: 'core.openwop.v2-form-when-fixture',
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
kind: 'form-content',
|
|
36
|
+
engines: { openwop: '>=2.0.0 <3.0.0' },
|
|
37
|
+
templates: [{
|
|
38
|
+
templateId: 'core.openwop.v2-form-when-fixture.shipping',
|
|
39
|
+
version: '1.0.0',
|
|
40
|
+
label: 'Shipping',
|
|
41
|
+
title: 'Shipping details',
|
|
42
|
+
fields: [
|
|
43
|
+
{ id: 'shipping', type: 'text', label: 'Shipping' },
|
|
44
|
+
{ id: 'region', type: 'text', label: 'Region', when },
|
|
45
|
+
],
|
|
46
|
+
}],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('v2-form-when-reuses-edge-conditions (RFC 0177 §E.4)', () => {
|
|
51
|
+
it('a field `when` is the edge-condition grammar { type, left, right } and no other expression language', async () => {
|
|
52
|
+
if (targetMajor() !== 2) return softSkip('inapplicable', 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2');
|
|
53
|
+
let doc: Record<string, unknown> | null;
|
|
54
|
+
try {
|
|
55
|
+
doc = await v2Discovery();
|
|
56
|
+
} catch {
|
|
57
|
+
doc = null;
|
|
58
|
+
}
|
|
59
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON');
|
|
60
|
+
if (!(await gateFamily('forms'))) return softSkip('inapplicable', 'v2 discovery does not advertise the forms family (RFC 0169 §A.2)');
|
|
61
|
+
const validate = v2Validator('form-content-pack-manifest');
|
|
62
|
+
const edge = validate(formPack({ type: 'equals', left: 'fields.shipping', right: 'international' }));
|
|
63
|
+
if (!edge.ok && /\bwhen\b/.test(edge.errors)) return softSkip('blocked', 'form-content-pack-manifest.schema.json lacks `when` — RFC 0177 §E.4 schema follow-up');
|
|
64
|
+
expect(edge.ok, req('openwop.requirement.0177.form-when-reuses-edge-conditions', SECTION, `a field carrying when: { type: "equals", left, right } MUST validate (${edge.errors})`)).toBe(true);
|
|
65
|
+
const foreign = validate(formPack({ op: 'eq', path: 'fields.shipping', value: 'international' }));
|
|
66
|
+
expect(foreign.ok, req('openwop.requirement.0177.form-when-reuses-edge-conditions', SECTION, 'a `when` outside the edge-condition grammar (a second expression language) MUST be rejected')).toBe(false);
|
|
67
|
+
const operator = validate(formPack({ type: 'jsonata', left: 'fields.shipping', right: 'international' }));
|
|
68
|
+
expect(operator.ok, req('openwop.requirement.0177.form-when-reuses-edge-conditions', SECTION, 'an operator outside expression|equals|notEquals|contains|regex|truthy|falsy MUST be rejected')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `header-scheme` (suite 2.0.0; RFC 0171 §C.1; RFC 0172 §A.4;
|
|
3
|
+
* `spec/v2/core/headers.md` §"Removed in v2"; `spec/v2/core/versioning.md` §1.4).
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. Every non-standard header is
|
|
6
|
+
* `OpenWOP-<Name>`; `Capabilities-Etag`, `X-Dedup` and the `X-openwop-*` family
|
|
7
|
+
* are removed from the v2 representation. Every response on any path MUST
|
|
8
|
+
* carry `OpenWOP-Version: <major>.<minor>`. The responses inspected are the
|
|
9
|
+
* v2 discovery document, a deliberately bad `GET /runs/does-not-exist`, and
|
|
10
|
+
* (when the fixture is seeded) a `POST /runs`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
15
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
16
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
17
|
+
import { req } from '../lib/requirement-ids.js';
|
|
18
|
+
|
|
19
|
+
const DOC = 'spec/v2/core/headers.md §Removed in v2';
|
|
20
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
21
|
+
const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
|
22
|
+
|
|
23
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
27
|
+
try { return await fn(); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function responses(): Promise<Array<[string, OpenWOPResponse]> | { reason: string }> {
|
|
31
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
32
|
+
const out: Array<[string, OpenWOPResponse]> = [];
|
|
33
|
+
const disco = await http(() => driver.get('/.well-known/openwop', { authenticated: false }));
|
|
34
|
+
if (disco !== null) out.push(['GET /.well-known/openwop', disco]);
|
|
35
|
+
const bad = await http(() => driver.get('/runs/does-not-exist'));
|
|
36
|
+
if (bad !== null) out.push(['GET /runs/does-not-exist', bad]);
|
|
37
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
38
|
+
if (created !== null) out.push(['POST /runs', created]);
|
|
39
|
+
if (out.length === 0) return { reason: 'no response could be collected (fetch failed on every probe)' };
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function offending(name: string): boolean {
|
|
44
|
+
const n = name.toLowerCase();
|
|
45
|
+
return n.startsWith('x-openwop-') || n === 'capabilities-etag' || n === 'x-dedup' || n === 'x-force-engine-version' || n.startsWith('x-pack-');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('v2 header-scheme (RFC 0171 §C.1; RFC 0172 §A.4)', () => {
|
|
49
|
+
it('no removed header family appears on any response', async () => {
|
|
50
|
+
const rs = await responses();
|
|
51
|
+
if (!Array.isArray(rs)) return softSkip('blocked', rs.reason);
|
|
52
|
+
for (const [label, res] of rs) {
|
|
53
|
+
const bad = [...res.headers.keys()].filter(offending);
|
|
54
|
+
expect(bad, req('openwop.requirement.0171.header-scheme.no-legacy-headers', DOC, `${label}: Capabilities-Etag, X-Dedup, X-Force-Engine-Version, X-Pack-* and X-openwop-* are removed in v2 — every non-standard header is OpenWOP-<Name>`)).toEqual([]);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('OpenWOP-Version names the contract on every response', async () => {
|
|
59
|
+
const rs = await responses();
|
|
60
|
+
if (!Array.isArray(rs)) return softSkip('blocked', rs.reason);
|
|
61
|
+
for (const [label, res] of rs) {
|
|
62
|
+
const v = res.headers.get('openwop-version');
|
|
63
|
+
expect(v !== null && VERSION.test(v.trim()), req('openwop.requirement.0171.header-scheme.version-on-every-response', 'spec/v2/core/versioning.md §1.4', `${label}: a response on any path MUST carry OpenWOP-Version: <major>.<minor> (got ${String(v)})`)).toBe(true);
|
|
64
|
+
expect(v?.trim().split('.')[0], req('openwop.requirement.0171.header-scheme.version-on-every-response', 'spec/v2/core/versioning.md §1.4', `${label}: the request named major 2, so the response MUST report the 2.x contract it used (reporting another major is a silent downgrade)`)).toBe('2');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `id-grammar` (suite 2.0.0; RFC 0170 §D.1;
|
|
3
|
+
* `spec/v2/core/identity.md` §5 "Identifier grammars").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. Every id in a run snapshot and in its
|
|
6
|
+
* event log matches its kind in `schemas/v2/ids.schema.json` (the snapshot and
|
|
7
|
+
* event schemas `$ref` the grammars, so a full-document validation is the
|
|
8
|
+
* witness; `runId` is additionally checked against the tenant-bound grammar
|
|
9
|
+
* by hand so a failure names the field). A crafted id whose tenant segment is
|
|
10
|
+
* not the caller's MUST be refused — `403 id_tenant_mismatch`, or `404
|
|
11
|
+
* not_found` where the host chooses not to leak existence.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
16
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
17
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
18
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
19
|
+
import { req } from '../lib/requirement-ids.js';
|
|
20
|
+
|
|
21
|
+
const DOC = 'spec/v2/core/identity.md §5';
|
|
22
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
23
|
+
const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
24
|
+
const FOREIGN_RUN_ID = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
|
|
25
|
+
|
|
26
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
27
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
30
|
+
try { return await fn(); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function createRun(): Promise<{ runId: string } | { reason: string }> {
|
|
34
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
35
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
36
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
37
|
+
const runId = (res.json as { runId?: unknown } | undefined)?.runId;
|
|
38
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
39
|
+
return { runId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('v2 id-grammar (RFC 0170 §D.1)', () => {
|
|
43
|
+
it('every id on the run snapshot matches its ids.schema.json kind', async () => {
|
|
44
|
+
const c = await createRun();
|
|
45
|
+
if ('reason' in c) return softSkip('blocked', c.reason);
|
|
46
|
+
const res = await http(() => driver.get(`/runs/${encodeURIComponent(c.runId)}`));
|
|
47
|
+
if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${res?.status ?? 'no response'} for the run just created`);
|
|
48
|
+
expect(RUN_ID.test(c.runId), req('openwop.requirement.0170.id-grammar.snapshot', DOC, `runId MUST be tenant-bound <tenantId>/<opaque> with a host-minted opaque segment ^[A-Za-z0-9._~-]{16,128}$ (got ${c.runId})`)).toBe(true);
|
|
49
|
+
const r = v2Validator('run-snapshot')(res.json);
|
|
50
|
+
expect(r.ok, req('openwop.requirement.0170.id-grammar.snapshot', DOC, `every id field on RunSnapshot MUST match its kind in schemas/v2/ids.schema.json (${r.errors})`)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('every id on the run events matches its ids.schema.json kind', async () => {
|
|
54
|
+
const c = await createRun();
|
|
55
|
+
if ('reason' in c) return softSkip('blocked', c.reason);
|
|
56
|
+
const res = await http(() => driver.get(`/runs/${encodeURIComponent(c.runId)}/events/poll?timeout=1`));
|
|
57
|
+
if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${res?.status ?? 'no response'}`);
|
|
58
|
+
const events = (res.json as { events?: unknown } | undefined)?.events;
|
|
59
|
+
if (!Array.isArray(events) || events.length === 0) return softSkip('blocked', 'the poll returned no events for the run just created — nothing to check the eventId / runId / nodeId grammars against');
|
|
60
|
+
const validate = v2Validator('run-event');
|
|
61
|
+
for (const ev of events) {
|
|
62
|
+
const r = validate(ev);
|
|
63
|
+
expect(r.ok, req('openwop.requirement.0170.id-grammar.events', DOC, `every RunEventDoc id (eventId, runId, nodeId, causationId) MUST match its kind — event ${String((ev as { eventId?: unknown }).eventId)} (${r.errors})`)).toBe(true);
|
|
64
|
+
expect((ev as { runId?: unknown }).runId, req('openwop.requirement.0170.id-grammar.events', DOC, 'every event MUST carry the run\'s own runId')).toBe(c.runId);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a run id whose tenant segment is not the caller\'s is refused', async () => {
|
|
69
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
70
|
+
const res = await http(() => driver.get(`/runs/${encodeURIComponent(FOREIGN_RUN_ID)}`));
|
|
71
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{foreign runId} unreachable (fetch failed)');
|
|
72
|
+
expect([403, 404].includes(res.status), req('openwop.requirement.0170.id-grammar.tenant-binding', DOC, `a tenant-bound id whose tenant segment is not the caller's MUST be rejected with 403 id_tenant_mismatch (or 404 not_found where existence is not leaked); got ${res.status}`)).toBe(true);
|
|
73
|
+
const code = readErrorCode(res.json);
|
|
74
|
+
expect(code === 'id_tenant_mismatch' || code === 'not_found', req('openwop.requirement.0170.id-grammar.tenant-binding', DOC, `the refusal MUST carry id_tenant_mismatch or not_found (got ${String(code)})`)).toBe(true);
|
|
75
|
+
expect(res.status === 403 ? code === 'id_tenant_mismatch' : code === 'not_found', req('openwop.requirement.0170.id-grammar.tenant-binding', 'spec/v2/core/errors.md', 'the code MUST be answered with its registered HTTP status (id_tenant_mismatch → 403, not_found → 404)')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `idempotency-key-grammar` (suite 2.0.0; RFC 0170 §D.3;
|
|
3
|
+
* `spec/v2/core/idempotency.md` §"Layer 1: Idempotency-Key").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `Idempotency-Key` MUST match
|
|
6
|
+
* `^[A-Za-z0-9._~-]{22,128}$`; a value outside the grammar MUST be rejected
|
|
7
|
+
* with `400 idempotency_key_invalid` (and MUST NOT be cached); a 22+ character
|
|
8
|
+
* key with 128 bits of entropy is accepted. The positive leg runs first so a
|
|
9
|
+
* host that cannot create the noop fixture at all records `blocked` rather than
|
|
10
|
+
* a false negative on the grammar.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
16
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
17
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
18
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
19
|
+
import { req } from '../lib/requirement-ids.js';
|
|
20
|
+
|
|
21
|
+
const DOC = 'spec/v2/core/idempotency.md §Layer 1';
|
|
22
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
23
|
+
const GRAMMAR = /^[A-Za-z0-9._~-]{22,128}$/;
|
|
24
|
+
|
|
25
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
26
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
29
|
+
try { return await fn(); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 128 bits of entropy, base64url — 22 characters, inside the grammar. */
|
|
33
|
+
function validKey(): string {
|
|
34
|
+
return randomBytes(16).toString('base64url');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('v2 idempotency-key-grammar (RFC 0170 §D.3)', () => {
|
|
38
|
+
it('a 22+ character key inside the grammar is accepted', async () => {
|
|
39
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
40
|
+
const key = validKey();
|
|
41
|
+
expect(GRAMMAR.test(key), req('openwop.requirement.0170.idempotency-key-grammar.valid-accepted', DOC, 'the suite\'s key MUST itself be inside ^[A-Za-z0-9._~-]{22,128}$')).toBe(true);
|
|
42
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }, { headers: { 'Idempotency-Key': key } }));
|
|
43
|
+
if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
44
|
+
if (res.status >= 400 && readErrorCode(res.json) !== 'idempotency_key_invalid') {
|
|
45
|
+
return softSkip('blocked', `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the create itself was refused, so the key grammar cannot be observed (fixture not seeded?)`);
|
|
46
|
+
}
|
|
47
|
+
expect(res.status, req('openwop.requirement.0170.idempotency-key-grammar.valid-accepted', DOC, 'a UUIDv4-strength key in base64url form satisfies the grammar and MUST NOT be refused as idempotency_key_invalid')).toBe(201);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('a key outside the grammar is refused with 400 idempotency_key_invalid', async () => {
|
|
51
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
52
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }, { headers: { 'Idempotency-Key': 'short' } }));
|
|
53
|
+
if (res === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
54
|
+
if (res.status >= 400 && readErrorCode(res.json) !== 'idempotency_key_invalid') {
|
|
55
|
+
// Disambiguate "the key was not examined" from "the host refused the
|
|
56
|
+
// create for another reason": the same body under a valid key is the control.
|
|
57
|
+
const control = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }, { headers: { 'Idempotency-Key': validKey() } }));
|
|
58
|
+
if (control === null || control.status >= 400) {
|
|
59
|
+
return softSkip('blocked', `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''} and the control create under a valid key answered ${control?.status ?? 'no response'} — the create itself is refused, so the key grammar cannot be observed (fixture not seeded?)`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
expect(res.status, req('openwop.requirement.0170.idempotency-key-grammar.invalid-refused', DOC, 'a host MUST reject an Idempotency-Key outside ^[A-Za-z0-9._~-]{22,128}$ with 400')).toBe(400);
|
|
63
|
+
expect(readErrorCode(res.json), req('openwop.requirement.0170.idempotency-key-grammar.invalid-refused', DOC, 'the refusal code MUST be idempotency_key_invalid')).toBe('idempotency_key_invalid');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `interrupt-token-scheme` (suite 2.0.0; RFC 0170 §E.1;
|
|
3
|
+
* `spec/v2/core/identity.md` §4 "Resume tokens"; `spec/v2/core/interrupt.md` §Tokens).
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. A resume token is
|
|
6
|
+
* `ow2.<alg>.<kid>.<payload>.<mac>`. A host MUST refuse a token whose `alg` it
|
|
7
|
+
* does not advertise or whose `kid` it does not hold with
|
|
8
|
+
* `401 interrupt_token_invalid`. The signed-token surface
|
|
9
|
+
* (`GET /interrupts/{token}`) is a SHOULD, so a host that answers `404
|
|
10
|
+
* not_found` to every token probe records `inapplicable`; once the surface is
|
|
11
|
+
* observed to be mounted, a well-formed token under an unheld `kid` MUST be
|
|
12
|
+
* `interrupt_token_invalid`, never `not_found`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
17
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
18
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
19
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
20
|
+
import { req } from '../lib/requirement-ids.js';
|
|
21
|
+
|
|
22
|
+
const DOC = 'spec/v2/core/identity.md §4';
|
|
23
|
+
const UNPREFIXED = 'b3BlbndvcC1jb25mb3JtYW5jZQ.0123456789abcdef0123456789abcdef';
|
|
24
|
+
const UNHELD_KID = 'ow2.hs256.nokid.b3BlbndvcC1jb25mb3JtYW5jZQ.0123456789abcdef0123456789abcdef';
|
|
25
|
+
|
|
26
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
27
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
30
|
+
try { return await fn(); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
function inspect(token: string): Promise<OpenWOPResponse | null> {
|
|
33
|
+
return http(() => driver.get(`/interrupts/${encodeURIComponent(token)}`, { authenticated: false }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('v2 interrupt-token-scheme (RFC 0170 §E.1)', () => {
|
|
37
|
+
it('a token without the ow2. prefix is refused', async () => {
|
|
38
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
39
|
+
const res = await inspect(UNPREFIXED);
|
|
40
|
+
if (res === null) return softSkip('blocked', 'GET /interrupts/{token} unreachable (fetch failed)');
|
|
41
|
+
expect([401, 404].includes(res.status), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', DOC, `a token outside the ow2.<alg>.<kid>.<payload>.<mac> grammar MUST NOT resolve — 401 interrupt_token_invalid (or 404 not_found where the signed-token surface is not mounted); got ${res.status}`)).toBe(true);
|
|
42
|
+
const code = readErrorCode(res.json);
|
|
43
|
+
expect(res.status === 401 ? code === 'interrupt_token_invalid' : code === 'not_found', req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry its registered code (401 → interrupt_token_invalid, 404 → not_found); got ${String(code)}`)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('a well-formed token under a kid the host does not hold is 401 interrupt_token_invalid', async () => {
|
|
47
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
48
|
+
const probe = await inspect(UNPREFIXED);
|
|
49
|
+
const res = await inspect(UNHELD_KID);
|
|
50
|
+
if (probe === null || res === null) return softSkip('blocked', 'GET /interrupts/{token} unreachable (fetch failed)');
|
|
51
|
+
if (probe.status === 404 && res.status === 404) return softSkip('inapplicable', 'the signed-token surface GET /interrupts/{token} is not mounted (404 to every token probe) — a SHOULD in interrupt.md; the kid rule cannot be observed');
|
|
52
|
+
expect(res.status, req('openwop.requirement.0170.interrupt-token-scheme.unheld-kid-refused', DOC, 'a host MUST refuse a token whose kid it does not hold with 401')).toBe(401);
|
|
53
|
+
expect(readErrorCode(res.json), req('openwop.requirement.0170.interrupt-token-scheme.unheld-kid-refused', DOC, 'the refusal code MUST be interrupt_token_invalid (one code per state — never not_found for a token the host cannot verify)')).toBe('interrupt_token_invalid');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `lane-issuer-advertised` (suite 2.0.0; RFC 0170 §B.2–§B.4;
|
|
3
|
+
* `spec/v2/core/identity.md` §2.1–§2.3; facet `spec/v2/facets/auth.schema.json`).
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. Every member of `auth.lanes[]` MUST
|
|
6
|
+
* name `lane`, `issuers[]` (min 1, the realm), `revocation` and
|
|
7
|
+
* `minimumAssurance`; `revocationWindowSeconds` MUST accompany a rule that
|
|
8
|
+
* names a window (`exp-and-recheck`, `short-lived`, `rebind`). A host that does
|
|
9
|
+
* not advertise the `auth` family records `inapplicable`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
14
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
15
|
+
import { req } from '../lib/requirement-ids.js';
|
|
16
|
+
|
|
17
|
+
const DOC = 'spec/v2/core/identity.md §2.1';
|
|
18
|
+
const LANES = new Set(['api-key', 'oauth2', 'oidc', 'mtls', 'saml', 'scim', 'ldap', 'workload', 'session', 'anonymous']);
|
|
19
|
+
const REVOCATION = new Set(['next-request', 'exp-and-recheck', 'crl', 'ocsp', 'short-lived', 'not-on-or-after', 'bound-connection', 'rebind', 'delegation-expiry']);
|
|
20
|
+
const WINDOWED = new Set(['exp-and-recheck', 'short-lived', 'rebind']);
|
|
21
|
+
const ASSURANCE = new Set(['bearer', 'sender-constrained', 'key-bound']);
|
|
22
|
+
const PROOFS = new Set(['mtls-key-binding', 'dpop', 'svid-chain']);
|
|
23
|
+
|
|
24
|
+
async function lanes(): Promise<Array<Record<string, unknown>> | { reason: string }> {
|
|
25
|
+
let doc: Record<string, unknown> | null;
|
|
26
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
27
|
+
if (!doc) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
28
|
+
const auth = await familyAdvertised('auth');
|
|
29
|
+
if (auth === null) return { reason: 'the host does not advertise the `auth` family at the v2 root' };
|
|
30
|
+
const arr = auth['lanes'];
|
|
31
|
+
if (!Array.isArray(arr) || arr.length === 0) return { reason: 'FAIL-SHAPED: `auth` is advertised but `auth.lanes[]` is absent or empty (REQUIRED, minItems 1) — the capabilities-root-closed scenario fails the document' };
|
|
32
|
+
return arr.filter((l): l is Record<string, unknown> => l !== null && typeof l === 'object');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('v2 lane-issuer-advertised (RFC 0170 §B.2–§B.4)', () => {
|
|
36
|
+
it('every auth.lanes[] member names lane, issuers[], revocation and minimumAssurance', async () => {
|
|
37
|
+
const ls = await lanes();
|
|
38
|
+
if (!Array.isArray(ls)) return softSkip(ls.reason.startsWith('FAIL-SHAPED') ? 'blocked' : 'inapplicable', ls.reason);
|
|
39
|
+
for (const l of ls) {
|
|
40
|
+
const name = String(l['lane']);
|
|
41
|
+
expect(LANES.has(name), req('openwop.requirement.0170.lane-issuer-advertised.members', DOC, `auth.lanes[].lane MUST be one of the ten lanes (got ${name})`)).toBe(true);
|
|
42
|
+
const issuers = l['issuers'];
|
|
43
|
+
expect(Array.isArray(issuers) && issuers.length > 0 && issuers.every((i) => typeof i === 'string' && i.length > 0), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.2', `lane ${name} MUST advertise its trust root in issuers[] (min 1, non-empty strings)`)).toBe(true);
|
|
44
|
+
expect(REVOCATION.has(String(l['revocation'])), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.2', `lane ${name} MUST name its revocation rule`)).toBe(true);
|
|
45
|
+
expect(ASSURANCE.has(String(l['minimumAssurance'])), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.3', `lane ${name} MUST advertise minimumAssurance: bearer | sender-constrained | key-bound`)).toBe(true);
|
|
46
|
+
if (l['delegationProofs'] !== undefined) {
|
|
47
|
+
expect(Array.isArray(l['delegationProofs']) && (l['delegationProofs'] as unknown[]).every((p) => PROOFS.has(String(p))), req('openwop.requirement.0170.lane-issuer-advertised.members', 'spec/v2/core/identity.md §2.4', `lane ${name}: delegationProofs[] MUST be drawn from mtls-key-binding | dpop | svid-chain`)).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('a windowed revocation rule advertises revocationWindowSeconds', async () => {
|
|
53
|
+
const ls = await lanes();
|
|
54
|
+
if (!Array.isArray(ls)) return softSkip(ls.reason.startsWith('FAIL-SHAPED') ? 'blocked' : 'inapplicable', ls.reason);
|
|
55
|
+
const windowed = ls.filter((l) => WINDOWED.has(String(l['revocation'])));
|
|
56
|
+
if (windowed.length === 0) return softSkip('inapplicable', 'no advertised lane uses a windowed revocation rule (exp-and-recheck | short-lived | rebind)');
|
|
57
|
+
for (const l of windowed) {
|
|
58
|
+
const w = l['revocationWindowSeconds'];
|
|
59
|
+
expect(Number.isInteger(w) && (w as number) >= 1, req('openwop.requirement.0170.lane-issuer-advertised.window', 'spec/v2/core/identity.md §2.2', `lane ${String(l['lane'])} (revocation ${String(l['revocation'])}) MUST advertise revocationWindowSeconds (integer ≥ 1)`)).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §C.1 — `legacy-profiles-absent` (suite 2.0.0, target major 2; unaided).
|
|
3
|
+
*
|
|
4
|
+
* `a2a-0.3-legacy` and `mcp-2025-06-18-legacy` do not exist in the v2 tree: the
|
|
5
|
+
* `profiles[]` item patterns admit no `-legacy` suffix
|
|
6
|
+
* (`spec/v2/facets/a2a.schema.json` `^a2a-[0-9]+\.[0-9]+$`,
|
|
7
|
+
* `spec/v2/facets/mcp.schema.json` `^mcp-[0-9]{4}-[0-9]{2}-[0-9]{2}$`), and a
|
|
8
|
+
* host that still speaks a legacy version does so as a private, non-advertised
|
|
9
|
+
* behavior (`spec/v2/core/interop.md` §Legacy profiles are absent; RFC 0175 row
|
|
10
|
+
* C8.1). No root `profiles[]` exists either (RFC 0169 §C.1).
|
|
11
|
+
*
|
|
12
|
+
* Unaided; gated only in the sense that a host advertising neither facet has
|
|
13
|
+
* no `profiles[]` to check — that leg records `inapplicable`, the root leg runs
|
|
14
|
+
* on every host.
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v2/core/interop.md §Legacy profiles are absent
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { req } from '../lib/requirement-ids.js';
|
|
23
|
+
|
|
24
|
+
const PATTERNS: Record<string, RegExp> = {
|
|
25
|
+
a2a: /^a2a-[0-9]+\.[0-9]+$/,
|
|
26
|
+
mcp: /^mcp-[0-9]{4}-[0-9]{2}-[0-9]{2}$/,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
30
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function profilesOf(doc: Record<string, unknown>, family: string): string[] | null {
|
|
34
|
+
const rec = doc[family];
|
|
35
|
+
if (!rec || typeof rec !== 'object') return null;
|
|
36
|
+
const p = (rec as { profiles?: unknown }).profiles;
|
|
37
|
+
return Array.isArray(p) ? p.map(String) : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('RFC 0175 §C.1 — legacy-profiles-absent (unaided)', () => {
|
|
41
|
+
it('no -legacy id appears in a2a.profiles or mcp.profiles and each id matches its facet pattern', async () => {
|
|
42
|
+
const doc = await discovery();
|
|
43
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
44
|
+
let checked = 0;
|
|
45
|
+
for (const family of ['a2a', 'mcp'] as const) {
|
|
46
|
+
const profiles = profilesOf(doc, family);
|
|
47
|
+
if (profiles === null) continue;
|
|
48
|
+
checked++;
|
|
49
|
+
for (const id of profiles) {
|
|
50
|
+
expect(
|
|
51
|
+
/-legacy$/.test(id),
|
|
52
|
+
req('openwop.requirement.0175.legacy-profiles-absent', 'interop.md §Legacy profiles are absent', `${family}.profiles MUST NOT carry a -legacy id (got ${id}); the legacy code paths are not part of the v2 corpus (RFC 0175 §C.1)`),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
expect(
|
|
55
|
+
PATTERNS[family]!.test(id),
|
|
56
|
+
req('openwop.requirement.0175.legacy-profiles-absent', `facets/${family}.schema.json profiles[]`, `${family}.profiles ids MUST match ${PATTERNS[family]!.source} (got ${id})`),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
// The facet as a whole — versions/revisions, floor, refreshedAt — validates
|
|
60
|
+
// through the capabilities schema, whose family records are the facets.
|
|
61
|
+
const check = v2Validator('capabilities')({ protocolVersions: ['2.0'], preferredVersion: '2.0', [family]: doc[family] });
|
|
62
|
+
expect(
|
|
63
|
+
check.ok,
|
|
64
|
+
req('openwop.requirement.0175.legacy-profiles-absent', `facets/${family}.schema.json`, `the ${family} facet MUST validate against its facet schema (capabilities.schema.json ${family}): ${check.errors}`),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
}
|
|
67
|
+
if (checked === 0) softSkip('inapplicable', 'host advertises neither a2a nor mcp — no profiles[] to check for a -legacy id');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('no root profiles[] exists in the v2 representation', async () => {
|
|
71
|
+
const doc = await discovery();
|
|
72
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
73
|
+
expect(
|
|
74
|
+
'profiles' in doc,
|
|
75
|
+
req('openwop.requirement.0175.legacy-profiles-absent.no-root-profiles', 'capabilities.md §Profiles', 'no `profiles[]` exists at the v2 root — a profile is a predicate over the declaration file, not an advertisement (RFC 0169 §C.1)'),
|
|
76
|
+
).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|