@openwop/openwop-conformance 1.153.0 → 1.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +9 -0
  3. package/dist/cli.js +7 -2
  4. package/package.json +31 -2
  5. package/schemas/CORPUS-STAMP.json +99 -3
  6. package/src/cli.ts +7 -5
  7. package/src/global-setup.ts +13 -0
  8. package/src/lib/corpus-stamp.ts +125 -0
  9. package/src/lib/capabilities-auth-subject-link.test.ts +0 -103
  10. package/src/lib/fork-availability.test.ts +0 -69
  11. package/src/lib/global-setup.test.ts +0 -76
  12. package/src/lib/grpc-framing.test.ts +0 -96
  13. package/src/lib/oidc-issuer.test.ts +0 -328
  14. package/src/lib/otel-collector-grpc.test.ts +0 -191
  15. package/src/lib/otel-collector.test.ts +0 -303
  16. package/src/lib/otlp-protobuf.test.ts +0 -461
  17. package/src/lib/polling.test.ts +0 -80
  18. package/src/lib/requirement-ids.test.ts +0 -83
  19. package/src/lib/requirement-ledger.test.ts +0 -75
  20. package/src/lib/risk-disposition.test.ts +0 -91
  21. package/src/lib/saml-idp.test.ts +0 -127
  22. package/src/lib/spec-coherence-registry.test.ts +0 -155
  23. package/src/lib/webhook-receiver.test.ts +0 -144
  24. package/src/scenarios/artifact-schema-compile-bounded.test.ts +0 -126
  25. package/src/scenarios/artifact-type-legacy-ids.test.ts +0 -124
  26. package/src/scenarios/capability-example-root-layout.test.ts +0 -272
  27. package/src/scenarios/certification-floor-enforcement.test.ts +0 -204
  28. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +0 -70
  29. package/src/scenarios/compensation-profile.test.ts +0 -340
  30. package/src/scenarios/core-manifest-and-extension-registry.test.ts +0 -250
  31. package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +0 -219
  32. package/src/scenarios/edge-condition-truthy-falsy.test.ts +0 -108
  33. package/src/scenarios/effect-identity-composition.test.ts +0 -129
  34. package/src/scenarios/effect-identity-cross-scope.test.ts +0 -82
  35. package/src/scenarios/error-envelope-canonical-shape.test.ts +0 -64
  36. package/src/scenarios/form-content-packs.test.ts +0 -415
  37. package/src/scenarios/multi-region-effect-vocabulary.test.ts +0 -175
  38. package/src/scenarios/normative-example-extraction.test.ts +0 -242
  39. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +0 -309
  40. package/src/scenarios/pack-manifest-extensions.test.ts +0 -203
  41. package/src/scenarios/protocol-version-grammar.test.ts +0 -119
  42. package/src/scenarios/registry-declarative-kinds.test.ts +0 -121
  43. package/src/scenarios/rfc-0147-self-audit.test.ts +0 -104
  44. package/src/scenarios/rfc-lifecycle-coherence.test.ts +0 -215
  45. package/src/scenarios/semantic-digest-v2.test.ts +0 -128
  46. package/src/scenarios/spec-corpus-validity.test.ts +0 -1727
  47. package/src/scenarios/spec-section-citations.test.ts +0 -132
  48. package/src/scenarios/tool-result-trust-monotone.test.ts +0 -168
  49. package/src/scenarios/versioned-composition-profiles.test.ts +0 -201
  50. package/src/scenarios/workflow-chain-internal-flag.test.ts +0 -84
  51. package/src/scenarios/workload-identity-profile.test.ts +0 -184
