@openwop/openwop-conformance 1.0.0 → 1.1.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 (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +293 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -11,19 +11,28 @@
11
11
  *
12
12
  * Two layers:
13
13
  *
14
- * - **Direct fake-peer probe** (always when peer started): walks the
15
- * fake peer through SUBMITTED → WORKING → INPUT_REQUIRED → COMPLETED
16
- * and asserts the AgentCard + task lifecycle wire shape.
14
+ * - **Direct peer probe** (always when an A2A endpoint is configured):
15
+ * walks the fake peer through SUBMITTED → WORKING → COMPLETED and
16
+ * asserts the AgentCard + task lifecycle wire shape. With
17
+ * `OPENWOP_A2A_REAL_PEER_URL` set, points at a real reference A2A
18
+ * peer with relaxed shape-only assertions.
17
19
  * - **Host-mediated reverse-projection** (gated on fixture
18
20
  * advertisement): when the host advertises
19
21
  * `conformance-a2a-task-roundtrip`, run it against the fake peer
20
22
  * forced into AUTH_REQUIRED / REJECTED to verify the host applies
21
- * the documented projections.
23
+ * the documented projections. **Real-peer mode does NOT exercise
24
+ * drift points** — real peers don't expose a state-forcing API,
25
+ * so these subtests stay fake-only.
22
26
  *
23
- * Operator contract: `OPENWOP_A2A_FAKE_PEER=true` on suite side; configure
24
- * the host to use the printed AgentCard URL.
27
+ * Operator contract:
28
+ * - `OPENWOP_A2A_FAKE_PEER=true` boots the in-process synthetic
29
+ * peer. Asserts the deterministic echo skill + drift-point states.
30
+ * - `OPENWOP_A2A_REAL_PEER_URL=<base-url>` — points the direct probe
31
+ * at a real A2A reference implementation. Drift-point subtests
32
+ * soft-skip in this mode. Phase 3 T3.4 interop-evidence path.
25
33
  *
26
34
  * @see spec/v1/a2a-integration.md §"State projection"
35
+ * @see docs/PROTOCOL-GAP-CLOSURE-PLAN.md Phase 3 T3.4
27
36
  */
28
37
 
29
38
  import { describe, it, expect } from 'vitest';
@@ -34,39 +43,149 @@ import { pollUntilTerminal, pollUntilStatus } from '../lib/polling.js';
34
43
 
35
44
  const ROUNDTRIP_FIXTURE = 'conformance-a2a-task-roundtrip';
36
45
 
46
+ /** Resolve the A2A endpoint to probe: real-peer env wins; otherwise the in-process fake. */
47
+ function probePeer(): { url: string; isReal: boolean } | null {
48
+ const real = process.env.OPENWOP_A2A_REAL_PEER_URL;
49
+ if (real && real.length > 0) return { url: real.replace(/\/$/, ''), isReal: true };
50
+ const fake = getA2AFakePeer();
51
+ if (fake) return { url: fake.endpoint(), isReal: false };
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * POST a JSON-RPC 2.0 envelope at `endpoint` and return the parsed
57
+ * response. Throws if the envelope is malformed; surfaces JSON-RPC
58
+ * error responses as a `{error}` field per spec so callers can assert
59
+ * on them.
60
+ */
61
+ async function rpc(
62
+ endpoint: string,
63
+ method: string,
64
+ params: unknown,
65
+ id: number,
66
+ ): Promise<{
67
+ status: number;
68
+ result?: Record<string, unknown>;
69
+ error?: { code: number; message: string };
70
+ }> {
71
+ const res = await fetch(endpoint, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
74
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
75
+ });
76
+ const body = (await res.json()) as {
77
+ result?: Record<string, unknown>;
78
+ error?: { code: number; message: string };
79
+ };
80
+ const out: {
81
+ status: number;
82
+ result?: Record<string, unknown>;
83
+ error?: { code: number; message: string };
84
+ } = { status: res.status };
85
+ if (body.result !== undefined) out.result = body.result;
86
+ if (body.error !== undefined) out.error = body.error;
87
+ return out;
88
+ }
89
+
37
90
  describe('a2a-task-roundtrip: AgentCard + task lifecycle', () => {
38
- it('AgentCard exposes protocolVersion + skills; task SUBMITTED COMPLETED', async () => {
39
- const peer = getA2AFakePeer();
40
- if (!peer) {
91
+ it('AgentCard exposes protocolVersion + skills; message/send + tasks/get round-trip per A2A v0.3 JSON-RPC', async () => {
92
+ const probe = probePeer();
93
+ if (!probe) {
41
94
  // eslint-disable-next-line no-console
42
- console.warn('[a2a-task-roundtrip] peer not started; set OPENWOP_A2A_FAKE_PEER=true');
95
+ console.warn(
96
+ '[a2a-task-roundtrip] no A2A endpoint configured; set OPENWOP_A2A_FAKE_PEER=true ' +
97
+ 'or OPENWOP_A2A_REAL_PEER_URL=<base-url>',
98
+ );
43
99
  return;
44
100
  }
45
- peer.reset();
101
+ if (!probe.isReal) getA2AFakePeer()!.reset();
46
102
 
47
- // AgentCard fetch.
48
- const card = await fetch(`${peer.endpoint()}/agent.json`);
103
+ // AgentCard at the A2A v0.3 well-known path
104
+ // (`AGENT_CARD_PATH` from @a2a-js/sdk: `.well-known/agent-card.json`).
105
+ const card = await fetch(`${probe.url}/.well-known/agent-card.json`);
49
106
  expect(card.status).toBe(200);
50
- const cardJson = (await card.json()) as { protocolVersion?: string; skills?: unknown[] };
107
+ const cardJson = (await card.json()) as {
108
+ protocolVersion?: string;
109
+ skills?: ReadonlyArray<{ id?: string; name?: string }>;
110
+ url?: string;
111
+ additionalInterfaces?: ReadonlyArray<{ url?: string; transport?: string }>;
112
+ };
51
113
  expect(typeof cardJson.protocolVersion).toBe('string');
52
114
  expect(Array.isArray(cardJson.skills)).toBe(true);
115
+ expect((cardJson.skills ?? []).length).toBeGreaterThan(0);
53
116
 
54
- // Create + poll a task.
55
- const create = await fetch(`${peer.endpoint()}/tasks`, {
56
- method: 'POST',
57
- headers: { 'Content-Type': 'application/json' },
58
- body: JSON.stringify({ skill: 'echo', input: { text: 'hello' } }),
59
- });
60
- expect(create.status).toBe(200);
61
- const { taskId } = (await create.json()) as { taskId: string; state: string };
117
+ // Find the JSON-RPC transport endpoint. A2A v0.3 hosts MAY advertise
118
+ // multiple transports via `additionalInterfaces`; we pick the first
119
+ // JSONRPC entry, falling back to `card.url`.
120
+ const jsonrpcIface = (cardJson.additionalInterfaces ?? []).find(
121
+ (i) => i.transport === 'JSONRPC',
122
+ );
123
+ const rpcUrl = jsonrpcIface?.url ?? cardJson.url ?? `${probe.url}/a2a/jsonrpc`;
124
+ expect(typeof rpcUrl).toBe('string');
125
+
126
+ if (probe.isReal) {
127
+ // Real-peer interop evidence (Phase 3 T3.4). A2A v0.3 returns
128
+ // EITHER a Task (long-running) OR a Message (direct response)
129
+ // for `message/send` — both are spec-conformant; we only assert
130
+ // the envelope shape.
131
+ const firstSkill = cardJson.skills?.[0];
132
+ const sendRes = await rpc(
133
+ rpcUrl,
134
+ 'message/send',
135
+ {
136
+ message: {
137
+ kind: 'message',
138
+ messageId: `probe-${Date.now()}`,
139
+ role: 'user',
140
+ parts: [{ kind: 'text', text: 'interop ping' }],
141
+ },
142
+ },
143
+ 1,
144
+ );
145
+ expect(sendRes.status).toBe(200);
146
+ // Spec-conformant: result is either a Task or a Message envelope.
147
+ const sendResult = sendRes.result ?? {};
148
+ const kind = (sendResult.kind ?? '') as string;
149
+ expect(['task', 'message']).toContain(kind);
150
+ // eslint-disable-next-line no-console
151
+ console.warn(
152
+ `[a2a-task-roundtrip] real-peer interop OK against ${probe.url} ` +
153
+ `(skill=${firstSkill?.id ?? firstSkill?.name}, kind=${kind})`,
154
+ );
155
+ return;
156
+ }
157
+
158
+ // Fake-peer path: deterministic state forcing, assert verbatim.
159
+ const fake = getA2AFakePeer()!;
160
+ const sendRes = await rpc(
161
+ rpcUrl,
162
+ 'message/send',
163
+ {
164
+ message: {
165
+ kind: 'message',
166
+ messageId: 'probe-fake-1',
167
+ role: 'user',
168
+ parts: [{ kind: 'text', text: 'hello' }],
169
+ },
170
+ },
171
+ 1,
172
+ );
173
+ expect(sendRes.status).toBe(200);
174
+ expect(sendRes.error).toBeUndefined();
175
+ const task = sendRes.result as { id?: string; kind?: string; status?: { state?: string } };
176
+ expect(task.kind).toBe('task');
177
+ expect(typeof task.id).toBe('string');
62
178
 
63
- // Advance through states.
64
- peer.advanceTask(taskId, 'WORKING');
65
- peer.advanceTask(taskId, 'COMPLETED');
179
+ // Advance through WORKING → COMPLETED via the fake's internal API.
180
+ fake.advanceTask(task.id!, 'WORKING');
181
+ fake.advanceTask(task.id!, 'COMPLETED');
66
182
 
67
- const get = await fetch(`${peer.endpoint()}/tasks/${taskId}`);
68
- const finalTask = (await get.json()) as { state: string };
69
- expect(finalTask.state).toBe('COMPLETED');
183
+ const getRes = await rpc(rpcUrl, 'tasks/get', { id: task.id }, 2);
184
+ expect(getRes.status).toBe(200);
185
+ expect(getRes.error).toBeUndefined();
186
+ const finalTask = getRes.result as { status?: { state?: string } };
187
+ // A2A v0.3 wire form uses lowercase-hyphen state names.
188
+ expect(finalTask.status?.state).toBe('completed');
70
189
  });
71
190
  });
72
191
 
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 1 — confidence-escalation contract (CP-1).
3
+ * Normative reference: RFCS/0002-agent-identity-and-reasoning-events.md
3
4
  *
4
5
  * Verifies: when an `agent.decided` event carries `confidence < threshold`,
5
6
  * the host MUST emit `node.suspended { reason: 'low-confidence' }` and
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 3 — CTI-1 cross-tenant isolation invariant.
3
+ * Normative reference: RFCS/0004-memory-layer.md
3
4
  *
4
5
  * Verifies the CTI-1 normative invariant: a `memoryRef` resolved by a
5
6
  * MemoryAdapter MUST return entries scoped to a single tenant. If
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 3 — SR-1 secret-redaction invariant.
3
+ * Normative reference: RFCS/0004-memory-layer.md
3
4
  *
4
5
  * Verifies the SR-1 normative invariant: when a memory write contains
5
6
  * a value the run's BYOK vault resolved during the run, the persisted
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 3 — MemoryAdapter list/get round-trip.
3
+ * Normative reference: RFCS/0004-memory-layer.md
3
4
  *
4
5
  * Verifies that a host advertising `capabilities.agents.memoryBackends:
5
6
  * ['long-term']` resolves `AgentRef.memoryRef` to MemoryEntry results
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 3 — TTL expiry semantics for MemoryEntry.
3
+ * Normative reference: RFCS/0004-memory-layer.md
3
4
  *
4
5
  * Verifies that memory entries carrying `expiresAt` in the past are
5
6
  * NOT surfaced by `MemoryAdapter.list()` / `get()`. The fixture writes
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 1 — `message` reducer idempotency invariant.
3
+ * Normative reference: RFCS/0002-agent-identity-and-reasoning-events.md
3
4
  *
4
5
  * Verifies the canonical `message` reducer's contract from
5
6
  * `spec/v1/channels-and-reducers.md` §`message`:
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 1 — agent identity (RunSnapshot.agent / runOrchestrator).
3
+ * Normative reference: RFCS/0002-agent-identity-and-reasoning-events.md
3
4
  *
4
5
  * Verifies:
5
6
  * 1. Hosts advertising `capabilities.agents.supported: true` populate
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 2 — agent-pack export round-trips workspace agents → AgentManifest.
3
+ * Normative reference: RFCS/0003-agent-packs.md
3
4
  *
4
5
  * Verifies that a host's workspace-scoped agent registry can project
5
6
  * agents into the canonical AgentManifest shape for export/distribution.
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 2 — agent-pack install registers AgentManifest entries.
3
+ * Normative reference: RFCS/0003-agent-packs.md
3
4
  *
4
5
  * Verifies that a pack containing an `agents[]` array surfaces those
5
6
  * agent manifests via the host's pack registry. The wire-shape contract
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Multi-Agent Shift Phase 2 — sourceManifestId provenance round-trip.
3
+ * Normative reference: RFCS/0003-agent-packs.md
3
4
  *
4
5
  * Verifies that when a workspace agent originates from a pack install,
5
6
  * the agent's runtime AgentRef AND the exported AgentManifest both
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { describe, it, expect } from 'vitest';
19
19
  import { driver } from '../lib/driver.js';
20
+ import { behaviorGate } from '../lib/behavior-gate.js';
20
21
 
21
22
  interface AuditIntegrityCaps {
22
23
  hashChain?: boolean;
@@ -39,9 +40,7 @@ async function isProfileAdvertised(): Promise<boolean> {
39
40
 
40
41
  describe('audit-log-integrity: profile shape', () => {
41
42
  it('host that claims the profile advertises required capability fields', async () => {
42
- if (!(await isProfileAdvertised())) {
43
- // eslint-disable-next-line no-console
44
- console.warn('[audit-log-integrity] profile not advertised; skipping');
43
+ if (!behaviorGate('openwop-audit-log-integrity', await isProfileAdvertised())) {
45
44
  return;
46
45
  }
47
46
 
@@ -64,9 +63,7 @@ describe('audit-log-integrity: profile shape', () => {
64
63
 
65
64
  describe('audit-log-integrity: verify endpoint returns chainValid', () => {
66
65
  it('GET /v1/audit/verify on a recent range reports chainValid: true', async () => {
67
- if (!(await isProfileAdvertised())) {
68
- // eslint-disable-next-line no-console
69
- console.warn('[audit-log-integrity] profile not advertised; skipping');
66
+ if (!behaviorGate('openwop-audit-log-integrity', await isProfileAdvertised())) {
70
67
  return;
71
68
  }
72
69
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * RFC 0010 §B: openwop-auth-api-key-rotation profile.
3
+ *
4
+ * Verifies that hosts claiming the rotation profile satisfy
5
+ * `spec/v1/auth-profiles.md` §`openwop-auth-api-key-rotation`:
6
+ *
7
+ * 1. `capabilities.auth.profiles[]` includes `openwop-auth-api-key-rotation`
8
+ * and `capabilities.auth.rotation.supported === true`.
9
+ * 2. When `minGraceSeconds` is advertised, it's an integer ≥ 0;
10
+ * production-profile hosts SHOULD advertise ≥ 86400 (24h) per spec.
11
+ * 3. When the operator supplies a secondary key via
12
+ * `OPENWOP_TEST_SECONDARY_API_KEY`, both keys MUST authenticate the
13
+ * same operation. The rotation invariant: both map to the same
14
+ * principal/tenant within the grace window.
15
+ * 4. An invalid bearer (a synthetic canary, not a real key) returns
16
+ * 401 `invalid_token`; the response body MUST NOT echo the canary
17
+ * in any field (`auth.md` §3 credential-redaction MUST).
18
+ *
19
+ * The two-key overlap soft-skips when `OPENWOP_TEST_SECONDARY_API_KEY`
20
+ * is unset. The capability-shape and canary-redaction assertions run
21
+ * unconditionally when the profile is advertised.
22
+ *
23
+ * @see RFCS/0010-auth-profile-conformance.md §B
24
+ * @see spec/v1/auth-profiles.md §`openwop-auth-api-key-rotation`
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { driver } from '../lib/driver.js';
29
+ import { behaviorGate } from '../lib/behavior-gate.js';
30
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
31
+
32
+ interface RotationCaps {
33
+ supported?: boolean;
34
+ minGraceSeconds?: number;
35
+ }
36
+
37
+ interface AuthCaps {
38
+ profiles?: string[];
39
+ rotation?: RotationCaps;
40
+ }
41
+
42
+ const PROFILE = 'openwop-auth-api-key-rotation';
43
+ const FIXTURE = 'conformance-noop';
44
+ const CANARY = 'hk_openwop_canary_d1d2d3d4_NOT_A_REAL_KEY';
45
+
46
+ async function readAuthCaps(): Promise<AuthCaps | undefined> {
47
+ const disco = await driver.get('/.well-known/openwop');
48
+ return (disco.json as { capabilities?: { auth?: AuthCaps } }).capabilities?.auth;
49
+ }
50
+
51
+ function isProfileAdvertised(auth: AuthCaps | undefined): boolean {
52
+ return (
53
+ Array.isArray(auth?.profiles) &&
54
+ auth.profiles.includes(PROFILE) &&
55
+ auth.rotation?.supported === true
56
+ );
57
+ }
58
+
59
+ describe('auth-api-key-rotation: capability shape', () => {
60
+ it('host claiming rotation profile advertises required fields', async () => {
61
+ const auth = await readAuthCaps();
62
+
63
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
64
+ return;
65
+ }
66
+
67
+ expect(auth?.profiles?.includes(PROFILE), driver.describe(
68
+ 'auth-profiles.md §`openwop-auth-api-key-rotation`',
69
+ 'capabilities.auth.profiles MUST include openwop-auth-api-key-rotation when the profile is claimed',
70
+ )).toBe(true);
71
+
72
+ expect(auth?.rotation?.supported, driver.describe(
73
+ 'auth-profiles.md §`openwop-auth-api-key-rotation`',
74
+ 'capabilities.auth.rotation.supported MUST be true when the profile is claimed',
75
+ )).toBe(true);
76
+
77
+ if (auth?.rotation?.minGraceSeconds !== undefined) {
78
+ expect(
79
+ Number.isInteger(auth.rotation.minGraceSeconds) &&
80
+ auth.rotation.minGraceSeconds >= 0,
81
+ driver.describe(
82
+ 'capabilities.schema.json auth.rotation.minGraceSeconds',
83
+ 'minGraceSeconds MUST be a non-negative integer when advertised',
84
+ ),
85
+ ).toBe(true);
86
+
87
+ if (auth.rotation.minGraceSeconds < 86400) {
88
+ // eslint-disable-next-line no-console
89
+ console.warn(
90
+ `[auth-api-key-rotation] minGraceSeconds=${auth.rotation.minGraceSeconds} is below the 24h floor auth-profiles.md SHOULDs for production-profile hosts`,
91
+ );
92
+ }
93
+ }
94
+ });
95
+ });
96
+
97
+ describe('auth-api-key-rotation: two-key overlap', () => {
98
+ it('primary + secondary keys both authenticate the same operation', async () => {
99
+ const auth = await readAuthCaps();
100
+
101
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
102
+ return;
103
+ }
104
+
105
+ const secondaryKey = process.env.OPENWOP_TEST_SECONDARY_API_KEY;
106
+ if (!secondaryKey) {
107
+ // eslint-disable-next-line no-console
108
+ console.warn(
109
+ '[auth-api-key-rotation] OPENWOP_TEST_SECONDARY_API_KEY not supplied; skipping two-key overlap assertion',
110
+ );
111
+ return;
112
+ }
113
+
114
+ if (!isFixtureAdvertised(FIXTURE)) {
115
+ // eslint-disable-next-line no-console
116
+ console.warn(
117
+ `[auth-api-key-rotation] ${FIXTURE} not advertised; skipping overlap assertion`,
118
+ );
119
+ return;
120
+ }
121
+
122
+ // Primary key — uses driver's default Authorization header (env-loaded).
123
+ const primary = await driver.post('/v1/runs', { workflowId: FIXTURE });
124
+ expect(primary.status, driver.describe(
125
+ 'auth-profiles.md §`openwop-auth-api-key-rotation`',
126
+ 'primary key MUST authenticate POST /v1/runs during rotation grace',
127
+ )).toBe(201);
128
+ const primaryRunId = (primary.json as { runId: string }).runId;
129
+
130
+ // Secondary key — supplied via env, sent verbatim.
131
+ const secondary = await driver.post(
132
+ '/v1/runs',
133
+ { workflowId: FIXTURE },
134
+ {
135
+ authenticated: false,
136
+ headers: { Authorization: `Bearer ${secondaryKey}` },
137
+ },
138
+ );
139
+ expect(secondary.status, driver.describe(
140
+ 'auth-profiles.md §`openwop-auth-api-key-rotation`',
141
+ 'secondary key MUST authenticate POST /v1/runs during rotation grace',
142
+ )).toBe(201);
143
+ const secondaryRunId = (secondary.json as { runId: string }).runId;
144
+
145
+ // Both runs MUST be distinct (different keys are still independent
146
+ // authentications, not idempotent retries) but MUST have been
147
+ // accepted, proving overlap is honored.
148
+ expect(primaryRunId).not.toBe(secondaryRunId);
149
+ });
150
+ });
151
+
152
+ describe('auth-api-key-rotation: canary redaction', () => {
153
+ it('invalid bearer returns 401 without echoing the canary credential', async () => {
154
+ const auth = await readAuthCaps();
155
+
156
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
157
+ return;
158
+ }
159
+
160
+ const res = await driver.post(
161
+ '/v1/runs',
162
+ { workflowId: FIXTURE },
163
+ {
164
+ authenticated: false,
165
+ headers: { Authorization: `Bearer ${CANARY}` },
166
+ },
167
+ );
168
+
169
+ expect(res.status, driver.describe(
170
+ 'auth.md §3',
171
+ 'invalid bearer MUST return 401, not 200 or 403',
172
+ )).toBe(401);
173
+
174
+ // The response body MUST NOT echo the canary in any field. We
175
+ // check the serialized JSON to catch echoes even in nested fields.
176
+ const serialized = JSON.stringify(res.json ?? {});
177
+ expect(serialized.includes(CANARY), driver.describe(
178
+ 'auth.md §"No credential echo" + threat-model-auth-profiles.md A1',
179
+ 'rotation-profile hosts MUST NOT echo the rejected credential in error responses',
180
+ )).toBe(false);
181
+ });
182
+ });