@openwop/openwop-conformance 2.35.0 → 2.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -3
  3. package/coverage.md +13 -1
  4. package/dist/lib/certification-bundle-v3.js +21 -4
  5. package/dist/spec-artifacts.lock.json +2 -2
  6. package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
  7. package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
  8. package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
  9. package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
  10. package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
  11. package/fixtures/a2ui-v09/negative-image-component.json +145 -0
  12. package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
  13. package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
  14. package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
  15. package/fixtures/conformance-artifact-emit.json +28 -0
  16. package/fixtures/conformance-clarification-nested.json +53 -0
  17. package/fixtures/conformance-clarification-sensitive.json +49 -0
  18. package/fixtures/conformance-credential.json +25 -0
  19. package/fixtures/conformance-mcp-client.json +28 -0
  20. package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
  21. package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
  22. package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
  23. package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
  24. package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
  25. package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
  26. package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
  27. package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
  28. package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
  29. package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
  30. package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
  31. package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
  32. package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
  33. package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
  34. package/fixtures/node-pack-runtime/negative-headers.json +37 -0
  35. package/fixtures/node-pack-runtime/negative-http.json +31 -0
  36. package/fixtures/node-pack-runtime/negative-icons.json +37 -0
  37. package/fixtures/node-pack-runtime/negative-meta.json +36 -0
  38. package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
  39. package/fixtures/node-pack-runtime/negative-packages.json +41 -0
  40. package/fixtures/node-pack-runtime/negative-sse.json +31 -0
  41. package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
  42. package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
  43. package/fixtures/node-pack-runtime/negative-variables.json +38 -0
  44. package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
  45. package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
  46. package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
  47. package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
  48. package/fixtures/oauth-providers/synthetic.json +36 -4
  49. package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
  50. package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
  51. package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
  52. package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
  53. package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
  54. package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
  55. package/fixtures.md +139 -0
  56. package/package.json +2 -2
  57. package/requirements.json +4480 -97
  58. package/scenario-majors.json +93 -6
  59. package/schemas/CORPUS-STAMP.json +103 -89
  60. package/src/lib/behavior-gate.ts +7 -1
  61. package/src/lib/certification-bundle-v3.ts +18 -4
  62. package/src/lib/localized-content.ts +53 -0
  63. package/src/lib/mcp-fake-server.ts +105 -4
  64. package/src/lib/node-pack-runtime.ts +41 -0
  65. package/src/lib/oauth-as-double.ts +316 -0
  66. package/src/lib/protected-resource.ts +88 -0
  67. package/src/lib/standard-webhooks.ts +120 -0
  68. package/src/lib/toolCatalog.ts +54 -4
  69. package/src/lib/trace-context.ts +98 -0
  70. package/src/lib/v2.ts +6 -0
  71. package/src/lib/webhook-receiver.ts +201 -1
  72. package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
  73. package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
  74. package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
  75. package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
  76. package/src/scenarios/debugBundle.test.ts +41 -0
  77. package/src/scenarios/fixtures-valid.test.ts +72 -0
  78. package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
  79. package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
  80. package/src/scenarios/localized-content-delivery.test.ts +26 -15
  81. package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
  82. package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
  83. package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
  84. package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
  85. package/src/scenarios/tool-catalog-projection.test.ts +48 -12
  86. package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
  87. package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
  88. package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
  89. package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
  90. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
  91. package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
  92. package/src/scenarios/v2-auth-challenge.test.ts +119 -0
  93. package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
  94. package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
  95. package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
  96. package/src/scenarios/v2-configurable-closed.test.ts +1 -1
  97. package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
  98. package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
  99. package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
  100. package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
  101. package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
  102. package/src/scenarios/v2-implementation-informational.test.ts +33 -0
  103. package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
  104. package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
  105. package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
  106. package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
  107. package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
  108. package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
  109. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
  110. package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
  111. package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
  112. package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
  113. package/src/scenarios/v2-run-cancel.test.ts +1 -1
  114. package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
  115. package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
  116. package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
  117. package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
  118. package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
  119. package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
  120. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
  121. package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
  122. package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
  123. package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * RFC 0200 §A — the shared gate and the derived URL for the protected-resource
