@openwop/openwop-conformance 2.42.9 → 2.43.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.
- package/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/coverage.md +1 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures.md +2 -1
- package/package.json +2 -2
- package/requirements.json +350 -16
- package/scenario-majors.json +17 -5
- package/schemas/CORPUS-STAMP.json +37 -36
- package/src/lib/auditIntegrity.ts +45 -0
- package/src/scenarios/audit-anomaly-shape.test.ts +100 -0
- package/src/scenarios/audit-checkpoint-signature.test.ts +14 -15
- package/src/scenarios/audit-log-integrity.test.ts +102 -46
- package/src/scenarios/byok-roundtrip.test.ts +21 -7
- package/src/scenarios/v2-approval-reject-disposition.test.ts +162 -0
- package/src/scenarios/v2-registry-lifecycle.test.ts +232 -0
|
@@ -6,12 +6,23 @@
|
|
|
6
6
|
* asserts credentialRefs DO resolve and DO get used (positive test) —
|
|
7
7
|
* with redaction-safe verification via SHA-256 hashing.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* This is a FLOOR scenario (`openwop.floor.byok-roundtrip`), so a host that
|
|
10
|
+
* cannot run it has NOT witnessed the floor. When the canary fixture is not
|
|
11
|
+
* advertised, or the start-run call answers 404/422, every test records
|
|
12
|
+
* `blocked` with the reason — never a trivial pass. A production posture that
|
|
13
|
+
* keeps the `conformance.secret.echo` seam off therefore leaves its claimed
|
|
14
|
+
* `openwop-secrets` profile uncertified, and only that profile: the cut's
|
|
15
|
+
* other claims still certify. Hosts that opt into `OPENWOP_CONFORMANCE_FIXTURES=1`
|
|
12
16
|
* AND pre-provision a secret under `openwop-conformance-canary-secret`
|
|
13
17
|
* expose the surface and the scenarios run end-to-end.
|
|
14
18
|
*
|
|
19
|
+
* Why not `describe.skipIf`: a file whose tests are all skipped WITHOUT a
|
|
20
|
+
* recorded reason is an unclassified return (RFC 0148 §A), and an
|
|
21
|
+
* unclassified FLOOR row in a claimed profile rejects the whole
|
|
22
|
+
* certification, not just that profile. Measured on openwop-app's
|
|
23
|
+
* 2026-09-28 post-deploy major-1 cut: 2,116 rows, `certified: none`, the
|
|
24
|
+
* only unclassified row being this file.
|
|
25
|
+
*
|
|
15
26
|
* The scenarios assert shape + non-empty + redaction, not exact value
|
|
16
27
|
* equality — any host-defined canary value works as long as it's
|
|
17
28
|
* non-empty and the host's `secrets.resolve` returns it intact.
|
|
@@ -34,17 +45,18 @@ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
|
34
45
|
|
|
35
46
|
const BYOK_WORKFLOW_ID = 'openwop-smoke-byok-roundtrip';
|
|
36
47
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(BYOK_WORKFLOW_ID);
|
|
48
|
+
const NO_FIXTURE_REASON = `precondition not met — the \`${BYOK_WORKFLOW_ID}\` fixture is not advertised, so this host cannot witness the BYOK roundtrip floor (the \`conformance.secret.echo\` seam and the \`openwop-conformance-canary-secret\` canary are off)`;
|
|
37
49
|
|
|
38
|
-
describe
|
|
50
|
+
describe('byok: end-to-end credentialRef resolution roundtrip (openwop-byok profile)', () => {
|
|
39
51
|
it('the canary fixture run MUST resolve a host-provisioned secret and emit SHA-256 hex', async () => {
|
|
52
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
40
53
|
const create = await driver.post('/v1/runs', {
|
|
41
54
|
workflowId: 'openwop-smoke-byok-roundtrip',
|
|
42
55
|
});
|
|
43
56
|
|
|
44
|
-
// Fixture absent OR canary not provisioned
|
|
45
|
-
// Scenario passes trivially.
|
|
57
|
+
// Fixture absent OR canary not provisioned: the floor is not witnessed.
|
|
46
58
|
if (create.status === 404 || create.status === 422) {
|
|
47
|
-
return softSkip('
|
|
59
|
+
return softSkip('blocked', 'precondition not met — `create.status === 404 || create.status === 422` returned early (fixture advertised but the run could not start: canary not provisioned or seam unavailable)');
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
expect(create.status, req('openwop.it.byok-roundtrip.the-canary-fixture-run-must-resolve-a-host-provisioned-secret-and-emit-sha-256-h',
|
|
@@ -102,6 +114,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('byok: end-to-end credentialRef resolution roun
|
|
|
102
114
|
});
|
|
103
115
|
|
|
104
116
|
it('BYOK fixture run MUST emit a node.completed event for the resolve step', async () => {
|
|
117
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
105
118
|
const create = await driver.post('/v1/runs', {
|
|
106
119
|
workflowId: 'openwop-smoke-byok-roundtrip',
|
|
107
120
|
});
|
|
@@ -128,6 +141,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('byok: end-to-end credentialRef resolution roun
|
|
|
128
141
|
});
|
|
129
142
|
|
|
130
143
|
it('BYOK fixture run event log MUST NOT echo the resolved secret value (redaction)', async () => {
|
|
144
|
+
if (SKIP_NO_FIXTURE) return softSkip('blocked', NO_FIXTURE_REASON);
|
|
131
145
|
const create = await driver.post('/v1/runs', {
|
|
132
146
|
workflowId: 'openwop-smoke-byok-roundtrip',
|
|
133
147
|
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0223 — a rejected approval gate fails closed, and the failure is
|
|
3
|
+
* routable (`spec/v2/core/interrupt.md` §Rejection; suite 2.43.0, target
|
|
4
|
+
* major 2; gated on the `interrupt` family).
|
|
5
|
+
*
|
|
6
|
+
* Legs:
|
|
7
|
+
* 1. `conformance-approval` (one gate, no edges) resolved `reject` ⇒ the run
|
|
8
|
+
* terminates `failed`, and both the snapshot `error.code` and
|
|
9
|
+
* `run.failed.error.code` are `approval_rejected`;
|
|
10
|
+
* 2. the same run's `interrupt.resolved` records `action: "reject"` and
|
|
11
|
+
* `decision: "rejected"` (the `action` half is interrupt.md §Events, RFC
|
|
12
|
+
* 0183; RFC 0223 names both for a reject);
|
|
13
|
+
* 3. the gate's `node.failed` carries `approval_rejected` with
|
|
14
|
+
* `retryable: false`, and `run.failed.failedNodeId` names the gate;
|
|
15
|
+
* 4. `conformance-interrupt-quorum` (requiredApprovals 3, `majority`): one
|
|
16
|
+
* reject leaves the run `waiting-approval`; a second decides the gate and
|
|
17
|
+
* the run terminates `failed` with `approval_rejected`.
|
|
18
|
+
*
|
|
19
|
+
* The legs are separate rows so a host that misses one field fails that row
|
|
20
|
+
* only. The timeout disposition is not tested: no registered fixture carries a
|
|
21
|
+
* gate with a short `timeoutMs`, and the suite cannot make a host's timer fire.
|
|
22
|
+
* The routed case (an `all_complete` / `any_failed` edge) is not tested either:
|
|
23
|
+
* `conformance-approval` has no edges, and widening a fixture every host serves
|
|
24
|
+
* would force a re-registration (fixtures.md §conformance-approval-refine).
|
|
25
|
+
*
|
|
26
|
+
* The quorum leg presents distinct votes on one bearer through the resume
|
|
27
|
+
* value's `voter` member, as `interrupt-quorum-resolution` does at major 1. A
|
|
28
|
+
* host that counts both votes as one principal cannot be driven to a majority
|
|
29
|
+
* this way; that records `blocked`, not a failure.
|
|
30
|
+
*
|
|
31
|
+
* @see spec/v2/core/interrupt.md §Rejection
|
|
32
|
+
* @see spec/v2/errors.json approval_rejected
|
|
33
|
+
* @see RFCS/0223-approval-reject-disposition.md
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
38
|
+
import { gateFamily } from '../lib/v2.js';
|
|
39
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
40
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
41
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
42
|
+
import { req } from '../lib/requirement-ids.js';
|
|
43
|
+
|
|
44
|
+
const DOC = 'spec/v2/core/interrupt.md §Rejection (RFC 0223)';
|
|
45
|
+
const R = (slug: string): string => `openwop.requirement.0223.${slug}`;
|
|
46
|
+
const FIXTURE = 'conformance-approval';
|
|
47
|
+
const QUORUM = 'conformance-interrupt-quorum';
|
|
48
|
+
const NODE_ID = 'gate';
|
|
49
|
+
const CODE = 'approval_rejected';
|
|
50
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
51
|
+
|
|
52
|
+
interface Ev { type?: string; nodeId?: string; payload?: Record<string, unknown> }
|
|
53
|
+
|
|
54
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
55
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
56
|
+
|
|
57
|
+
async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
|
|
58
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
59
|
+
return res?.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
|
|
60
|
+
}
|
|
61
|
+
async function waitStatus(runId: string, wanted: ReadonlySet<string>, timeoutMs: number): Promise<Record<string, unknown> | null> {
|
|
62
|
+
const deadline = Date.now() + timeoutMs;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const s = await snapshot(runId);
|
|
65
|
+
if (s !== null && wanted.has(String(s['status']))) return s;
|
|
66
|
+
if (Date.now() > deadline) return s;
|
|
67
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function events(runId: string): Promise<Ev[]> {
|
|
71
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
|
|
72
|
+
return ((res?.json as { events?: Ev[] } | null)?.events ?? []);
|
|
73
|
+
}
|
|
74
|
+
const errorCode = (o: unknown): string | undefined => (o as { code?: unknown } | null | undefined)?.code as string | undefined;
|
|
75
|
+
const resolve = (runId: string, resumeValue: unknown): Promise<OpenWOPResponse | null> => http(() => driver.post(`/runs/${enc(runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue }));
|
|
76
|
+
|
|
77
|
+
/** Create a run of `fixture` and wait for it to suspend on its gate. */
|
|
78
|
+
async function suspended(fixture: string): Promise<{ runId: string } | { reason: string }> {
|
|
79
|
+
const res = await http(() => driver.post('/runs', { workflowId: fixture }));
|
|
80
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
81
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
82
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${fixture}) answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
83
|
+
const s = await waitStatus(runId, new Set(['waiting-approval', ...TERMINAL]), 10_000);
|
|
84
|
+
if (s?.['status'] !== 'waiting-approval') return { reason: `the ${fixture} run did not suspend on its approval gate (status ${String(s?.['status'])})` };
|
|
85
|
+
return { runId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One rejected `conformance-approval` run per file: legs 1–3 read the same run. */
|
|
89
|
+
let rejected: Promise<{ runId: string; terminal: Record<string, unknown> | null; evs: Ev[] } | { reason: string }> | undefined;
|
|
90
|
+
function rejectedRun(): Promise<{ runId: string; terminal: Record<string, unknown> | null; evs: Ev[] } | { reason: string }> {
|
|
91
|
+
rejected ??= (async () => {
|
|
92
|
+
const s = await suspended(FIXTURE);
|
|
93
|
+
if ('reason' in s) return s;
|
|
94
|
+
const res = await resolve(s.runId, { action: 'reject' });
|
|
95
|
+
if (res === null || res.status < 200 || res.status >= 300) return { reason: `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract` };
|
|
96
|
+
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
97
|
+
return { runId: s.runId, terminal, evs: await events(s.runId) };
|
|
98
|
+
})();
|
|
99
|
+
return rejected;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conformance-approval)', () => {
|
|
103
|
+
it('a rejected gate with no routing edge fails the run with approval_rejected', async () => {
|
|
104
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
105
|
+
const r = await rejectedRun();
|
|
106
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
107
|
+
const id = R('reject-fails-run');
|
|
108
|
+
expect(r.terminal?.['status'], req(id, DOC, `with no edge admitting a failed source the run MUST terminate failed (got ${String(r.terminal?.['status'])})`)).toBe('failed');
|
|
109
|
+
expect(errorCode(r.terminal?.['error']), req(id, `${DOC}; runs.md §Snapshot`, 'the terminal snapshot error.code MUST be approval_rejected')).toBe(CODE);
|
|
110
|
+
const failed = r.evs.find((e) => e.type === 'run.failed');
|
|
111
|
+
expect(errorCode(failed?.payload?.['error']), req(id, DOC, 'run.failed.error.code MUST be approval_rejected')).toBe(CODE);
|
|
112
|
+
}, 45_000);
|
|
113
|
+
|
|
114
|
+
it('interrupt.resolved records action reject and decision rejected', async () => {
|
|
115
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
116
|
+
const r = await rejectedRun();
|
|
117
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
118
|
+
const id = R('reject-recorded');
|
|
119
|
+
const resolved = r.evs.find((e) => e.type === 'interrupt.resolved');
|
|
120
|
+
expect(resolved, req(id, `${DOC}; interrupt.md §Events`, 'the reject MUST be recorded as interrupt.resolved')).toBeDefined();
|
|
121
|
+
expect(
|
|
122
|
+
{ action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'] },
|
|
123
|
+
req(id, `${DOC}; interrupt.md §Events (RFC 0183)`, 'interrupt.resolved MUST carry action "reject" and decision "rejected"'),
|
|
124
|
+
).toEqual({ action: 'reject', decision: 'rejected' });
|
|
125
|
+
}, 45_000);
|
|
126
|
+
|
|
127
|
+
it('the gate node fails with approval_rejected, not retryable, and run.failed names it', async () => {
|
|
128
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
129
|
+
const r = await rejectedRun();
|
|
130
|
+
if ('reason' in r) return softSkip('blocked', r.reason);
|
|
131
|
+
const id = R('reject-fails-node');
|
|
132
|
+
const nodeFailed = r.evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
133
|
+
expect(nodeFailed, req(id, DOC, 'the gate that does not turn the rejection into an output MUST emit node.failed')).toBeDefined();
|
|
134
|
+
const err = nodeFailed?.payload?.['error'] as { code?: unknown; retryable?: unknown } | undefined;
|
|
135
|
+
expect({ code: err?.code, retryable: err?.retryable }, req(id, DOC, 'node.failed.error MUST carry code approval_rejected and retryable false')).toEqual({ code: CODE, retryable: false });
|
|
136
|
+
const runFailed = r.evs.find((e) => e.type === 'run.failed');
|
|
137
|
+
expect(runFailed?.payload?.['failedNodeId'], req(id, DOC, 'run.failed.failedNodeId MUST name the gate')).toBe(NODE_ID);
|
|
138
|
+
}, 45_000);
|
|
139
|
+
|
|
140
|
+
it('a majority reject decides the quorum gate on the deciding vote and fails the run with approval_rejected', async () => {
|
|
141
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
142
|
+
if (!isFixtureAdvertised(QUORUM)) return softSkip('inapplicable', `fixture ${QUORUM} is not advertised — the host does not claim the quorum fixture`);
|
|
143
|
+
const s = await suspended(QUORUM);
|
|
144
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
145
|
+
const id = R('quorum-reject-fails-run');
|
|
146
|
+
const first = await resolve(s.runId, { action: 'reject', voter: 'approver-1' });
|
|
147
|
+
if (first === null || first.status < 200 || first.status >= 300) return softSkip('blocked', `the first reject vote answered ${first?.status ?? 'nothing'} ${readErrorCode(first?.json) ?? ''}`);
|
|
148
|
+
const between = (await snapshot(s.runId))?.['status'];
|
|
149
|
+
const resolvedEarly = (await events(s.runId)).some((e) => e.type === 'interrupt.resolved');
|
|
150
|
+
const second = await resolve(s.runId, { action: 'reject', voter: 'approver-2' });
|
|
151
|
+
if (second === null || second.status < 200 || second.status >= 300) return softSkip('blocked', `the second reject vote answered ${second?.status ?? 'nothing'} ${readErrorCode(second?.json) ?? ''}`);
|
|
152
|
+
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
153
|
+
if (between === 'waiting-approval' && terminal?.['status'] === 'waiting-approval') {
|
|
154
|
+
await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
155
|
+
return softSkip('blocked', 'the host counted both votes as one approver (one bearer; the resume value\'s voter was not honoured), so a majority could not be caused');
|
|
156
|
+
}
|
|
157
|
+
expect(between, req(id, DOC, 'one reject of three under majority does not decide the gate: the run MUST stay waiting-approval')).toBe('waiting-approval');
|
|
158
|
+
expect(resolvedEarly, req(id, DOC, 'a vote that does not decide the gate MUST NOT emit interrupt.resolved')).toBe(false);
|
|
159
|
+
expect(terminal?.['status'], req(id, DOC, `two rejects of three under majority MUST resolve the gate rejected and fail the run (got ${String(terminal?.['status'])})`)).toBe('failed');
|
|
160
|
+
expect(errorCode(terminal?.['error']), req(id, DOC, 'the terminal snapshot error.code MUST be approval_rejected')).toBe(CODE);
|
|
161
|
+
}, 60_000);
|
|
162
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0222 — v2 registry lifecycle and signing keys, witnessed against a
|
|
3
|
+
* registry (`spec/v2/core/packs.md` §"Version manifests" and §Signing; suite
|
|
4
|
+
* 2.43.0, target major 2).
|
|
5
|
+
*
|
|
6
|
+
* This scenario reads a REGISTRY, not the host under test. It runs only when
|
|
7
|
+
* one is named: `OPENWOP_REGISTRY_URL=<origin>` (a mirror, a vendor registry,
|
|
8
|
+
* or `node registry/scripts/serve.mjs` in openwop-registry), or
|
|
9
|
+
* `OPENWOP_TEST_PUBLIC_REGISTRY=true` for `https://packs.openwop.dev`. With
|
|
10
|
+
* neither it records `inapplicable`, like `registry-public.test.ts`: a host
|
|
11
|
+
* conformance run MUST NOT need outbound access to a registry. It is a major-2
|
|
12
|
+
* file, so it runs in a major-2 lane (`OPENWOP_TARGET_MAJOR=2`).
|
|
13
|
+
*
|
|
14
|
+
* Every path is resolved through `.well-known/openwop-registry` `endpoints.v2`
|
|
15
|
+
* (packs.md §"The registry tree"), never constructed.
|
|
16
|
+
*
|
|
17
|
+
* How it FAILS:
|
|
18
|
+
* - a per-pack index names a yanked version `latest` while the pack has one
|
|
19
|
+
* that is not yanked, or disagrees with the version manifest's `yanked` /
|
|
20
|
+
* `versionDeprecated`, or the registry-wide row disagrees with it;
|
|
21
|
+
* - a yanked version's manifest, tarball or signature is no longer served;
|
|
22
|
+
* - a served version names a key that `signingKeys[]` no longer lists, or one
|
|
23
|
+
* whose `permittedNamespaces` does not admit the pack, or whose signature
|
|
24
|
+
* does not verify. A key that is no longer `active` still verifies what it
|
|
25
|
+
* signed; the scenario refuses only a key that disappeared.
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v2/core/packs.md §"Version manifests", §Signing
|
|
28
|
+
* @see RFCS/0222-v2-registry-operations.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
33
|
+
import { gunzipSync } from 'node:zlib';
|
|
34
|
+
import { req } from '../lib/requirement-ids.js';
|
|
35
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
36
|
+
|
|
37
|
+
type Json = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
const ID_YANK = 'openwop.requirement.0222.yanked-version-lifecycle';
|
|
40
|
+
const ID_KEYS = 'openwop.requirement.0222.signing-keys-cover-served-versions';
|
|
41
|
+
const SPEC_VM = 'RFC 0222 · packs.md §"Version manifests"';
|
|
42
|
+
const SPEC_SIG = 'RFC 0222 · packs.md §Signing';
|
|
43
|
+
|
|
44
|
+
const BASE = (process.env['OPENWOP_REGISTRY_URL'] ?? (process.env['OPENWOP_TEST_PUBLIC_REGISTRY'] === 'true' ? 'https://packs.openwop.dev' : '')).replace(/\/+$/, '');
|
|
45
|
+
const NO_REGISTRY = 'no registry named — set OPENWOP_REGISTRY_URL (or OPENWOP_TEST_PUBLIC_REGISTRY=true for packs.openwop.dev); a host run does not read a registry';
|
|
46
|
+
|
|
47
|
+
async function fetchWithRetry(url: string): Promise<Response> {
|
|
48
|
+
for (let attempt = 0; ; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
return await fetch(url, { headers: { Accept: 'application/json, */*' } });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (attempt >= 2) throw err;
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getJson(path: string): Promise<{ status: number; json: Json | null }> {
|
|
59
|
+
const res = await fetchWithRetry(`${BASE}${path}`);
|
|
60
|
+
if (res.status !== 200) return { status: res.status, json: null };
|
|
61
|
+
try {
|
|
62
|
+
return { status: 200, json: (await res.json()) as Json };
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: 200, json: null };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function getBytes(path: string): Promise<{ status: number; bytes: Buffer }> {
|
|
69
|
+
const res = await fetchWithRetry(`${BASE}${path}`);
|
|
70
|
+
return { status: res.status, bytes: Buffer.from(await res.arrayBuffer()) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run `fn` over `items`, `width` at a time. */
|
|
74
|
+
async function pool<T, R>(items: readonly T[], width: number, fn: (t: T) => Promise<R>): Promise<R[]> {
|
|
75
|
+
const out: R[] = new Array(items.length);
|
|
76
|
+
let next = 0;
|
|
77
|
+
await Promise.all(Array.from({ length: Math.min(width, items.length) }, async () => {
|
|
78
|
+
for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i]!);
|
|
79
|
+
}));
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The exact bytes of `pack.json` inside a gzipped USTAR tarball (what a v2 signature covers). */
|
|
84
|
+
function packJsonBytes(tgz: Buffer): Buffer | null {
|
|
85
|
+
const tar = gunzipSync(tgz);
|
|
86
|
+
for (let off = 0; off + 512 <= tar.length; ) {
|
|
87
|
+
const nameBuf = tar.subarray(off, off + 100);
|
|
88
|
+
const end = nameBuf.indexOf(0);
|
|
89
|
+
const name = nameBuf.subarray(0, end < 0 ? 100 : end).toString('utf8');
|
|
90
|
+
if (!name) return null;
|
|
91
|
+
const size = parseInt(tar.subarray(off + 124, off + 136).toString('ascii').replace(/\0/g, '').trim() || '0', 8);
|
|
92
|
+
if (name === 'pack.json' || name === './pack.json' || name === 'package/pack.json') return tar.subarray(off + 512, off + 512 + size);
|
|
93
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fill(template: string, name: string, version?: string): string {
|
|
99
|
+
return template.replace('{name}', name).replace('{version}', version ?? '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Registry {
|
|
103
|
+
readonly discovery: Json;
|
|
104
|
+
readonly v2: Record<string, string>;
|
|
105
|
+
readonly packs: Json[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let cached: Promise<Registry | string> | null = null;
|
|
109
|
+
|
|
110
|
+
/** Discovery + the v2 index, or the reason the registry is unreadable (an assertion failure, not a skip). */
|
|
111
|
+
function registry(): Promise<Registry | string> {
|
|
112
|
+
cached ??= (async () => {
|
|
113
|
+
const wk = await getJson('/.well-known/openwop-registry');
|
|
114
|
+
if (!wk.json) return `GET /.well-known/openwop-registry answered ${wk.status} without a JSON document`;
|
|
115
|
+
const endpoints = (wk.json['endpoints'] ?? {}) as Json;
|
|
116
|
+
const v2 = endpoints['v2'] as Record<string, string> | undefined;
|
|
117
|
+
if (!v2 || typeof v2['registryIndex'] !== 'string' || typeof v2['packMetadata'] !== 'string' || typeof v2['versionManifest'] !== 'string') {
|
|
118
|
+
return 'the discovery document has no endpoints.v2 {registryIndex, packMetadata, versionManifest}';
|
|
119
|
+
}
|
|
120
|
+
const idx = await getJson(v2['registryIndex']);
|
|
121
|
+
if (!idx.json || !Array.isArray(idx.json['packs'])) return `GET ${v2['registryIndex']} answered ${idx.status} without a packs[] index`;
|
|
122
|
+
return { discovery: wk.json, v2, packs: idx.json['packs'] as Json[] };
|
|
123
|
+
})();
|
|
124
|
+
return cached;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Version { readonly pack: string; readonly version: string; readonly manifest: Json }
|
|
128
|
+
|
|
129
|
+
describe('RFC 0222 — v2 registry lifecycle and signing keys', () => {
|
|
130
|
+
it('a yanked version is never latest while an unyanked one exists, the flags agree across index and manifest, and a yanked version stays served', async () => {
|
|
131
|
+
if (!BASE) return softSkip('inapplicable', NO_REGISTRY);
|
|
132
|
+
const reg = await registry();
|
|
133
|
+
expect(typeof reg, req(ID_YANK, SPEC_VM, `the registry MUST serve a v2 index through endpoints.v2 — ${typeof reg === 'string' ? reg : ''}`)).toBe('object');
|
|
134
|
+
if (typeof reg === 'string') throw new Error(reg);
|
|
135
|
+
|
|
136
|
+
const problems: string[] = [];
|
|
137
|
+
const yanked: Version[] = [];
|
|
138
|
+
await pool(reg.packs, 8, async (row) => {
|
|
139
|
+
const name = String(row['name']);
|
|
140
|
+
const meta = await getJson(fill(reg.v2['packMetadata']!, name));
|
|
141
|
+
if (!meta.json) { problems.push(`${name}: pack metadata answered ${meta.status}`); return; }
|
|
142
|
+
const versions = (Array.isArray(meta.json['versions']) ? meta.json['versions'] : []) as Json[];
|
|
143
|
+
const unyanked = versions.filter((v) => v['yanked'] !== true).map((v) => String(v['version']));
|
|
144
|
+
const latest = String(meta.json['latest']);
|
|
145
|
+
const latestRow = versions.find((v) => v['version'] === latest);
|
|
146
|
+
if (latestRow?.['yanked'] === true && unyanked.length > 0) problems.push(`${name}: latest ${latest} is yanked while ${unyanked.join(', ')} is not`);
|
|
147
|
+
if (row['latestVersion'] !== latest) problems.push(`${name}: registry index latestVersion ${String(row['latestVersion'])} ≠ pack metadata latest ${latest}`);
|
|
148
|
+
if (row['yanked'] === true && unyanked.length > 0) problems.push(`${name}: registry index marks the pack yanked while ${unyanked.join(', ')} is not`);
|
|
149
|
+
for (const v of versions) {
|
|
150
|
+
const version = String(v['version']);
|
|
151
|
+
const vm = await getJson(fill(reg.v2['versionManifest']!, name, version));
|
|
152
|
+
if (!vm.json) { problems.push(`${name}@${version}: version manifest answered ${vm.status}`); continue; }
|
|
153
|
+
if ((vm.json['yanked'] === true) !== (v['yanked'] === true)) problems.push(`${name}@${version}: index yanked=${String(v['yanked'])} but manifest yanked=${String(vm.json['yanked'])}`);
|
|
154
|
+
if ((vm.json['versionDeprecated'] === true) !== (v['versionDeprecated'] === true)) problems.push(`${name}@${version}: index versionDeprecated=${String(v['versionDeprecated'])} but manifest versionDeprecated=${String(vm.json['versionDeprecated'])}`);
|
|
155
|
+
if (vm.json['yanked'] === true) yanked.push({ pack: name, version, manifest: vm.json });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
expect(problems, req(ID_YANK, SPEC_VM, 'the per-pack index MUST NOT name a yanked version latest while an unyanked one exists, and the index MUST agree with each version manifest')).toEqual([]);
|
|
159
|
+
|
|
160
|
+
if (yanked.length === 0) {
|
|
161
|
+
// partial-witness-ok: the index/manifest agreement was asserted over every
|
|
162
|
+
// version; the served-after-yank half has nothing to observe.
|
|
163
|
+
return softSkip('inapplicable', 'no version in this v2 tree is yanked — the latest rule held vacuously and there is no yanked version whose files could be checked');
|
|
164
|
+
}
|
|
165
|
+
const unserved: string[] = [];
|
|
166
|
+
await pool(yanked, 8, async ({ pack, version }) => {
|
|
167
|
+
for (const key of ['versionTarball', 'versionSignature'] as const) {
|
|
168
|
+
const tmpl = reg.v2[key];
|
|
169
|
+
if (typeof tmpl !== 'string') { unserved.push(`endpoints.v2.${key} is absent`); continue; }
|
|
170
|
+
const r = await getBytes(fill(tmpl, pack, version));
|
|
171
|
+
if (r.status !== 200 || r.bytes.byteLength === 0) unserved.push(`${pack}@${version}: ${key} answered ${r.status}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
expect(unserved, req(ID_YANK, SPEC_VM, `the registry MUST keep serving a yanked version's manifest, tarball and signature at their exact paths (${yanked.length} yanked)`)).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('every served version names a listed key whose permittedNamespaces admit it, and its signature verifies — whatever the key status', async () => {
|
|
178
|
+
if (!BASE) return softSkip('inapplicable', NO_REGISTRY);
|
|
179
|
+
const reg = await registry();
|
|
180
|
+
expect(typeof reg, req(ID_KEYS, SPEC_SIG, `the registry MUST serve a v2 index through endpoints.v2 — ${typeof reg === 'string' ? reg : ''}`)).toBe('object');
|
|
181
|
+
if (typeof reg === 'string') throw new Error(reg);
|
|
182
|
+
|
|
183
|
+
const keys = (Array.isArray(reg.discovery['signingKeys']) ? reg.discovery['signingKeys'] : []) as Json[];
|
|
184
|
+
const malformed = keys.filter((k) => typeof k['keyId'] !== 'string' || typeof k['publicKeyUrl'] !== 'string' || typeof k['status'] !== 'string' || !Array.isArray(k['permittedNamespaces']));
|
|
185
|
+
expect(malformed.map((k) => String(k['keyId'])), req(ID_KEYS, SPEC_SIG, 'every signingKeys[] entry MUST carry keyId, publicKeyUrl, status and permittedNamespaces')).toEqual([]);
|
|
186
|
+
const byId = new Map(keys.map((k) => [String(k['keyId']), k]));
|
|
187
|
+
const publicKeys = new Map<string, ReturnType<typeof createPublicKey> | null>();
|
|
188
|
+
|
|
189
|
+
const permits = (key: Json, name: string): boolean => (key['permittedNamespaces'] as unknown[]).some((p) => {
|
|
190
|
+
const pat = String(p);
|
|
191
|
+
return pat.endsWith('.*') ? name.startsWith(pat.slice(0, -1)) : pat === name;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const versions: Version[] = [];
|
|
195
|
+
await pool(reg.packs, 8, async (row) => {
|
|
196
|
+
const name = String(row['name']);
|
|
197
|
+
const meta = await getJson(fill(reg.v2['packMetadata']!, name));
|
|
198
|
+
for (const v of (Array.isArray(meta.json?.['versions']) ? meta.json!['versions'] : []) as Json[]) {
|
|
199
|
+
const vm = await getJson(fill(reg.v2['versionManifest']!, name, String(v['version'])));
|
|
200
|
+
if (vm.json) versions.push({ pack: name, version: String(v['version']), manifest: vm.json });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
expect(versions.length, req(ID_KEYS, SPEC_SIG, 'the v2 tree MUST serve at least one version manifest to witness')).toBeGreaterThan(0);
|
|
204
|
+
|
|
205
|
+
const problems: string[] = [];
|
|
206
|
+
const statuses = new Map<string, number>();
|
|
207
|
+
await pool(versions, 8, async ({ pack, version, manifest }) => {
|
|
208
|
+
const at = `${pack}@${version}`;
|
|
209
|
+
const keyId = String((manifest['signing'] as Json | undefined)?.['keyId'] ?? '');
|
|
210
|
+
const key = byId.get(keyId);
|
|
211
|
+
if (!key) { problems.push(`${at}: signed by ${keyId || '(no keyId)'}, which signingKeys[] does not list`); return; }
|
|
212
|
+
if (!permits(key, pack)) { problems.push(`${at}: key ${keyId} does not permit this namespace (${(key['permittedNamespaces'] as unknown[]).join(', ')})`); return; }
|
|
213
|
+
statuses.set(String(key['status']), (statuses.get(String(key['status'])) ?? 0) + 1);
|
|
214
|
+
if (!publicKeys.has(keyId)) {
|
|
215
|
+
const r = await getBytes(String(key['publicKeyUrl']));
|
|
216
|
+
let pk: ReturnType<typeof createPublicKey> | null = null;
|
|
217
|
+
try { pk = r.status === 200 ? createPublicKey(r.bytes.toString('utf8')) : null; } catch { pk = null; }
|
|
218
|
+
publicKeys.set(keyId, pk);
|
|
219
|
+
}
|
|
220
|
+
const pk = publicKeys.get(keyId);
|
|
221
|
+
if (!pk) { problems.push(`${at}: publicKeyUrl for ${keyId} does not serve a public key`); return; }
|
|
222
|
+
const tgz = await getBytes(fill(reg.v2['versionTarball'] ?? '', pack, version));
|
|
223
|
+
const sig = await getBytes(fill(reg.v2['versionSignature'] ?? '', pack, version));
|
|
224
|
+
if (tgz.status !== 200 || sig.status !== 200) { problems.push(`${at}: tarball ${tgz.status}, signature ${sig.status}`); return; }
|
|
225
|
+
if (typeof manifest['integrity'] === 'string' && manifest['integrity'] !== `sha256-${createHash('sha256').update(tgz.bytes).digest('base64')}`) problems.push(`${at}: integrity does not match the served tarball`);
|
|
226
|
+
const signed = packJsonBytes(tgz.bytes);
|
|
227
|
+
if (!signed) { problems.push(`${at}: the tarball has no pack.json`); return; }
|
|
228
|
+
if (!cryptoVerify(null, signed, pk, sig.bytes)) problems.push(`${at}: the signature does not verify under ${keyId}`);
|
|
229
|
+
});
|
|
230
|
+
expect(problems, req(ID_KEYS, SPEC_SIG, `every served version MUST name a listed key permitted for its namespace and verify under it, whatever the key status (${versions.length} versions; key status ${JSON.stringify(Object.fromEntries(statuses))})`)).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|