@openwop/openwop-conformance 1.0.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 (175) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +241 -0
  3. package/api/asyncapi.yaml +481 -0
  4. package/api/openapi.yaml +830 -0
  5. package/api/redocly.yaml +8 -0
  6. package/coverage.md +80 -0
  7. package/dist/cli.js +161 -0
  8. package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
  9. package/fixtures/conformance-agent-identity.json +27 -0
  10. package/fixtures/conformance-agent-low-confidence.json +29 -0
  11. package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
  12. package/fixtures/conformance-agent-memory-redaction.json +32 -0
  13. package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
  14. package/fixtures/conformance-agent-memory-ttl.json +31 -0
  15. package/fixtures/conformance-agent-pack-export.json +26 -0
  16. package/fixtures/conformance-agent-pack-install.json +26 -0
  17. package/fixtures/conformance-agent-pack-provenance.json +31 -0
  18. package/fixtures/conformance-agent-reasoning.json +29 -0
  19. package/fixtures/conformance-approval.json +27 -0
  20. package/fixtures/conformance-cancellable.json +33 -0
  21. package/fixtures/conformance-cap-breach.json +27 -0
  22. package/fixtures/conformance-capability-missing.json +23 -0
  23. package/fixtures/conformance-channel-ttl.json +60 -0
  24. package/fixtures/conformance-clarification.json +30 -0
  25. package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
  26. package/fixtures/conformance-conversation-lifecycle.json +32 -0
  27. package/fixtures/conformance-conversation-replay.json +33 -0
  28. package/fixtures/conformance-conversation-vs-clarification.json +26 -0
  29. package/fixtures/conformance-delay.json +33 -0
  30. package/fixtures/conformance-dispatch-loop.json +38 -0
  31. package/fixtures/conformance-failure.json +23 -0
  32. package/fixtures/conformance-idempotent.json +30 -0
  33. package/fixtures/conformance-identity.json +32 -0
  34. package/fixtures/conformance-interrupt-auth-required.json +28 -0
  35. package/fixtures/conformance-interrupt-external-event.json +33 -0
  36. package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
  37. package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
  38. package/fixtures/conformance-interrupt-quorum.json +30 -0
  39. package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
  40. package/fixtures/conformance-message-reducer.json +31 -0
  41. package/fixtures/conformance-multi-node.json +21 -0
  42. package/fixtures/conformance-noop.json +23 -0
  43. package/fixtures/conformance-orchestrator-dispatch.json +47 -0
  44. package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
  45. package/fixtures/conformance-orchestrator-terminate.json +44 -0
  46. package/fixtures/conformance-stream-text.json +26 -0
  47. package/fixtures/conformance-subworkflow-child.json +21 -0
  48. package/fixtures/conformance-subworkflow-parent.json +49 -0
  49. package/fixtures/conformance-version-fold.json +23 -0
  50. package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
  51. package/fixtures/pack-manifests/pack-private-example.json +26 -0
  52. package/fixtures.md +404 -0
  53. package/package.json +48 -0
  54. package/schemas/README.md +75 -0
  55. package/schemas/agent-manifest.schema.json +107 -0
  56. package/schemas/agent-ref.schema.json +53 -0
  57. package/schemas/capabilities.schema.json +287 -0
  58. package/schemas/channel-written-payload.schema.json +55 -0
  59. package/schemas/conversation-event.schema.json +120 -0
  60. package/schemas/conversation-turn.schema.json +72 -0
  61. package/schemas/debug-bundle.schema.json +196 -0
  62. package/schemas/dispatch-config.schema.json +46 -0
  63. package/schemas/error-envelope.schema.json +25 -0
  64. package/schemas/memory-entry.schema.json +36 -0
  65. package/schemas/memory-list-options.schema.json +21 -0
  66. package/schemas/node-pack-manifest.schema.json +235 -0
  67. package/schemas/orchestrator-decision.schema.json +60 -0
  68. package/schemas/run-event-payloads.schema.json +663 -0
  69. package/schemas/run-event.schema.json +116 -0
  70. package/schemas/run-options.schema.json +81 -0
  71. package/schemas/run-orchestrator-decided-event.schema.json +20 -0
  72. package/schemas/run-snapshot.schema.json +121 -0
  73. package/schemas/suspend-request.schema.json +182 -0
  74. package/schemas/workflow-definition.schema.json +430 -0
  75. package/src/cli.ts +187 -0
  76. package/src/lib/a2a-fake-peer.ts +233 -0
  77. package/src/lib/canaries.ts +186 -0
  78. package/src/lib/driver.ts +96 -0
  79. package/src/lib/env.ts +49 -0
  80. package/src/lib/fixtures.ts +93 -0
  81. package/src/lib/mcp-fake-server.ts +185 -0
  82. package/src/lib/multi-agent-capabilities.ts +155 -0
  83. package/src/lib/multiProcess.ts +141 -0
  84. package/src/lib/otel-collector.ts +312 -0
  85. package/src/lib/paths.ts +198 -0
  86. package/src/lib/polling.ts +81 -0
  87. package/src/lib/profiles.ts +258 -0
  88. package/src/lib/sse.ts +172 -0
  89. package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
  90. package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
  91. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
  92. package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
  93. package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
  94. package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
  95. package/src/scenarios/agentMessageReducer.test.ts +57 -0
  96. package/src/scenarios/agentMetadata.test.ts +56 -0
  97. package/src/scenarios/agentPackExport.test.ts +45 -0
  98. package/src/scenarios/agentPackInstall.test.ts +50 -0
  99. package/src/scenarios/agentPackProvenance.test.ts +53 -0
  100. package/src/scenarios/agentReasoningEvents.test.ts +72 -0
  101. package/src/scenarios/append-ordering.test.ts +91 -0
  102. package/src/scenarios/approval-payload.test.ts +120 -0
  103. package/src/scenarios/audit-log-integrity.test.ts +106 -0
  104. package/src/scenarios/auth.test.ts +55 -0
  105. package/src/scenarios/byok-roundtrip.test.ts +166 -0
  106. package/src/scenarios/cancellation.test.ts +68 -0
  107. package/src/scenarios/cap-breach.test.ts +149 -0
  108. package/src/scenarios/channel-ttl.test.ts +70 -0
  109. package/src/scenarios/configurable-schema.test.ts +76 -0
  110. package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
  111. package/src/scenarios/conversationLifecycle.test.ts +64 -0
  112. package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
  113. package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
  114. package/src/scenarios/cost-attribution.test.ts +207 -0
  115. package/src/scenarios/debugBundle.test.ts +222 -0
  116. package/src/scenarios/discovery.test.ts +147 -0
  117. package/src/scenarios/dispatchLoop.test.ts +52 -0
  118. package/src/scenarios/errors.test.ts +144 -0
  119. package/src/scenarios/eventOrdering.test.ts +144 -0
  120. package/src/scenarios/failure-path.test.ts +46 -0
  121. package/src/scenarios/fixtures-gating.test.ts +137 -0
  122. package/src/scenarios/fixtures-valid.test.ts +140 -0
  123. package/src/scenarios/highConcurrency.test.ts +263 -0
  124. package/src/scenarios/idempotency.test.ts +83 -0
  125. package/src/scenarios/idempotencyRetry.test.ts +130 -0
  126. package/src/scenarios/identity-passthrough.test.ts +54 -0
  127. package/src/scenarios/interrupt-approval.test.ts +97 -0
  128. package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
  129. package/src/scenarios/interrupt-clarification.test.ts +45 -0
  130. package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
  131. package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
  132. package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
  133. package/src/scenarios/interruptRace.test.ts +176 -0
  134. package/src/scenarios/maliciousManifest.test.ts +154 -0
  135. package/src/scenarios/mcp-discoverability.test.ts +129 -0
  136. package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
  137. package/src/scenarios/multi-node-ordering.test.ts +60 -0
  138. package/src/scenarios/multi-region-idempotency.test.ts +52 -0
  139. package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
  140. package/src/scenarios/orchestratorDispatch.test.ts +66 -0
  141. package/src/scenarios/orchestratorTermination.test.ts +54 -0
  142. package/src/scenarios/otel-emission.test.ts +113 -0
  143. package/src/scenarios/otel-trace-propagation.test.ts +90 -0
  144. package/src/scenarios/pack-registry-publish.test.ts +93 -0
  145. package/src/scenarios/pack-registry.test.ts +328 -0
  146. package/src/scenarios/pause-resume.test.ts +109 -0
  147. package/src/scenarios/policies.test.ts +162 -0
  148. package/src/scenarios/profileDerivation.test.ts +335 -0
  149. package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
  150. package/src/scenarios/rate-limit-envelope.test.ts +97 -0
  151. package/src/scenarios/redaction.test.ts +254 -0
  152. package/src/scenarios/redactionAdversarial.test.ts +162 -0
  153. package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
  154. package/src/scenarios/replay-fork.test.ts +216 -0
  155. package/src/scenarios/replayDeterminism.test.ts +171 -0
  156. package/src/scenarios/route-coverage.test.ts +129 -0
  157. package/src/scenarios/runs-lifecycle.test.ts +65 -0
  158. package/src/scenarios/runtime-capabilities.test.ts +118 -0
  159. package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
  160. package/src/scenarios/staleClaim.test.ts +223 -0
  161. package/src/scenarios/stream-modes-buffer.test.ts +148 -0
  162. package/src/scenarios/stream-modes-mixed.test.ts +149 -0
  163. package/src/scenarios/stream-modes.test.ts +139 -0
  164. package/src/scenarios/streamReconnect.test.ts +162 -0
  165. package/src/scenarios/subworkflow.test.ts +126 -0
  166. package/src/scenarios/version-negotiation.test.ts +157 -0
  167. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
  168. package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
  169. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
  170. package/src/scenarios/wasm-pack-load.test.ts +75 -0
  171. package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
  172. package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
  173. package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
  174. package/src/setup.ts +173 -0
  175. package/vitest.config.ts +17 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Multi-Agent Shift Phase 5 — orchestrator terminate decision (CO-3).
