@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2744 -4
- package/scenario-majors.json +155 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Certification bundle v3 (RFC 0168 §E; schemas/v2/certification-bundle.schema.json).
|
|
3
|
+
*
|
|
4
|
+
* What v3 adds over v2 and why:
|
|
5
|
+
* - a closed root and `witnessSha256` over the reporter record (RFC 0148 §C
|
|
6
|
+
* asked for it; the v2 schema listed it optional and no emitter wrote it);
|
|
7
|
+
* - `assertionCount`, `host.build`, per-profile `evidenceTier`/`witnessCount`,
|
|
8
|
+
* `detail` (required when any result is not executed-pass);
|
|
9
|
+
* - an Ed25519 attestation over the canonical JSON of
|
|
10
|
+
* { witnessSha256, host.build, suite.version, discovery.sha256 } — the same
|
|
11
|
+
* commit rebuilt once scored 283/22 and 303/2 and nothing in the bundle could
|
|
12
|
+
* tell them apart; the build id plus the signature can;
|
|
13
|
+
* - `evidenceTier: independent` REQUIRES a verifier signature under a key id
|
|
14
|
+
* distinct from the host's (RFC 0148 R5: a malicious generator fabricates
|
|
15
|
+
* witnesses — a self-signed "independent" claim is refused, not warned).
|
|
16
|
+
*
|
|
17
|
+
* This module is schema-agnostic on purpose (the schema check is a separate step
|
|
18
|
+
* in cli.ts) and pure: it never reads the environment.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
|
|
23
|
+
|
|
24
|
+
export interface BundleV3Requirement {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly scenario: string;
|
|
27
|
+
readonly result: BundleV3Result;
|
|
28
|
+
readonly assertions?: number;
|
|
29
|
+
readonly detail?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface BundleV3Profile {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly evidenceTier: 'self' | 'steward' | 'independent';
|
|
34
|
+
readonly witnessCount: number;
|
|
35
|
+
readonly certified: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface BundleV3Relaxation { readonly obligation: string; readonly durability: 'session' | 'deployment' | 'persisted'; readonly reason: string }
|
|
38
|
+
export interface BundleV3Signature { readonly alg: 'ed25519'; readonly keyId: string; readonly sig: string; readonly over: readonly string[]; readonly verifierKeyId?: string }
|
|
39
|
+
export interface BundleV3 {
|
|
40
|
+
bundleVersion: '3';
|
|
41
|
+
generatedAt: string;
|
|
42
|
+
suite: { name: '@openwop/openwop-conformance'; version: string; targetMajor: 1 | 2; specArtifactsVersion: string; stampSha256?: string };
|
|
43
|
+
host: { name: string; version: string; vendor?: string; build: { kind: 'image-digest' | 'commit' | 'artifact-sha256'; id: string }; signingKeyId?: string; relaxations?: BundleV3Relaxation[] };
|
|
44
|
+
discovery: { url: string; sha256: string; protocolVersions: string[]; preferredVersion: string };
|
|
45
|
+
claimedProfiles: BundleV3Profile[];
|
|
46
|
+
results: { totals: Record<'executedPass' | 'executedFail' | 'skipped' | 'inapplicable' | 'blocked', number>; requirements: BundleV3Requirement[] };
|
|
47
|
+
witnessSha256: string;
|
|
48
|
+
assertionCount: number;
|
|
49
|
+
detail?: { nonPass: { id: string; result: string; reason: string }[] };
|
|
50
|
+
signature: BundleV3Signature;
|
|
51
|
+
verifierSignature?: { alg: 'ed25519'; keyId: string; sig: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'] as const;
|
|
55
|
+
|
|
56
|
+
/** Deterministic JSON: keys sorted at every level, no whitespace. */
|
|
57
|
+
export function canonicalJSON(value: unknown): string {
|
|
58
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;
|
|
59
|
+
if (value !== null && typeof value === 'object') {
|
|
60
|
+
return `{${Object.keys(value as Record<string, unknown>).sort().map((k) => `${JSON.stringify(k)}:${canonicalJSON((value as Record<string, unknown>)[k])}`).join(',')}}`;
|
|
61
|
+
}
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
|
|
66
|
+
export function witnessDigest(rows: readonly BundleV3Requirement[]): string {
|
|
67
|
+
const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
|
|
68
|
+
return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The bytes the attestation covers. */
|
|
72
|
+
export function attestationPayload(b: Pick<BundleV3, 'witnessSha256' | 'host' | 'suite' | 'discovery'>): Buffer {
|
|
73
|
+
return Buffer.from(canonicalJSON({ witnessSha256: b.witnessSha256, 'host.build': b.host.build, 'suite.version': b.suite.version, 'discovery.sha256': b.discovery.sha256 }), 'utf8');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toBase64url(buf: Buffer): string { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
77
|
+
function fromBase64url(s: string): Buffer { return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
|
|
78
|
+
|
|
79
|
+
export function signBundleV3(bundle: Omit<BundleV3, 'signature'>, privateKeyPem: string, keyId: string): BundleV3Signature {
|
|
80
|
+
const key = createPrivateKey(privateKeyPem);
|
|
81
|
+
if (key.asymmetricKeyType !== 'ed25519') throw new Error(`bundle v3 signing key must be Ed25519, got ${String(key.asymmetricKeyType)}`);
|
|
82
|
+
const sig = edSign(null, attestationPayload(bundle), key);
|
|
83
|
+
return { alg: 'ed25519', keyId, sig: toBase64url(sig), over: [...SIGNATURE_OVER] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function verifierSign(bundle: Pick<BundleV3, 'witnessSha256' | 'host' | 'suite' | 'discovery'>, privateKeyPem: string, keyId: string): NonNullable<BundleV3['verifierSignature']> {
|
|
87
|
+
const key = createPrivateKey(privateKeyPem);
|
|
88
|
+
return { alg: 'ed25519', keyId, sig: toBase64url(edSign(null, attestationPayload(bundle), key)) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function publicKeyFromPrivate(privateKeyPem: string): string {
|
|
92
|
+
return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' }) as string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface VerifyV3Options {
|
|
96
|
+
/** The host's public key (PEM). Without it the signature's bytes are checked for shape only and the verdict says `signatureVerified: false`. */
|
|
97
|
+
readonly hostPublicKeyPem?: string;
|
|
98
|
+
/** The verifier's public key (PEM) — required to verify an `independent` claim. */
|
|
99
|
+
readonly verifierPublicKeyPem?: string;
|
|
100
|
+
}
|
|
101
|
+
export interface V3Rejection { readonly kind: string; readonly detail: string; readonly profile?: string }
|
|
102
|
+
export interface V3Verdict {
|
|
103
|
+
readonly rejections: V3Rejection[];
|
|
104
|
+
readonly signatureVerified: boolean;
|
|
105
|
+
readonly verifierSignatureVerified: boolean;
|
|
106
|
+
readonly certifiedProfiles: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3Verdict {
|
|
110
|
+
const rejections: V3Rejection[] = [];
|
|
111
|
+
if (bundle.bundleVersion !== '3') rejections.push({ kind: 'not-v3', detail: `bundleVersion is ${JSON.stringify(bundle.bundleVersion)}, expected "3"` });
|
|
112
|
+
const rows = bundle.results?.requirements ?? [];
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
for (const r of rows) {
|
|
115
|
+
if (seen.has(r.id)) rejections.push({ kind: 'duplicate-requirement', detail: `${r.id}: more than one row (RFC 0148 §A)` });
|
|
116
|
+
seen.add(r.id);
|
|
117
|
+
if (r.result !== 'executed-pass' && !(r.detail && r.detail.trim())) rejections.push({ kind: 'reason-missing', detail: `${r.id}: '${r.result}' recorded without a reason` });
|
|
118
|
+
if (r.result === 'executed-pass' && !(r.assertions && r.assertions > 0)) rejections.push({ kind: 'vacuous-pass', detail: `${r.id}: executed-pass with no assertions is a witness of nothing (RFC 0148 §C)` });
|
|
119
|
+
}
|
|
120
|
+
const count = (d: BundleV3Result): number => rows.filter((r) => r.result === d).length;
|
|
121
|
+
const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
|
|
122
|
+
for (const k of Object.keys(expected) as (keyof typeof expected)[]) if (bundle.results?.totals?.[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
123
|
+
const digest = witnessDigest(rows);
|
|
124
|
+
if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
|
|
125
|
+
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
126
|
+
if (bundle.assertionCount !== assertions) rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
127
|
+
const nonPass = rows.filter((r) => r.result !== 'executed-pass');
|
|
128
|
+
if (nonPass.length > 0 && !bundle.detail) rejections.push({ kind: 'detail-missing', detail: `${nonPass.length} non-pass row(s) and no detail block` });
|
|
129
|
+
if (!bundle.host?.build?.kind || !bundle.host?.build?.id) rejections.push({ kind: 'build-missing', detail: 'host.build.{kind,id} is required — a bundle attributes to a BUILD, not a commit' });
|
|
130
|
+
|
|
131
|
+
// Signature
|
|
132
|
+
let signatureVerified = false;
|
|
133
|
+
const sig = bundle.signature;
|
|
134
|
+
if (!sig || sig.alg !== 'ed25519' || !sig.keyId || !sig.sig) rejections.push({ kind: 'signature-missing', detail: 'signature.{alg: ed25519, keyId, sig} is required in v3' });
|
|
135
|
+
else if (JSON.stringify(sig.over) !== JSON.stringify(SIGNATURE_OVER)) rejections.push({ kind: 'signature-over', detail: `signature.over must be ${JSON.stringify(SIGNATURE_OVER)}` });
|
|
136
|
+
else if (opts.hostPublicKeyPem) {
|
|
137
|
+
const key: KeyObject = createPublicKey(opts.hostPublicKeyPem);
|
|
138
|
+
signatureVerified = edVerify(null, attestationPayload(bundle), key, fromBase64url(sig.sig));
|
|
139
|
+
if (!signatureVerified) rejections.push({ kind: 'signature-invalid', detail: 'the attestation does not verify under the host key' });
|
|
140
|
+
}
|
|
141
|
+
// Independent tier
|
|
142
|
+
let verifierSignatureVerified = false;
|
|
143
|
+
const claimsIndependent = (bundle.claimedProfiles ?? []).some((p) => p.evidenceTier === 'independent');
|
|
144
|
+
if (claimsIndependent) {
|
|
145
|
+
const vs = bundle.verifierSignature;
|
|
146
|
+
if (!vs || !vs.keyId || !vs.sig) rejections.push({ kind: 'independent-unsigned', detail: 'evidenceTier independent requires a verifierSignature (RFC 0168 §E.2)' });
|
|
147
|
+
else if (vs.keyId === sig?.keyId || vs.keyId === bundle.host?.signingKeyId) rejections.push({ kind: 'independent-self-signed', detail: 'the verifier key must be distinct from the host key (RFC 0148 R5)' });
|
|
148
|
+
else if (opts.verifierPublicKeyPem) {
|
|
149
|
+
verifierSignatureVerified = edVerify(null, attestationPayload(bundle), createPublicKey(opts.verifierPublicKeyPem), fromBase64url(vs.sig));
|
|
150
|
+
if (!verifierSignatureVerified) rejections.push({ kind: 'verifier-signature-invalid', detail: 'the verifier attestation does not verify' });
|
|
151
|
+
} else rejections.push({ kind: 'independent-unverifiable', detail: 'an independent claim needs the verifier public key to verify; refused, not assumed' });
|
|
152
|
+
}
|
|
153
|
+
// Relaxations: a relaxed obligation's profile cannot certify (RFC 0173 §A.2).
|
|
154
|
+
const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
|
|
155
|
+
const certifiedProfiles: string[] = [];
|
|
156
|
+
for (const p of bundle.claimedProfiles ?? []) {
|
|
157
|
+
if (p.certified && relaxed.size > 0 && [...relaxed].some((o) => p.id.includes(o))) rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
|
|
158
|
+
else if (p.certified) certifiedProfiles.push(p.id);
|
|
159
|
+
}
|
|
160
|
+
if (expected.blocked > 0 && certifiedProfiles.length > 0) rejections.push({ kind: 'blocked-certified', detail: `${expected.blocked} blocked row(s): a bundle with blocked > 0 does not certify (RFC 0168 §E.1)` });
|
|
161
|
+
// RFC 0168 §E.2: the verifier REFUSES, it does not warn — but a refusal is
|
|
162
|
+
// scoped to what it names. A rejection carrying a `profile` removes that
|
|
163
|
+
// profile only (a relaxation on one obligation does not poison an unrelated
|
|
164
|
+
// one, RFC 0173 §A.2); a rejection that names no profile is a statement about
|
|
165
|
+
// the bundle — a bad witness digest, an unverifiable signature, a self-signed
|
|
166
|
+
// `independent` claim, a blocked row — and nothing in it certifies.
|
|
167
|
+
const bundleWide = rejections.filter((r) => !('profile' in r) || !r.profile);
|
|
168
|
+
const scoped = new Set(rejections.map((r) => r.profile).filter((p): p is string => typeof p === 'string'));
|
|
169
|
+
return {
|
|
170
|
+
rejections,
|
|
171
|
+
signatureVerified,
|
|
172
|
+
verifierSignatureVerified,
|
|
173
|
+
certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/lib/driver.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { loadEnv } from './env.js';
|
|
13
|
+
import { seamPath, targetMajor } from './seams.js';
|
|
13
14
|
|
|
14
15
|
export interface OpenWOPResponse {
|
|
15
16
|
readonly status: number;
|
|
@@ -37,10 +38,17 @@ class OpenWOPDriver {
|
|
|
37
38
|
const env = loadEnv();
|
|
38
39
|
// An absolute URL is used as-is (a host may advertise its MCP server mount or
|
|
39
40
|
// an A2A endpoint on another origin); a path is joined to the base URL.
|
|
40
|
-
|
|
41
|
+
// Suite 2.0.0: under target major 2 a v1 seam path is rewritten to its
|
|
42
|
+
// api/seams-v2.yaml address (RFC 0168 §C.2) and every request names the
|
|
43
|
+
// contract it speaks with `OpenWOP-Version` (RFC 0172 §A.3) unless the
|
|
44
|
+
// scenario set one itself (the negotiation scenarios do).
|
|
45
|
+
const major = targetMajor();
|
|
46
|
+
const effectivePath = major === 2 ? seamPath(path) : path;
|
|
47
|
+
const url = /^https?:\/\//i.test(effectivePath) ? effectivePath : `${env.baseUrl}${effectivePath}`;
|
|
41
48
|
|
|
42
49
|
const headers: Record<string, string> = {
|
|
43
50
|
Accept: 'application/json',
|
|
51
|
+
...(major === 2 && !Object.keys(init.headers ?? {}).some((h) => h.toLowerCase() === 'openwop-version') ? { 'OpenWOP-Version': '2.0' } : {}),
|
|
44
52
|
...(init.headers ?? {}),
|
|
45
53
|
};
|
|
46
54
|
if (init.body !== undefined && headers['Content-Type'] === undefined) {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suite 2.0.0 — the era-`2` event-log seed for the RFC 0176 persisted-data
|
|
3
|
+
* scenarios (`v2-v1-events-translated`, `v2-unmapped-type-refused`,
|
|
4
|
+
* `v2-fork-a-v1-run`, `v2-pinned-run-disposition`).
|
|
5
|
+
*
|
|
6
|
+
* A v2 host reading a run written by a v1 host translates every event through
|
|
7
|
+
* `spec/v2/event-codemap.json` at the storage boundary (persistence.md §The
|
|
8
|
+
* reader rule). To witness that from outside, the suite needs a run whose
|
|
9
|
+
* persisted log is in v1 vocabulary — which only a host that predates the cut
|
|
10
|
+
* has, unless a seam writes one. The seam these scenarios drive (seams-profile
|
|
11
|
+
* gated, RFC 0168 §C):
|
|
12
|
+
*
|
|
13
|
+
* POST /conformance/seams/sample/event-log/seed
|
|
14
|
+
* { eventLogSchemaVersion: 2, status, events: [{ type, sequence, payload, timestamp?, causationId? }] }
|
|
15
|
+
* → 201 { runId }
|
|
16
|
+
*
|
|
17
|
+
* The host MUST persist the rows verbatim (v1 `type` strings, the given
|
|
18
|
+
* `sequence` space, no era stamp — the run reads as era `2` by the absent-⇒-`2`
|
|
19
|
+
* rule) and MUST NOT translate them at write time; the point of the seam is
|
|
20
|
+
* that the read projection does the work. `status` is the snapshot status the
|
|
21
|
+
* seeded run holds (`completed` for a finished log, `running` for a run the
|
|
22
|
+
* pinned-run disposition must decide). The seam is catalogued in
|
|
23
|
+
* `api/seams-v2.yaml` (`seedEra2EventLog`); a host answering `404` /
|
|
24
|
+
* `403` / `405` records `blocked` naming it (never a pass), and a host that
|
|
25
|
+
* answers anything but `201 { runId }` records `blocked` with the status.
|
|
26
|
+
*
|
|
27
|
+
* The scenarios name the seam by its v1-shaped path
|
|
28
|
+
* (`/v1/host/sample/event-log/seed`); under target major 2 the driver rewrites
|
|
29
|
+
* it to the address above (lib/seams.ts).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { driver, type OpenWOPResponse } from './driver.js';
|
|
35
|
+
import { loadEnv } from './env.js';
|
|
36
|
+
import { seamPath, seamsProfileAdvertised } from './seams.js';
|
|
37
|
+
import { readErrorCode } from './error-envelope.js';
|
|
38
|
+
import { V1_DIR } from './paths.js';
|
|
39
|
+
|
|
40
|
+
export const SEED_PATH_V1 = '/v1/host/sample/event-log/seed';
|
|
41
|
+
export const SEED_PATH = seamPath(SEED_PATH_V1);
|
|
42
|
+
|
|
43
|
+
export interface SeedEvent {
|
|
44
|
+
readonly type: string;
|
|
45
|
+
readonly sequence: number;
|
|
46
|
+
readonly payload: Record<string, unknown>;
|
|
47
|
+
readonly timestamp?: string;
|
|
48
|
+
readonly causationId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReadEvent {
|
|
52
|
+
readonly type?: unknown;
|
|
53
|
+
readonly sequence?: unknown;
|
|
54
|
+
readonly payload?: unknown;
|
|
55
|
+
readonly eventId?: unknown;
|
|
56
|
+
readonly timestamp?: unknown;
|
|
57
|
+
readonly causationId?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type Seeded =
|
|
61
|
+
| { readonly ok: true; readonly runId: string }
|
|
62
|
+
| { readonly ok: false; readonly kind: 'blocked' | 'inapplicable' | 'skipped'; readonly reason: string };
|
|
63
|
+
|
|
64
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
65
|
+
try { return await fn(); } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The gate every era-2 scenario applies before touching the seam. */
|
|
69
|
+
export function era2Gate(doc: Record<string, unknown> | null): Seeded | null {
|
|
70
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'discovery unreachable' };
|
|
71
|
+
if (!seamsProfileAdvertised(doc)) {
|
|
72
|
+
return { ok: false, kind: 'blocked', reason: `seams profile not advertised (conformance.seamsProfile !== openwop-conformance-seams-v2) — an era-2 log can only be seeded through ${SEED_PATH}` };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Seed an era-2 log in v1 vocabulary; the reason names the seam when it is absent. */
|
|
78
|
+
export async function seedEra2Log(events: readonly SeedEvent[], status: 'completed' | 'running' | 'paused' = 'completed'): Promise<Seeded> {
|
|
79
|
+
const res = await http(() => driver.post(SEED_PATH_V1, { eventLogSchemaVersion: 2, status, events }));
|
|
80
|
+
if (res === null) return { ok: false, kind: 'blocked', reason: `${SEED_PATH} unreachable (fetch failed)` };
|
|
81
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) {
|
|
82
|
+
return { ok: false, kind: 'blocked', reason: `no seam seeds an era-2 event log — ${SEED_PATH} answered ${res.status} (uncatalogued in api/seams-v2.yaml; the reader rule is seam-gated, RFC 0176 falsifiability §A.3)` };
|
|
83
|
+
}
|
|
84
|
+
const runId = (res.json as { runId?: unknown } | undefined)?.runId;
|
|
85
|
+
if (res.status !== 201 || typeof runId !== 'string') {
|
|
86
|
+
return { ok: false, kind: 'blocked', reason: `${SEED_PATH} answered ${res.status} ${readErrorCode(res.json) ?? ''} without a 201 { runId } — the seed contract is not honoured`.trim() };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, runId };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `GET /runs/{runId}/events/poll` under OpenWOP-Version: 2.0 (the driver adds the header). */
|
|
92
|
+
export async function pollEvents(runId: string, query = ''): Promise<OpenWOPResponse | null> {
|
|
93
|
+
return http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1${query}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function eventsOf(body: unknown): ReadEvent[] {
|
|
97
|
+
const events = (body as { events?: unknown } | undefined)?.events;
|
|
98
|
+
return Array.isArray(events) ? (events as ReadEvent[]) : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `GET /runs/{runId}/events?streamMode=debug` under OpenWOP-Version: 2.0 —
|
|
103
|
+
* `debug` admits every event (events.md §Stream modes), so a `version.pinned`
|
|
104
|
+
* or a vendor row is visible. lib/sse.ts sends no version header, and a
|
|
105
|
+
* header-less request selects the v1 representation (RFC 0172 §A.3), so the
|
|
106
|
+
* stream is read here with the header the scenario is speaking.
|
|
107
|
+
*/
|
|
108
|
+
export async function streamEvents(runId: string, timeoutMs = 8_000): Promise<{ status: number; events: ReadEvent[] } | null> {
|
|
109
|
+
const env = loadEnv();
|
|
110
|
+
const ctl = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(`${env.baseUrl}/runs/${encodeURIComponent(runId)}/events?streamMode=debug`, {
|
|
114
|
+
headers: { Accept: 'text/event-stream', Authorization: `Bearer ${env.apiKey}`, 'OpenWOP-Version': '2.0' },
|
|
115
|
+
signal: ctl.signal,
|
|
116
|
+
});
|
|
117
|
+
if (res.status !== 200 || res.body === null) return { status: res.status, events: [] };
|
|
118
|
+
let text = '';
|
|
119
|
+
try { text = await res.text(); } catch (err) { if ((err as { name?: string }).name !== 'AbortError') throw err; }
|
|
120
|
+
const events: ReadEvent[] = [];
|
|
121
|
+
for (const frame of text.split(/\r?\n\r?\n/)) {
|
|
122
|
+
const data = frame.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
|
|
123
|
+
if (data.length === 0) continue;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(data) as unknown;
|
|
126
|
+
for (const e of Array.isArray(parsed) ? parsed : [parsed]) if (e && typeof e === 'object') events.push(e as ReadEvent);
|
|
127
|
+
} catch { /* a keep-alive or a non-JSON frame */ }
|
|
128
|
+
}
|
|
129
|
+
return { status: res.status, events };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if ((err as { name?: string }).name === 'AbortError') return { status: 0, events: [] };
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `POST /runs/{runId}:fork`; null when the request could not be made. */
|
|
139
|
+
export async function forkRun(runId: string, body: Record<string, unknown>): Promise<OpenWOPResponse | null> {
|
|
140
|
+
return http(() => driver.post(`/runs/${encodeURIComponent(runId)}:fork`, body));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The v1→v2 rows the seeds use, taken from `spec/v2/event-codemap.json` when the
|
|
145
|
+
* corpus is present (the published tarball ships the codemap beside the
|
|
146
|
+
* schemas; a layout without it falls back to the same rows spelled here, which
|
|
147
|
+
* is what the codemap says for them — `counts.renamed: 36`).
|
|
148
|
+
*/
|
|
149
|
+
const FALLBACK: ReadonlyArray<readonly [string, string]> = [
|
|
150
|
+
['run.started', 'run.started'],
|
|
151
|
+
['run.completed', 'run.completed'],
|
|
152
|
+
['run.cancelled', 'run.cancelled'],
|
|
153
|
+
['version.pinned', 'version.pinned'],
|
|
154
|
+
['agent.toolCalled', 'agent.tool-called'],
|
|
155
|
+
['agent.toolReturned', 'agent.tool-returned'],
|
|
156
|
+
['run.resuming', 'run.resume-started'],
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
let codemap: Map<string, string> | undefined;
|
|
160
|
+
export function codemapV1toV2(): Map<string, string> {
|
|
161
|
+
if (codemap) return codemap;
|
|
162
|
+
const m = new Map<string, string>(FALLBACK);
|
|
163
|
+
const candidate = V1_DIR ? join(V1_DIR, '..', 'v2', 'event-codemap.json') : null;
|
|
164
|
+
if (candidate && existsSync(candidate)) {
|
|
165
|
+
try {
|
|
166
|
+
const doc = JSON.parse(readFileSync(candidate, 'utf8')) as { rows?: Array<{ v1?: unknown; v2?: unknown }> };
|
|
167
|
+
for (const r of doc.rows ?? []) if (typeof r.v1 === 'string' && typeof r.v2 === 'string') m.set(r.v1, r.v2);
|
|
168
|
+
} catch { /* the fallback rows stand */ }
|
|
169
|
+
}
|
|
170
|
+
codemap = m;
|
|
171
|
+
return m;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A minimal era-2 log in v1 vocabulary: two renamed rows between run.started and run.completed. */
|
|
175
|
+
export function v1FixtureLog(workflowId = 'conformance-noop'): SeedEvent[] {
|
|
176
|
+
const t0 = Date.parse('2026-01-15T10:00:00.000Z');
|
|
177
|
+
const ts = (i: number) => new Date(t0 + i * 1000).toISOString();
|
|
178
|
+
return [
|
|
179
|
+
{ type: 'run.started', sequence: 0, payload: { workflowId }, timestamp: ts(0) },
|
|
180
|
+
{ type: 'agent.toolCalled', sequence: 1, payload: { agentId: 'conformance-agent', toolName: 'echo', callId: 'call-0001' }, timestamp: ts(1) },
|
|
181
|
+
{ type: 'agent.toolReturned', sequence: 2, payload: { agentId: 'conformance-agent', toolName: 'echo', callId: 'call-0001', status: 'ok' }, timestamp: ts(2) },
|
|
182
|
+
{ type: 'run.completed', sequence: 3, payload: { durationMs: 3000 }, timestamp: ts(3) },
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The observable part of an event for RFC 0041 §C byte-equivalence: type, sequence, payload (clock fields and minted ids excluded). */
|
|
187
|
+
export function observable(e: ReadEvent): string {
|
|
188
|
+
return JSON.stringify({ type: e.type, sequence: e.sequence, payload: canonical(e.payload) });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function canonical(v: unknown): unknown {
|
|
192
|
+
if (Array.isArray(v)) return v.map(canonical);
|
|
193
|
+
if (v !== null && typeof v === 'object') {
|
|
194
|
+
return Object.fromEntries(Object.keys(v as Record<string, unknown>).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])]));
|
|
195
|
+
}
|
|
196
|
+
return v;
|
|
197
|
+
}
|
|
@@ -74,6 +74,27 @@ export function scenarioFileOfItId(requirementId: string): string | null {
|
|
|
74
74
|
return `${rest.slice(0, dot)}.test.ts`;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Every FILE-derived id back to its file: the per-test `openwop.it.<stem>.<title>`
|
|
79
|
+
* plus the per-file rows the ledger also carries, `openwop.scenario.<stem>` and
|
|
80
|
+
* `openwop.floor.<stem>`. Gate rows (`openwop.profile.*`, `openwop.family.*`),
|
|
81
|
+
* explicit `req()` ids and the `openwop.floor.any.<prefix>` GROUP name no single
|
|
82
|
+
* file and return null. A caller that understood only `it` ids read a per-file
|
|
83
|
+
* row as an un-cited requirement and demanded a `req()` literal that, by
|
|
84
|
+
* construction, never exists.
|
|
85
|
+
*/
|
|
86
|
+
export function scenarioFileOfId(requirementId: string): string | null {
|
|
87
|
+
const it = scenarioFileOfItId(requirementId);
|
|
88
|
+
if (it !== null) return it;
|
|
89
|
+
for (const prefix of ['openwop.scenario.', 'openwop.floor.']) {
|
|
90
|
+
if (!requirementId.startsWith(prefix)) continue;
|
|
91
|
+
const rest = requirementId.slice(prefix.length);
|
|
92
|
+
if (rest.length === 0 || rest.startsWith('any.')) return null;
|
|
93
|
+
return `${rest}.test.ts`;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/**
|
|
78
99
|
* Collision suffixing: the first occurrence keeps the bare id, later ones get
|
|
79
100
|
* `~2`, `~3`, … Keyed per file so a worker running many files never crosses
|
|
@@ -61,6 +61,11 @@ export interface LedgerEntry {
|
|
|
61
61
|
* program exists to make visible; the runner treats it as unclassified for
|
|
62
62
|
* a claimed profile's floor. */
|
|
63
63
|
readonly assertionCount?: number;
|
|
64
|
+
/** The scenario file that recorded it. An explicit `req()` id carries no file
|
|
65
|
+
* in its own text, so without this the `--certify` runner cannot attribute
|
|
66
|
+
* the row to a file and drops it — which is how every explicit requirement id
|
|
67
|
+
* went missing from bundle v3 while the per-`it` ids came through. */
|
|
68
|
+
readonly scenarioFile?: string;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
const ledger = new Map<string, LedgerEntry>();
|
|
@@ -82,7 +87,7 @@ export function recordRequirement(
|
|
|
82
87
|
requirementId: string,
|
|
83
88
|
disposition: Disposition,
|
|
84
89
|
detail?: string,
|
|
85
|
-
extras?: { assertionCount?: number },
|
|
90
|
+
extras?: { assertionCount?: number; scenarioFile?: string },
|
|
86
91
|
): void {
|
|
87
92
|
const prior = ledger.get(requirementId);
|
|
88
93
|
if (prior !== undefined && prior.disposition !== disposition) {
|
|
@@ -102,6 +107,7 @@ export function recordRequirement(
|
|
|
102
107
|
disposition,
|
|
103
108
|
...(detail === undefined ? {} : { detail }),
|
|
104
109
|
...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
|
|
110
|
+
...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
|
|
105
111
|
};
|
|
106
112
|
ledger.set(requirementId, entry);
|
|
107
113
|
journal.push(entry);
|
|
@@ -293,7 +293,11 @@ export function deriveRequirementDispositions(
|
|
|
293
293
|
// them. This is the granularity RFC 0148 §A describes and the G8 fix.
|
|
294
294
|
const emitted = new Set(rows.map((r) => r.requirementId));
|
|
295
295
|
for (const e of [...ledger].sort((a, b) => a.requirementId.localeCompare(b.requirementId))) {
|
|
296
|
-
|
|
296
|
+
// Prefer the file the entry recorded: an explicit `req()` id
|
|
297
|
+
// (`openwop.requirement.…`) is authored, not file-derived, so deriving a
|
|
298
|
+
// file from the id alone returned null and the row was dropped — every
|
|
299
|
+
// explicit requirement id was missing from bundle v3 for that reason.
|
|
300
|
+
const file = e.scenarioFile ?? scenarioFileOfItId(e.requirementId);
|
|
297
301
|
// Attribute only to files this run reported on: a worker's ledger can carry
|
|
298
302
|
// rows from files outside the certified set (the suite's own lib tests, or
|
|
299
303
|
// a filtered run), and those are not evidence about the host.
|
package/src/lib/seams.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suite 2.0.0 — the seams profile (RFC 0168 §C; `openwop-conformance-seams-v2`).
|
|
3
|
+
*
|
|
4
|
+
* v1 scenarios reach the host-sample seams at `/v1/host/sample/…`,
|
|
5
|
+
* `/v1/host/workspace/files…` and `/v1/packs-test/…`. In v2 those operations
|
|
6
|
+
* leave the canonical API for `api/seams-v2.yaml` under `/conformance/seams/…`
|
|
7
|
+
* (RFC 0168 §C.2). Under target major 2 the driver rewrites the v1 seam path
|
|
8
|
+
* to its v2 address, so the 165 scenarios that name a seam keep working
|
|
9
|
+
* without a sweep; the sweep to `seamPath()` is the P3-F polish.
|
|
10
|
+
*
|
|
11
|
+
* How a v2 host advertises the profile: RFC 0168 §C.1 forbids a `testSeams`
|
|
12
|
+
* capability flag and RFC 0169 §C.1 removes the root `profiles[]`; the
|
|
13
|
+
* reconciliation (recorded against RFC 0168 at its flip) is the
|
|
14
|
+
* `conformance` METADATA key (RFC 0169 §A.1a): `conformance.seamsProfile:
|
|
15
|
+
* "openwop-conformance-seams-v2"`. Metadata, not a capability family.
|
|
16
|
+
*/
|
|
17
|
+
export const SEAMS_PROFILE_ID = 'openwop-conformance-seams-v2';
|
|
18
|
+
export const SEAMS_PREFIX = '/conformance/seams';
|
|
19
|
+
|
|
20
|
+
const V1_SEAM_PREFIXES: ReadonlyArray<readonly [RegExp, string]> = [
|
|
21
|
+
[/^\/v1\/host\/sample\//, `${SEAMS_PREFIX}/sample/`],
|
|
22
|
+
[/^\/v1\/host\/workspace\/files/, `${SEAMS_PREFIX}/workspace/files`],
|
|
23
|
+
[/^\/v1\/packs-test\//, `${SEAMS_PREFIX}/packs-test/`],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** The v2 address of a v1 seam path; a non-seam path is returned unchanged. */
|
|
27
|
+
export function seamPath(v1Path: string): string {
|
|
28
|
+
for (const [re, to] of V1_SEAM_PREFIXES) if (re.test(v1Path)) return v1Path.replace(re, to);
|
|
29
|
+
return v1Path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isSeamPath(path: string): boolean {
|
|
33
|
+
return V1_SEAM_PREFIXES.some(([re]) => re.test(path)) || path.startsWith(`${SEAMS_PREFIX}/`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a (v2) discovery document advertises the seams profile. */
|
|
37
|
+
export function seamsProfileAdvertised(doc: Readonly<Record<string, unknown>> | null | undefined): boolean {
|
|
38
|
+
const conf = doc?.['conformance'];
|
|
39
|
+
return typeof conf === 'object' && conf !== null && (conf as Record<string, unknown>)['seamsProfile'] === SEAMS_PROFILE_ID;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The target major the runner selected (RFC 0168 §D.3); 1 when unset. */
|
|
43
|
+
export function targetMajor(): 1 | 2 {
|
|
44
|
+
return process.env['OPENWOP_TARGET_MAJOR'] === '2' ? 2 : 1;
|
|
45
|
+
}
|
package/src/lib/v2.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suite 2.0.0 — helpers for v2 scenarios (`v2-*.test.ts`, target major 2).
|
|
3
|
+
*
|
|
4
|
+
* v2Discovery() the v2 representation of /.well-known/openwop
|
|
5
|
+
* (OpenWOP-Version: 2.0), memoized per worker.
|
|
6
|
+
* familyAdvertised(key) RFC 0169 §A.2: presence of the family record IS the
|
|
7
|
+
* claim; there is no `supported` field. Returns the
|
|
8
|
+
* record or null.
|
|
9
|
+
* v2Validator(name) an Ajv 2020 validator for schemas/v2/<name>, with the
|
|
10
|
+
* whole v2 tree registered so cross-file $refs resolve
|
|
11
|
+
* (SCHEMAS_DIR is the spec-artifacts peer in the
|
|
12
|
+
* published layout — lib/paths.ts).
|
|
13
|
+
* gateFamily(key) the RFC 0148 §B gate for a v2 family: records
|
|
14
|
+
* `inapplicable` (family absent) or `skipped`
|
|
15
|
+
* (opted out) through behaviorGate under the
|
|
16
|
+
* requirement id `openwop.family.<key>`, and returns
|
|
17
|
+
* the record when advertised.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, readdirSync, statSync } 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 { driver } from './driver.js';
|
|
24
|
+
import { SCHEMAS_DIR } from './paths.js';
|
|
25
|
+
import { behaviorGate } from './behavior-gate.js';
|
|
26
|
+
|
|
27
|
+
let cached: Record<string, unknown> | null | undefined;
|
|
28
|
+
export async function v2Discovery(): Promise<Record<string, unknown> | null> {
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
30
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Version': '2.0' } });
|
|
31
|
+
cached = res.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Record<string, unknown>) : null;
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
34
|
+
export function resetV2Discovery(): void { cached = undefined; }
|
|
35
|
+
|
|
36
|
+
export async function familyAdvertised(key: string): Promise<Record<string, unknown> | null> {
|
|
37
|
+
const doc = await v2Discovery();
|
|
38
|
+
const rec = doc?.[key];
|
|
39
|
+
return rec && typeof rec === 'object' && !Array.isArray(rec) ? (rec as Record<string, unknown>) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function gateFamily(key: string): Promise<Record<string, unknown> | null> {
|
|
43
|
+
const rec = await familyAdvertised(key);
|
|
44
|
+
return behaviorGate(`family.${key}`, rec !== null) ? rec : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let ajv: Ajv2020 | undefined;
|
|
48
|
+
function v2Ajv(): Ajv2020 {
|
|
49
|
+
if (ajv) return ajv;
|
|
50
|
+
ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
51
|
+
addFormats(ajv);
|
|
52
|
+
const root = join(SCHEMAS_DIR, 'v2');
|
|
53
|
+
const walk = (d: string): string[] => readdirSync(d).flatMap((n) => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : p.endsWith('.schema.json') ? [p] : []; });
|
|
54
|
+
for (const p of walk(root)) { try { ajv.addSchema(JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>); } catch { /* a duplicate $id is registered once */ } }
|
|
55
|
+
return ajv;
|
|
56
|
+
}
|
|
57
|
+
export function v2Validator(name: string): (doc: unknown) => { ok: boolean; errors: string } {
|
|
58
|
+
const id = `https://openwop.dev/spec/v2/${name.endsWith('.schema.json') ? name : `${name}.schema.json`}`;
|
|
59
|
+
const a = v2Ajv();
|
|
60
|
+
const validate = a.getSchema(id) ?? a.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', ...name.split('/')), 'utf8')) as Record<string, unknown>);
|
|
61
|
+
return (doc: unknown) => ({ ok: validate(doc) as boolean, errors: a.errorsText(validate.errors, { separator: '; ' }) });
|
|
62
|
+
}
|