3
+ * scenarios.
4
+ *
5
+ * The gate is the lane, not a `.supported` seat: §A.1 binds a host that advertises an
6
+ * `auth.lanes[]` member with `lane ∈ {oauth2, oidc}` and binds no other. A host with
7
+ * neither records `inapplicable`; a host with one that then answers `404` at the derived
8
+ * URL records an `executed-fail`, never a soft-skip — the lane is the advertisement and
9
+ * the document is owed.
10
+ *
11
+ * @see spec/v2/core/identity.md §2.5
12
+ * @see RFCS/0200-host-as-oauth-protected-resource.md §A, §B
13
+ */
14
+
15
+ import { loadEnv } from './env.js';
16
+ import { v2Discovery } from './v2.js';
17
+ import type { SoftSkipKind } from './soft-skip.js';
18
+
19
+ export const PRM_SEGMENT = '/.well-known/oauth-protected-resource';
20
+
21
+ export interface Lane {
22
+ readonly lane?: unknown;
23
+ readonly issuers?: unknown;
24
+ readonly minimumAssurance?: unknown;
25
+ readonly delegationProofs?: unknown;
26
+ }
27
+
28
+ export type PrmGate =
29
+ | { readonly ok: true; readonly doc: Record<string, unknown>; readonly lanes: readonly Lane[]; readonly resource: string; readonly prmUrl: string }
30
+ | { readonly ok: false; readonly kind: SoftSkipKind; readonly reason: string };
31
+
32
+ /**
33
+ * RFC 9728 §3 / §3.1 — insert `/.well-known/oauth-protected-resource` between the host
34
+ * component and any path of the resource identifier, removing a terminating slash after
35
+ * the host. A root-mounted host gets `https://h.example/.well-known/…`; a host under
36
+ * `/api` gets `https://h.example/.well-known/…/api`.
37
+ */
38
+ export function prmUrlFor(resource: string): string {
39
+ const u = new URL(resource);
40
+ const path = u.pathname.replace(/\/$/, '');
41
+ return `${u.origin}${PRM_SEGMENT}${path}`;
42
+ }
43
+
44
+ /** The lanes §A.1 gates on, read off the live discovery document. */
45
+ export function oauthLanes(doc: Record<string, unknown>): Lane[] {
46
+ const auth = doc['auth'] as { lanes?: unknown } | undefined;
47
+ const lanes = Array.isArray(auth?.lanes) ? (auth.lanes as Lane[]) : [];
48
+ return lanes.filter((l) => l.lane === 'oauth2' || l.lane === 'oidc');
49
+ }
50
+
51
+ /** Every lane the host advertises, OAuth or not — the mount leg reads this. */
52
+ export function allLanes(doc: Record<string, unknown>): Lane[] {
53
+ const auth = doc['auth'] as { lanes?: unknown } | undefined;
54
+ return Array.isArray(auth?.lanes) ? (auth.lanes as Lane[]) : [];
55
+ }
56
+
57
+ /**
58
+ * The URL-form issuers of the OAuth lanes. `identity.md` §2.5 says
59
+ * `authorization_servers` MUST list exactly these: a `urn:` trust root (the api-key,
60
+ * session and anonymous lanes) is not an authorization server and is excluded.
61
+ */
62
+ export function urlIssuers(lanes: readonly Lane[]): string[] {
63
+ return [...new Set(lanes.flatMap((l) => (Array.isArray(l.issuers) ? l.issuers.map(String) : [])).filter((i) => /^https?:\/\//.test(i)))].sort();
64
+ }
65
+
66
+ export async function prmGate(): Promise<PrmGate> {
67
+ let doc: Record<string, unknown> | null = null;
68
+ try { doc = await v2Discovery(); } catch { doc = null; }
69
+ if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable — the lanes cannot be read, so nothing is gated' };
70
+ const lanes = oauthLanes(doc);
71
+ if (lanes.length === 0) {
72
+ return { ok: false, kind: 'inapplicable', reason: 'no auth.lanes[] member has lane oauth2 or oidc — RFC 0200 §A.1 does not bind this host (serving the metadata anyway is RECOMMENDED, not required, and is not measured here)' };
73
+ }
74
+ const resource = loadEnv().baseUrl;
75
+ return { ok: true, doc, lanes, resource, prmUrl: prmUrlFor(resource) };
76
+ }
77
+
78
+ /** Parse a `WWW-Authenticate: Bearer k="v", …` value into its scheme and auth-params. */
79
+ export function parseChallenge(raw: string | null): { scheme: string; params: Record<string, string> } | null {
80
+ if (raw === null || raw.trim() === '') return null;
81
+ const m = /^\s*([A-Za-z0-9!#$%&'*+.^_`|~-]+)\s*(.*)$/s.exec(raw);
82
+ if (!m) return null;
83
+ const params: Record<string, string> = {};
84
+ for (const p of (m[2] ?? '').matchAll(/([A-Za-z0-9!#$%&'*+.^_`|~-]+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g)) {
85
+ params[(p[1] as string).toLowerCase()] = (p[2] ?? p[3] ?? '');
86
+ }
87
+ return { scheme: (m[1] as string).toLowerCase(), params };
88
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * RFC 0201 — shared plumbing for the four `v2-webhook-*` Standard Webhooks
3
+ * scenarios: the gate, the registration call, the run that produces a
4
+ * delivery, and cleanup. The verifier and the modal receiver live in
5
+ * `webhook-receiver.ts`; this file only drives the host.
6
+ *
7
+ * Gating (RFC 0201 §Conformance): every leg reads the v2 `webhooks` facet. A
8
+ * host that does not list `standard-webhooks-1` records `inapplicable`; a host
9
+ * whose egress guard refuses the loopback receiver records `blocked`, as the
10
+ * other webhook scenarios do; `OPENWOP_WEBHOOK_RECEIVER_URL` (a public https
11
+ * front for the receiver) waives nothing and is the certification posture.
12
+ *
13
+ * @see spec/v2/core/webhooks.md §Standard Webhooks
14
+ * @see RFCS/0201-standard-webhooks-signature-scheme.md
15
+ */
16
+
17
+ import { driver, type OpenWOPResponse } from './driver.js';
18
+ import { v2Discovery, gateFamily } from './v2.js';
19
+ import { readErrorCode } from './error-envelope.js';
20
+ import type { SoftSkipKind } from './soft-skip.js';
21
+ import { projectBoundId } from './bound-id.js';
22
+ import { hitHeader, type ModalHit } from './webhook-receiver.js';
23
+
24
+ export const STANDARD_WEBHOOKS_ALG = 'standard-webhooks-1';
25
+ export const SW_FIXTURE = 'conformance-noop';
26
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
27
+
28
+ export type SwGate =
29
+ | { readonly ok: true; readonly doc: Record<string, unknown>; readonly facet: Record<string, unknown> }
30
+ | { readonly ok: false; readonly kind: SoftSkipKind; readonly reason: string };
31
+
32
+ /**
33
+ * The gate every RFC 0201 leg shares. A refusal carries its disposition so the
34
+ * leg records it at the return site (`check-req-only` rule (c)):
35
+ * `const g = await swGate(); if (!g.ok) return softSkip(g.kind, g.reason);`.
36
+ */
37
+ export async function swGate(): Promise<SwGate> {
38
+ let doc: Record<string, unknown> | null = null;
39
+ try { doc = await v2Discovery(); } catch { doc = null; }
40
+ if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
41
+ const facet = await gateFamily('webhooks');
42
+ if (!facet) return { ok: false, kind: 'inapplicable', reason: 'webhooks family not advertised (gate recorded under openwop.family.webhooks)' };
43
+ const algs = Array.isArray(facet['signatureAlgorithms']) ? (facet['signatureAlgorithms'] as unknown[]) : [];
44
+ if (!algs.includes(STANDARD_WEBHOOKS_ALG)) {
45
+ return { ok: false, kind: 'inapplicable', reason: `webhooks.signatureAlgorithms does not list ${STANDARD_WEBHOOKS_ALG} (RFC 0201 §A.3) — the companion scheme is not offered` };
46
+ }
47
+ const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
48
+ if (!fixtures.includes(SW_FIXTURE)) return { ok: false, kind: 'inapplicable', reason: `${SW_FIXTURE} fixture not advertised — no run to deliver` };
49
+ return { ok: true, doc, facet };
50
+ }
51
+
52
+ const registered: string[] = [];
53
+
54
+ /** `POST /webhooks`; every 201 is remembered for `unregisterAllSw()`. */
55
+ export async function registerSw(body: Record<string, unknown>): Promise<OpenWOPResponse> {
56
+ const res = await driver.post('/webhooks', body);
57
+ const id = (res.json as { webhookId?: unknown } | null)?.webhookId;
58
+ if (res.status === 201 && typeof id === 'string') registered.push(id);
59
+ return res;
60
+ }
61
+
62
+ /**
63
+ * The `blocked` reason when the host's SSRF guard refused the loopback receiver,
64
+ * else `null`. A refusal of the operator's PUBLIC front is a finding, not a
65
+ * block, and throws.
66
+ */
67
+ export function loopbackRefusal(res: OpenWOPResponse, tunnelled: boolean): string | null {
68
+ if (res.status === 400 && readErrorCode(res.json) === 'webhook_url_rejected') {
69
+ if (tunnelled) throw new Error('host rejected the operator-supplied public https receiver with webhook_url_rejected — a public https destination is legitimate under webhooks.md §Egress');
70
+ return 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the suite receiver to witness';
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Unregister every subscription this worker registered (best effort; the verdict is already recorded). */
76
+ export async function unregisterAllSw(): Promise<void> {
77
+ for (const id of registered.splice(0)) {
78
+ try { await driver.delete(`/webhooks/${projectBoundId(id)}`); } catch { /* best effort */ }
79
+ }
80
+ }
81
+
82
+ /** Start one run of the noop fixture and wait (bounded) for it to finish. */
83
+ export async function driveRun(timeoutMs = 10_000): Promise<{ status: number; runId: string | null }> {
84
+ const create = await driver.post('/runs', { workflowId: SW_FIXTURE });
85
+ const runId = create.status === 201 ? ((create.json as { runId?: unknown } | null)?.runId as string | undefined) ?? null : null;
86
+ if (runId !== null) {
87
+ const deadline = Date.now() + timeoutMs;
88
+ for (;;) {
89
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
90
+ const st = res.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : '';
91
+ if (TERMINAL.has(st) || Date.now() > deadline) break;
92
+ await new Promise((r) => setTimeout(r, 200));
93
+ }
94
+ }
95
+ return { status: create.status, runId };
96
+ }
97
+
98
+ export async function waitFor(pred: () => boolean, timeoutMs: number): Promise<boolean> {
99
+ const deadline = Date.now() + timeoutMs;
100
+ while (Date.now() < deadline) {
101
+ if (pred()) return true;
102
+ await new Promise((r) => setTimeout(r, 200));
103
+ }
104
+ return pred();
105
+ }
106
+
107
+ /** The DELIVERIES (not verification requests) one subscription received for one run. */
108
+ export function deliveriesFor(hits: readonly ModalHit[], webhookId: string, runId: string | null): ModalHit[] {
109
+ return hits.filter((h) => {
110
+ if (h.verification) return false;
111
+ if ((hitHeader(h, 'openwop-webhook-id') ?? hitHeader(h, 'x-openwop-webhook-id')) !== webhookId) return false;
112
+ if (runId === null) return true;
113
+ try { return (JSON.parse(h.body) as { runId?: unknown }).runId === runId; } catch { return false; }
114
+ });
115
+ }
116
+
117
+ /** Every `webhook-*` header name on a hit (RFC 0201 §B.8 reads this as "must be empty" on a non-opted subscription). */
118
+ export function standardWebhooksHeaderNames(hit: ModalHit): string[] {
119
+ return Object.keys(hit.headers).filter((k) => k.toLowerCase().startsWith('webhook-'));
120
+ }
@@ -21,14 +21,47 @@
21
21
  */
22
22
  import { driver } from './driver.js';
23
23
  import { readCapabilityFamily } from './discovery-capabilities.js';
24
+ import { behaviorGate } from './behavior-gate.js';
25
+ import { targetMajor } from './seams.js';
26
+ import { familyAdvertised } from './v2.js';
27
+ import { softSkip } from './soft-skip.js';
28
+
29
+ /**
30
+ * The catalog path for the major in play (RFC 0204 G3). `GET /tools` is the v2
31
+ * spelling (`spec/v2/path-manifest.json`); the driver never rewrites a `/v1/`
32
+ * address that is not a seam, so a hard-coded `/v1/tools` on a v2 host is a
33
+ * suite 404 reported as a host defect.
34
+ */
35
+ export const toolsPath = (suffix = ''): string => `${targetMajor() === 2 ? '' : '/v1'}/tools${suffix}`;
24
36
 
25
37
  /** Reads `toolCatalog` from discovery (root-first per RFC 0073); null when
26
- * unadvertised. */
38
+ * unadvertised. At major 2 the record's presence is the claim (RFC 0169 §A.2). */
27
39
  export async function readToolCatalogCap(): Promise<Record<string, unknown> | null> {
40
+ if (targetMajor() === 2) return familyAdvertised('toolCatalog');
28
41
  const tc = await readCapabilityFamily<Record<string, unknown>>('toolCatalog');
29
42
  return tc && typeof tc === 'object' ? tc : null;
30
43
  }
31
44
 
45
+ /**
46
+ * The gate the three `tool-catalog-*` files share, major-aware (RFC 0204 G3).
47
+ * Major 1: the v1 `supported` / sub-flag seat through `behaviorGate`, unchanged.
48
+ * Major 2: there is no `supported` field — the record is the claim, a facet is
49
+ * present or absent — and an unadvertised family is recorded `inapplicable`
50
+ * rather than failed under strict mode, so promoting these files to both majors
51
+ * de-certifies no v2 host that never advertised `toolCatalog`.
52
+ * `facet` names the sub-flag (`compactView`) a leg needs, if any.
53
+ */
54
+ export async function toolCatalogGate(profile: string, facet?: string): Promise<Record<string, unknown> | null> {
55
+ const cap = await readToolCatalogCap();
56
+ if (targetMajor() === 2) {
57
+ if (cap === null) { softSkip('inapplicable', 'toolCatalog not advertised at major 2 — the host publishes no catalog'); return null; }
58
+ if (facet !== undefined && cap[facet] !== true) { softSkip('inapplicable', `toolCatalog.${facet} not advertised at major 2`); return null; }
59
+ return cap;
60
+ }
61
+ const on = facet === undefined ? cap?.supported === true : cap?.[facet] === true;
62
+ return behaviorGate(profile, on) ? (cap ?? {}) : null;
63
+ }
64
+
32
65
  export interface ToolDescriptor {
33
66
  toolId?: string;
34
67
  source?: string;
@@ -39,7 +72,7 @@ export interface ToolDescriptor {
39
72
  /** GET the NORMATIVE tool catalog (RFC 0078 §B `GET /v1/tools`); null when the
40
73
  * host doesn't serve it (404/405/501). */
41
74
  export async function listTools(): Promise<ToolDescriptor[] | null> {
42
- const res = await driver.get('/v1/tools');
75
+ const res = await driver.get(toolsPath());
43
76
  if (res.status === 404 || res.status === 405 || res.status === 501) return null;
44
77
  return (res.json as ToolDescriptor[] | undefined) ?? [];
45
78
  }
@@ -50,7 +83,7 @@ export async function listTools(): Promise<ToolDescriptor[] | null> {
50
83
  export async function getTool(
51
84
  toolId: string,
52
85
  ): Promise<{ status: number; descriptor: ToolDescriptor | undefined }> {
53
- const res = await driver.get(`/v1/tools/${encodeURIComponent(toolId)}`);
86
+ const res = await driver.get(toolsPath(`/${encodeURIComponent(toolId)}`));
54
87
  return { status: res.status, descriptor: res.json as ToolDescriptor | undefined };
55
88
  }
56
89
 
@@ -91,7 +124,7 @@ export interface CompactToolDescriptor {
91
124
  * null when the host doesn't serve the read (404/405/501) or the body isn't
92
125
  * the expected envelope shape. */
93
126
  export async function listToolsCompact(): Promise<CompactToolDescriptor[] | null> {
94
- const res = await driver.get('/v1/tools?view=compact');
127
+ const res = await driver.get(toolsPath('?view=compact'));
95
128
  if (res.status === 404 || res.status === 405 || res.status === 501) return null;
96
129
  const body = res.json;
97
130
  if (!body || typeof body !== 'object') return null;
@@ -168,3 +201,20 @@ export const SAFETY_TIERS = ['pure', 'read', 'write', 'exec'];
168
201
  /** Content keys a `ToolDescriptor` / `tool.session.*` MUST NEVER carry (SR-1):
169
202
  * no credential/secret material. */
170
203
  export const TOOL_CONTENT_FORBIDDEN = ['secret', 'credential', 'credentials', 'token', 'apiKey', 'password'];
204
+
205
+ /**
206
+ * RFC 0204 §D.12 (`spec/v2/core/tool-catalog.md` §The descriptor): the four MCP
207
+ * hints as a function of the descriptor's OWN host-assigned fields — never of
208
+ * MCP's defaults (`destructiveHint` and `openWorldHint` default to `true`
209
+ * upstream) and never of anything a server said.
210
+ */
211
+ export function expectedAnnotations(d: ToolDescriptor): Record<'readOnlyHint' | 'destructiveHint' | 'idempotentHint' | 'openWorldHint', boolean> {
212
+ const tier = d.safetyTier;
213
+ const rp = d['replayPolicy'];
214
+ return {
215
+ readOnlyHint: tier === 'pure' || tier === 'read',
216
+ destructiveHint: tier === 'write' || tier === 'exec',
217
+ idempotentHint: rp === 'deterministic' || rp === 'idempotent',
218
+ openWorldHint: d['egress'] !== 'none',
219
+ };
220
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * RFC 0207 — W3C Trace Context carriers across MCP and A2A.
3
+ *
4
+ * One place that knows the `traceparent` grammar and the two carriers a host
5
+ * may use, so the v1 and v2 carrier scenarios cannot disagree on what counts:
6
+ *
7
+ * MCP — `params._meta.traceparent` (unprefixed; MCP 2026-07-28 `_meta`,
8
+ * SEP-414) OR the HTTP `traceparent` header of the request.
9
+ * A2A — `params.message.metadata.openwop.traceparent` OR the HTTP header.
10
+ *
11
+ * Either carrier conforms (decisions log D4); the in-message one is SHOULD.
12
+ * `classifyCarriers` reports which it found so the row's detail keeps the
13
+ * SHOULD visible (`header-only`) without failing a conforming host.
14
+ *
15
+ * @see RFCS/0207-trace-context-across-mcp-and-a2a.md §A, §B
16
+ * @see https://www.w3.org/TR/trace-context/ §3.2 (traceparent)
17
+ */
18
+ import { randomBytes } from 'node:crypto';
19
+
20
+ export interface Traceparent {
21
+ readonly version: string;
22
+ readonly traceId: string;
23
+ readonly parentId: string;
24
+ readonly flags: string;
25
+ }
26
+
27
+ const ZERO_TRACE = '0'.repeat(32);
28
+ const ZERO_SPAN = '0'.repeat(16);
29
+ const GRAMMAR = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
30
+
31
+ /** A fresh, valid version-00 `traceparent` with a random trace id and span id, sampled. */
32
+ export function makeTraceparent(): Traceparent & { readonly header: string } {
33
+ let traceId = randomBytes(16).toString('hex');
34
+ while (traceId === ZERO_TRACE) traceId = randomBytes(16).toString('hex');
35
+ let parentId = randomBytes(8).toString('hex');
36
+ while (parentId === ZERO_SPAN) parentId = randomBytes(8).toString('hex');
37
+ return { version: '00', traceId, parentId, flags: '01', header: `00-${traceId}-${parentId}-01` };
38
+ }
39
+
40
+ /**
41
+ * Parse a `traceparent` per W3C Trace Context §3.2: lowercase hex, version
42
+ * `ff` invalid, all-zero trace id or parent id invalid, and a version-00 value
43
+ * has exactly four fields. Returns null for anything else.
44
+ */
45
+ export function parseTraceparent(value: unknown): Traceparent | null {
46
+ if (typeof value !== 'string') return null;
47
+ const v = value.trim();
48
+ // A future version may append fields after a '-'; version 00 has exactly four.
49
+ const m = GRAMMAR.exec(v.slice(0, 55));
50
+ if (m === null) return null;
51
+ const version = m[1]!; const traceId = m[2]!; const parentId = m[3]!; const flags = m[4]!;
52
+ if (version === 'ff') return null;
53
+ if (v.length > 55 && (version === '00' || v[55] !== '-')) return null;
54
+ if (traceId === ZERO_TRACE || parentId === ZERO_SPAN) return null;
55
+ return { version, traceId, parentId, flags };
56
+ }
57
+
58
+ /** `params._meta.traceparent` of a recorded MCP request, or undefined. Only the UNPREFIXED key counts. */
59
+ export function mcpMetaTraceparent(params: unknown): unknown {
60
+ const meta = (params as { _meta?: unknown } | null | undefined)?._meta;
61
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return undefined;
62
+ return (meta as Record<string, unknown>)['traceparent'];
63
+ }
64
+
65
+ /** `params.message.metadata.openwop.traceparent` of a recorded A2A JSON-RPC body, or undefined. */
66
+ export function a2aMetadataTraceparent(body: unknown): unknown {
67
+ const message = (body as { params?: { message?: unknown } } | null | undefined)?.params?.message;
68
+ const metadata = (message as { metadata?: unknown } | null | undefined)?.metadata;
69
+ const openwop = (metadata as { openwop?: unknown } | null | undefined)?.openwop;
70
+ if (openwop === null || typeof openwop !== 'object' || Array.isArray(openwop)) return undefined;
71
+ return (openwop as Record<string, unknown>)['traceparent'];
72
+ }
73
+
74
+ export type CarrierVerdict =
75
+ | { readonly ok: true; readonly detail: 'both' | 'in-message' | 'header-only' }
76
+ | { readonly ok: false; readonly reason: string };
77
+
78
+ /**
79
+ * The D4 verdict for one recorded outbound request.
80
+ *
81
+ * - neither carrier present → fail
82
+ * - a present carrier does not parse → fail (W3C grammar)
83
+ * - a present carrier's trace id is not `traceId` → fail (the host minted a new trace)
84
+ * - otherwise pass; detail names what was found — `header-only` when the
85
+ * in-message carrier (SHOULD) is absent.
86
+ */
87
+ export function classifyCarriers(inMessage: unknown, header: string | undefined, traceId: string, names: { inMessage: string }): CarrierVerdict {
88
+ const hasMsg = inMessage !== undefined;
89
+ const hasHdr = header !== undefined;
90
+ if (!hasMsg && !hasHdr) return { ok: false, reason: `neither carrier is present: no ${names.inMessage} and no HTTP traceparent header` };
91
+ for (const [where, raw] of [[names.inMessage, hasMsg ? inMessage : undefined], ['the HTTP traceparent header', hasHdr ? header : undefined]] as const) {
92
+ if (raw === undefined) continue;
93
+ const tp = parseTraceparent(raw);
94
+ if (tp === null) return { ok: false, reason: `${where} is not a valid W3C traceparent: ${JSON.stringify(raw)}` };
95
+ if (tp.traceId !== traceId.toLowerCase()) return { ok: false, reason: `${where} carries trace id ${tp.traceId}, not the suite's ${traceId} — the host started a new trace instead of propagating the run's` };
96
+ }
97
+ return { ok: true, detail: hasMsg && hasHdr ? 'both' : hasMsg ? 'in-message' : 'header-only' };
98
+ }
package/src/lib/v2.ts CHANGED
@@ -60,3 +60,9 @@ export function v2Validator(name: string): (doc: unknown) => { ok: boolean; erro
60
60
  const validate = a.getSchema(id) ?? a.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', ...name.split('/')), 'utf8')) as Record<string, unknown>);
61
61
  return (doc: unknown) => ({ ok: validate(doc) as boolean, errors: a.errorsText(validate.errors, { separator: '; ' }) });
62
62
  }
63
+ /** A validator for a `$ref` into the v2 tree (`<file>.schema.json#/$defs/<name>`), resolved against the registered v2 schemas. */
64
+ export function v2RefValidator(ref: string): (doc: unknown) => { ok: boolean; errors: string } {
65
+ const a = v2Ajv();
66
+ const validate = a.getSchema(`https://openwop.dev/spec/v2/${ref}`) ?? a.compile({ $ref: `https://openwop.dev/spec/v2/${ref}` });
67
+ return (doc: unknown) => ({ ok: validate(doc) as boolean, errors: a.errorsText(validate.errors, { separator: '; ' }) });
68
+ }
@@ -15,7 +15,8 @@
15
15
  * implementation; this file is a conformance-suite mirror)
16
16
  */
17
17
 
18
- import { createHmac, timingSafeEqual } from 'node:crypto';
18
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
19
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
19
20
 
20
21
  /**
21
22
  * The `X-openwop-Signature` value prefix, per `webhooks.md` §"Delivery headers"
@@ -343,3 +344,202 @@ export function receiverBinding(): { bind: string; advertise: string } {
343
344
  if (!advertise) return { bind: '127.0.0.1', advertise: '127.0.0.1' };
344
345
  return { bind: '0.0.0.0', advertise };
345
346
  }
347
+
348
+ // ─────────────────────────────────────────────────────────────────────────────
349
+ // RFC 0201 — the Standard Webhooks 1.0.0 companion scheme (`standard-webhooks-1`)
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+ //
352
+ // Implemented FROM THE SPEC RECIPE, not by importing the upstream library, so
353
+ // that a library defect cannot make a leg pass: Standard Webhooks 1.0.0
354
+ // §"Signature scheme" — "the message's: ID, timestamp and body are concatenated
355
+ // … `msg_id.timestamp.payload`", HMAC-SHA256, base64, prefixed `v1,`; the
356
+ // secret is `whsec_` + base64 of 24–64 random bytes, and the KEY is the
357
+ // decoded bytes; §"Webhook headers" — `webhook-signature` is a space-delimited
358
+ // list, and a verifier tries each entry. `webhook-receiver.test.ts` pins this
359
+ // against the upstream reference library's own `sign` test vector.
360
+
361
+ /** RFC 0201 §C.10 — `webhook-id` grammar (Standard Webhooks forbids `.` in the id). */
362
+ export const STANDARD_WEBHOOKS_ID = /^[A-Za-z0-9_-]{16,128}$/;
363
+ export const WHSEC_PREFIX = 'whsec_';
364
+
365
+ /** The HMAC key of a `whsec_` secret: its base64 body, decoded. `null` when the secret is not that form or decodes outside 24–64 bytes. */
366
+ export function decodeWhsec(secret: string): Buffer | null {
367
+ if (!secret.startsWith(WHSEC_PREFIX)) return null;
368
+ const body = secret.slice(WHSEC_PREFIX.length);
369
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return null;
370
+ const key = Buffer.from(body, 'base64');
371
+ return key.length >= 24 && key.length <= 64 ? key : null;
372
+ }
373
+
374
+ /** A fresh `whsec_` secret of `bytes` random bytes (default 32). */
375
+ export function mintWhsec(bytes = 32): string {
376
+ return `${WHSEC_PREFIX}${randomBytes(bytes).toString('base64')}`;
377
+ }
378
+
379
+ /** One `webhook-signature` entry, `v1,<base64>`, over `{id}.{timestamp}.{rawBody}`. */
380
+ export function standardWebhooksSign(secret: string, id: string, timestamp: string | number, rawBody: string | Buffer): string {
381
+ const key = decodeWhsec(secret);
382
+ if (key === null) throw new Error('standardWebhooksSign: secret is not a whsec_ secret of 24–64 bytes');
383
+ const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
384
+ return `v1,${createHmac('sha256', key).update(`${id}.${String(timestamp)}.${bodyStr}`, 'utf8').digest('base64')}`;
385
+ }
386
+
387
+ export type StandardWebhooksRejection =
388
+ | 'missing_headers'
389
+ | 'malformed_id'
390
+ | 'malformed_timestamp'
391
+ | 'timestamp_out_of_tolerance'
392
+ | 'no_matching_signature';
393
+
394
+ export interface StandardWebhooksVerdict {
395
+ readonly accepted: boolean;
396
+ readonly reason?: StandardWebhooksRejection;
397
+ /** Every space-separated entry of `webhook-signature`. */
398
+ readonly entries: readonly string[];
399
+ /** How many of `entries` verify under the given secret. */
400
+ readonly matched: number;
401
+ }
402
+
403
+ type HeaderBag = Record<string, string | string[] | undefined>;
404
+
405
+ function header(headers: HeaderBag, name: string): string | undefined {
406
+ const lower = name.toLowerCase();
407
+ for (const [k, v] of Object.entries(headers)) {
408
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v.join(', ') : v;
409
+ }
410
+ return undefined;
411
+ }
412
+
413
+ /**
414
+ * Verify a Standard Webhooks delivery the way an unmodified receiver would:
415
+ * constant-time compare of every `v1,` entry, ±5 minutes tolerance (the
416
+ * reference library's default, and the window v1/v2 §Verification set).
417
+ * Non-`v1,` entries (`v1a,` — the asymmetric scheme) are listed but never match.
418
+ */
419
+ export function verifyStandardWebhooks(
420
+ rawBody: string | Buffer,
421
+ headers: HeaderBag,
422
+ secret: string,
423
+ options: { toleranceSeconds?: number; nowSeconds?: number } = {},
424
+ ): StandardWebhooksVerdict {
425
+ const id = header(headers, 'webhook-id');
426
+ const ts = header(headers, 'webhook-timestamp');
427
+ const sig = header(headers, 'webhook-signature');
428
+ if (id === undefined || ts === undefined || sig === undefined) return { accepted: false, reason: 'missing_headers', entries: [], matched: 0 };
429
+ const entries = sig.split(' ').filter((e) => e.length > 0);
430
+ if (id.includes('.') || id.length === 0) return { accepted: false, reason: 'malformed_id', entries, matched: 0 };
431
+ if (!/^\d+$/.test(ts)) return { accepted: false, reason: 'malformed_timestamp', entries, matched: 0 };
432
+ const tolerance = options.toleranceSeconds ?? DEFAULT_FRESHNESS_WINDOW_SECONDS;
433
+ const now = options.nowSeconds ?? Math.floor(Date.now() / 1000);
434
+ if (Math.abs(now - Number(ts)) > tolerance) return { accepted: false, reason: 'timestamp_out_of_tolerance', entries, matched: 0 };
435
+ const expected = Buffer.from(standardWebhooksSign(secret, id, ts, rawBody).slice(3), 'base64');
436
+ let matched = 0;
437
+ for (const entry of entries) {
438
+ const comma = entry.indexOf(',');
439
+ if (comma < 0 || entry.slice(0, comma) !== 'v1') continue;
440
+ const given = Buffer.from(entry.slice(comma + 1), 'base64');
441
+ if (given.length === expected.length && timingSafeEqual(given, expected)) matched += 1;
442
+ }
443
+ return matched > 0 ? { accepted: true, entries, matched } : { accepted: false, reason: 'no_matching_signature', entries, matched };
444
+ }
445
+
446
+ /** One request the modal receiver saw. `mode` is the path segment it was routed by. */
447
+ export interface ModalHit {
448
+ readonly mode: string;
449
+ readonly path: string;
450
+ readonly method: string;
451
+ readonly headers: HeaderBag;
452
+ readonly body: string;
453
+ readonly status: number;
454
+ readonly verification: boolean;
455
+ readonly at: number;
456
+ }
457
+
458
+ /**
459
+ * RFC 0201 — the suite receiver with a VERIFICATION MODE per path.
460
+ *
461
+ * One server, many behaviours, routed by the path segment after a per-scenario
462
+ * nonce (`/<nonce>/<mode>`), because behind a public front every scenario shares
463
+ * ONE tunnelled URL and ONE pinned port: the mode has to travel in the URL the
464
+ * host is given, not in which local server happens to be listening.
465
+ *
466
+ * echo verification → 200 `{ challenge }`; delivery → 204
467
+ * no-echo verification → 200 `{}`; delivery → 204
468
+ * wrong-echo verification → 200 `{ challenge: <another value> }`; delivery → 204
469
+ * redirect anything → 307 to `redirect-target` (a host MUST NOT follow it)
470
+ * redirect-target echo, but every hit is recorded so a followed redirect shows
471
+ * fail2 verification → echo; delivery → 500 for the first two attempts of
472
+ * each `(OpenWOP-Webhook-Id, runId, sequence)`, then 204
473
+ *
474
+ * A request is a VERIFICATION when its JSON body's `type` is
475
+ * `openwop.webhook.verification`; every hit is recorded either way.
476
+ */
477
+ export async function startModalReceiver(): Promise<{
478
+ server: Server;
479
+ nonce: string;
480
+ hits: ModalHit[];
481
+ /** The URL to register for `mode` — the public front when `OPENWOP_WEBHOOK_RECEIVER_URL` is set, else loopback. */
482
+ urlFor: (mode: string) => { url: string; tunnelled: boolean };
483
+ close: () => Promise<void>;
484
+ }> {
485
+ const nonce = randomBytes(6).toString('hex');
486
+ const hits: ModalHit[] = [];
487
+ const failures = new Map<string, number>();
488
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
489
+ const chunks: Buffer[] = [];
490
+ request.on('data', (c: Buffer) => chunks.push(c));
491
+ request.on('end', () => {
492
+ const body = Buffer.concat(chunks).toString('utf8');
493
+ const path = request.url ?? '/';
494
+ const segs = path.split('?')[0]!.split('/').filter((x) => x.length > 0);
495
+ const at = segs.indexOf(nonce);
496
+ const mode = at >= 0 && at + 1 < segs.length ? segs[at + 1]! : '';
497
+ let parsed: Record<string, unknown> | null = null;
498
+ try { const j = JSON.parse(body) as unknown; parsed = j && typeof j === 'object' && !Array.isArray(j) ? (j as Record<string, unknown>) : null; } catch { /* not JSON */ }
499
+ const verification = parsed?.['type'] === 'openwop.webhook.verification';
500
+ const challenge = typeof parsed?.['challenge'] === 'string' ? (parsed['challenge'] as string) : undefined;
501
+ const record = (status: number): void => { hits.push({ mode, path, method: request.method ?? '', headers: request.headers, body, status, verification, at: Date.now() }); };
502
+ const json = (status: number, obj: unknown): void => { record(status); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
503
+ const empty = (status: number, extra: Record<string, string> = {}): void => { record(status); res.writeHead(status, extra); res.end(); };
504
+ switch (mode) {
505
+ case 'echo':
506
+ case 'redirect-target':
507
+ return verification ? json(200, { challenge }) : empty(204);
508
+ case 'no-echo':
509
+ return verification ? json(200, {}) : empty(204);
510
+ case 'wrong-echo':
511
+ return verification ? json(200, { challenge: challenge === undefined ? 'not-the-challenge' : `${challenge}x` }) : empty(204);
512
+ case 'redirect':
513
+ return empty(307, { Location: path.replace(`/${nonce}/redirect`, `/${nonce}/redirect-target`) });
514
+ case 'fail2': {
515
+ if (verification) return json(200, { challenge });
516
+ const ev = parsed?.['event'] as { sequence?: unknown } | undefined;
517
+ const key = `${String(header(request.headers, 'openwop-webhook-id') ?? header(request.headers, 'x-openwop-webhook-id') ?? '')}|${String(parsed?.['runId'] ?? '')}|${String(ev?.sequence ?? '')}`;
518
+ const n = (failures.get(key) ?? 0) + 1;
519
+ failures.set(key, n);
520
+ return empty(n <= 2 ? 500 : 204);
521
+ }
522
+ default:
523
+ return empty(404);
524
+ }
525
+ });
526
+ });
527
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
528
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
529
+ const binding = receiverBinding();
530
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
531
+ const addr = server.address();
532
+ if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
533
+ const local = `http://${binding.advertise}:${addr.port}`;
534
+ const urlFor = (mode: string): { url: string; tunnelled: boolean } => {
535
+ const front = resolveRegistrationUrl(`${local}/`);
536
+ return { url: `${front.url.replace(/\/+$/, '')}/${nonce}/${mode}`, tunnelled: front.tunnelled };
537
+ };
538
+ const close = (): Promise<void> => new Promise<void>((resolve) => server.close(() => resolve()));
539
+ return { server, nonce, hits, urlFor, close };
540
+ }
541
+
542
+ /** Header lookup over a recorded hit, case-insensitive. */
543
+ export function hitHeader(hit: { headers: HeaderBag }, name: string): string | undefined {
544
+ return header(hit.headers, name);
545
+ }
@@ -125,7 +125,16 @@ describe('category: artifact-type-pack manifest validation', () => {
125
125
  const manifest = validManifest();
126
126
  manifest.artifactTypes[0]!.exportFormats = ['PPTX']; // uppercase, unprefixed
127
127
  const errs = failsWith(manifest, 'pattern');
128
- expect(errs.length, req('openwop.it.artifact-type-pack-manifest-validation.negative-a-non-conforming-exportformats-identifier-is-rejected-reserved-core-ven', 'artifact-type-packs.md', 'artifact-type-packs.md: exportFormats identifiers are lowercase core ids OR vendor.*/x- extensions')).toBeGreaterThan(0);
128
+ expect(errs.length, req('openwop.it.artifact-type-pack-manifest-validation.negative-a-non-conforming-exportformats-identifier-is-rejected-reserved-core-ven', 'artifact-type-packs.md', 'artifact-type-packs.md: exportFormats identifiers are reserved-core + vendor.*/x- + lowercase media type (RFC 0205 §C.9) — PPTX is none of them')).toBeGreaterThan(0);
129
+ });
130
+
131
+ it('positive: lowercase media types validate beside the reserved aliases; Application/PDF does not (RFC 0205 §C.9)', () => {
132
+ const m = validManifest();
133
+ m.artifactTypes[0]!.exportFormats = ['application/pdf', 'model/step', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'pdf'];
134
+ expect(validate(m), req('openwop.it.artifact-type-pack-manifest-validation.positive-lowercase-media-types-validate-beside-the-reserved-aliases-application', 'RFC 0205 §C.9', `media types (including the 73-char pptx type) MUST validate beside the aliases. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
135
+ const bad = validManifest();
136
+ bad.artifactTypes[0]!.exportFormats = ['Application/PDF'];
137
+ expect(failsWith(bad, 'pattern').length, req('openwop.it.artifact-type-pack-manifest-validation.positive-lowercase-media-types-validate-beside-the-reserved-aliases-application', 'RFC 0205 §C.9', 'a media type MUST be lowercase')).toBeGreaterThan(0);
129
138
  });
130
139
 
131
140
  it('positive: the validation field accepts "open"/"closed" and rejects other values (RFC 0075)', () => {