3
+ *
4
+ * Verifies that when an `core.orchestrator.supervisor` emits a decision
5
+ * with `kind: 'terminate'`:
6
+ * 1. `runOrchestrator.decided` event carries the terminate decision.
7
+ * 2. `run.completed` follows (NOT `run.failed`).
8
+ * 3. No further `runOrchestrator.decided` events are emitted (CO-3).
9
+ *
10
+ * Capability-gated: skips when host doesn't advertise
11
+ * `capabilities.agents.orchestrator: true`. Fixture-gated: requires
12
+ * `conformance-orchestrator-terminate`.
13
+ *
14
+ * @see schemas/orchestrator-decision.schema.json (TerminateDecision)
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver } from '../lib/driver.js';
19
+ import { pollUntilTerminal } from '../lib/polling.js';
20
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
21
+ import { isOrchestratorSupported } from '../lib/multi-agent-capabilities.js';
22
+
23
+ const FIXTURE = 'conformance-orchestrator-terminate';
24
+ const SKIP = !isOrchestratorSupported() || !isFixtureAdvertised(FIXTURE);
25
+
26
+ describe.skipIf(SKIP)('orchestratorTermination: terminate decision → run.completed (CO-3)', () => {
27
+ it('terminate is the final orchestrator decision; run completes cleanly', async () => {
28
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
29
+ expect(create.status).toBe(201);
30
+ const runId = (create.json as { runId: string }).runId;
31
+
32
+ const terminal = await pollUntilTerminal(runId);
33
+ expect(terminal.status).toBe('completed');
34
+
35
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
36
+ const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown>; sequence?: number }> })
37
+ .events ?? [];
38
+
39
+ const decisions = list.filter((e) => e.type === 'runOrchestrator.decided');
40
+ expect(decisions.length).toBeGreaterThan(0);
41
+
42
+ const lastDecision = decisions[decisions.length - 1];
43
+ const decision = lastDecision.payload?.decision as { kind?: string } | undefined;
44
+ expect(decision?.kind).toBe('terminate');
45
+
46
+ // CO-3: no terminate after another terminate. Equivalent: only one
47
+ // terminate decision per run.
48
+ const terminates = decisions.filter((e) => {
49
+ const d = e.payload?.decision as { kind?: string } | undefined;
50
+ return d?.kind === 'terminate';
51
+ });
52
+ expect(terminates.length).toBe(1);
53
+ });
54
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Track 11: OTel span emission verification.
3
+ *
4
+ * Verifies that hosts claiming observability conformance emit the
5
+ * canonical `openwop.*` spans + attributes documented in
6
+ * `spec/v1/observability.md` §"Run-level attributes" and §"Node-level
7
+ * attributes". Uses the in-process OTLP/HTTP-JSON collector started by
8
+ * `setup.ts` when `OPENWOP_OTEL_COLLECTOR=true`.
9
+ *
10
+ * Operator contract for this scenario to exercise the host:
11
+ * 1. Start the conformance suite with `OPENWOP_OTEL_COLLECTOR=true`.
12
+ * 2. Configure the host with
13
+ * `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>` (port
14
+ * printed at suite init) and
15
+ * `OTEL_EXPORTER_OTLP_PROTOCOL=http/json`.
16
+ *
17
+ * Skip conditions:
18
+ * - Collector disabled (`OPENWOP_OTEL_COLLECTOR` unset / false).
19
+ * - Host does not advertise `capabilities.observability` (presumed
20
+ * non-conformant for OTel emission).
21
+ * - Required fixture (`conformance-noop`) not advertised.
22
+ *
23
+ * @see spec/v1/observability.md §"Span attributes"
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { driver } from '../lib/driver.js';
28
+ import { pollUntilTerminal } from '../lib/polling.js';
29
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
30
+ import { getCollector, waitForRunSpans } from '../lib/otel-collector.js';
31
+
32
+ const FIXTURE = 'conformance-noop';
33
+
34
+ async function isObservabilityAdvertised(): Promise<boolean> {
35
+ try {
36
+ const disco = await driver.get('/.well-known/openwop');
37
+ const caps = (disco.json as { capabilities?: { observability?: unknown } }).capabilities ?? {};
38
+ return caps.observability !== undefined;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ describe('otel-emission: required run-level + node-level attributes', () => {
45
+ it('host emits openwop.run + openwop.node.* spans with required attributes', async () => {
46
+ if (!getCollector()) {
47
+ // eslint-disable-next-line no-console
48
+ console.warn('[otel-emission] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run');
49
+ return;
50
+ }
51
+ if (!isFixtureAdvertised(FIXTURE)) {
52
+ // eslint-disable-next-line no-console
53
+ console.warn(`[otel-emission] fixture ${FIXTURE} not advertised; skipping`);
54
+ return;
55
+ }
56
+ if (!(await isObservabilityAdvertised())) {
57
+ // eslint-disable-next-line no-console
58
+ console.warn('[otel-emission] host does not advertise capabilities.observability; skipping');
59
+ return;
60
+ }
61
+
62
+ const collector = getCollector()!;
63
+ collector.reset();
64
+
65
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
66
+ expect(create.status).toBe(201);
67
+ const runId = (create.json as { runId: string }).runId;
68
+
69
+ await pollUntilTerminal(runId, { timeoutMs: 15_000 });
70
+
71
+ const runSpans = await waitForRunSpans(runId, { timeoutMs: 5_000, minCount: 2 });
72
+
73
+ expect(runSpans.length, driver.describe(
74
+ 'observability.md §"Span attributes"',
75
+ 'host MUST emit at least one openwop.* span carrying openwop.run_id',
76
+ )).toBeGreaterThan(0);
77
+
78
+ // Required run-level attributes per §"Run-level attributes": MUST
79
+ // include openwop.run_id (which we filtered on) and openwop.workflow_id.
80
+ const anySpanHasWorkflowId = runSpans.some(
81
+ (s) => s.attributes.get('openwop.workflow_id') === FIXTURE,
82
+ );
83
+ expect(anySpanHasWorkflowId, driver.describe(
84
+ 'observability.md §"Run-level attributes"',
85
+ 'spans MUST carry openwop.workflow_id matching the run\'s workflow',
86
+ )).toBe(true);
87
+
88
+ // Find an openwop.run span (lifecycle span; named per §"Span naming").
89
+ const runSpan = runSpans.find((s) => s.name === 'openwop.run' || s.name.startsWith('openwop.run.'));
90
+ expect(runSpan, driver.describe(
91
+ 'observability.md §"Span naming"',
92
+ 'host MUST emit a span named openwop.run (or openwop.run.<phase>) per run',
93
+ )).toBeDefined();
94
+
95
+ // Find an openwop.node.<typeId> span; conformance-noop has one node.
96
+ const nodeSpan = runSpans.find((s) => s.name.startsWith('openwop.node.'));
97
+ expect(nodeSpan, driver.describe(
98
+ 'observability.md §"Span naming"',
99
+ 'host MUST emit a span named openwop.node.<typeId> per node execution',
100
+ )).toBeDefined();
101
+
102
+ if (nodeSpan) {
103
+ // Required node-level attributes.
104
+ expect(typeof nodeSpan.attributes.get('openwop.node_id')).toBe('string');
105
+ expect(typeof nodeSpan.attributes.get('openwop.node_type')).toBe('string');
106
+ const attempt = nodeSpan.attributes.get('openwop.node_attempt');
107
+ expect(typeof attempt === 'number' && attempt >= 0, driver.describe(
108
+ 'observability.md §"Node-level attributes"',
109
+ 'openwop.node_attempt MUST be a non-negative number',
110
+ )).toBe(true);
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Track 11: W3C Trace Context propagation verification.
3
+ *
4
+ * Verifies that hosts claiming observability conformance honor inbound
5
+ * `traceparent` headers — spans emitted during the run MUST share the
6
+ * caller-provided traceId so distributed traces stitch correctly across
7
+ * client→host→provider boundaries.
8
+ *
9
+ * Reuses the in-process OTel collector from `setup.ts`.
10
+ *
11
+ * Skip conditions:
12
+ * - Collector disabled.
13
+ * - Host doesn't advertise `capabilities.observability`.
14
+ * - Fixture `conformance-noop` not advertised.
15
+ *
16
+ * @see spec/v1/observability.md §"Trace context propagation"
17
+ * @see https://www.w3.org/TR/trace-context/
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { pollUntilTerminal } from '../lib/polling.js';
23
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
24
+ import { getCollector, waitForRunSpans } from '../lib/otel-collector.js';
25
+
26
+ const FIXTURE = 'conformance-noop';
27
+
28
+ /** Build a syntactically-valid traceparent with a known traceId. */
29
+ function makeTraceparent(): { header: string; traceId: string } {
30
+ // W3C format: 00-<32 hex traceId>-<16 hex spanId>-01
31
+ const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
32
+ const spanId = '00f067aa0ba902b7';
33
+ return { header: `00-${traceId}-${spanId}-01`, traceId };
34
+ }
35
+
36
+ async function isObservabilityAdvertised(): Promise<boolean> {
37
+ try {
38
+ const disco = await driver.get('/.well-known/openwop');
39
+ const caps = (disco.json as { capabilities?: { observability?: unknown } }).capabilities ?? {};
40
+ return caps.observability !== undefined;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ describe('otel-trace-propagation: inbound traceparent threads to emitted spans', () => {
47
+ it('host-emitted spans share the caller-supplied traceId', async () => {
48
+ if (!getCollector()) {
49
+ // eslint-disable-next-line no-console
50
+ console.warn('[otel-trace-propagation] collector not started; skipping');
51
+ return;
52
+ }
53
+ if (!isFixtureAdvertised(FIXTURE)) {
54
+ return;
55
+ }
56
+ if (!(await isObservabilityAdvertised())) {
57
+ // eslint-disable-next-line no-console
58
+ console.warn('[otel-trace-propagation] capabilities.observability not advertised; skipping');
59
+ return;
60
+ }
61
+
62
+ const collector = getCollector()!;
63
+ collector.reset();
64
+
65
+ const { header, traceId } = makeTraceparent();
66
+ const create = await driver.post(
67
+ '/v1/runs',
68
+ { workflowId: FIXTURE },
69
+ { headers: { traceparent: header } },
70
+ );
71
+ expect(create.status).toBe(201);
72
+ const runId = (create.json as { runId: string }).runId;
73
+
74
+ await pollUntilTerminal(runId, { timeoutMs: 15_000 });
75
+
76
+ const runSpans = await waitForRunSpans(runId, { timeoutMs: 5_000, minCount: 1 });
77
+
78
+ expect(runSpans.length).toBeGreaterThan(0);
79
+
80
+ // OTLP encodes traceId as a 32-char lowercase hex string in JSON. Compare case-insensitively
81
+ // since some exporters emit uppercase.
82
+ const wantTrace = traceId.toLowerCase();
83
+ const matching = runSpans.filter((s) => s.traceId.toLowerCase() === wantTrace);
84
+
85
+ expect(matching.length, driver.describe(
86
+ 'observability.md §"Trace context propagation"',
87
+ 'spans emitted during a run started with an inbound traceparent MUST share its traceId',
88
+ )).toBeGreaterThan(0);
89
+ });
90
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pack-registry publish scenarios — `node-packs.md` §"PUT /v1/packs/{name}/-/{version}.tgz".
3
+ *
4
+ * The 19-code error catalog for the publish endpoint, recorded as
5
+ * `it.todo()` scenarios that document the publish contract until OpenWOP
6
+ * defines a test-mode registry namespace.
7
+ *
8
+ * Why placeholders:
9
+ *
10
+ * The publish path is gated on `packs:publish` scope (see auth.md) plus
11
+ * a binary tarball upload. Round-trip scenarios from a black-box suite
12
+ * would either:
13
+ * 1. Require the suite's `OPENWOP_API_KEY` to carry super-admin / publish
14
+ * scope on the host under test — gives the suite the ability to
15
+ * stomp on the real catalog, NOT acceptable for v1.
16
+ * 2. Require a host-provided test-mode `/v1/packs-test/*` namespace
17
+ * that mirrors the real surface but writes to an isolated catalog —
18
+ * this surface doesn't exist in the spec yet.
19
+ *
20
+ * Until option 2 is specified, the scenarios below document the
21
+ * error-code contract so they become runnable once the isolated surface
22
+ * exists.
23
+ *
24
+ * @see node-packs.md §"PUT /v1/packs/{name}/-/{version}.tgz"
25
+ * @see auth.md §"`packs:publish` scope"
26
+ * @see schemas/node-pack-manifest.schema.json
27
+ */
28
+
29
+ import { describe, it } from 'vitest';
30
+
31
+ describe('pack-registry-publish: URL / scope error catalog (deferred — no test-mode surface)', () => {
32
+ it.todo('PUT with a name that doesn\'t match `core.*` / `vendor.*` / `community.*` / `private.*` MUST return 400 invalid_pack_scope — public registries (packs.openwop.dev) MUST additionally refuse `private.*` and `local.*`');
33
+
34
+ it.todo('PUT with a single-segment URL pack name MUST return 400 invalid_pack_name (URL pack-name doesn\'t match the reverse-DNS pattern at all)');
35
+
36
+ it.todo('PUT with a non-semver URL version MUST return 400 invalid_version');
37
+ });
38
+
39
+ describe('pack-registry-publish: body-shape error catalog (deferred — no test-mode surface)', () => {
40
+ it.todo('PUT with a JSON body (instead of tarball bytes) MUST return 400 invalid_body — body is not a Buffer / not octet-stream-shaped');
41
+
42
+ it.todo('PUT with an empty body MUST return 400 invalid_body');
43
+ });
44
+
45
+ describe('pack-registry-publish: tarball extraction error catalog (deferred — no test-mode surface)', () => {
46
+ it.todo('PUT with a body that isn\'t a valid gzip stream MUST return 400 tarball_gunzip_failed');
47
+
48
+ it.todo('PUT with decompressed bytes exceeding the registry\'s cap (recommended default: 50 MB) MUST return 400 tarball_too_large');
49
+
50
+ it.todo('PUT with no `pack.json` at the tarball root MUST return 400 tarball_manifest_missing');
51
+
52
+ it.todo('PUT with `pack.json` exceeding the registry\'s per-file cap (recommended default: 256 KB) MUST return 400 tarball_manifest_too_large');
53
+
54
+ it.todo('PUT with `pack.json` that isn\'t valid JSON MUST return 400 tarball_manifest_not_json');
55
+
56
+ it.todo('PUT with `manifest.runtime.entry` declaring a path that isn\'t in the tarball MUST return 400 tarball_entry_missing');
57
+
58
+ it.todo('PUT with an entry source exceeding the registry\'s per-file cap (recommended default: 5 MB) MUST return 400 tarball_entry_too_large');
59
+
60
+ it.todo('PUT with a tarball entry whose name contains `..` or otherwise escapes the pack root MUST return 400 tarball_path_traversal');
61
+
62
+ it.todo('PUT with a tar stream that the parser can\'t read past the gzip layer MUST return 400 tarball_tar_parse_failed');
63
+ });
64
+
65
+ describe('pack-registry-publish: manifest contents error catalog (deferred — no test-mode surface)', () => {
66
+ it.todo('PUT with a `pack.json` that fails schema validation MUST return 400 invalid_manifest — detail message includes the failing path');
67
+
68
+ it.todo('PUT with `manifest.name` and/or `manifest.version` differing from the URL params MUST return 400 manifest_mismatch — registries MAY emit the granular pair (`manifest_name_mismatch` / `manifest_version_mismatch`); clients MUST handle either');
69
+
70
+ it.todo('PUT with server-computed SHA-256 not matching `X-Pack-Sha256` (when supplied) MUST return 400 pack_integrity_failure');
71
+
72
+ it.todo('PUT with `runtime.language` value not accepted by the registry MUST return 400 unsupported_runtime');
73
+ });
74
+
75
+ describe('pack-registry-publish: authorization + conflict (deferred — no test-mode surface)', () => {
76
+ it.todo('PUT without `packs:publish` scope or namespace claim MUST return 403 forbidden');
77
+
78
+ it.todo('PUT for an existing (name, version) with DIFFERENT content MUST return 409 conflict — registries MAY emit `version_conflict`; either form is spec-allowed');
79
+
80
+ it.todo('PUT for an existing (name, version) with IDENTICAL sha256 content MUST return 200 OK with the existing record (idempotent re-publish)');
81
+ });
82
+
83
+ describe('pack-registry-publish: unpublish window (deferred — no test-mode surface)', () => {
84
+ it.todo('DELETE /v1/packs/{name}/-/{version} for a version older than the registry\'s unpublish window (default 72h) MUST return 400 unpublish_window_expired — use the yank flow for security incidents past the window');
85
+ });
86
+
87
+ describe('pack-registry-publish: signature endpoint pairing (deferred — no test-mode surface)', () => {
88
+ it.todo('after a PUT with a `signing.signatureRef` blob in the tarball, GET /v1/packs/{name}/-/{version}.sig MUST return the persisted signature (200 with bytes OR 302 to a signed URL)');
89
+
90
+ it.todo('after a PUT WITHOUT a signature blob, GET /v1/packs/{name}/-/{version}.sig MUST return 404 signature_not_available');
91
+
92
+ it.todo('after a YANK, GET /v1/packs/{name}/-/{version}.sig MUST return 404 signature_not_available — yanked tarballs MUST NOT serve their signatures (consumers shouldn\'t be verifying against known-bad packs)');
93
+ });
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Pack-registry read scenarios — `node-packs.md` §"Registry HTTP API".
3
+ *
4
+ * Vendor-neutral discovery-shape contracts for the read surface of a
5
+ * OpenWOP-compliant pack registry. Hosts that DO NOT operate a registry are
6
+ * spec-allowed to omit the entire `/v1/packs/*` namespace; this suite
7
+ * detects that case via a probe on `GET /v1/packs/-/search` and
8
+ * trivially-passes the rest of the scenarios when no registry is
9
+ * present.
10
+ *
11
+ * Why discovery-shape only:
12
+ *
13
+ * The publish path is gated on `packs:publish` scope (auth.md) and a
14
+ * binary tarball upload — both outside the black-box surface this suite
15
+ * asserts. Round-trip publish scenarios live in
16
+ * `pack-registry-publish.test.ts` as documented TODO scenarios until
17
+ * OpenWOP defines a test-mode registry namespace that lets conformance
18
+ * suites publish without touching the real catalog. Hosts should cover
19
+ * publish round-trips in host-specific route tests until that isolated
20
+ * surface exists.
21
+ *
22
+ * What IS testable cross-implementation: error envelopes for the read
23
+ * endpoints (`pack_not_found`, `invalid_pack_name`, `invalid_version`,
24
+ * `signature_not_available`), the search-result shape, and the keychain
25
+ * shape when present.
26
+ *
27
+ * Scenario gating:
28
+ *
29
+ * - **Registry presence probe** runs once; if the host returns 404 for
30
+ * the search endpoint with a non-openwop error envelope (or a static-html
31
+ * 404), the scenarios short-circuit. Hosts ARE NOT required to ship a
32
+ * pack registry.
33
+ *
34
+ * - **Read-endpoint error envelopes** assert the shape of error
35
+ * responses to known-bad inputs (nonexistent pack names, bad scopes).
36
+ * Hosts that ship a registry MUST return openwop error envelopes here.
37
+ *
38
+ * - **Keychain shape** is gated on whether the host serves a keychain
39
+ * for the probed namespace (signing is OPTIONAL per the spec).
40
+ *
41
+ * @see node-packs.md §"Registry HTTP API"
42
+ * @see registry-operations.md §"Signing keychain"
43
+ * @see schemas/node-pack-manifest.schema.json
44
+ */
45
+
46
+ import { describe, it, expect } from 'vitest';
47
+ import { driver } from '../lib/driver.js';
48
+
49
+ /** A nonsense pack name in the `private.*` scope. Hosts MUST NOT have
50
+ * shipped a real pack at this name (the namespace is scoped to a
51
+ * randomized suffix). */
52
+ const NONEXISTENT_PACK = `private.conformance-probe.does-not-exist-${Date.now()}`;
53
+ const NONEXISTENT_VERSION = '0.0.0-conformance';
54
+
55
+ /** Reverse-DNS pack name pattern from `node-packs.md` §Naming. The `private`
56
+ * scope is part of the v1.0 pack-name pattern (spec-side companion to the runtime's
57
+ * pre-existing acceptance). */
58
+ const PACK_NAME_RE = /^(core|vendor|community|private)\.[a-z][a-z0-9_-]*(\.[a-z][a-zA-Z0-9_-]*)+$/;
59
+
60
+ /** Semantic Versioning 2.0.0. */
61
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
62
+
63
+ interface RegistryProbeResult {
64
+ /** True when the host advertises a pack registry (any /v1/packs/* read returns an OpenWOP-shaped response). */
65
+ readonly registryPresent: boolean;
66
+ /** Last status code observed on the probe. */
67
+ readonly probeStatus: number;
68
+ }
69
+
70
+ /** Probe `GET /v1/packs/-/search?q=` once per process; cached. */
71
+ let cachedProbe: RegistryProbeResult | null = null;
72
+ async function probeRegistry(): Promise<RegistryProbeResult> {
73
+ if (cachedProbe) return cachedProbe;
74
+ // Empty query is the cheapest probe — server SHOULD reject empty-q
75
+ // with `400 validation_error` if the registry is mounted, OR return
76
+ // 200 with `{results: []}` if it tolerates empty queries. Either
77
+ // shape proves the registry is mounted. A 404 with a non-JSON body
78
+ // (or no `error` field) means the host doesn't run a registry.
79
+ const res = await driver.get('/v1/packs/-/search?q=', { authenticated: false });
80
+ const body = res.json as { error?: unknown; results?: unknown } | undefined;
81
+ // Heuristic: if the response is JSON-shaped (has either `error` or
82
+ // `results`), the registry is mounted. A wholly missing namespace
83
+ // returns the host's catch-all 404 which usually has no body or a
84
+ // host-specific shape.
85
+ const looksLikeWop = res.status === 200 || (typeof body === 'object' && body !== null && ('error' in body || 'results' in body));
86
+ cachedProbe = { registryPresent: looksLikeWop, probeStatus: res.status };
87
+ return cachedProbe;
88
+ }
89
+
90
+ describe('pack-registry: read-endpoint shape contracts', () => {
91
+ it('GET /v1/packs/-/search returns an OpenWOP-shaped response (or registry is absent)', async () => {
92
+ const probe = await probeRegistry();
93
+ if (!probe.registryPresent) return; // Host doesn't ship a registry.
94
+
95
+ const res = await driver.get('/v1/packs/-/search?q=', { authenticated: false });
96
+ // Spec doesn't pin empty-q behavior — both 400 (validation) and 200
97
+ // (empty results) are acceptable. We only assert the response is
98
+ // JSON-shaped.
99
+ expect([200, 400]).toContain(res.status);
100
+ const body = res.json as Record<string, unknown> | undefined;
101
+ expect(body, driver.describe(
102
+ 'node-packs.md §"GET /v1/packs/-/search"',
103
+ 'response MUST be JSON',
104
+ )).toBeDefined();
105
+
106
+ if (res.status === 200) {
107
+ expect(Array.isArray(body?.results), driver.describe(
108
+ 'node-packs.md §"GET /v1/packs/-/search"',
109
+ 'search response MUST carry a `results` array',
110
+ )).toBe(true);
111
+ } else {
112
+ expect(typeof body?.error, driver.describe(
113
+ 'rest-endpoints.md §"Error envelope"',
114
+ '400 response MUST carry a string `error` field',
115
+ )).toBe('string');
116
+ }
117
+ });
118
+
119
+ it('GET /v1/packs/{nonexistent} returns 404 pack_not_found with openwop error envelope', async () => {
120
+ const probe = await probeRegistry();
121
+ if (!probe.registryPresent) return;
122
+
123
+ const res = await driver.get(`/v1/packs/${encodeURIComponent(NONEXISTENT_PACK)}`, {
124
+ authenticated: false,
125
+ });
126
+ expect(res.status, driver.describe(
127
+ 'node-packs.md §"GET /v1/packs/{name}"',
128
+ 'unknown pack name MUST return 404',
129
+ )).toBe(404);
130
+
131
+ const body = res.json as { error?: unknown } | undefined;
132
+ expect(typeof body?.error, driver.describe(
133
+ 'rest-endpoints.md §"Error envelope"',
134
+ '404 response MUST carry a string `error` field',
135
+ )).toBe('string');
136
+ // Reference impl emits `pack_not_found`; spec doesn't pin the exact
137
+ // string — but the prefix `pack_` is the documented family.
138
+ expect((body?.error as string).length, driver.describe(
139
+ 'rest-endpoints.md §"Error envelope"',
140
+ '`error` field MUST be non-empty',
141
+ )).toBeGreaterThan(0);
142
+ });
143
+
144
+ it('GET /v1/packs/{name}/-/{version}.json returns 404 for nonexistent version', async () => {
145
+ const probe = await probeRegistry();
146
+ if (!probe.registryPresent) return;
147
+
148
+ const res = await driver.get(
149
+ `/v1/packs/${encodeURIComponent(NONEXISTENT_PACK)}/-/${NONEXISTENT_VERSION}.json`,
150
+ { authenticated: false },
151
+ );
152
+ expect(res.status, driver.describe(
153
+ 'node-packs.md §"GET /v1/packs/{name}/-/{version}.json"',
154
+ 'unknown (name, version) MUST return 404',
155
+ )).toBe(404);
156
+
157
+ const body = res.json as { error?: unknown } | undefined;
158
+ expect(typeof body?.error, driver.describe(
159
+ 'rest-endpoints.md §"Error envelope"',
160
+ '404 response MUST carry a string `error` field',
161
+ )).toBe('string');
162
+ });
163
+
164
+ it('GET /v1/packs/{name}/-/{version}.sig returns 404 signature_not_available for nonexistent version', async () => {
165
+ const probe = await probeRegistry();
166
+ if (!probe.registryPresent) return;
167
+
168
+ const res = await driver.get(
169
+ `/v1/packs/${encodeURIComponent(NONEXISTENT_PACK)}/-/${NONEXISTENT_VERSION}.sig`,
170
+ { authenticated: false },
171
+ );
172
+ // Spec: 404 signature_not_available is the canonical code; the four
173
+ // cases (missing / yanked / unsigned / storage-unwired) are
174
+ // intentionally indistinguishable. A 302 redirect to a storage-
175
+ // backed signed URL is also spec-allowed for VALID signatures —
176
+ // for a NONEXISTENT pack, only 404 is correct.
177
+ expect(res.status, driver.describe(
178
+ 'node-packs.md §"GET /v1/packs/{name}/-/{version}.sig"',
179
+ 'nonexistent (name, version) MUST return 404 signature_not_available',
180
+ )).toBe(404);
181
+
182
+ const body = res.json as { error?: unknown } | undefined;
183
+ expect(typeof body?.error, driver.describe(
184
+ 'node-packs.md §"GET /v1/packs/{name}/-/{version}.sig"',
185
+ '404 response MUST carry a string `error` field — `signature_not_available` is the canonical code',
186
+ )).toBe('string');
187
+ expect((body?.error as string).length).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('GET /v1/packs/{bad-name}/-/{version}.json returns 400 invalid_pack_name', async () => {
191
+ const probe = await probeRegistry();
192
+ if (!probe.registryPresent) return;
193
+
194
+ // Single-segment name violates the reverse-DNS pattern.
195
+ const res = await driver.get('/v1/packs/not-reverse-dns/-/1.0.json', {
196
+ authenticated: false,
197
+ });
198
+ expect(res.status, driver.describe(
199
+ 'node-packs.md §"GET /v1/packs/{name}/-/{version}.json"',
200
+ 'malformed pack name MUST return 400 invalid_pack_name',
201
+ )).toBe(400);
202
+
203
+ const body = res.json as { error?: unknown } | undefined;
204
+ expect(typeof body?.error, driver.describe(
205
+ 'rest-endpoints.md §"Error envelope"',
206
+ '400 response MUST carry a string `error` field',
207
+ )).toBe('string');
208
+ });
209
+
210
+ it('GET /v1/packs/{name}/-/{bad-version}.json returns 400 invalid_version', async () => {
211
+ const probe = await probeRegistry();
212
+ if (!probe.registryPresent) return;
213
+
214
+ // `not-a-version` violates semver.
215
+ const res = await driver.get(
216
+ `/v1/packs/${encodeURIComponent(NONEXISTENT_PACK)}/-/not-a-version.json`,
217
+ { authenticated: false },
218
+ );
219
+ expect(res.status, driver.describe(
220
+ 'node-packs.md §"GET /v1/packs/{name}/-/{version}.json"',
221
+ 'non-semver version MUST return 400 invalid_version',
222
+ )).toBe(400);
223
+
224
+ const body = res.json as { error?: unknown } | undefined;
225
+ expect(typeof body?.error).toBe('string');
226
+ });
227
+ });
228
+
229
+ describe('pack-registry: catalog response shape (when populated)', () => {
230
+ it('GET /v1/packs/{name} catalog records validate against the documented shape', async () => {
231
+ const probe = await probeRegistry();
232
+ if (!probe.registryPresent) return;
233
+
234
+ // Probe search for a real entry. If the catalog is empty, skip.
235
+ const search = await driver.get('/v1/packs/-/search?q=', { authenticated: false });
236
+ if (search.status !== 200) return;
237
+ const results = (search.json as { results?: Array<{ name?: unknown }> } | undefined)?.results;
238
+ if (!Array.isArray(results) || results.length === 0) return;
239
+
240
+ // Walk up to 3 results and assert their catalog records are well-shaped.
241
+ const sample = results.slice(0, 3);
242
+ for (const entry of sample) {
243
+ const name = entry.name;
244
+ if (typeof name !== 'string') continue;
245
+ expect(name, driver.describe(
246
+ 'node-packs.md §"Naming"',
247
+ `search result name "${name}" MUST match reverse-DNS pattern`,
248
+ )).toMatch(PACK_NAME_RE);
249
+
250
+ const cat = await driver.get(`/v1/packs/${encodeURIComponent(name)}`, {
251
+ authenticated: false,
252
+ });
253
+ expect(cat.status, driver.describe(
254
+ 'node-packs.md §"GET /v1/packs/{name}"',
255
+ `catalog read for known pack "${name}" MUST return 200`,
256
+ )).toBe(200);
257
+
258
+ const body = cat.json as {
259
+ name?: unknown;
260
+ versions?: Record<string, unknown>;
261
+ 'dist-tags'?: { latest?: unknown };
262
+ } | undefined;
263
+ expect(body?.name, driver.describe(
264
+ 'node-packs.md §"GET /v1/packs/{name}"',
265
+ 'catalog response MUST echo the requested pack name',
266
+ )).toBe(name);
267
+ expect(typeof body?.versions, driver.describe(
268
+ 'node-packs.md §"GET /v1/packs/{name}"',
269
+ 'catalog response MUST carry a `versions` map',
270
+ )).toBe('object');
271
+
272
+ // Every version key MUST be valid semver per the spec.
273
+ const versionKeys = Object.keys(body?.versions ?? {});
274
+ for (const v of versionKeys) {
275
+ expect(v, driver.describe(
276
+ 'node-packs.md §"Versioning"',
277
+ `version key "${v}" MUST match SemVer 2.0.0`,
278
+ )).toMatch(SEMVER_RE);
279
+ }
280
+ }
281
+ });
282
+ });
283
+
284
+ describe('pack-registry: keychain shape (when present)', () => {
285
+ it('GET /v1/packs/{name}/-/keychain returns well-formed key entries when present', async () => {
286
+ const probe = await probeRegistry();
287
+ if (!probe.registryPresent) return;
288
+
289
+ // Probe for any real pack to fetch its keychain. Skip if no packs.
290
+ const search = await driver.get('/v1/packs/-/search?q=', { authenticated: false });
291
+ if (search.status !== 200) return;
292
+ const results = (search.json as { results?: Array<{ name?: unknown }> } | undefined)?.results;
293
+ if (!Array.isArray(results) || results.length === 0) return;
294
+
295
+ const first = results[0]?.name;
296
+ if (typeof first !== 'string') return;
297
+
298
+ const res = await driver.get(`/v1/packs/${encodeURIComponent(first)}/-/keychain`, {
299
+ authenticated: false,
300
+ });
301
+ // 200 with `{keys: []}` and 404 are both spec-allowed (keychain is
302
+ // optional; not every namespace publishes one).
303
+ expect([200, 404]).toContain(res.status);
304
+ if (res.status === 404) return;
305
+
306
+ const body = res.json as { keys?: unknown; namespace?: unknown } | undefined;
307
+ expect(Array.isArray(body?.keys), driver.describe(
308
+ 'registry-operations.md §"Signing keychain"',
309
+ 'keychain response MUST carry a `keys` array',
310
+ )).toBe(true);
311
+
312
+ const keys = body?.keys as Array<Record<string, unknown>>;
313
+ for (const key of keys) {
314
+ expect(typeof key.kid, driver.describe(
315
+ 'registry-operations.md §"Signing keychain"',
316
+ 'each key MUST have a string `kid`',
317
+ )).toBe('string');
318
+ expect(typeof key.algorithm, driver.describe(
319
+ 'registry-operations.md §"Signing keychain"',
320
+ 'each key MUST have a string `algorithm`',
321
+ )).toBe('string');
322
+ expect(typeof key.publicKey, driver.describe(
323
+ 'registry-operations.md §"Signing keychain"',
324
+ 'each key MUST have a string `publicKey`',
325
+ )).toBe('string');
326
+ }
327
+ });
328
+ });