@openwop/openwop-conformance 2.42.9 → 2.43.1
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 +29 -0
- package/README.md +3 -3
- package/coverage.md +1 -1
- package/dist/cli.js +15 -12
- package/dist/lib/certification-bundle-v3.js +19 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-reject-loopback.json +33 -0
- package/fixtures/conformance-approval-reject-routed.json +32 -0
- package/fixtures/conformance-approval-timeout-approve.json +46 -0
- package/fixtures/conformance-approval-timeout.json +28 -0
- package/fixtures.md +59 -2
- package/package.json +2 -2
- package/requirements.json +535 -38
- package/scenario-majors.json +20 -5
- package/schemas/CORPUS-STAMP.json +55 -54
- package/src/cli.ts +15 -12
- package/src/lib/auditIntegrity.ts +45 -0
- package/src/lib/certification-bundle-v3.ts +25 -0
- package/src/lib/standard-webhooks.ts +53 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +63 -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-agent-org-chart-served-shape.test.ts +63 -0
- package/src/scenarios/v2-approval-reject-disposition.test.ts +262 -0
- package/src/scenarios/v2-error-registry.test.ts +59 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
- package/src/scenarios/v2-pack-isolation.test.ts +10 -6
- package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
- package/src/scenarios/v2-registry-lifecycle.test.ts +232 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
|
@@ -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
|
+
});
|
|
@@ -10,15 +10,20 @@
|
|
|
10
10
|
* checked before lookup (`403 id_tenant_mismatch`), refuses a subscription that
|
|
11
11
|
* did not opt in (`400 validation_error`), and returns no secret.
|
|
12
12
|
*
|
|
13
|
-
* The post-overlap leg waits for `previousSecretExpiresAt`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* The post-overlap leg waits for `previousSecretExpiresAt` when `overlapSeconds`
|
|
14
|
+
* fits inside the suite's retry-wait cap (`OPENWOP_WEBHOOK_RETRY_WAIT_MS`,
|
|
15
|
+
* operator-raisable). Past the cap (suite 2.43.1), a seams-profile host that
|
|
16
|
+
* serves the OPTIONAL `shortenRotationOverlap` seam (host-sample-test-seams.md
|
|
17
|
+
* §29) has this one subscription's overlap cut to a few seconds, and the leg
|
|
18
|
+
* waits for that; a served seam that breaks its contract records `blocked`.
|
|
19
|
+
* A host without the seam is not observed, and the row says so with a
|
|
20
|
+
* `partial-witness:` detail — which the acceptance predicate refuses to cite as
|
|
21
|
+
* `executed-pass` (RFC 0201 §Falsifiability).
|
|
18
22
|
*
|
|
19
23
|
* How it FAILS: signing only with the new secret (one entry); moving
|
|
20
24
|
* `OpenWOP-Signature` to the new secret at rotation; a foreign-tenant id that
|
|
21
|
-
* reaches the lookup (`404` instead of `403`); a response that echoes the secret
|
|
25
|
+
* reaches the lookup (`404` instead of `403`); a response that echoes the secret;
|
|
26
|
+
* the previous secret still signing after the (shortened) overlap.
|
|
22
27
|
*
|
|
23
28
|
* @see spec/v2/core/webhooks.md §Standard Webhooks
|
|
24
29
|
* @see RFCS/0201-standard-webhooks-signature-scheme.md §E
|
|
@@ -32,6 +37,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
32
37
|
import { softSkip } from '../lib/soft-skip.js';
|
|
33
38
|
import { projectBoundId } from '../lib/bound-id.js';
|
|
34
39
|
import { retryWaitCapMs } from '../lib/webhook-retry-window.js';
|
|
40
|
+
import { seamsProfileAdvertised } from '../lib/seams.js';
|
|
35
41
|
import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks, type ModalHit } from '../lib/webhook-receiver.js';
|
|
36
42
|
import {
|
|
37
43
|
STANDARD_WEBHOOKS_ALG,
|
|
@@ -39,12 +45,15 @@ import {
|
|
|
39
45
|
driveRun,
|
|
40
46
|
loopbackRefusal,
|
|
41
47
|
registerSw,
|
|
48
|
+
shortenRotationOverlap,
|
|
42
49
|
swGate,
|
|
43
50
|
unregisterAllSw,
|
|
44
51
|
waitFor,
|
|
45
52
|
} from '../lib/standard-webhooks.js';
|
|
46
53
|
|
|
47
54
|
const CAP_MS = retryWaitCapMs();
|
|
55
|
+
/** The overlap the §29 seam sets: long enough that the call returns inside it, short enough to wait out. */
|
|
56
|
+
const SEAM_OVERLAP_SECONDS = 3;
|
|
48
57
|
|
|
49
58
|
let closeReceiver: (() => Promise<void>) | null = null;
|
|
50
59
|
afterEach(async () => {
|
|
@@ -114,17 +123,25 @@ describe('RFC 0201 §E — secret rotation overlaps, then retires (gated on webh
|
|
|
114
123
|
expect(underOld.matched, req(ID, 'RFC 0201 §E.20', 'one entry MUST verify under the previous secret')).toBe(1);
|
|
115
124
|
expect(v1SignedBy(s1, d), req(ID, 'RFC 0201 §E.20', 'OpenWOP-Signature MUST stay on the previous secret during the overlap')).toBe(true);
|
|
116
125
|
|
|
117
|
-
// After the overlap:
|
|
118
|
-
|
|
126
|
+
// After the overlap: when it fits the operator's wait cap, or when the §29 seam shortens it.
|
|
127
|
+
let expiresAt = Date.parse(body.previousSecretExpiresAt ?? '');
|
|
119
128
|
if (expiresAt - Date.now() > CAP_MS) {
|
|
129
|
+
const unobserved = (why: string): string => `the post-overlap leg is not observed: the advertised webhooks.secretRotation.overlapSeconds is ${overlapSeconds}s (expiry ${new Date(expiresAt).toISOString()}) and the suite wait cap is ${CAP_MS}ms, and ${why}; raise OPENWOP_WEBHOOK_RETRY_WAIT_MS past the overlap, or serve the seam, to witness it`;
|
|
120
130
|
// partial-witness-ok: the tenant check, the rotate response and the dual signature during
|
|
121
131
|
// the overlap were asserted above. "The previous secret MUST NOT sign after
|
|
122
132
|
// previousSecretExpiresAt" cannot be observed inside a bounded run when the host's
|
|
123
133
|
// advertised webhooks.secretRotation.overlapSeconds outlasts the suite's wait cap, and
|
|
124
134
|
// blocking every long-overlap host would deny certification to conforming hosts
|
|
125
|
-
// (steward ruling, 2026-09-27). The
|
|
126
|
-
//
|
|
127
|
-
return softSkip('inapplicable',
|
|
135
|
+
// (steward ruling, 2026-09-27). The §29 seam closes it, but only for a host that
|
|
136
|
+
// claims the seams profile; this one did not. The row detail names both numbers.
|
|
137
|
+
if (!seamsProfileAdvertised(g.doc)) return softSkip('inapplicable', unobserved('the seams profile is not advertised, so the optional shortenRotationOverlap seam (host-sample-test-seams.md §29) is not offered'));
|
|
138
|
+
const cut = await shortenRotationOverlap(webhookId, SEAM_OVERLAP_SECONDS, foreign);
|
|
139
|
+
// partial-witness-ok: as above — the dual-signing half was asserted. The §29 seam is
|
|
140
|
+
// OPTIONAL and newer than this leg, so a host that does not serve it keeps the partial
|
|
141
|
+
// witness it held before the seam existed, never `blocked`.
|
|
142
|
+
if (!cut.ok && cut.unserved) return softSkip('inapplicable', unobserved(cut.reason));
|
|
143
|
+
if (!cut.ok) return softSkip('blocked', cut.reason);
|
|
144
|
+
expiresAt = cut.expiresAt;
|
|
128
145
|
}
|
|
129
146
|
await new Promise((r) => setTimeout(r, Math.max(0, expiresAt - Date.now()) + 1_500));
|
|
130
147
|
const after = await oneDelivery(rx, webhookId);
|