@openwop/openwop-conformance 1.153.0 → 1.155.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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +11 -2
  3. package/api/openapi.yaml +17 -1
  4. package/coverage.md +3 -0
  5. package/dist/cli.js +7 -2
  6. package/package.json +31 -2
  7. package/requirements.json +256 -20
  8. package/schemas/CORPUS-STAMP.json +100 -3
  9. package/schemas/README.md +1 -0
  10. package/schemas/capabilities.schema.json +7 -0
  11. package/schemas/run-event-payloads.schema.json +9 -0
  12. package/schemas/run-snapshot.schema.json +4 -0
  13. package/schemas/subject.schema.json +56 -0
  14. package/src/cli.ts +7 -5
  15. package/src/global-setup.ts +13 -0
  16. package/src/lib/corpus-stamp.ts +125 -0
  17. package/src/scenarios/anonymous-actor-shape.test.ts +6 -1
  18. package/src/scenarios/auth-subject-link.test.ts +20 -1
  19. package/src/scenarios/discovery.test.ts +12 -0
  20. package/src/scenarios/identity-owner-shape.test.ts +7 -3
  21. package/src/scenarios/owner-subject-echo.test.ts +127 -0
  22. package/src/scenarios/owner-subject-shape.test.ts +96 -0
  23. package/src/scenarios/protocol-versions-array.test.ts +103 -0
  24. package/src/scenarios/webhook-signed-delivery.test.ts +22 -0
  25. package/src/lib/capabilities-auth-subject-link.test.ts +0 -103
  26. package/src/lib/fork-availability.test.ts +0 -69
  27. package/src/lib/global-setup.test.ts +0 -76
  28. package/src/lib/grpc-framing.test.ts +0 -96
  29. package/src/lib/oidc-issuer.test.ts +0 -328
  30. package/src/lib/otel-collector-grpc.test.ts +0 -191
  31. package/src/lib/otel-collector.test.ts +0 -303
  32. package/src/lib/otlp-protobuf.test.ts +0 -461
  33. package/src/lib/polling.test.ts +0 -80
  34. package/src/lib/requirement-ids.test.ts +0 -83
  35. package/src/lib/requirement-ledger.test.ts +0 -75
  36. package/src/lib/risk-disposition.test.ts +0 -91
  37. package/src/lib/saml-idp.test.ts +0 -127
  38. package/src/lib/spec-coherence-registry.test.ts +0 -155
  39. package/src/lib/webhook-receiver.test.ts +0 -144
  40. package/src/scenarios/artifact-schema-compile-bounded.test.ts +0 -126
  41. package/src/scenarios/artifact-type-legacy-ids.test.ts +0 -124
  42. package/src/scenarios/capability-example-root-layout.test.ts +0 -272
  43. package/src/scenarios/certification-floor-enforcement.test.ts +0 -204
  44. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +0 -70
  45. package/src/scenarios/compensation-profile.test.ts +0 -340
  46. package/src/scenarios/core-manifest-and-extension-registry.test.ts +0 -250
  47. package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +0 -219
  48. package/src/scenarios/edge-condition-truthy-falsy.test.ts +0 -108
  49. package/src/scenarios/effect-identity-composition.test.ts +0 -129
  50. package/src/scenarios/effect-identity-cross-scope.test.ts +0 -82
  51. package/src/scenarios/error-envelope-canonical-shape.test.ts +0 -64
  52. package/src/scenarios/form-content-packs.test.ts +0 -415
  53. package/src/scenarios/multi-region-effect-vocabulary.test.ts +0 -175
  54. package/src/scenarios/normative-example-extraction.test.ts +0 -242
  55. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +0 -309
  56. package/src/scenarios/pack-manifest-extensions.test.ts +0 -203
  57. package/src/scenarios/protocol-version-grammar.test.ts +0 -119
  58. package/src/scenarios/registry-declarative-kinds.test.ts +0 -121
  59. package/src/scenarios/rfc-0147-self-audit.test.ts +0 -104
  60. package/src/scenarios/rfc-lifecycle-coherence.test.ts +0 -215
  61. package/src/scenarios/semantic-digest-v2.test.ts +0 -128
  62. package/src/scenarios/spec-corpus-validity.test.ts +0 -1727
  63. package/src/scenarios/spec-section-citations.test.ts +0 -132
  64. package/src/scenarios/tool-result-trust-monotone.test.ts +0 -168
  65. package/src/scenarios/versioned-composition-profiles.test.ts +0 -201
  66. package/src/scenarios/workflow-chain-internal-flag.test.ts +0 -84
  67. package/src/scenarios/workload-identity-profile.test.ts +0 -184
