@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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/lib/certification-bundle-v3.js +21 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
- package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
- package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
- package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
- package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
- package/fixtures/a2ui-v09/negative-image-component.json +145 -0
- package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
- package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
- package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
- package/fixtures/conformance-artifact-emit.json +28 -0
- package/fixtures/conformance-clarification-nested.json +53 -0
- package/fixtures/conformance-clarification-sensitive.json +49 -0
- package/fixtures/conformance-credential.json +25 -0
- package/fixtures/conformance-mcp-client.json +28 -0
- package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
- package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
- package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
- package/fixtures/node-pack-runtime/negative-headers.json +37 -0
- package/fixtures/node-pack-runtime/negative-http.json +31 -0
- package/fixtures/node-pack-runtime/negative-icons.json +37 -0
- package/fixtures/node-pack-runtime/negative-meta.json +36 -0
- package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
- package/fixtures/node-pack-runtime/negative-packages.json +41 -0
- package/fixtures/node-pack-runtime/negative-sse.json +31 -0
- package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
- package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
- package/fixtures/node-pack-runtime/negative-variables.json +38 -0
- package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
- package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
- package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
- package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
- package/fixtures/oauth-providers/synthetic.json +36 -4
- package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
- package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
- package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
- package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
- package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
- package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
- package/fixtures.md +139 -0
- package/package.json +2 -2
- package/requirements.json +4480 -97
- package/scenario-majors.json +93 -6
- package/schemas/CORPUS-STAMP.json +103 -89
- package/src/lib/behavior-gate.ts +7 -1
- package/src/lib/certification-bundle-v3.ts +18 -4
- package/src/lib/localized-content.ts +53 -0
- package/src/lib/mcp-fake-server.ts +105 -4
- package/src/lib/node-pack-runtime.ts +41 -0
- package/src/lib/oauth-as-double.ts +316 -0
- package/src/lib/protected-resource.ts +88 -0
- package/src/lib/standard-webhooks.ts +120 -0
- package/src/lib/toolCatalog.ts +54 -4
- package/src/lib/trace-context.ts +98 -0
- package/src/lib/v2.ts +6 -0
- package/src/lib/webhook-receiver.ts +201 -1
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
- package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
- package/src/scenarios/debugBundle.test.ts +41 -0
- package/src/scenarios/fixtures-valid.test.ts +72 -0
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
- package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
- package/src/scenarios/localized-content-delivery.test.ts +26 -15
- package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
- package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
- package/src/scenarios/tool-catalog-projection.test.ts +48 -12
- package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
- package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
- package/src/scenarios/v2-auth-challenge.test.ts +119 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
- package/src/scenarios/v2-configurable-closed.test.ts +1 -1
- package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
- package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
- package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
- package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
- package/src/scenarios/v2-implementation-informational.test.ts +33 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
- package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
- package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
- package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
- package/src/scenarios/v2-run-cancel.test.ts +1 -1
- package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
- package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
- package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
- package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
- package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
|
@@ -163,11 +163,25 @@ export interface V3Verdict {
|
|
|
163
163
|
|
|
164
164
|
const OPT_OUT_ROW = 'openwop.profile.';
|
|
165
165
|
/** The opt-outs a bundle's signed rows record. */
|
|
166
|
+
/**
|
|
167
|
+
* 2.35.1. The 2.35.0 version read only `openwop.profile.<name>` rows — which
|
|
168
|
+
* never reach a bundle — so on MyndHyve's real bundle (four declared opt-outs)
|
|
169
|
+
* it derived NOTHING, and `opted-out-but-advertised` could not fire: a rule
|
|
170
|
+
* pinned only by synthetic self-test rows. The name now rides on the signed
|
|
171
|
+
* per-test rows' detail (`…OPENWOP_OPTED_OUT_PROFILES: <name>`); both carriers
|
|
172
|
+
* are read. A bundle cut before 2.35.1 carries no names and derives an empty set.
|
|
173
|
+
*/
|
|
174
|
+
const OPT_OUT_DETAIL = /OPENWOP_OPTED_OUT_PROFILES: ([a-z][A-Za-z0-9_.-]*)/;
|
|
166
175
|
export function optedOutFromRows(rows: ReadonlyArray<{ readonly id: string; readonly result: string; readonly detail?: string }>): string[] {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.
|
|
176
|
+
const names = new Set<string>();
|
|
177
|
+
for (const r of rows) {
|
|
178
|
+
if (r.result !== 'skipped') continue;
|
|
179
|
+
const d = r.detail ?? '';
|
|
180
|
+
if (r.id.startsWith(OPT_OUT_ROW) && d.includes('OPENWOP_OPTED_OUT_PROFILES')) names.add(r.id.slice(OPT_OUT_ROW.length));
|
|
181
|
+
const m = OPT_OUT_DETAIL.exec(d);
|
|
182
|
+
if (m) names.add(m[1] as string);
|
|
183
|
+
}
|
|
184
|
+
return [...names].sort();
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3Verdict {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `resolveSection` reference algorithm of `spec/v1/localized-content.md`
|
|
3
|
+
* §C, shared verbatim with conforming hosts (RFC 0103; amended by RFC 0206
|
|
4
|
+
* §B.4). One copy, so the major-1 scenario and the corpus coherence test
|
|
5
|
+
* cannot drift apart (RFC 0206 register G5).
|
|
6
|
+
*
|
|
7
|
+
* `resolveSectionRfc0103` is the algorithm exactly as RFC 0103 shipped it,
|
|
8
|
+
* FROZEN: it exists only so the RFC 0206 §B.5 identity property can be
|
|
9
|
+
* checked (the amended algorithm returns what this one returns on every
|
|
10
|
+
* section that was valid before RFC 0206). Never edit it.
|
|
11
|
+
*
|
|
12
|
+
* @see spec/v1/localized-content.md §C
|
|
13
|
+
* @see RFCS/0206-locale-keys-accept-negotiated-bcp47.md §B
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Section = {
|
|
17
|
+
data: Record<string, unknown>;
|
|
18
|
+
localizations: Record<string, Record<string, unknown>>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** RFC 0206 §A.1 — the case-canonical BCP 47 subset every stored locale key matches. */
|
|
22
|
+
export const LOCALE_KEY_PATTERN = '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$';
|
|
23
|
+
/** RFC 0103's subset, superseded by RFC 0206 (kept for the superset and identity properties). */
|
|
24
|
+
export const LOCALE_KEY_PATTERN_RFC0103 = '^[a-z]{2}(-[A-Z]{2})?$';
|
|
25
|
+
|
|
26
|
+
/** §C as amended by RFC 0206 §B.4: exact → script-family → language-family → base. */
|
|
27
|
+
export function resolveSection(section: Section, negotiatedLocale: string, baseLocale: string): Record<string, unknown> {
|
|
28
|
+
const loc = section.localizations ?? {};
|
|
29
|
+
if (negotiatedLocale === baseLocale || Object.keys(loc).length === 0) return section.data;
|
|
30
|
+
if (loc[negotiatedLocale]) return { ...section.data, ...loc[negotiatedLocale] };
|
|
31
|
+
const parts = negotiatedLocale.split('-');
|
|
32
|
+
if (parts.length >= 3 && /^[A-Za-z]{4}$/.test(parts[1]!)) {
|
|
33
|
+
const ls = `${parts[0]}-${parts[1]}`;
|
|
34
|
+
if (loc[ls]) return { ...section.data, ...loc[ls] };
|
|
35
|
+
}
|
|
36
|
+
if (parts.length >= 2) {
|
|
37
|
+
const lang = parts[0]!;
|
|
38
|
+
if (loc[lang]) return { ...section.data, ...loc[lang] };
|
|
39
|
+
}
|
|
40
|
+
return section.data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** §C exactly as RFC 0103 shipped it — FROZEN (the RFC 0206 §B.5 identity oracle). */
|
|
44
|
+
export function resolveSectionRfc0103(section: Section, negotiatedLocale: string, baseLocale: string): Record<string, unknown> {
|
|
45
|
+
const loc = section.localizations ?? {};
|
|
46
|
+
if (negotiatedLocale === baseLocale || Object.keys(loc).length === 0) return section.data;
|
|
47
|
+
if (loc[negotiatedLocale]) return { ...section.data, ...loc[negotiatedLocale] };
|
|
48
|
+
if (negotiatedLocale.includes('-')) {
|
|
49
|
+
const lang = negotiatedLocale.split('-')[0]!;
|
|
50
|
+
if (loc[lang]) return { ...section.data, ...loc[lang] };
|
|
51
|
+
}
|
|
52
|
+
return section.data;
|
|
53
|
+
}
|
|
@@ -61,6 +61,19 @@ export interface McpInvocation {
|
|
|
61
61
|
readonly revision: string;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* RFC 0204: one request the server answered, with the JSON-RPC `result` (or
|
|
66
|
+
* `error`) it sent back, byte-for-byte as serialised. `ctx.mcp` rows compare a
|
|
67
|
+
* host's resolved value to THIS — the expected value is what the suite's server
|
|
68
|
+
* actually returned, never something read from the host.
|
|
69
|
+
*/
|
|
70
|
+
export interface McpExchange {
|
|
71
|
+
readonly method: string;
|
|
72
|
+
readonly params: unknown;
|
|
73
|
+
readonly result?: unknown;
|
|
74
|
+
readonly error?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
/** MCP 2026-07-28 spec-reserved error codes (§error-codes; the -32020..-32099
|
|
65
78
|
* range is reserved for the specification). */
|
|
66
79
|
export const MCP_ERR = {
|
|
@@ -96,6 +109,34 @@ const NEEDS_INPUT_LOOP_TOOL = {
|
|
|
96
109
|
inputSchema: { type: 'object', properties: { rounds: { type: 'integer', minimum: 1 } }, required: ['rounds'] },
|
|
97
110
|
};
|
|
98
111
|
|
|
112
|
+
/**
|
|
113
|
+
* RFC 0204 fixture tools (2026-07-28 only). `structured-echo` returns a fixed
|
|
114
|
+
* `CallToolResult` carrying `structuredContent` and the caller's `nonce` in
|
|
115
|
+
* `_meta`, so a host that re-wraps, drops `_meta` or answers from a cache
|
|
116
|
+
* cannot reproduce it; `always-error` answers `isError: true` with a fixed
|
|
117
|
+
* `content[]` (a tool error is a result, not a rejection).
|
|
118
|
+
*/
|
|
119
|
+
const STRUCTURED_ECHO_TOOL = {
|
|
120
|
+
name: 'structured-echo',
|
|
121
|
+
title: 'Structured echo',
|
|
122
|
+
description: 'Returns a fixed CallToolResult: text "ok", structuredContent { n: 7, nonce }, and _meta carrying the nonce.',
|
|
123
|
+
inputSchema: { type: 'object', properties: { nonce: { type: 'string' } }, required: ['nonce'], additionalProperties: false },
|
|
124
|
+
outputSchema: { type: 'object', properties: { n: { type: 'integer' }, nonce: { type: 'string' } }, required: ['n', 'nonce'], additionalProperties: false },
|
|
125
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
126
|
+
_meta: { 'dev.openwop.conformance/fixture': 'rfc-0204' },
|
|
127
|
+
};
|
|
128
|
+
const ALWAYS_ERROR_TOOL = {
|
|
129
|
+
name: 'always-error',
|
|
130
|
+
description: 'Always answers a CallToolResult with isError: true and a fixed content[].',
|
|
131
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
132
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
133
|
+
};
|
|
134
|
+
export const ALWAYS_ERROR_CONTENT = [{ type: 'text', text: 'conformance: always-error is a tool error, reported inside the result' }] as const;
|
|
135
|
+
/** RFC 0204 §D.11: the server claims read-only; a host MUST NOT believe it. */
|
|
136
|
+
export const READONLY_CLAIM_PREFIX = 'conformance_readonly_claim_';
|
|
137
|
+
/** Tools per `tools/list` page under 2026-07-28 (RFC 0204 §A.4: the host returns ONE page and forwards the cursor). */
|
|
138
|
+
export const TOOLS_PAGE_SIZE = 3;
|
|
139
|
+
|
|
99
140
|
export class McpFakeServer {
|
|
100
141
|
private _server: Server | null = null;
|
|
101
142
|
private _boundPort = 0;
|
|
@@ -105,6 +146,9 @@ export class McpFakeServer {
|
|
|
105
146
|
/** RFC 0153 §D witness hook: when set, tool results carry an extension
|
|
106
147
|
* `_meta` key asserting authority the host MUST treat as opaque. */
|
|
107
148
|
private _nextResultAssertsAuthority = false;
|
|
149
|
+
private readonly _exchanges: McpExchange[] = [];
|
|
150
|
+
/** Per-instance, so no host can have classified the read-only-claim tool in advance. */
|
|
151
|
+
private readonly _claimNonce = Math.random().toString(36).slice(2, 10);
|
|
108
152
|
|
|
109
153
|
/**
|
|
110
154
|
* @param opts.protocolVersions revisions spoken. Default `['2026-07-28',
|
|
@@ -166,7 +210,29 @@ export class McpFakeServer {
|
|
|
166
210
|
return this._invocations;
|
|
167
211
|
}
|
|
168
212
|
|
|
213
|
+
/** RFC 0204: every answered request with the result/error it was sent. */
|
|
214
|
+
exchanges(): readonly McpExchange[] {
|
|
215
|
+
return this._exchanges;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** RFC 0204 §D.11: the name of the tool this server annotates `readOnlyHint: true` (unknowable before start). */
|
|
219
|
+
readonlyClaimToolName(): string {
|
|
220
|
+
return `${READONLY_CLAIM_PREFIX}${this._claimNonce}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The 2026-07-28 `tools/list` catalogue, in page order. */
|
|
224
|
+
currentTools(): ReadonlyArray<Record<string, unknown>> {
|
|
225
|
+
const claim = {
|
|
226
|
+
name: this.readonlyClaimToolName(),
|
|
227
|
+
description: 'Claims to be read-only. It is not classified by any host; RFC 0204 §D.11 makes it safetyTier "write".',
|
|
228
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
229
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
230
|
+
};
|
|
231
|
+
return [ECHO_TOOL, STRUCTURED_ECHO_TOOL, ALWAYS_ERROR_TOOL, claim, NEEDS_INPUT_TOOL, NEEDS_INPUT_LOOP_TOOL];
|
|
232
|
+
}
|
|
233
|
+
|
|
169
234
|
reset(): void {
|
|
235
|
+
this._exchanges.length = 0;
|
|
170
236
|
this._invocations.length = 0;
|
|
171
237
|
this._stateCounter = 0;
|
|
172
238
|
this._nextResultAssertsAuthority = false;
|
|
@@ -235,11 +301,23 @@ export class McpFakeServer {
|
|
|
235
301
|
}
|
|
236
302
|
|
|
237
303
|
if (revision === CURRENT) {
|
|
238
|
-
|
|
304
|
+
const [status, payload] = this._respondCurrent(rpc, headers);
|
|
305
|
+
if (typeof rpc.method === 'string') {
|
|
306
|
+
// Recorded as the client will parse it (a JSON round trip), so a
|
|
307
|
+
// deep-equal against a host's resolved value compares like with like.
|
|
308
|
+
const wire = JSON.parse(JSON.stringify(payload)) as { result?: unknown; error?: unknown };
|
|
309
|
+
this._exchanges.push({ method: rpc.method, params: rpc.params ?? null, ...('result' in wire ? { result: wire.result } : {}), ...('error' in wire ? { error: wire.error } : {}) });
|
|
310
|
+
}
|
|
311
|
+
json(status, payload);
|
|
239
312
|
return;
|
|
240
313
|
}
|
|
241
314
|
// legacy (2025-06-18 handshake, or an explicit legacy header)
|
|
242
|
-
|
|
315
|
+
const legacy = this._respondLegacy(rpc);
|
|
316
|
+
if (typeof rpc.method === 'string') {
|
|
317
|
+
const wire = JSON.parse(JSON.stringify(legacy)) as { result?: unknown; error?: unknown };
|
|
318
|
+
this._exchanges.push({ method: rpc.method, params: rpc.params ?? null, ...('result' in wire ? { result: wire.result } : {}), ...('error' in wire ? { error: wire.error } : {}) });
|
|
319
|
+
}
|
|
320
|
+
json(200, legacy);
|
|
243
321
|
}
|
|
244
322
|
|
|
245
323
|
// ─── MCP 2026-07-28 ────────────────────────────────────────────────────────
|
|
@@ -282,8 +360,21 @@ export class McpFakeServer {
|
|
|
282
360
|
cacheScope: 'public',
|
|
283
361
|
});
|
|
284
362
|
|
|
285
|
-
case 'tools/list':
|
|
286
|
-
|
|
363
|
+
case 'tools/list': {
|
|
364
|
+
// RFC 0204 §A.4: paged, so a host that merges pages or drops the
|
|
365
|
+
// cursor is visible. The cursor is opaque to the client; an unknown
|
|
366
|
+
// one is refused, never silently read as page 1.
|
|
367
|
+
const tools = this.currentTools();
|
|
368
|
+
const cursor = params['cursor'];
|
|
369
|
+
let start = 0;
|
|
370
|
+
if (cursor !== undefined) {
|
|
371
|
+
const m = typeof cursor === 'string' ? /^fake-page:(\d+)$/.exec(cursor) : null;
|
|
372
|
+
if (m === null || Number(m[1]) <= 0 || Number(m[1]) >= tools.length) return err(200, -32602, `Invalid cursor: ${JSON.stringify(cursor)}`);
|
|
373
|
+
start = Number(m[1]);
|
|
374
|
+
}
|
|
375
|
+
const end = Math.min(tools.length, start + TOOLS_PAGE_SIZE);
|
|
376
|
+
return ok({ resultType: 'complete', tools: tools.slice(start, end), ...(end < tools.length ? { nextCursor: `fake-page:${end}` } : {}), ttlMs: 60_000, cacheScope: 'private' });
|
|
377
|
+
}
|
|
287
378
|
|
|
288
379
|
case 'tools/call': {
|
|
289
380
|
const name = params['name'];
|
|
@@ -298,6 +389,16 @@ export class McpFakeServer {
|
|
|
298
389
|
this._nextResultAssertsAuthority = false;
|
|
299
390
|
return ok({ resultType: 'complete', content: [{ type: 'text', text }], isError: false, ...extra });
|
|
300
391
|
}
|
|
392
|
+
if (name === 'structured-echo') {
|
|
393
|
+
const nonce = String(((params['arguments'] ?? {}) as { nonce?: unknown }).nonce ?? '');
|
|
394
|
+
return ok({ resultType: 'complete', content: [{ type: 'text', text: 'ok' }], structuredContent: { n: 7, nonce }, _meta: { 'dev.openwop.conformance/nonce': nonce } });
|
|
395
|
+
}
|
|
396
|
+
if (name === 'always-error') {
|
|
397
|
+
return ok({ resultType: 'complete', content: [...ALWAYS_ERROR_CONTENT], isError: true });
|
|
398
|
+
}
|
|
399
|
+
if (name === this.readonlyClaimToolName()) {
|
|
400
|
+
return ok({ resultType: 'complete', content: [{ type: 'text', text: 'this tool claimed to be read-only' }] });
|
|
401
|
+
}
|
|
301
402
|
if (name === 'needs_input_loop') {
|
|
302
403
|
// The ceiling fixture (RFC 0175 §E.1): re-issues `input_required` for
|
|
303
404
|
// `arguments.rounds` retries before completing, so a host's MRTR loop
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference manifest-semantics check for the `remote` runtime's MCP registry
|
|
3
|
+
* record (RFC 0203 §A.3).
|
|
4
|
+
*
|
|
5
|
+
* JSON Schema cannot compare two values in one document, so the rule "when
|
|
6
|
+
* `runtime.mcpServer` is present, `runtime.entry` MUST equal
|
|
7
|
+
* `runtime.mcpServer.remotes[0].url`" is stated in prose
|
|
8
|
+
* (`spec/v2/core/node-pack-runtimes.md` §"The MCP registry record") and
|
|
9
|
+
* enforced by a validator. This is the suite's reference form of that
|
|
10
|
+
* validator; the registry publish validator (openwop-registry) applies the
|
|
11
|
+
* same equality.
|
|
12
|
+
*
|
|
13
|
+
* Returns `null` when the manifest satisfies the rule (including every
|
|
14
|
+
* manifest that carries no `mcpServer`), or the refusal a validator emits.
|
|
15
|
+
*
|
|
16
|
+
* @see RFCS/0203-remote-runtime-mcp-registry-record.md §A.3
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface RemoteEntryRefusal {
|
|
20
|
+
readonly code: 'pack_validation_failed';
|
|
21
|
+
readonly message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
25
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function remoteEntryBinding(manifest: unknown): RemoteEntryRefusal | null {
|
|
29
|
+
const runtime = asRecord(asRecord(manifest)?.['runtime']);
|
|
30
|
+
if (runtime === null) return null;
|
|
31
|
+
const record = asRecord(runtime['mcpServer']);
|
|
32
|
+
if (record === null) return null;
|
|
33
|
+
const remotes = record['remotes'];
|
|
34
|
+
const first = Array.isArray(remotes) ? asRecord(remotes[0]) : null;
|
|
35
|
+
const url = first?.['url'];
|
|
36
|
+
if (typeof url === 'string' && runtime['entry'] === url) return null;
|
|
37
|
+
return {
|
|
38
|
+
code: 'pack_validation_failed',
|
|
39
|
+
message: `runtime.entry (${JSON.stringify(runtime['entry'])}) MUST equal runtime.mcpServer.remotes[0].url (${JSON.stringify(url)})`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0199 — a suite-owned OAuth 2.0 authorization-server double, and a
|
|
3
|
+
* protected-resource double (RFC 9728) for the MCP-reach legs.
|
|
4
|
+
*
|
|
5
|
+
* The host under test is the OAuth CLIENT. Every leg that asserts "the host
|
|
6
|
+
* made no token request" reads THIS double's counter, which the suite owns —
|
|
7
|
+
* never the host's own ledger (a row that reads the host's ledger to prove the
|
|
8
|
+
* host did not act cannot fail).
|
|
9
|
+
*
|
|
10
|
+
* GET /.well-known/oauth-authorization-server RFC 8414 metadata (switchable)
|
|
11
|
+
* GET /authorize plays the consenting user: 302 to
|
|
12
|
+
* redirect_uri?code&state[&iss]
|
|
13
|
+
* POST /token authorization_code (PKCE-checked)
|
|
14
|
+
* and refresh_token; EVERY request counted
|
|
15
|
+
*
|
|
16
|
+
* The protected-resource double serves `/.well-known/oauth-protected-resource<path>`
|
|
17
|
+
* and answers any other request `401` with a `resource_metadata` challenge.
|
|
18
|
+
*
|
|
19
|
+
* Reachability. Each double listens on loopback (or `OPENWOP_CONFORMANCE_HARNESS_HOST`)
|
|
20
|
+
* and is advertised through `resolvePublicFront`, so an operator can front it
|
|
21
|
+
* with a public https tunnel: `OPENWOP_OAUTH_AS_URL` (+ `_PORT` to pin the
|
|
22
|
+
* listener), `OPENWOP_OAUTH_AS2_URL` (the foreign issuer a PRM may name), and
|
|
23
|
+
* `OPENWOP_OAUTH_RESOURCE_URL`. Without a front the host must relax its egress
|
|
24
|
+
* guard to reach them, which a bundle records as a relaxation.
|
|
25
|
+
*/
|
|
26
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
27
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
28
|
+
import { receiverBinding, resolvePublicFront } from './webhook-receiver.js';
|
|
29
|
+
import { driver, type OpenWOPResponse } from './driver.js';
|
|
30
|
+
import { v2Discovery, familyAdvertised } from './v2.js';
|
|
31
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from './seams.js';
|
|
32
|
+
|
|
33
|
+
export interface TokenRequest {
|
|
34
|
+
grantType: string;
|
|
35
|
+
params: Record<string, string>;
|
|
36
|
+
at: number;
|
|
37
|
+
status: number;
|
|
38
|
+
/** why the double refused it, when it did */
|
|
39
|
+
refused?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AuthorizeRequest { params: Record<string, string>; at: number }
|
|
43
|
+
|
|
44
|
+
interface CodeRecord { clientId: string; redirectUri: string; challenge: string | null; method: string | null; resource: string | null; used: boolean; scope: string }
|
|
45
|
+
|
|
46
|
+
export interface AsDouble {
|
|
47
|
+
/** The issuer identifier — the public front when set, else the local URL. No trailing slash. */
|
|
48
|
+
readonly issuer: string;
|
|
49
|
+
readonly authorizeUrl: string;
|
|
50
|
+
readonly tokenUrl: string;
|
|
51
|
+
readonly tunnelled: boolean;
|
|
52
|
+
readonly tokenRequests: TokenRequest[];
|
|
53
|
+
readonly authorizeRequests: AuthorizeRequest[];
|
|
54
|
+
readonly metadataHits: string[];
|
|
55
|
+
/** RFC 9207 — advertise authorization_response_iss_parameter_supported (default true). */
|
|
56
|
+
issParameterSupported: boolean;
|
|
57
|
+
/** RFC 8414 code_challenge_methods_supported (default ['S256']; `null` omits the member). */
|
|
58
|
+
codeChallengeMethods: string[] | null;
|
|
59
|
+
/** Overrides for the metadata document (a mismatch the host must refuse). */
|
|
60
|
+
metadataOverride: Record<string, unknown>;
|
|
61
|
+
/** When true every refresh_token grant is refused invalid_grant (a revoked refresh token). */
|
|
62
|
+
refreshRevoked: boolean;
|
|
63
|
+
/** Token requests whose grant is authorization_code. */
|
|
64
|
+
codeExchanges(): TokenRequest[];
|
|
65
|
+
close(): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const S256 = (verifier: string): string => createHash('sha256').update(verifier).digest('base64url');
|
|
69
|
+
|
|
70
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
71
|
+
return new Promise((ok) => {
|
|
72
|
+
const chunks: Buffer[] = [];
|
|
73
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
74
|
+
req.on('end', () => ok(Buffer.concat(chunks).toString('utf8')));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formOf(body: string, contentType: string | undefined): Record<string, string> {
|
|
79
|
+
if (contentType !== undefined && /json/i.test(contentType)) {
|
|
80
|
+
try { return Object.fromEntries(Object.entries(JSON.parse(body) as Record<string, unknown>).map(([k, v]) => [k, String(v)])); } catch { return {}; }
|
|
81
|
+
}
|
|
82
|
+
return Object.fromEntries(new URLSearchParams(body).entries());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function listen(server: Server, portEnv: string): Promise<string> {
|
|
86
|
+
const pinned = Number(process.env[portEnv] ?? '');
|
|
87
|
+
const port = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
88
|
+
const binding = receiverBinding();
|
|
89
|
+
// A pinned port is shared by every scenario file that starts this double, and
|
|
90
|
+
// vitest runs files in parallel: wait for the holder to close rather than fail.
|
|
91
|
+
const deadline = Date.now() + 25_000;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const e = await new Promise<NodeJS.ErrnoException | null>((ok) => {
|
|
94
|
+
const onErr = (err: NodeJS.ErrnoException): void => ok(err);
|
|
95
|
+
server.once('error', onErr);
|
|
96
|
+
server.listen(port, binding.bind, () => { server.off('error', onErr); ok(null); });
|
|
97
|
+
});
|
|
98
|
+
if (e === null) break;
|
|
99
|
+
if (e.code !== 'EADDRINUSE' || port === 0 || Date.now() > deadline) throw e;
|
|
100
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
101
|
+
}
|
|
102
|
+
const addr = server.address();
|
|
103
|
+
if (typeof addr !== 'object' || addr === null) throw new Error('double address unavailable');
|
|
104
|
+
return `http://${binding.advertise}:${addr.port}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Start an authorization-server double. `frontEnv` names its public front (default OPENWOP_OAUTH_AS_URL). */
|
|
108
|
+
export async function startAsDouble(frontEnv = 'OPENWOP_OAUTH_AS_URL'): Promise<AsDouble> {
|
|
109
|
+
const tokenRequests: TokenRequest[] = [];
|
|
110
|
+
const authorizeRequests: AuthorizeRequest[] = [];
|
|
111
|
+
const metadataHits: string[] = [];
|
|
112
|
+
const codes = new Map<string, CodeRecord>();
|
|
113
|
+
const refresh = new Set<string>();
|
|
114
|
+
let issuer = '';
|
|
115
|
+
const state = {
|
|
116
|
+
issParameterSupported: true,
|
|
117
|
+
codeChallengeMethods: ['S256'] as string[] | null,
|
|
118
|
+
metadataOverride: {} as Record<string, unknown>,
|
|
119
|
+
refreshRevoked: false,
|
|
120
|
+
};
|
|
121
|
+
const json = (res: ServerResponse, status: number, body: unknown): void => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
|
122
|
+
const server = createServer((req, res) => {
|
|
123
|
+
void (async () => {
|
|
124
|
+
const url = new URL(req.url ?? '/', 'http://double');
|
|
125
|
+
if (req.method === 'GET' && url.pathname.startsWith('/.well-known/oauth-authorization-server')) {
|
|
126
|
+
metadataHits.push(url.pathname);
|
|
127
|
+
const md: Record<string, unknown> = {
|
|
128
|
+
issuer,
|
|
129
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
130
|
+
token_endpoint: `${issuer}/token`,
|
|
131
|
+
response_types_supported: ['code'],
|
|
132
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
133
|
+
authorization_response_iss_parameter_supported: state.issParameterSupported,
|
|
134
|
+
...(state.codeChallengeMethods === null ? {} : { code_challenge_methods_supported: state.codeChallengeMethods }),
|
|
135
|
+
...state.metadataOverride,
|
|
136
|
+
};
|
|
137
|
+
return json(res, 200, md);
|
|
138
|
+
}
|
|
139
|
+
if (req.method === 'GET' && url.pathname === '/authorize') {
|
|
140
|
+
const params = Object.fromEntries(url.searchParams.entries());
|
|
141
|
+
authorizeRequests.push({ params, at: Date.now() });
|
|
142
|
+
const redirectUri = params['redirect_uri'];
|
|
143
|
+
if (params['response_type'] !== 'code' || redirectUri === undefined) return json(res, 400, { error: 'invalid_request' });
|
|
144
|
+
const code = `code-${randomBytes(12).toString('hex')}`;
|
|
145
|
+
codes.set(code, { clientId: params['client_id'] ?? '', redirectUri, challenge: params['code_challenge'] ?? null, method: params['code_challenge_method'] ?? null, resource: params['resource'] ?? null, used: false, scope: params['scope'] ?? '' });
|
|
146
|
+
const to = new URL(redirectUri);
|
|
147
|
+
to.searchParams.set('code', code);
|
|
148
|
+
if (params['state'] !== undefined) to.searchParams.set('state', params['state']);
|
|
149
|
+
to.searchParams.set('iss', issuer);
|
|
150
|
+
res.writeHead(302, { Location: to.toString() });
|
|
151
|
+
return res.end();
|
|
152
|
+
}
|
|
153
|
+
if (req.method === 'POST' && url.pathname === '/token') {
|
|
154
|
+
const params = formOf(await readBody(req), req.headers['content-type']);
|
|
155
|
+
const grantType = params['grant_type'] ?? '';
|
|
156
|
+
const record = (status: number, refused?: string): void => { tokenRequests.push({ grantType, params, at: Date.now(), status, ...(refused ? { refused } : {}) }); };
|
|
157
|
+
if (grantType === 'authorization_code') {
|
|
158
|
+
const c = codes.get(params['code'] ?? '');
|
|
159
|
+
if (!c || c.used) { record(400, 'unknown or used code'); return json(res, 400, { error: 'invalid_grant' }); }
|
|
160
|
+
c.used = true;
|
|
161
|
+
if (params['redirect_uri'] !== c.redirectUri) { record(400, 'redirect_uri mismatch'); return json(res, 400, { error: 'invalid_grant' }); }
|
|
162
|
+
if (c.challenge !== null) {
|
|
163
|
+
const verifier = params['code_verifier'] ?? '';
|
|
164
|
+
const ok = c.method === 'S256' ? S256(verifier) === c.challenge : verifier === c.challenge;
|
|
165
|
+
if (!ok) { record(400, 'PKCE verifier does not match'); return json(res, 400, { error: 'invalid_grant' }); }
|
|
166
|
+
}
|
|
167
|
+
const rt = `rt-${randomBytes(12).toString('hex')}`;
|
|
168
|
+
refresh.add(rt);
|
|
169
|
+
record(200);
|
|
170
|
+
return json(res, 200, { access_token: `at-${randomBytes(12).toString('hex')}`, token_type: 'Bearer', expires_in: 3600, refresh_token: rt, scope: c.scope });
|
|
171
|
+
}
|
|
172
|
+
if (grantType === 'refresh_token') {
|
|
173
|
+
if (state.refreshRevoked || !refresh.has(params['refresh_token'] ?? '')) { record(400, 'refresh token revoked'); return json(res, 400, { error: 'invalid_grant' }); }
|
|
174
|
+
record(200);
|
|
175
|
+
return json(res, 200, { access_token: `at-${randomBytes(12).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });
|
|
176
|
+
}
|
|
177
|
+
record(400, 'unsupported grant');
|
|
178
|
+
return json(res, 400, { error: 'unsupported_grant_type' });
|
|
179
|
+
}
|
|
180
|
+
return json(res, 404, { error: 'not_found' });
|
|
181
|
+
})();
|
|
182
|
+
});
|
|
183
|
+
const local = await listen(server, frontEnv.replace(/_URL$/, '_PORT'));
|
|
184
|
+
const front = resolvePublicFront(frontEnv, local);
|
|
185
|
+
issuer = front.url.replace(/\/+$/, '');
|
|
186
|
+
const d: AsDouble = {
|
|
187
|
+
issuer,
|
|
188
|
+
authorizeUrl: `${issuer}/authorize`,
|
|
189
|
+
tokenUrl: `${issuer}/token`,
|
|
190
|
+
tunnelled: front.tunnelled,
|
|
191
|
+
tokenRequests,
|
|
192
|
+
authorizeRequests,
|
|
193
|
+
metadataHits,
|
|
194
|
+
get issParameterSupported() { return state.issParameterSupported; },
|
|
195
|
+
set issParameterSupported(v: boolean) { state.issParameterSupported = v; },
|
|
196
|
+
get codeChallengeMethods() { return state.codeChallengeMethods; },
|
|
197
|
+
set codeChallengeMethods(v: string[] | null) { state.codeChallengeMethods = v; },
|
|
198
|
+
get metadataOverride() { return state.metadataOverride; },
|
|
199
|
+
set metadataOverride(v: Record<string, unknown>) { state.metadataOverride = v; },
|
|
200
|
+
get refreshRevoked() { return state.refreshRevoked; },
|
|
201
|
+
set refreshRevoked(v: boolean) { state.refreshRevoked = v; },
|
|
202
|
+
codeExchanges: () => tokenRequests.filter((t) => t.grantType === 'authorization_code'),
|
|
203
|
+
close: () => new Promise<void>((ok) => server.close(() => ok())),
|
|
204
|
+
};
|
|
205
|
+
return d;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ResourceDouble {
|
|
209
|
+
/** The protected resource's canonical URI (the MCP server URL a pack names). */
|
|
210
|
+
readonly resource: string;
|
|
211
|
+
readonly tunnelled: boolean;
|
|
212
|
+
readonly hits: string[];
|
|
213
|
+
/** The PRM document served; mutate to rewrite it (the pinning leg). */
|
|
214
|
+
prm: Record<string, unknown>;
|
|
215
|
+
close(): Promise<void>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Start a protected-resource double at path `/mcp`, whose PRM names `authorizationServers`. */
|
|
219
|
+
export async function startResourceDouble(authorizationServers: string[], frontEnv = 'OPENWOP_OAUTH_RESOURCE_URL'): Promise<ResourceDouble> {
|
|
220
|
+
const hits: string[] = [];
|
|
221
|
+
let resource = '';
|
|
222
|
+
const holder = { prm: {} as Record<string, unknown> };
|
|
223
|
+
const server = createServer((req, res) => {
|
|
224
|
+
const url = new URL(req.url ?? '/', 'http://double');
|
|
225
|
+
hits.push(`${req.method ?? ''} ${url.pathname}`);
|
|
226
|
+
if (req.method === 'GET' && url.pathname.startsWith('/.well-known/oauth-protected-resource')) {
|
|
227
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
228
|
+
return res.end(JSON.stringify(holder.prm));
|
|
229
|
+
}
|
|
230
|
+
res.writeHead(401, { 'WWW-Authenticate': `Bearer resource_metadata="${new URL(resource).origin}/.well-known/oauth-protected-resource/mcp"` });
|
|
231
|
+
return res.end();
|
|
232
|
+
});
|
|
233
|
+
const local = await listen(server, frontEnv.replace(/_URL$/, '_PORT'));
|
|
234
|
+
const front = resolvePublicFront(frontEnv, local);
|
|
235
|
+
resource = `${front.url.replace(/\/+$/, '')}/mcp`;
|
|
236
|
+
holder.prm = { resource, authorization_servers: authorizationServers, bearer_methods_supported: ['header'] };
|
|
237
|
+
return {
|
|
238
|
+
resource,
|
|
239
|
+
tunnelled: front.tunnelled,
|
|
240
|
+
hits,
|
|
241
|
+
get prm() { return holder.prm; },
|
|
242
|
+
set prm(v: Record<string, unknown>) { holder.prm = v; },
|
|
243
|
+
close: () => new Promise<void>((ok) => server.close(() => ok())),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Play the user agent through one grant: GET the authorization URL at the
|
|
249
|
+
* double (it answers 302 to the host's redirect URI with code/state/iss) and
|
|
250
|
+
* return that callback URL, unfollowed, so a leg can alter it before the host
|
|
251
|
+
* sees it.
|
|
252
|
+
*/
|
|
253
|
+
export async function consent(authorizationUrl: string): Promise<URL | null> {
|
|
254
|
+
const res = await fetch(authorizationUrl, { redirect: 'manual' });
|
|
255
|
+
const loc = res.headers.get('location');
|
|
256
|
+
return res.status >= 300 && res.status < 400 && loc !== null ? new URL(loc) : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
// Host-side helpers shared by the three RFC 0199 scenarios.
|
|
261
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
export const AUTHORIZE_START = `${SEAMS_PREFIX}/sample/oauth/authorize-start`;
|
|
265
|
+
export const EXPIRE_REFRESH = `${SEAMS_PREFIX}/sample/oauth/expire-refresh`;
|
|
266
|
+
const MINT = `${SEAMS_PREFIX}/sample/auth/credential/mint`;
|
|
267
|
+
|
|
268
|
+
export type OAuthGate = { ok: true; oauth: Record<string, unknown>; providers: Set<string> } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
|
|
269
|
+
|
|
270
|
+
/** `oauth` advertised at major 2 with `authorization_code`, and the seams profile (the grant is driven through `authorize-start`). */
|
|
271
|
+
export async function oauthGate(): Promise<OAuthGate> {
|
|
272
|
+
const doc = await v2Discovery();
|
|
273
|
+
if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
274
|
+
const oauth = await familyAdvertised('oauth');
|
|
275
|
+
if (!oauth) return { ok: false, kind: 'inapplicable', reason: 'oauth not advertised at major 2 — the host is not an OAuth client' };
|
|
276
|
+
const grants = Array.isArray(oauth['grants']) ? (oauth['grants'] as unknown[]).map(String) : [];
|
|
277
|
+
if (!grants.includes('authorization_code')) return { ok: false, kind: 'inapplicable', reason: `oauth.grants [${grants.join(', ')}] does not list authorization_code` };
|
|
278
|
+
// An absent seams advert means the host never claimed the instrument — inapplicable, not blocked.
|
|
279
|
+
if (!seamsProfileAdvertised(doc)) return { ok: false, kind: 'inapplicable', reason: 'conformance.seamsProfile not advertised (the host claims no seams instrument) — the grant can only be driven against the suite\'s authorization-server double through the authorize-start seam' };
|
|
280
|
+
const providers = new Set((Array.isArray(oauth['providers']) ? (oauth['providers'] as Array<{ id?: unknown }>) : []).map((p) => String(p.id)));
|
|
281
|
+
return { ok: true, oauth, providers };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const bearerInit = (bearer: string | undefined): { authenticated?: boolean; headers?: Record<string, string> } =>
|
|
285
|
+
bearer === undefined ? {} : { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } };
|
|
286
|
+
|
|
287
|
+
/** POST the authorize-start seam as `bearer` (default: the suite's key). */
|
|
288
|
+
export function authorizeStart(body: Record<string, unknown>, bearer?: string): Promise<OpenWOPResponse> {
|
|
289
|
+
return driver.post(AUTHORIZE_START, body, bearerInit(bearer));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The `authorizationUrl` a 201 answered, parsed; null otherwise. */
|
|
293
|
+
export function authorizationUrlOf(r: OpenWOPResponse): URL | null {
|
|
294
|
+
const u = (r.json as { authorizationUrl?: unknown } | undefined)?.authorizationUrl;
|
|
295
|
+
if (r.status !== 201 || typeof u !== 'string') return null;
|
|
296
|
+
try { return new URL(u); } catch { return null; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** GET a host URL as the user agent (a callback or a connectUrl), never following a redirect. */
|
|
300
|
+
export async function userAgentGet(url: string | URL, bearer: string | null): Promise<{ status: number; location: string | null; text: string }> {
|
|
301
|
+
const res = await fetch(url, { redirect: 'manual', headers: bearer === null ? { Accept: 'application/json' } : { Accept: 'application/json', Authorization: `Bearer ${bearer}` } });
|
|
302
|
+
return { status: res.status, location: res.headers.get('location'), text: await res.text() };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A second Subject: a fresh credential from the per-lane mint seam (same
|
|
307
|
+
* tenant, new subject), else OPENWOP_TEST_TENANT_B_API_KEY. null ⇒ the
|
|
308
|
+
* same-Subject legs are blocked.
|
|
309
|
+
*/
|
|
310
|
+
export async function secondSubject(): Promise<{ bearer: string; how: string } | null> {
|
|
311
|
+
const minted = await driver.post(MINT, { lane: 'api-key' }).catch(() => null);
|
|
312
|
+
const cred = (minted?.json as { credential?: unknown } | undefined)?.credential;
|
|
313
|
+
if (minted !== null && minted.status < 300 && typeof cred === 'string') return { bearer: cred, how: 'a fresh api-key Subject from the credential mint seam' };
|
|
314
|
+
const b = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
|
|
315
|
+
return b ? { bearer: b, how: 'OPENWOP_TEST_TENANT_B_API_KEY' } : null;
|
|
316
|
+
}
|