@@ -1,91 +0,0 @@
1
- /**
2
- * Unit tests for the closed/transferred predicate in
3
- * `scripts/generate-assurance-status.mjs`.
4
- *
5
- * This predicate is not cosmetic. Project-wide gates have been keyed to the
6
- * open-Critical count it produces, so a false closure silently loosens a
7
- * constraint — and a false open silently keeps one in force.
8
- *
9
- * It previously matched the bare substring `closed` anywhere in a row's status
10
- * cell, which produced two failures in opposite directions:
11
- *
12
- * · RFC 0151 R1 ("Compensation executes twice", Critical) reads
13
- * "Open — ... unwitnessed" and was counted CLOSED from 2026-08-16 onward,
14
- * because the cell mentions "(G1 closed 2026-08-16)" — a DIFFERENT item's
15
- * closure. A substring of an adjacent concept.
16
- * · A row stating that a risk "cannot be closed by repository work" was
17
- * counted closed by saying so.
18
- *
19
- * The predicate is duplicated here rather than imported because the generator is
20
- * a standalone ESM script with no exports; the duplication is pinned by
21
- * `matches the generator's source` below, which fails if the two drift.
22
- *
23
- * @see scripts/generate-assurance-status.mjs
24
- */
25
-
26
- import { describe, it, expect } from 'vitest';
27
- import { readFileSync, existsSync } from 'node:fs';
28
- import { join, dirname } from 'node:path';
29
- import { V1_DIR } from './paths.js';
30
-
31
- const NEGATED = /\b(cannot|can ?not|could not|will not|never|not)\s+be\s+(closed|resolved)\b|\bnot closed\b/i;
32
- const EXPLICIT = /\*\*(CLOSED|Closed)\b|~~|Realised and remediated/i;
33
- const TRANSFERRED = /\*\*(?:OPEN\s+—\s+)?TRANSFERRED\b/i;
34
-
35
- function disposition(status: string): 'closed' | 'transferred' | 'open' {
36
- const closed = EXPLICIT.test(status) && !NEGATED.test(status);
37
- if (closed) return 'closed';
38
- return TRANSFERRED.test(status) ? 'transferred' : 'open';
39
- }
40
-
41
- describe('assurance risk disposition', () => {
42
- it('does NOT read a nested reference to another item as this row being closed', () => {
43
- // The exact RFC 0151 R1 shape. "Open", "unwitnessed", and a parenthetical
44
- // about gap G1 closing — a different thing entirely.
45
- const status =
46
- 'Open — **Sweep 2026-08-16:** **Mitigated in prose** — inverse-action identity tuple stated; ' +
47
- '`compensation.md` §C now states the persistence shape (G1 closed 2026-08-16); unwitnessed for retry-stability';
48
- expect(disposition(status)).toBe('open');
49
- });
50
-
51
- it('does NOT read a row that says a risk cannot be closed as closed', () => {
52
- expect(disposition('**OPEN — TRANSFERRED.** This risk cannot be closed by repository work.')).not.toBe('closed');
53
- });
54
-
55
- it('reads an explicit marker as closed', () => {
56
- expect(disposition('**CLOSED.** The recurrence mechanism is in the tree and executing.')).toBe('closed');
57
- expect(disposition('~~superseded~~ — folded into RFC 0150 §D')).toBe('closed');
58
- expect(disposition('**Realised and remediated in scope:** bundle 1 invalidated')).toBe('closed');
59
- });
60
-
61
- it('distinguishes transferred from open, because §A.1 turns on the difference', () => {
62
- expect(disposition('**OPEN — TRANSFERRED to a named tracked surface.** Tracked in KNOWN-LIMITS.')).toBe('transferred');
63
- expect(disposition('Open — unwitnessed. No host advertises the capability.')).toBe('open');
64
- });
65
-
66
- it('is not fooled by the word appearing in ordinary prose', () => {
67
- expect(disposition('Open — the comment window closed without review.')).toBe('open');
68
- expect(disposition('Open — closes when a host implements fencing.')).toBe('open');
69
- });
70
-
71
- it('matches the generator source — fails if the two drift apart', () => {
72
- // `V1_DIR` is null in the PUBLISHED package layout, which ships no `spec/`
73
- // and no `scripts/`. Resolving the path at module scope — or casting the
74
- // null away — is what made six scenarios throw at import for every npm
75
- // consumer while staying green in a repo checkout. Resolve inside the test
76
- // and skip when the repo is not there.
77
- if (V1_DIR === null) return;
78
- const script = join(dirname(V1_DIR), '..', 'scripts', 'generate-assurance-status.mjs');
79
- if (!existsSync(script)) return;
80
- const src = readFileSync(script, 'utf8');
81
- // Guard the shape, not the byte-for-byte text: the generator must still gate
82
- // on an explicit marker AND a negation check, never on a bare substring.
83
- expect(src, 'generator must keep the negation guard').toMatch(/const negated = /);
84
- expect(src, 'generator must require an explicit closed marker').toMatch(/const explicitlyClosed =/);
85
- expect(src, 'generator must still separate transferred rows').toMatch(/const transferred = /);
86
- expect(
87
- /const closed = \/\(\^\|\\s\)\(closed\|resolved/.test(src),
88
- 'the bare-substring test must not come back',
89
- ).toBe(false);
90
- });
91
- });
@@ -1,127 +0,0 @@
1
- /**
2
- * Server-free unit tests for the synthetic SAML IdP harness — specifically its
3
- * TWO-TRUST-ROOT behaviour (RFC 0163 §B, gap G-fixture, #1163).
4
- *
5
- * These assertions prove the FIXTURE, not any host: two instances with distinct
6
- * `entityID`s and signing keys can mint an assertion for the SAME opaque
7
- * subject id (an identifier collision) while remaining cryptographically and
8
- * by-issuer distinguishable. That is what makes the §B same-IdP MUST
9
- * witnessable — a host comparing the SAML Issuer to the SCIM connection's IdP
10
- * entityID has a well-defined, causable negative.
11
- *
12
- * They live here rather than in `scenarios/auth-subject-link-key-class.test.ts`
13
- * on purpose: a scenario file that passes fixture self-tests and soft-skips its
14
- * host legs resolves to `executed-pass` in every host's certification bundle
15
- * (conformance-certification.md gap G8), which would credit a host that never
16
- * advertised subject linking with a witness about a fixture. `src/lib/` tests
17
- * run in the published suite but produce no scenario ledger row, exactly like
18
- * `oidc-issuer.test.ts`.
19
- *
20
- * @see conformance/src/lib/saml-idp.ts
21
- * @see RFCS/0163-subject-linking-hardening.md §B
22
- *
23
- * The RFC 0050 §A reference suite (1 positive + 6 negative variants against the
24
- * same fixture) moved here from `scenarios/auth-saml-profile.test.ts` on
25
- * 2026-09-02 (RFC 0163 gap G5) for the same reason.
26
- */
27
-
28
- import { describe, it, expect } from 'vitest';
29
- import { createSyntheticSamlIdp, DEFAULT_SAML_ENTITY_ID, type SamlVariant } from './saml-idp.js';
30
-
31
- const COLLIDING_SUBJECT = 'idp-op-8f3a';
32
-
33
- describe('saml-idp: two-trust-root fixture (RFC 0163 §B)', () => {
34
- const idpA = createSyntheticSamlIdp({ entityID: 'urn:openwop:conformance:idp-A' });
35
- const idpB = createSyntheticSamlIdp({ entityID: 'urn:openwop:conformance:idp-B' });
36
-
37
- it('a lone instance keeps the canonical default entityID (RFC 0050 behaviour unchanged)', () => {
38
- const lone = createSyntheticSamlIdp();
39
- expect(lone.entityID).toBe(DEFAULT_SAML_ENTITY_ID);
40
- expect(lone.verify(lone.mint('valid')).valid).toBe(true);
41
- });
42
-
43
- it('the two IdPs have distinct entityIDs and distinct signing keys (distinct trust roots)', () => {
44
- expect(idpA.entityID).not.toBe(idpB.entityID);
45
- expect(idpA.certificatePem).not.toBe(idpB.certificatePem);
46
- });
47
-
48
- it('both IdPs can mint a valid assertion for the SAME colliding subject id', () => {
49
- const a = idpA.verify(idpA.mint('valid', { subject: COLLIDING_SUBJECT }));
50
- const b = idpB.verify(idpB.mint('valid', { subject: COLLIDING_SUBJECT }));
51
- expect(a.valid, `IdP-A must accept its own assertion; got ${a.reason}`).toBe(true);
52
- expect(b.valid, `IdP-B must accept its own assertion; got ${b.reason}`).toBe(true);
53
- });
54
-
55
- it('the colliding assertions carry DIFFERENT signed Issuers (the trust-root discriminator)', () => {
56
- const assertionA = idpA.mint('valid', { subject: COLLIDING_SUBJECT });
57
- const assertionB = idpB.mint('valid', { subject: COLLIDING_SUBJECT });
58
- expect(idpA.issuerOf(assertionA)).toBe('urn:openwop:conformance:idp-A');
59
- expect(idpB.issuerOf(assertionB)).toBe('urn:openwop:conformance:idp-B');
60
- expect(idpA.issuerOf(assertionA)).not.toBe(idpB.issuerOf(assertionB));
61
- });
62
-
63
- it('IdP-A REJECTS an assertion minted by IdP-B for the same subject (cross-root does not verify)', () => {
64
- // The crux of RFC 0163 §B: an identifier collision across two trust roots
65
- // is NOT a link. IdP-B signs with a different key AND stamps a different
66
- // Issuer, so IdP-A's validator refuses it — a match on the opaque subject
67
- // string alone can never authorize across the roots.
68
- const crossRoot = idpB.mint('valid', { subject: COLLIDING_SUBJECT });
69
- const r = idpA.verify(crossRoot);
70
- expect(r.valid, 'an assertion from a DIFFERENT trust root MUST NOT verify against this IdP').toBe(false);
71
- expect(r.reason).toBe('bad-signature');
72
- expect(idpA.issuerOf(crossRoot)).not.toBe(idpA.entityID);
73
- });
74
-
75
- it('the Issuer is inside the signed element: swapping it post-signing breaks verification', () => {
76
- // RFC 0163 §B.1 requires the trust-root identity to be signed so it cannot
77
- // be rewritten to impersonate another root. Rewrite IdP-B's Issuer to
78
- // IdP-A's entityID on an otherwise valid IdP-B assertion: the issuer now
79
- // READS as A, but the signature was computed over B's canonical form.
80
- const forged = idpB
81
- .mint('valid', { subject: COLLIDING_SUBJECT })
82
- .replace('<saml:Issuer>urn:openwop:conformance:idp-B</saml:Issuer>', '<saml:Issuer>urn:openwop:conformance:idp-A</saml:Issuer>');
83
- expect(idpA.issuerOf(forged)).toBe(idpA.entityID);
84
- expect(idpA.verify(forged).valid, 'a re-stamped Issuer MUST NOT verify').toBe(false);
85
- expect(idpB.verify(forged).valid, 'the original root MUST NOT verify it either').toBe(false);
86
- });
87
- });
88
-
89
- describe('saml-idp: synthetic-IdP reference suite (RFC 0050 §A)', () => {
90
- // Server-free: the bundled synthetic IdP (conformance/src/lib/saml-idp.ts)
91
- // mints a valid assertion + the 6 negative variants, and its verify()
92
- // implements the RFC 0050 §A MUST list. This proves each negative is
93
- // detectably malformed and gives the suite a reference SAML validator.
94
- // A host's real ACS validates the SAME assertions over the
95
- // `auth/saml/validate` seam (scenarios/auth-saml-profile.test.ts, gated on
96
- // OPENWOP_TEST_SAML_IDP_URL).
97
- const idp = createSyntheticSamlIdp();
98
-
99
- it('publishes a PEM signing certificate', () => {
100
- expect(idp.certificatePem).toContain('BEGIN PUBLIC KEY');
101
- });
102
-
103
- it('accepts a valid signed, in-window, non-wrapped assertion', () => {
104
- const r = idp.verify(idp.mint('valid'));
105
- expect(r.valid, `expected valid; got reason=${r.reason}`).toBe(true);
106
- });
107
-
108
- const negatives: ReadonlyArray<[Exclude<SamlVariant, 'valid'>, string]> = [
109
- ['alg-none', 'alg-none'],
110
- ['unsigned', 'unsigned'],
111
- ['bad-signature', 'bad-signature'],
112
- ['expired', 'expired'],
113
- ['not-yet-valid', 'not-yet-valid'],
114
- ['signature-wrapping', 'signature-wrapping'],
115
- ];
116
-
117
- for (const [variant, expectedReason] of negatives) {
118
- it(`rejects the ${variant} assertion (RFC 0050 §A MUST)`, () => {
119
- const r = idp.verify(idp.mint(variant));
120
- expect(r.valid, `${variant} MUST be rejected`).toBe(false);
121
- expect(
122
- r.reason,
123
- `${variant} MUST be rejected for the ${expectedReason} reason`,
124
- ).toBe(expectedReason);
125
- });
126
- }
127
- });
@@ -1,155 +0,0 @@
1
- /**
2
- * Keeps `SPEC_COHERENCE_SCENARIOS` honest by re-deriving it from source.
3
- *
4
- * A hand-maintained list of filenames is a claim that decays silently: a new
5
- * spec-coherence scenario lands and reports `blocked` in every host's bundle
6
- * forever, or one grows a `driver` call and starts telling hosts a requirement
7
- * about their own behaviour does not apply to them. Neither shows up as a
8
- * failure anywhere — which is the whole reason the original defect survived.
9
- *
10
- * The membership rule is mechanical, so the check can be too:
11
- * gates on `V1_DIR === null` AND never calls `driver.get/post/delete`.
12
- */
13
-
14
- import { describe, expect, it } from 'vitest';
15
- import { readFileSync, readdirSync } from 'node:fs';
16
- import { join } from 'node:path';
17
- import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL, SPEC_COHERENCE_EXCLUDED } from './spec-coherence.js';
18
- import { resolveFileRecord } from './scenario-disposition.js';
19
-
20
- const SCENARIOS = new URL('../scenarios/', import.meta.url).pathname;
21
-
22
- function derive(): { pure: string[]; hostTouching: string[] } {
23
- const pure: string[] = [];
24
- const hostTouching: string[] = [];
25
- for (const f of readdirSync(SCENARIOS)) {
26
- if (!f.endsWith('.test.ts')) continue;
27
- const src = readFileSync(join(SCENARIOS, f), 'utf8');
28
- if (!/V1_DIR\s*===?\s*null/.test(src)) continue;
29
- (/\bdriver\.(get|post|delete)\b/.test(src) ? hostTouching : pure).push(f);
30
- }
31
- return { pure: pure.sort(), hostTouching: hostTouching.sort() };
32
- }
33
-
34
- describe('SPEC_COHERENCE_SCENARIOS is derivable, not asserted', () => {
35
- it('matches every scenario that reads spec/v1 and never drives a host', () => {
36
- const { pure } = derive();
37
- const listed = [...SPEC_COHERENCE_SCENARIOS].sort();
38
- // Named diffs rather than a bare inequality: a failure here should say
39
- // which file to add or drop, not that two sets differ.
40
- expect(pure.filter((f) => !SPEC_COHERENCE_SCENARIOS.has(f)), 'reads spec/v1, drives no host, NOT in the registry — it will report `blocked` in every host bundle').toEqual([]);
41
- expect(listed.filter((f) => !pure.includes(f)), 'in the registry but no longer qualifies — it now drives a host, or stopped reading spec/v1').toEqual([]);
42
- });
43
-
44
- it('the EXCLUDED set is exactly the host-touching ones — the exclusions are checked, not asserted in prose', () => {
45
- // These used to live only in a docblock sentence. A peer grepping this file
46
- // for membership matched that sentence and read all three named scenarios
47
- // as members — the opposite of the truth, and the dangerous direction: it
48
- // would mean host-behaviour rows downgraded to "does not apply to you" as a
49
- // credit. Naming them in prose made the file lie to a reasonable reader.
50
- const { hostTouching } = derive();
51
- expect([...SPEC_COHERENCE_EXCLUDED].sort()).toEqual(hostTouching);
52
- });
53
-
54
- it('the two sets are disjoint and jointly exhaustive over the V1_DIR-gated files', () => {
55
- // Disjoint: no scenario can be both "does not apply to any host" and
56
- // "applies but was unwitnessable". Exhaustive: every V1_DIR-gated file has
57
- // a decided disposition, so none falls back to the unclassified marker.
58
- const { pure, hostTouching } = derive();
59
- const overlap = [...SPEC_COHERENCE_SCENARIOS].filter((f) => SPEC_COHERENCE_EXCLUDED.has(f));
60
- expect(overlap, 'a scenario cannot be both inapplicable-to-all-hosts and blocked-for-this-host').toEqual([]);
61
- const union = new Set([...SPEC_COHERENCE_SCENARIOS, ...SPEC_COHERENCE_EXCLUDED]);
62
- expect([...union].sort()).toEqual([...pure, ...hostTouching].sort());
63
- });
64
-
65
- it('excludes the host-touching ones, which are honestly `blocked`', () => {
66
- // These assert ADVERTISED behaviour that a missing dependency prevented
67
- // exercising — RFC 0148 §A's definition of `blocked`, verbatim. Calling
68
- // them `inapplicable` would tell a host a requirement about its own
69
- // behaviour does not apply to it.
70
- const { hostTouching } = derive();
71
- expect(hostTouching.length, 'expected some V1_DIR-gated scenarios to also drive the host').toBeGreaterThan(0);
72
- for (const f of hostTouching) {
73
- expect(SPEC_COHERENCE_SCENARIOS.has(f), `${f} drives a host and must NOT be classified inapplicable`).toBe(false);
74
- }
75
- });
76
-
77
- it('the registry is non-empty — an empty set would silently disable the fix', () => {
78
- expect(SPEC_COHERENCE_SCENARIOS.size).toBeGreaterThan(20);
79
- });
80
- });
81
-
82
- describe('resolveFileRecord classifies a corpus scenario as inapplicable, not blocked', () => {
83
- // A published-layout run: describe.skipIf fires at COLLECTION, so vitest
84
- // reports the file's tests as skipped, nothing notes a reason, and before
85
- // this change resolveFileRecord returned `blocked` with the unclassified
86
- // marker — the row a host operator could not tell from a real gap.
87
- const CORPUS = 'protocol-version-grammar.test.ts';
88
-
89
- it('a corpus scenario that never ran is inapplicable, with a reason aimed at the host operator', () => {
90
- const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, CORPUS);
91
- expect(r.disposition).toBe('inapplicable');
92
- expect(r.detail).toBe(SPEC_COHERENCE_DETAIL);
93
- expect(r.detail).toContain('asserts nothing about a host');
94
- expect(r.detail).toContain('OPENWOP_CONFORMANCE_ROOT');
95
- });
96
-
97
- it('WITHOUT the registry it would still be blocked — the branch is what changes it', () => {
98
- // Same inputs, filename withheld: the pre-change behaviour. This is the
99
- // negative control; if it ever returns `inapplicable`, the branch is not
100
- // what is doing the work and the test above proves nothing.
101
- const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null);
102
- expect(r.disposition).toBe('blocked');
103
- });
104
-
105
- it('a non-corpus scenario is untouched', () => {
106
- const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, 'webhook-signed-delivery.test.ts');
107
- expect(r.disposition).toBe('blocked');
108
- });
109
-
110
- it('a corpus scenario that FAILED is never laundered into inapplicable', () => {
111
- // The guard that matters: if the corpus IS present and an assertion fails,
112
- // that is a real spec-coherence defect and must stay executed-fail.
113
- const r = resolveFileRecord(['pass', 'fail'], undefined, 12, null, CORPUS);
114
- expect(r.disposition).toBe('executed-fail');
115
- });
116
-
117
- it('a corpus scenario that RAN and passed stays executed-pass', () => {
118
- const r = resolveFileRecord(['pass'], undefined, 40, null, CORPUS);
119
- expect(r.disposition).toBe('executed-pass');
120
- });
121
- });
122
-
123
- describe('the published layout is what makes these rows comparable across hosts', () => {
124
- // Load-bearing and, until now, tested nowhere.
125
- //
126
- // A spec-coherence row is `inapplicable` only when V1_DIR is null, and V1_DIR
127
- // is null only when the layout resolves to `published`. Two peers established
128
- // by measurement what the code implies: `resolveLayout()` keys off PKG_ROOT,
129
- // not the consuming repo, so an npm-installed consumer's parent is always
130
- // `node_modules/@openwop/` — which never contains `schemas/` no matter where
131
- // the host's checkout sits on disk. Every npm-consuming host therefore gets
132
- // the same answer, and the dispositions are comparable BY CONSTRUCTION.
133
- //
134
- // One peer had generalised the opposite way — "the disposition is a property
135
- // of where the bundle was cut" — from a host whose own runner sets
136
- // OPENWOP_CONFORMANCE_ROOT when it finds a sibling checkout. True of that
137
- // host, false of the artifact. The distinction only survives if something
138
- // holds the artifact to it.
139
- //
140
- // The thing that would break it is a change that looks HELPFUL: adding
141
- // `spec` to `files` so "the corpus tests run for consumers too". That would
142
- // silently give npm consumers V1_DIR, the 28 would execute instead of
143
- // flipping, and every host's numbers would shift with no failure anywhere.
144
- it('the published package ships no spec/ — so V1_DIR is null for npm consumers', () => {
145
- const pkg = JSON.parse(readFileSync(join(SCENARIOS, '../../package.json'), 'utf8')) as { files: string[] };
146
- expect(
147
- pkg.files.filter((f) => f === 'spec' || f.startsWith('spec/')),
148
- 'adding spec/ to `files` would give npm consumers a V1_DIR, so the spec-coherence rows would execute '
149
- + 'instead of reporting `inapplicable` — changing every host bundle with no test going red',
150
- ).toEqual([]);
151
- // schemas/ IS shipped, and is what selects the `published` layout. If this
152
- // ever stops being true the layout resolves to neither branch.
153
- expect(pkg.files, 'schemas/ is what makes resolveLayout() pick `published`').toContain('schemas');
154
- });
155
- });
@@ -1,144 +0,0 @@
1
- /**
2
- * Unit tests for `webhook-receiver.ts` — the signature-header contract.
3
- *
4
- * This verifier is the reference a subscriber implementer copies. It required
5
- * `v1=` and therefore rejected, as malformed, the exact header `webhooks.md`
6
- * §"Delivery headers" mandates (`X-openwop-Signature: sha256={hex}`). The
7
- * divergence survived because `webhook-receiver-adversarial.test.ts` signs with
8
- * `signPayload` and verifies with `verifyWebhookDelivery` — a closed loop that is
9
- * self-consistent and wrong, and so green against every host. These cases pin the
10
- * header against the SPEC rather than against the suite's own output.
11
- *
12
- * @see webhook-receiver.ts, spec/v1/webhooks.md §"Delivery headers"
13
- */
14
-
15
- import { describe, it, expect, afterEach } from 'vitest';
16
- import { createHmac } from 'node:crypto';
17
- import {
18
- SIGNATURE_PREFIX,
19
- createReceiverState,
20
- verifyWebhookDelivery,
21
- signPayload,
22
- resolveRegistrationUrl,
23
- } from './webhook-receiver.js';
24
-
25
- const SECRET = 'shhh-not-a-real-secret';
26
- const BODY = JSON.stringify({ event: { type: 'run.completed' } });
27
-
28
- /** Build the header exactly as `webhooks.md` documents it, not as we emit it. */
29
- function specShapedHeader(ts: number): string {
30
- const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
31
- return `sha256=${hex}`;
32
- }
33
-
34
- describe('webhook-receiver: the X-openwop-Signature prefix follows the spec', () => {
35
- it('the constant is the spec value, not the algorithm-header value', () => {
36
- // `v1` names the SIGNING SCHEME (X-openwop-Signature-Algorithm). It is not
37
- // the encoding prefix. One value, two fields — the conflation this fixes.
38
- expect(SIGNATURE_PREFIX).toBe('sha256=');
39
- });
40
-
41
- it('ACCEPTS a header built to the spec by hand, with no help from signPayload', () => {
42
- const ts = Math.floor(Date.now() / 1000);
43
- const result = verifyWebhookDelivery(
44
- SECRET,
45
- specShapedHeader(ts),
46
- 'v1',
47
- String(ts),
48
- BODY,
49
- createReceiverState(),
50
- );
51
- expect(result.accepted).toBe(true);
52
- });
53
-
54
- it('REJECTS the pre-2026-08-19 `v1=` prefix as malformed — the shape the spec never defined', () => {
55
- const ts = Math.floor(Date.now() / 1000);
56
- const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
57
- const result = verifyWebhookDelivery(
58
- SECRET,
59
- `v1=${hex}`,
60
- 'v1',
61
- String(ts),
62
- BODY,
63
- createReceiverState(),
64
- );
65
- expect(result.accepted).toBe(false);
66
- if (!result.accepted) expect(result.reason).toBe('malformed_signature_header');
67
- });
68
-
69
- it('signPayload emits what the verifier accepts AND what the spec documents', () => {
70
- const ts = Math.floor(Date.now() / 1000);
71
- const { signatureHeader, algorithmHeader } = signPayload(SECRET, ts, BODY);
72
- // Both halves matter: agreeing with the verifier alone is the closed loop
73
- // that hid the bug, so this also compares against the hand-built header.
74
- expect(signatureHeader).toBe(specShapedHeader(ts));
75
- expect(algorithmHeader).toBe('v1');
76
- });
77
- });
78
-
79
- /**
80
- * `OPENWOP_WEBHOOK_RECEIVER_URL` validation.
81
- *
82
- * Each case names the gate it is about, taken from `webhooks.md`, rather than
83
- * restating the implementation: the point of the variable is to clear all three
84
- * SSRF gates honestly, so a value that cannot clear one of them is an operator
85
- * error the suite must refuse LOUDLY. A skip here would hide a
86
- * misconfiguration behind a disposition that reads as "the host could not be
87
- * exercised", which is a claim about the host and would be false.
88
- */
89
- describe('resolveRegistrationUrl — OPENWOP_WEBHOOK_RECEIVER_URL', () => {
90
- const LOCAL = 'http://127.0.0.1:54321/';
91
- const saved = process.env.OPENWOP_WEBHOOK_RECEIVER_URL;
92
- const set = (v: string | undefined) => {
93
- if (v === undefined) delete process.env.OPENWOP_WEBHOOK_RECEIVER_URL;
94
- else process.env.OPENWOP_WEBHOOK_RECEIVER_URL = v;
95
- };
96
- afterEach(() => set(saved));
97
-
98
- it('unset ⇒ registers the local receiver unchanged, not tunnelled', () => {
99
- set(undefined);
100
- expect(resolveRegistrationUrl(LOCAL)).toEqual({ url: LOCAL, tunnelled: false });
101
- });
102
-
103
- it('whitespace-only is treated as unset rather than as a malformed URL', () => {
104
- set(' ');
105
- expect(resolveRegistrationUrl(LOCAL)).toEqual({ url: LOCAL, tunnelled: false });
106
- });
107
-
108
- it('a public https front is used for registration and marked tunnelled', () => {
109
- set('https://tunnel.example.com/hook');
110
- expect(resolveRegistrationUrl(LOCAL)).toEqual({
111
- url: 'https://tunnel.example.com/hook',
112
- tunnelled: true,
113
- });
114
- });
115
-
116
- it('rejects http: — cannot clear gate 1 (webhooks.md §"Register": url MUST be https)', () => {
117
- set('http://tunnel.example.com/hook');
118
- expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/MUST be https/i);
119
- });
120
-
121
- it.each([
122
- ['loopback name', 'https://localhost/hook'],
123
- ['loopback v4', 'https://127.0.0.1/hook'],
124
- ['RFC1918 10/8', 'https://10.1.2.3/hook'],
125
- ['RFC1918 192.168/16', 'https://192.168.1.9/hook'],
126
- ['RFC1918 172.16/12', 'https://172.20.0.5/hook'],
127
- ['link-local', 'https://169.254.169.254/hook'],
128
- ])('rejects %s — cannot clear gate 2 (registration-time address check)', (_label, url) => {
129
- set(url);
130
- expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/publicly-resolvable/i);
131
- });
132
-
133
- it('rejects a value that is not a URL at all', () => {
134
- set('not a url');
135
- expect(() => resolveRegistrationUrl(LOCAL)).toThrow(/not a valid URL/i);
136
- });
137
-
138
- it('does not reject a public host that merely LOOKS private (172.32 is public)', () => {
139
- // 172.16.0.0/12 ends at 172.31.255.255. A naive /^172\./ check would
140
- // reject this and send an operator hunting a nonexistent misconfiguration.
141
- set('https://172.32.0.1/hook');
142
- expect(resolveRegistrationUrl(LOCAL).tunnelled).toBe(true);
143
- });
144
- });
@@ -1,126 +0,0 @@
1
- /**
2
- * Bounded artifact-schema compilation (RFC 0071, `Active`).
3
- *
4
- * Always-on, server-free assertion for the SECURITY invariant
5
- * `artifact-schema-compile-bounded`. Artifact-type packs ship third-party
6
- * JSON Schemas that the engine compiles (Ajv) at install + validation time;
7
- * an unbounded compile is a denial-of-service vector (schema bombs:
8
- * pathological `$ref` recursion, keyword-count explosion, oversized payloads,
9
- * catastrophic-backtracking `pattern`s). This scenario asserts two things
10
- * that must hold for every release regardless of which host runs it:
11
- *
12
- * PART 1 — contract present. `artifact-type-packs.md` carries the normative
13
- * bounded-compilation MUST (serialized-size, `$ref`-depth, keyword-count
14
- * bounds + wall-clock timeout), and `host-capabilities.md` §host.artifactTypes
15
- * references it. Guards against the requirement being silently dropped.
16
- *
17
- * PART 2 — defense is well-defined + implementable. A reference bounding
18
- * predicate built from representative finite limits rejects three schema
19
- * bombs and admits a benign artifact schema. The specific numeric limits are
20
- * host-configurable per the spec (advertised, not protocol-mandated); the
21
- * point is that *some* finite bound exists and catches the bombs while
22
- * passing legitimate schemas.
23
- *
24
- * The behavioral end-to-end form (a host rejects an over-bounds pack at
25
- * registry `PUT` with `pack_validation_failed`) is capability-gated on
26
- * `host.artifactTypes.supported` and is `host-pending` until a reference host
27
- * lands; this server-free scenario is the always-on floor.
28
- *
29
- * @see spec/v1/artifact-type-packs.md §"Bounded schema compilation (normative)"
30
- * @see SECURITY/threat-model-node-packs.md §"Distributed artifact schemas"
31
- * @see RFCS/0071-artifact-type-and-chat-card-packs.md
32
- */
33
-
34
- import { describe, it, expect } from 'vitest';
35
- import { readFileSync } from 'node:fs';
36
- import { join } from 'node:path';
37
- import { V1_DIR } from '../lib/paths.js';
38
-
39
- const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
-
41
- describe('artifact-schema-compile-bounded: contract present in the corpus (RFC 0071, server-free)', () => {
42
- const artifactDoc = V1_DIR ? readFileSync(join(V1_DIR, 'artifact-type-packs.md'), 'utf8') : '';
43
- const hostCaps = V1_DIR ? readFileSync(join(V1_DIR, 'host-capabilities.md'), 'utf8') : '';
44
-
45
- it.skipIf(V1_DIR === null)('artifact-type-packs.md declares the bounded-compilation MUST', () => {
46
- expect(
47
- /Bounded schema compilation/i.test(artifactDoc),
48
- why('artifact-type-packs.md', 'a "Bounded schema compilation" section MUST exist'),
49
- ).toBe(true);
50
- expect(
51
- /MUST bound/i.test(artifactDoc) && /MUST reject/i.test(artifactDoc),
52
- why('artifact-type-packs.md §"Bounded schema compilation"', 'host MUST bound + MUST reject over-limit schemas'),
53
- ).toBe(true);
54
- // The three structural axes + the timeout MUST all be named.
55
- for (const axis of [/byte size/i, /\$ref/i, /keyword/i, /timeout/i]) {
56
- expect(axis.test(artifactDoc), why('artifact-type-packs.md', `bound axis ${axis} MUST be named`)).toBe(true);
57
- }
58
- });
59
-
60
- it.skipIf(V1_DIR === null)('host-capabilities.md §host.artifactTypes references the bound', () => {
61
- expect(
62
- /artifact-schema-compile-bounded/.test(hostCaps),
63
- why('host-capabilities.md §host.artifactTypes', 'MUST reference the bounded-compilation invariant'),
64
- ).toBe(true);
65
- });
66
- });
67
-
68
- describe('artifact-schema-compile-bounded: a finite bound catches schema bombs (RFC 0071, server-free)', () => {
69
- // Representative, host-configurable limits (the spec leaves the exact values
70
- // to host advertisement; these stand in for "some finite bound").
71
- const LIMITS = { maxBytes: 64 * 1024, maxRefDepth: 16, maxKeywords: 2000 };
72
-
73
- function refDepth(node: unknown, seen = 0): number {
74
- if (node === null || typeof node !== 'object') return seen;
75
- const obj = node as Record<string, unknown>;
76
- const here = '$ref' in obj ? seen + 1 : seen;
77
- let max = here;
78
- for (const v of Object.values(obj)) max = Math.max(max, refDepth(v, here));
79
- return max;
80
- }
81
- function keywordCount(node: unknown): number {
82
- if (node === null || typeof node !== 'object') return 0;
83
- const obj = node as Record<string, unknown>;
84
- let n = Object.keys(obj).length;
85
- for (const v of Object.values(obj)) n += keywordCount(v);
86
- return n;
87
- }
88
- /** Reference bound predicate — the shape a conformant host applies at PUT/install. */
89
- function exceedsBounds(schema: unknown): boolean {
90
- const bytes = Buffer.byteLength(JSON.stringify(schema), 'utf8');
91
- if (bytes > LIMITS.maxBytes) return true;
92
- if (refDepth(schema) > LIMITS.maxRefDepth) return true;
93
- if (keywordCount(schema) > LIMITS.maxKeywords) return true;
94
- return false;
95
- }
96
-
97
- it('admits a benign artifact schema', () => {
98
- const benign = {
99
- $schema: 'https://json-schema.org/draft/2020-12/schema',
100
- $id: 'https://h.example/schemas/artifacts/vendor.acme.cad.model.schema.json',
101
- type: 'object',
102
- additionalProperties: false,
103
- required: ['name'],
104
- properties: { name: { type: 'string' }, dims: { type: 'array', items: { type: 'number' } } },
105
- };
106
- expect(exceedsBounds(benign), why('artifact-type-packs.md', 'a legitimate artifact schema MUST NOT be rejected')).toBe(false);
107
- });
108
-
109
- it('rejects a $ref-depth bomb', () => {
110
- // Nest $ref-bearing objects deeper than maxRefDepth so resolution depth accumulates.
111
- let node: Record<string, unknown> = { type: 'string' };
112
- for (let i = 0; i < LIMITS.maxRefDepth + 4; i++) node = { $ref: '#/x', properties: { nested: node } };
113
- expect(exceedsBounds({ type: 'object', properties: { deep: node } }), why('threat-model-node-packs.md', 'a $ref-depth bomb MUST be rejected')).toBe(true);
114
- });
115
-
116
- it('rejects a keyword-count bomb', () => {
117
- const props: Record<string, unknown> = {};
118
- for (let i = 0; i < LIMITS.maxKeywords + 100; i++) props[`p${i}`] = { type: 'string' };
119
- expect(exceedsBounds({ type: 'object', properties: props }), why('threat-model-node-packs.md', 'a keyword-count bomb MUST be rejected')).toBe(true);
120
- });
121
-
122
- it('rejects an oversized schema', () => {
123
- const huge = { type: 'object', description: 'x'.repeat(LIMITS.maxBytes + 1) };
124
- expect(exceedsBounds(huge), why('threat-model-node-packs.md', 'an over-size schema MUST be rejected')).toBe(true);
125
- });
126
- });