@@ -0,0 +1,127 @@
1
+ /**
2
+ * owner-subject-echo — RFC 0165 §B.2, §B.4 verification (host-facing, gated).
3
+ *
4
+ * A host that emits `owner.subject` on a new run MUST: keep it consistent with
5
+ * the owner triple (§B.2), echo it verbatim on `run.started` (§B.2; RFC 0048
6
+ * §C), and copy it verbatim onto a fork (§B.4 — `replay.md` §"Fork
7
+ * ownership"). This file drives the `conformance-noop` fixture, reads the
8
+ * snapshot, and gates every leg on the snapshot actually carrying a subject:
9
+ * a host that does not emit one is conformant in v1.x (`inapplicable`), and a
10
+ * host that emits one is held to the rules.
11
+ *
12
+ * @see RFCS/0165-v2-preparation-wire-shapes.md §B
13
+ * @see spec/v1/auth.md §"The Subject record"
14
+ * @see spec/v1/replay.md §"Fork ownership"
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver } from '../lib/driver.js';
19
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
20
+ import { softSkip } from '../lib/soft-skip.js';
21
+
22
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
23
+ const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
24
+
25
+ interface Subject {
26
+ issuer: string;
27
+ subjectId: string;
28
+ tenant: string;
29
+ lane: string;
30
+ kind: string;
31
+ keyClass?: string;
32
+ actor?: Subject;
33
+ }
34
+ interface Owner {
35
+ tenant?: string;
36
+ workspace?: string;
37
+ principal?: string;
38
+ principalKind?: string;
39
+ subject?: Subject;
40
+ }
41
+ interface Snapshot {
42
+ runId?: string;
43
+ status?: string;
44
+ owner?: Owner;
45
+ }
46
+ interface RawEvent {
47
+ type?: string;
48
+ sequence?: number;
49
+ payload?: { owner?: Owner };
50
+ }
51
+
52
+ async function createRun(): Promise<string> {
53
+ const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
54
+ if (create.status !== 201) throw new Error(`Failed to start ${NOOP_WORKFLOW_ID}: ${create.status}`);
55
+ return (create.json as { runId: string }).runId;
56
+ }
57
+
58
+ async function snapshot(runId: string): Promise<Snapshot> {
59
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
60
+ if (res.status !== 200) throw new Error(`GET /v1/runs/${runId}: ${res.status}`);
61
+ return res.json as Snapshot;
62
+ }
63
+
64
+ async function events(runId: string): Promise<readonly RawEvent[]> {
65
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`);
66
+ if (res.status !== 200) throw new Error(`events for ${runId}: ${res.status}`);
67
+ return (res.json as { events?: RawEvent[] })?.events ?? [];
68
+ }
69
+
70
+ function actorDepth(s: Subject | undefined): number {
71
+ let d = 0;
72
+ for (let cur = s?.actor; cur !== undefined; cur = cur.actor) d++;
73
+ return d;
74
+ }
75
+
76
+ describe.skipIf(SKIP_NO_NOOP)('owner-subject-echo: consistency, echo, fork (RFC 0165 §B — gated on a host that emits owner.subject)', () => {
77
+ it('subject is consistent with the owner triple (§B.2) and the actor chain is bounded (§B.5)', async () => {
78
+ const runId = await createRun();
79
+ const snap = await snapshot(runId);
80
+ const subject = snap.owner?.subject;
81
+ if (subject === undefined) {
82
+ return softSkip('inapplicable', 'host does not emit owner.subject on new runs (RFC 0165 §B — optional in v1.x)');
83
+ }
84
+ expect(subject.tenant, driver.describe('RFC 0165 §B.2', 'subject.tenant MUST equal owner.tenant')).toBe(snap.owner?.tenant);
85
+ if (snap.owner?.principal !== undefined) {
86
+ expect(subject.subjectId, driver.describe('RFC 0165 §B.2', 'subject.subjectId MUST equal owner.principal when both are present')).toBe(snap.owner.principal);
87
+ }
88
+ if (snap.owner?.principalKind !== undefined) {
89
+ expect(subject.kind, driver.describe('RFC 0165 §B.2', 'subject.kind MUST equal owner.principalKind when both are present')).toBe(snap.owner.principalKind);
90
+ }
91
+ if (subject.lane === 'saml' || subject.lane === 'scim') {
92
+ expect(subject.keyClass, driver.describe('RFC 0165 §B.2', 'keyClass MUST be present on a linkable lane')).toBeDefined();
93
+ } else {
94
+ expect(subject.keyClass, driver.describe('RFC 0165 §B.2', 'keyClass MUST be absent off the linkable lanes')).toBeUndefined();
95
+ }
96
+ expect(actorDepth(subject), driver.describe('RFC 0165 §B.5', 'actor chain depth MUST NOT exceed 4')).toBeLessThanOrEqual(4);
97
+ });
98
+
99
+ it('run.started echoes the snapshot subject verbatim (§B.2, RFC 0048 §C)', async () => {
100
+ const runId = await createRun();
101
+ const snap = await snapshot(runId);
102
+ if (snap.owner?.subject === undefined) {
103
+ return softSkip('inapplicable', 'host does not emit owner.subject on new runs (RFC 0165 §B — optional in v1.x)');
104
+ }
105
+ const started = (await events(runId)).find((e) => e.type === 'run.started');
106
+ expect(started, driver.describe('RFC 0048 §C', 'run.started MUST be present in the event log')).toBeDefined();
107
+ expect(started?.payload?.owner?.subject, driver.describe('RFC 0165 §B.2', 'run.started owner.subject MUST equal RunSnapshot.owner.subject')).toEqual(snap.owner.subject);
108
+ });
109
+
110
+ it('a fork copies owner.tenant and owner.subject verbatim onto the child (§B.4)', async () => {
111
+ const sourceRunId = await createRun();
112
+ const source = await snapshot(sourceRunId);
113
+ if (source.owner?.subject === undefined) {
114
+ return softSkip('inapplicable', 'host does not emit owner.subject on new runs (RFC 0165 §B — optional in v1.x)');
115
+ }
116
+ const fork = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, { fromSeq: 0, mode: 'replay' });
117
+ if (fork.status === 404 || fork.status === 501 || fork.status === 403) {
118
+ return softSkip('inapplicable', `host does not offer :fork on this run (${fork.status}); the fork-copy rule has no fork to check`);
119
+ }
120
+ expect(fork.status, driver.describe('replay.md §"Fork ownership"', ':fork MUST return 201 for a completed noop run')).toBe(201);
121
+ const childId = (fork.json as { runId?: string }).runId;
122
+ expect(typeof childId, 'fork response MUST include the child runId').toBe('string');
123
+ const child = await snapshot(childId as string);
124
+ expect(child.owner?.tenant, driver.describe('RFC 0165 §B.4', 'the child owner.tenant MUST equal the source')).toBe(source.owner.tenant);
125
+ expect(child.owner?.subject, driver.describe('RFC 0165 §B.4', 'the child owner.subject MUST be copied verbatim from the source')).toEqual(source.owner.subject);
126
+ });
127
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * owner-subject-shape — RFC 0165 §B.1–§B.2 verification (server-free).
3
+ *
4
+ * `schemas/subject.schema.json` is the OPTIONAL issuer-scoped identity record
5
+ * carried as `owner.subject` on `RunSnapshot` and on the `run.started` owner
6
+ * echo. This file proves the SCHEMA half of §B: the record compiles standalone,
7
+ * both owner sub-objects reference it as a declared optional member (they are
8
+ * `additionalProperties: false`, so an undeclared `subject` would be rejected —
9
+ * the live `principalKind` divergence RFC 0165 §Alternatives 3 names), and the
10
+ * opacity + key-class conditionals hold. Behaviour (echo, fork copy, legacy
11
+ * synthesis) is `owner-subject-echo.test.ts`, gated on a host that emits it.
12
+ *
13
+ * SECURITY invariant `subject-record-opaque` (witness class: claims-check —
14
+ * the schema pattern is what forbids an email-shaped or whitespace subjectId;
15
+ * no probe can prove a host never mints PII, only that the wire refuses it).
16
+ *
17
+ * @see RFCS/0165-v2-preparation-wire-shapes.md §B
18
+ * @see schemas/subject.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import Ajv2020 from 'ajv/dist/2020.js';
25
+ import { SCHEMAS_DIR } from '../lib/paths.js';
26
+
27
+ const read = (f: string): Record<string, unknown> => JSON.parse(readFileSync(join(SCHEMAS_DIR, f), 'utf8')) as Record<string, unknown>;
28
+ const subject = read('subject.schema.json');
29
+ const snapshot = read('run-snapshot.schema.json') as { $schema: string; $id: string; properties: { owner: Record<string, unknown> } };
30
+ const payloads = read('run-event-payloads.schema.json') as { $schema: string; $id: string; $defs: { runStarted: { properties: { owner: Record<string, unknown> } } } };
31
+
32
+ function ajvWithSubject(): Ajv2020 {
33
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
34
+ ajv.addSchema(subject);
35
+ return ajv;
36
+ }
37
+
38
+ const VALID = { issuer: 'https://idp.example.com/entity', subjectId: 'idp-op-8f3a', tenant: 'acme', lane: 'saml', kind: 'user', keyClass: 'opaque-idp' };
39
+
40
+ describe('owner-subject-shape: subject.schema.json (RFC 0165 §B.1)', () => {
41
+ const validate = ajvWithSubject().compile(subject);
42
+
43
+ it('compiles and accepts a full SAML subject', () => {
44
+ expect(validate(VALID), JSON.stringify(validate.errors)).toBe(true);
45
+ });
46
+
47
+ it('accepts the legacy issuer form (RFC 0165 §B.3) and a workload subject', () => {
48
+ expect(validate({ issuer: 'urn:openwop:legacy', subjectId: 'user_42', tenant: 'acme', lane: 'api-key', kind: 'user' }), JSON.stringify(validate.errors)).toBe(true);
49
+ expect(validate({ issuer: 'spiffe://trust.example', subjectId: 'spiffe://trust.example/ns/a/sa/b', tenant: 'acme', lane: 'workload', kind: 'workload' }), JSON.stringify(validate.errors)).toBe(true);
50
+ });
51
+
52
+ it('rejects an email-shaped or whitespace subjectId (invariant subject-record-opaque)', () => {
53
+ expect(validate({ ...VALID, subjectId: 'alice@example.com' }), 'RFC 0165 §B.2: subjectId MUST NOT be an email address').toBe(false);
54
+ expect(validate({ ...VALID, subjectId: 'Alice Example' }), 'RFC 0165 §B.2: subjectId MUST NOT contain whitespace (a display name)').toBe(false);
55
+ });
56
+
57
+ it('requires keyClass on the saml/scim lanes and forbids it elsewhere', () => {
58
+ const { keyClass: _k, ...noKey } = VALID;
59
+ expect(validate(noKey), 'RFC 0165 §B.2: keyClass MUST be present when lane is saml or scim').toBe(false);
60
+ expect(validate({ ...VALID, lane: 'oidc' }), 'RFC 0165 §B.2: keyClass MUST be absent when the lane is not linkable').toBe(false);
61
+ expect(validate({ ...noKey, lane: 'oidc' }), JSON.stringify(validate.errors)).toBe(true);
62
+ });
63
+
64
+ it('rejects an unknown member and a missing required member', () => {
65
+ expect(validate({ ...VALID, email: 'x' })).toBe(false);
66
+ const { tenant: _t, ...noTenant } = VALID;
67
+ expect(validate(noTenant)).toBe(false);
68
+ });
69
+
70
+ it('accepts a nested actor chain (depth is a host obligation, RFC 0165 §B.5)', () => {
71
+ const peer = { issuer: 'https://peer.example', subjectId: 'peer-1', tenant: 'acme', lane: 'workload', kind: 'workload' };
72
+ const { keyClass: _k, ...anon } = { ...VALID, kind: 'anonymous', lane: 'api-key', actor: peer };
73
+ expect(validate(anon), JSON.stringify(validate.errors)).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe('owner-subject-shape: both owner sub-objects declare `subject` (RFC 0165 §B.1)', () => {
78
+ // The extracted sub-schema needs its parent's `$id` as the base for the relative `subject.schema.json` $ref.
79
+ const ownerSnap = { $schema: snapshot.$schema, $id: snapshot.$id, ...snapshot.properties.owner };
80
+ const ownerEcho = { $schema: payloads.$schema, $id: payloads.$id, ...payloads.$defs.runStarted.properties.owner };
81
+
82
+ it('RunSnapshot.owner accepts a subject and still rejects an undeclared member', () => {
83
+ const v = ajvWithSubject().compile(ownerSnap);
84
+ expect(v({ tenant: 'acme', principal: 'idp-op-8f3a', principalKind: 'user', subject: VALID }), JSON.stringify(v.errors)).toBe(true);
85
+ expect(v({ tenant: 'acme', role: 'admin' })).toBe(false);
86
+ });
87
+
88
+ it('run.started owner echo accepts the same subject AND principalKind (the echo matches the snapshot)', () => {
89
+ const v = ajvWithSubject().compile(ownerEcho);
90
+ expect(
91
+ v({ tenant: 'acme', principal: 'idp-op-8f3a', principalKind: 'user', subject: VALID }),
92
+ 'RFC 0165 §B.1: run.started owner MUST declare subject and principalKind so a host can echo its snapshot owner verbatim',
93
+ ).toBe(true);
94
+ expect(v({ tenant: 'acme', subject: { ...VALID, subjectId: 'a@b' } })).toBe(false);
95
+ });
96
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * protocol-versions-array — RFC 0165 §A verification.
3
+ *
4
+ * `protocolVersions: string[]` is the OPTIONAL root advertisement of every
5
+ * `<major>.<minor>` a host serves, reserved for v2 major negotiation
6
+ * (`version-negotiation.md` §"`protocolVersions[]`"). Three things are
7
+ * checkable today:
8
+ *
9
+ * 1. Server-free: the schema declares the array with the SAME strict grammar
10
+ * as the scalar (RFC 0149 §C), not the looser A2A item pattern — one axis,
11
+ * one grammar; and the Levenshtein-1 neighbour of `protocolVersion` is a
12
+ * real property, so the root-layout typo lint cannot mistake it.
13
+ * 2. Host, presence-gated: when advertised, every item matches, items are
14
+ * unique, and the array contains `protocolVersion`'s value.
15
+ * 3. Host, presence-gated: profile derivation is unchanged by the array —
16
+ * `isCore` reads the scalar only (RFC 0165 §A.2, §Alternatives 6).
17
+ *
18
+ * A host that omits the array is conformant (`inapplicable`, not a pass).
19
+ *
20
+ * @see RFCS/0165-v2-preparation-wire-shapes.md §A
21
+ * @see spec/v1/version-negotiation.md §"Protocol version grammar"
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import Ajv2020 from 'ajv/dist/2020.js';
28
+ import { driver } from '../lib/driver.js';
29
+ import { SCHEMAS_DIR } from '../lib/paths.js';
30
+ import { isCore, type DiscoveryPayload } from '../lib/profiles.js';
31
+ import { softSkip } from '../lib/soft-skip.js';
32
+
33
+ interface CapsSchema {
34
+ $schema: string;
35
+ properties: {
36
+ protocolVersion?: { pattern?: string };
37
+ protocolVersions?: { type?: string; minItems?: number; uniqueItems?: boolean; items?: { pattern?: string } };
38
+ };
39
+ }
40
+
41
+ const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8')) as CapsSchema;
42
+
43
+ describe('protocol-versions-array: schema shape (RFC 0165 §A.1, server-free)', () => {
44
+ it('declares root `protocolVersions` as a unique, non-empty string array', () => {
45
+ const p = caps.properties.protocolVersions;
46
+ expect(p, 'RFC 0165 §A.1: capabilities.schema.json MUST declare root `protocolVersions`').toBeDefined();
47
+ expect(p?.type).toBe('array');
48
+ expect(p?.minItems).toBe(1);
49
+ expect(p?.uniqueItems).toBe(true);
50
+ });
51
+
52
+ it('item grammar equals the scalar grammar (one axis, one grammar)', () => {
53
+ expect(
54
+ caps.properties.protocolVersions?.items?.pattern,
55
+ 'RFC 0165 §A.1: protocolVersions items MUST use the RFC 0149 §C grammar, not the A2A item pattern',
56
+ ).toBe(caps.properties.protocolVersion?.pattern);
57
+ });
58
+
59
+ it('accepts ["1.11", "2.0"], rejects "01.0", "1.0.0", duplicates, and empty', () => {
60
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
61
+ const validate = ajv.compile({ $schema: caps.$schema, ...(caps.properties.protocolVersions as Record<string, unknown>) });
62
+ expect(validate(['1.11', '2.0'])).toBe(true);
63
+ expect(validate(['01.0'])).toBe(false);
64
+ expect(validate(['1.0.0'])).toBe(false);
65
+ expect(validate(['1.0', '1.0'])).toBe(false);
66
+ expect(validate([])).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('protocol-versions-array: advertisement (RFC 0165 §A.2 — presence-gated)', () => {
71
+ const GRAMMAR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
72
+
73
+ it('when advertised, every item matches the grammar, items are unique, and the array contains protocolVersion', async () => {
74
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
75
+ const doc = res.json as Record<string, unknown>;
76
+ const arr = doc['protocolVersions'];
77
+ if (arr === undefined) {
78
+ return softSkip('inapplicable', 'host does not advertise protocolVersions (RFC 0165 §A — optional in v1.x)');
79
+ }
80
+ expect(Array.isArray(arr), driver.describe('RFC 0165 §A.1', 'protocolVersions MUST be an array')).toBe(true);
81
+ const items = arr as unknown[];
82
+ expect(items.length, driver.describe('RFC 0165 §A.1', 'protocolVersions MUST be non-empty')).toBeGreaterThan(0);
83
+ for (const v of items) {
84
+ expect(typeof v === 'string' && GRAMMAR.test(v), driver.describe('RFC 0165 §A.1', `protocolVersions item ${JSON.stringify(v)} MUST match the RFC 0149 §C grammar`)).toBe(true);
85
+ }
86
+ expect(new Set(items).size, driver.describe('RFC 0165 §A.1', 'protocolVersions items MUST be unique')).toBe(items.length);
87
+ expect(items, driver.describe('RFC 0165 §A.2', 'protocolVersions MUST contain the value of protocolVersion')).toContain(doc['protocolVersion']);
88
+ });
89
+
90
+ it('profile derivation is unchanged by the array (isCore reads the scalar only)', async () => {
91
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
92
+ const doc = res.json as DiscoveryPayload & Record<string, unknown>;
93
+ if (doc['protocolVersions'] === undefined) {
94
+ return softSkip('inapplicable', 'host does not advertise protocolVersions (RFC 0165 §A — optional in v1.x)');
95
+ }
96
+ const without = { ...doc } as Record<string, unknown>;
97
+ delete without['protocolVersions'];
98
+ expect(
99
+ isCore(doc),
100
+ driver.describe('RFC 0165 §A.2', 'openwop-discovery-core derivation MUST NOT depend on protocolVersions in v1.x'),
101
+ ).toBe(isCore(without as DiscoveryPayload));
102
+ });
103
+ });
@@ -323,6 +323,28 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
323
323
  driver.describe('webhooks.md §"Delivery headers"', 'X-openwop-Signature MUST be sha256=HMAC-SHA256(secret, `${X-openwop-Timestamp}.${rawBody}`)'),
324
324
  ).toBe(expected);
325
325
 
326
+ // RFC 0165 §C.1 — dual emission of the `OpenWOP-*` family. SHOULD-level and
327
+ // presence-gated: when the new family is present, every value MUST equal its
328
+ // `X-openwop-*` counterpart and the signature verifies reading either.
329
+ const dual = first.headers['openwop-signature'];
330
+ if (dual === undefined) {
331
+ softSkip('inapplicable', 'host does not yet dual-emit the OpenWOP-* webhook header family (RFC 0165 §C.1 — SHOULD)');
332
+ } else {
333
+ for (const [neu, old] of [
334
+ ['openwop-webhook-id', 'x-openwop-webhook-id'],
335
+ ['openwop-event-type', 'x-openwop-event-type'],
336
+ ['openwop-timestamp', 'x-openwop-timestamp'],
337
+ ['openwop-signature', 'x-openwop-signature'],
338
+ ['openwop-signature-algorithm', 'x-openwop-signature-algorithm'],
339
+ ] as const) {
340
+ expect(first.headers[neu], driver.describe('RFC 0165 §C.1', `${neu} MUST equal ${old} when the OpenWOP-* family is emitted`)).toBe(first.headers[old]);
341
+ }
342
+ expect(
343
+ String(dual).replace('sha256=', ''),
344
+ driver.describe('RFC 0165 §C.1', 'OpenWOP-Signature MUST verify over the same bytes as X-openwop-Signature'),
345
+ ).toBe(expected);
346
+ }
347
+
326
348
  // Body should parse as JSON with a run event shape.
327
349
  const event = JSON.parse(first.body) as { type?: unknown; runId?: unknown };
328
350
  expect(typeof event.type).toBe('string');
@@ -1,103 +0,0 @@
1
- /**
2
- * Server-free pin of the RFC 0163 §C schema conditional in
3
- * `schemas/capabilities.schema.json`:
4
- *
5
- * auth.subjectLinking: true ⇒ auth.subjectLinkKey required (RFC 0163)
6
- * auth.subjectLinkKey ∈ { opaque-idp, configured-immutable } (RFC 0163)
7
- * profiles ⊇ {saml, scim} ⇒ subjectLinking const true + key required (RFC 0164)
8
- *
9
- * This checks the SCHEMA ARTIFACT, not any host, which is why it lives under
10
- * `src/lib/` (no scenario ledger row — see `saml-idp.test.ts` for the G8
11
- * reasoning). The host-facing assertion is the advertisement leg of
12
- * `scenarios/auth-subject-link-key-class.test.ts`, gated on the flag.
13
- *
14
- * @see RFCS/0163-subject-linking-hardening.md §C
15
- * @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
16
- */
17
-
18
- import { describe, it, expect } from 'vitest';
19
- import { readFileSync, readdirSync } from 'node:fs';
20
- import { join } from 'node:path';
21
- import Ajv2020 from 'ajv/dist/2020.js';
22
- import addFormats from 'ajv-formats';
23
- import { SCHEMAS_DIR } from './paths.js';
24
-
25
- function compileCapabilities() {
26
- const ajv = new Ajv2020({ allErrors: true, strict: false });
27
- addFormats(ajv);
28
- for (const file of readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json'))) {
29
- const s = JSON.parse(readFileSync(join(SCHEMAS_DIR, file), 'utf8')) as { $id?: string };
30
- ajv.addSchema(s, file);
31
- ajv.addSchema(s, `./${file}`);
32
- }
33
- // Validate the `auth` block in isolation: the conditional lives on that
34
- // object, and a full discovery document would drag in every root-level
35
- // required family (supportedEnvelopes, schemaVersions, limits, …) that has
36
- // nothing to do with what this file pins.
37
- return ajv.compile({ $ref: 'capabilities.schema.json#/properties/auth' });
38
- }
39
-
40
- const PROFILES = ['openwop-auth-saml', 'openwop-auth-scim'];
41
- function doc(auth: Record<string, unknown>) {
42
- return auth;
43
- }
44
-
45
- describe('capabilities.schema.json: auth.subjectLinkKey conditional (RFC 0163 §C)', () => {
46
- const validate = compileCapabilities();
47
-
48
- it('subjectLinking:true WITHOUT subjectLinkKey fails validation (§A.1 co-requirement is in the schema, not only prose)', () => {
49
- const ok = validate(doc({ profiles: PROFILES, subjectLinking: true }));
50
- expect(ok, JSON.stringify(validate.errors)).toBe(false);
51
- expect(JSON.stringify(validate.errors)).toContain('subjectLinkKey');
52
- });
53
-
54
- it('subjectLinking:true with a class member validates', () => {
55
- for (const k of ['opaque-idp', 'configured-immutable']) {
56
- const ok = validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: k }));
57
- expect(ok, `${k}: ${JSON.stringify(validate.errors)}`).toBe(true);
58
- }
59
- });
60
-
61
- it('a mutable/PII key is INEXPRESSIBLE: "email" fails the enum', () => {
62
- const ok = validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: 'email' }));
63
- expect(ok).toBe(false);
64
- });
65
-
66
- it('the retired Draft-era attribute names are not members (the enum names classes)', () => {
67
- for (const k of ['oid', 'immutable-id']) {
68
- expect(validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: k })), k).toBe(false);
69
- }
70
- });
71
-
72
- it('one profile alone does not require the flag or a key (SAML-only / SCIM-only hosts are unaffected)', () => {
73
- expect(validate(doc({ profiles: ['openwop-auth-saml'] }))).toBe(true);
74
- expect(validate(doc({ profiles: ['openwop-auth-scim'], subjectLinking: false }))).toBe(true);
75
- });
76
- });
77
-
78
- describe('capabilities.schema.json: the profile pair implies the contract (RFC 0164 §B)', () => {
79
- const validate = compileCapabilities();
80
-
81
- it('BOTH profiles with subjectLinking absent fails validation (the vulnerable pre-0164 shape)', () => {
82
- const ok = validate(doc({ profiles: PROFILES }));
83
- expect(ok, JSON.stringify(validate.errors)).toBe(false);
84
- expect(JSON.stringify(validate.errors)).toContain('subjectLinking');
85
- });
86
-
87
- it('BOTH profiles with subjectLinking:false fails validation (the flag is derived, not a choice)', () => {
88
- expect(validate(doc({ profiles: PROFILES, subjectLinking: false, subjectLinkKey: 'opaque-idp' }))).toBe(false);
89
- });
90
-
91
- it('BOTH profiles with subjectLinking:true but no subjectLinkKey fails validation', () => {
92
- expect(validate(doc({ profiles: PROFILES, subjectLinking: true }))).toBe(false);
93
- });
94
-
95
- it('BOTH profiles with subjectLinking:true + a class member validates (the derived advertisement)', () => {
96
- expect(validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: 'opaque-idp' }))).toBe(true);
97
- });
98
-
99
- it('profile order does not matter', () => {
100
- expect(validate(doc({ profiles: ['openwop-auth-scim', 'openwop-auth-api-key-rotation', 'openwop-auth-saml'], subjectLinking: true, subjectLinkKey: 'configured-immutable' }))).toBe(true);
101
- expect(validate(doc({ profiles: ['openwop-auth-scim', 'openwop-auth-api-key-rotation', 'openwop-auth-saml'] }))).toBe(false);
102
- });
103
- });
@@ -1,69 +0,0 @@
1
- /**
2
- * Pins `forkDeclined` (RFC 0148 §A).
3
- *
4
- * The risk in a helper like this is not that it misses a status — that shows up
5
- * as a red test somebody investigates. It is that it swallows one: every status
6
- * it accepts turns an assertion into a skip, silently, and a suite that skips
7
- * is indistinguishable from a suite that passes unless something records why.
8
- * So the load-bearing cases here are the NEGATIVE ones.
9
- */
10
-
11
- import { describe, expect, it, beforeEach } from 'vitest';
12
- import { forkDeclined } from './fork-availability.js';
13
- import { resetSoftSkips, softSkipDisposition } from './soft-skip.js';
14
-
15
- describe('forkDeclined', () => {
16
- beforeEach(() => resetSoftSkips());
17
-
18
- it('treats 404 and 403 the same as 501 — route absent is not weaker than route-declines', () => {
19
- // The asymmetry this fixes: every replay scenario handled 501 and none
20
- // handled 404. `501` means "I know this route and decline"; `404` means
21
- // "there is no such route" — strictly less implemented. The suite treated
22
- // the weaker signal as a skip and the stronger one as a defect, so a host
23
- // had to implement the route in order to say it had not implemented it.
24
- for (const status of [404, 403, 501]) {
25
- expect(forkDeclined(status, 'leg'), `status ${status} means the fork did not happen`).toBe(true);
26
- }
27
- });
28
-
29
- it('does NOT swallow any other status — a 500 is still a defect, not a skip', () => {
30
- // If this ever goes green for one of these, a real failure has become a
31
- // silent skip and no assertion downstream will ever run again.
32
- for (const status of [200, 201, 202, 400, 409, 422, 500, 502, 503]) {
33
- expect(forkDeclined(status, 'leg'), `status ${status} must reach the assertion`).toBe(false);
34
- }
35
- });
36
-
37
- it('records WHY, so the ledger row is not an unclassified return', () => {
38
- // Two of the eleven original call sites were a bare `return`. A bare return
39
- // in a file whose OTHER tests assert is invisible: the file records
40
- // `executed-pass` and the leg that never ran leaves no trace.
41
- forkDeclined(404, 'fanout-suppression replay fork');
42
- const noted = softSkipDisposition('fork-availability.test.ts');
43
- expect(noted?.kind).toBe('blocked');
44
- expect(noted?.reason).toContain('404');
45
- expect(noted?.reason).toContain('fanout-suppression replay fork');
46
- expect(noted?.reason).toContain('not mounted');
47
- });
48
-
49
- it('records nothing when the status is not a decline', () => {
50
- forkDeclined(201, 'leg');
51
- expect(softSkipDisposition('fork-availability.test.ts')).toBeNull();
52
- });
53
-
54
- it('names the leg, so one file with several forks stays attributable', () => {
55
- forkDeclined(501, 'branch fork');
56
- expect(softSkipDisposition('fork-availability.test.ts')?.reason).toContain('branch fork');
57
- });
58
-
59
- it('distinguishes the three declines in its reason text', () => {
60
- forkDeclined(404, 'a');
61
- const a = softSkipDisposition('fork-availability.test.ts')?.reason ?? '';
62
- resetSoftSkips();
63
- forkDeclined(501, 'a');
64
- const b = softSkipDisposition('fork-availability.test.ts')?.reason ?? '';
65
- // `blocked` alone does not tell a bundle reader whether the host lacks the
66
- // route or declines the range; the reason must.
67
- expect(a).not.toBe(b);
68
- });
69
- });
@@ -1,76 +0,0 @@
1
- /**
2
- * Pins the run-end disposition summary (`src/global-setup.ts`, RFC 0148 §A).
3
- *
4
- * The defect this guards is not "the summary is ugly" — it is that the suite
5
- * computed an honest disposition on every run and PUBLISHED it on one. Two
6
- * host implementers read vitest's `1 passed` as coverage for a requirement the
7
- * suite had internally classified `blocked`, because the classification had no
8
- * reader outside `--certify`.
9
- *
10
- * Server-free and pure: `summarise` takes the raw JSONL and returns the text.
11
- */
12
-
13
- import { describe, expect, it } from 'vitest';
14
- import { summarise } from '../global-setup.js';
15
-
16
- const line = (o: Record<string, unknown>): string => JSON.stringify(o);
17
-
18
- describe('global-setup: RFC 0148 §A disposition summary', () => {
19
- it('reads the ledger key the ledger actually writes (`requirementId`, not `id`)', () => {
20
- // Regression: the first draft of this reader keyed on `entry.id`, a field
21
- // `recordRequirement` has never written, and fell back to `(unnamed)`.
22
- // The fallback made a WRONG READER look like MISSING DATA — the same
23
- // failure shape the summary exists to expose, reproduced in the exposer.
24
- const out = summarise(
25
- line({ requirementId: 'openwop.scenario.webhook-signed-delivery', disposition: 'blocked', detail: 'guard rejected loopback' }),
26
- );
27
- expect(out).toContain('openwop.scenario.webhook-signed-delivery');
28
- expect(out).not.toContain('(unnamed)');
29
- });
30
-
31
- it('lists blocked / skipped / inapplicable as NOT witnessed, with the reason', () => {
32
- const out = summarise(
33
- [
34
- line({ requirementId: 'a', disposition: 'blocked', detail: 'seam absent' }),
35
- line({ requirementId: 'b', disposition: 'skipped', detail: 'operator opted out' }),
36
- line({ requirementId: 'c', disposition: 'inapplicable', detail: 'profile not advertised' }),
37
- ].join('\n'),
38
- );
39
- expect(out).toContain('did NOT witness');
40
- for (const s of ['seam absent', 'operator opted out', 'profile not advertised']) {
41
- expect(out).toContain(s);
42
- }
43
- });
44
-
45
- it('flags an executed-pass that asserted nothing — the other RFC 0148 §A vacuity shape', () => {
46
- const out = summarise(line({ requirementId: 'vacuous', disposition: 'executed-pass', assertionCount: 0 }));
47
- expect(out).toContain('executed-pass (0 assertions)');
48
- expect(out).toContain('vacuous');
49
- });
50
-
51
- it('does NOT flag a real executed-pass', () => {
52
- const out = summarise(line({ requirementId: 'real', disposition: 'executed-pass', assertionCount: 12 }));
53
- expect(out).toContain('executed-pass 1');
54
- expect(out).not.toContain('did NOT witness');
55
- });
56
-
57
- it('announces truncation rather than capping silently', () => {
58
- // A capped list that does not say it was capped reads as a complete one.
59
- const many = Array.from({ length: 45 }, (_, i) =>
60
- line({ requirementId: `r${i}`, disposition: 'blocked', detail: 'why' }),
61
- ).join('\n');
62
- const out = summarise(many);
63
- expect(out).toContain('5 more not listed');
64
- });
65
-
66
- it('returns null on an empty ledger so teardown prints nothing', () => {
67
- expect(summarise('')).toBeNull();
68
- expect(summarise('\n\n')).toBeNull();
69
- });
70
-
71
- it('skips unparseable lines without discarding the rest of the run', () => {
72
- const out = summarise(['{ not json', line({ requirementId: 'ok', disposition: 'blocked', detail: 'r' })].join('\n'));
73
- expect(out).toContain('ok');
74
- expect(out).toContain('blocked 1');
75
- });
76
- });