@nshipster/sosumi 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -1
- package/package.json +4 -2
- package/public/index.html +3 -1
- package/public/llms.txt +1 -1
- package/public/robots.txt +1 -0
- package/public/webmcp.js +71 -0
- package/src/index.ts +112 -1
- package/src/lib/a2a.ts +90 -0
- package/src/lib/external/fetch.ts +3 -0
- package/src/lib/hig/render.ts +94 -73
- package/src/lib/markdown.ts +115 -0
- package/src/lib/mcp.ts +150 -115
- package/src/lib/reference/render.ts +208 -147
- package/src/lib/search.ts +115 -55
- package/src/lib/types.ts +21 -0
- package/src/lib/url.ts +35 -0
- package/src/lib/webbotauth.ts +261 -0
- package/src/lib/webmcp.ts +38 -0
- package/wrangler.jsonc +1 -1
package/src/lib/search.ts
CHANGED
|
@@ -15,14 +15,24 @@ export interface SearchResponse {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Apple's current search backend, discovered from
|
|
18
|
-
// https://developer.apple.com/search/scripts/search.js (
|
|
18
|
+
// https://developer.apple.com/search/scripts/search.js (September 2026)
|
|
19
19
|
//
|
|
20
20
|
// Historical context:
|
|
21
21
|
// - The legacy /search/ HTML scraper broke when Apple switched to a JS-rendered SPA
|
|
22
22
|
// - PR #54 upstream (NSHipster/sosumi.ai) targeted /search/services/search.php with
|
|
23
23
|
// NDJSON-style streamed events; that endpoint is now also gone (404)
|
|
24
|
-
// -
|
|
25
|
-
|
|
24
|
+
// - /api/v1/search replaced it, and now 404s in turn
|
|
25
|
+
// - The current backend is /api/v1/query, which answers only in JSONL:
|
|
26
|
+
// one event per line, each tagged with a `kind`
|
|
27
|
+
const APPLE_SEARCH_SERVICE_URL = "https://devintserv.msc.sbz.apple.com/api/v1/query"
|
|
28
|
+
|
|
29
|
+
// The backend rejects a request that does not name the response channels it wants.
|
|
30
|
+
// `quickSearch` carries the top typeahead matches in a single event;
|
|
31
|
+
// `search` streams the full ranked list.
|
|
32
|
+
// The third channel Apple's own page requests, `ask`, is a generated answer,
|
|
33
|
+
// which is not what this service reports.
|
|
34
|
+
const INCLUDED_RESPONSES = ["quickSearch", "search"]
|
|
35
|
+
|
|
26
36
|
const DEFAULT_TARGET_RESULT_LOCALE = "en"
|
|
27
37
|
const TARGET_RESULT_LOCALE_BY_BASE_NAME = new Map([
|
|
28
38
|
["en", "en"],
|
|
@@ -49,7 +59,7 @@ async function searchAppleDeveloperDocsViaService(query: string): Promise<Search
|
|
|
49
59
|
method: "POST",
|
|
50
60
|
headers: {
|
|
51
61
|
"Content-Type": "application/json",
|
|
52
|
-
Accept: "application/
|
|
62
|
+
Accept: "application/jsonl",
|
|
53
63
|
// The MSC backend requires a browser-style Origin/Referer pair to accept the request.
|
|
54
64
|
Origin: "https://developer.apple.com",
|
|
55
65
|
Referer: "https://developer.apple.com/search/",
|
|
@@ -58,6 +68,7 @@ async function searchAppleDeveloperDocsViaService(query: string): Promise<Search
|
|
|
58
68
|
body: JSON.stringify({
|
|
59
69
|
text: query,
|
|
60
70
|
targetResultLocale: resolveTargetResultLocale(),
|
|
71
|
+
includedResponses: INCLUDED_RESPONSES,
|
|
61
72
|
}),
|
|
62
73
|
})
|
|
63
74
|
|
|
@@ -65,36 +76,104 @@ async function searchAppleDeveloperDocsViaService(query: string): Promise<Search
|
|
|
65
76
|
throw new Error(`Search request failed: ${response.status}`)
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
return extractSearchResults(await readSearchResponseItems(response))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The response body is JSONL: one JSON object per line.
|
|
83
|
+
// A `quickSearch` event carries its results inline,
|
|
84
|
+
// while `search` events stream the full list as diffs against a JSON text buffer,
|
|
85
|
+
// so those results can only be read once every line has been applied.
|
|
86
|
+
// Events of any other kind — `quickSearchFinished`, `searchFinished`, `ask` —
|
|
87
|
+
// are not part of the result set and are skipped.
|
|
88
|
+
async function readSearchResponseItems(response: Response): Promise<unknown[]> {
|
|
89
|
+
const items: unknown[] = []
|
|
90
|
+
let streamedSearch = ""
|
|
91
|
+
|
|
92
|
+
for (const line of (await response.text()).split("\n")) {
|
|
93
|
+
const trimmed = line.trim()
|
|
94
|
+
if (!trimmed) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const event = parseJson(trimmed)
|
|
99
|
+
if (!isJsonRecord(event)) {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (event.kind === "quickSearch") {
|
|
104
|
+
items.push(...resultsOf(event.response))
|
|
105
|
+
} else if (event.kind === "search") {
|
|
106
|
+
streamedSearch = applySearchDiff(streamedSearch, event.diff)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (streamedSearch) {
|
|
111
|
+
items.push(...resultsOf(parseJson(streamedSearch)))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return items
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Each `search` event appends to the buffer,
|
|
118
|
+
// after dropping `removeLast` characters from the end of what came before.
|
|
119
|
+
function applySearchDiff(buffer: string, diff: unknown): string {
|
|
120
|
+
if (!isJsonRecord(diff)) {
|
|
121
|
+
return buffer
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const removeLast = typeof diff.removeLast === "number" ? diff.removeLast : 0
|
|
125
|
+
const append = typeof diff.append === "string" ? diff.append : ""
|
|
126
|
+
|
|
127
|
+
return buffer.slice(0, Math.max(0, buffer.length - removeLast)) + append
|
|
71
128
|
}
|
|
72
129
|
|
|
73
|
-
|
|
130
|
+
function parseJson(text: string): unknown {
|
|
74
131
|
try {
|
|
75
|
-
|
|
76
|
-
return isJsonRecord(data) ? data : {}
|
|
132
|
+
return JSON.parse(text)
|
|
77
133
|
} catch {
|
|
78
134
|
throw new Error("Search response was not valid JSON")
|
|
79
135
|
}
|
|
80
136
|
}
|
|
81
137
|
|
|
138
|
+
function resultsOf(container: unknown): unknown[] {
|
|
139
|
+
if (!isJsonRecord(container)) {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Array.isArray(container.results) ? container.results : []
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The same page can be reported by more than one channel,
|
|
147
|
+
// so keep the first mention of each URL and drop the rest.
|
|
82
148
|
function extractSearchResults(items: unknown[]): SearchResult[] {
|
|
149
|
+
const seen = new Set<string>()
|
|
150
|
+
|
|
83
151
|
return items.flatMap((item) => {
|
|
84
152
|
const result = normalizeSearchResult(item)
|
|
85
|
-
|
|
153
|
+
if (!result || seen.has(result.url)) {
|
|
154
|
+
return []
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
seen.add(result.url)
|
|
158
|
+
return [result]
|
|
86
159
|
})
|
|
87
160
|
}
|
|
88
161
|
|
|
162
|
+
// A result is `{ metadata, origin }`, and its `metadata.metadataKind` says how to read it:
|
|
163
|
+
// - "documentation" for reference pages, with singular fields
|
|
164
|
+
// - "developer" for WWDC sessions and other media, with parallel arrays
|
|
165
|
+
// - "webPage" for marketing and swift.org pages, keyed by `sourceURL`
|
|
89
166
|
function normalizeSearchResult(item: unknown): SearchResult | null {
|
|
90
|
-
|
|
167
|
+
const record = unwrapResultValue(item)
|
|
168
|
+
if (!record || !isJsonRecord(record.metadata)) {
|
|
91
169
|
return null
|
|
92
170
|
}
|
|
93
171
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
172
|
+
const metadata = record.metadata
|
|
173
|
+
|
|
174
|
+
if (metadata.metadataKind === "documentation") {
|
|
175
|
+
const title = stringValue(metadata.title)
|
|
176
|
+
const url = stringValue(metadata.permalink)
|
|
98
177
|
if (!title || !url) {
|
|
99
178
|
return null
|
|
100
179
|
}
|
|
@@ -102,17 +181,16 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
|
|
|
102
181
|
return {
|
|
103
182
|
title,
|
|
104
183
|
url,
|
|
105
|
-
description: stringValue(
|
|
106
|
-
breadcrumbs: splitHierarchy(stringValue(
|
|
107
|
-
tags: compactStrings([stringValue(
|
|
184
|
+
description: stringValue(metadata.description) ?? "",
|
|
185
|
+
breadcrumbs: splitHierarchy(stringValue(metadata.hierarchy)),
|
|
186
|
+
tags: compactStrings([stringValue(metadata.kind)]),
|
|
108
187
|
type: "documentation",
|
|
109
188
|
}
|
|
110
189
|
}
|
|
111
190
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const url = firstString(developer.permalinks)
|
|
191
|
+
if (metadata.metadataKind === "developer") {
|
|
192
|
+
const title = firstString(metadata.titles)
|
|
193
|
+
const url = firstString(metadata.permalinks)
|
|
116
194
|
if (!title || !url) {
|
|
117
195
|
return null
|
|
118
196
|
}
|
|
@@ -120,20 +198,19 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
|
|
|
120
198
|
return {
|
|
121
199
|
title,
|
|
122
200
|
url,
|
|
123
|
-
description: firstString(
|
|
124
|
-
breadcrumbs: compactStrings([firstString(
|
|
201
|
+
description: firstString(metadata.descriptions) ?? "",
|
|
202
|
+
breadcrumbs: compactStrings([firstString(metadata.projectNames)]),
|
|
125
203
|
tags: compactStrings([
|
|
126
|
-
firstString(
|
|
127
|
-
firstString(
|
|
204
|
+
firstString(metadata.itemTypes),
|
|
205
|
+
firstString(metadata.deliveryLanguageCodes),
|
|
128
206
|
]),
|
|
129
|
-
type: (firstString(
|
|
207
|
+
type: (firstString(metadata.itemTypes) ?? "developer").toLowerCase(),
|
|
130
208
|
}
|
|
131
209
|
}
|
|
132
210
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const url = stringValue(devsite.sourceURL)
|
|
211
|
+
if (metadata.metadataKind === "webPage") {
|
|
212
|
+
const title = stringValue(metadata.title)
|
|
213
|
+
const url = stringValue(metadata.sourceURL)
|
|
137
214
|
if (!title || !url) {
|
|
138
215
|
return null
|
|
139
216
|
}
|
|
@@ -141,25 +218,7 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
|
|
|
141
218
|
return {
|
|
142
219
|
title,
|
|
143
220
|
url,
|
|
144
|
-
description: stringValue(
|
|
145
|
-
breadcrumbs: [],
|
|
146
|
-
tags: [],
|
|
147
|
-
type: "general",
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const swiftdocs = extractMetadataRecord(item.swiftdocs)
|
|
152
|
-
if (swiftdocs) {
|
|
153
|
-
const title = stringValue(swiftdocs.title)
|
|
154
|
-
const url = stringValue(swiftdocs.sourceURL)
|
|
155
|
-
if (!title || !url) {
|
|
156
|
-
return null
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
title,
|
|
161
|
-
url,
|
|
162
|
-
description: stringValue(swiftdocs.description) ?? "",
|
|
221
|
+
description: stringValue(metadata.description) ?? "",
|
|
163
222
|
breadcrumbs: [],
|
|
164
223
|
tags: [],
|
|
165
224
|
type: "general",
|
|
@@ -169,13 +228,14 @@ function normalizeSearchResult(item: unknown): SearchResult | null {
|
|
|
169
228
|
return null
|
|
170
229
|
}
|
|
171
230
|
|
|
172
|
-
|
|
173
|
-
|
|
231
|
+
// Streamed `search` results wrap their payload in `value` alongside a match excerpt,
|
|
232
|
+
// while `quickSearch` results carry it directly.
|
|
233
|
+
function unwrapResultValue(item: unknown): JsonRecord | null {
|
|
234
|
+
if (!isJsonRecord(item)) {
|
|
174
235
|
return null
|
|
175
236
|
}
|
|
176
237
|
|
|
177
|
-
|
|
178
|
-
return isJsonRecord(metadata) ? metadata : null
|
|
238
|
+
return isJsonRecord(item.value) ? item.value : item
|
|
179
239
|
}
|
|
180
240
|
|
|
181
241
|
function isJsonRecord(value: unknown): value is JsonRecord {
|
package/src/lib/types.ts
CHANGED
|
@@ -51,6 +51,13 @@ export interface ContentItem {
|
|
|
51
51
|
title?: string
|
|
52
52
|
name?: string
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Rich inline rendering of a reference's title (e.g. a symbol name wrapped in
|
|
56
|
+
* a `codeVoice` span). Present on entries in the `references` map and preferred
|
|
57
|
+
* over `title` so code spans survive inside link text.
|
|
58
|
+
*/
|
|
59
|
+
titleInlineContent?: ContentItem[]
|
|
60
|
+
|
|
54
61
|
// Tokenized content
|
|
55
62
|
tokens?: Token[]
|
|
56
63
|
|
|
@@ -69,12 +76,20 @@ export interface ContentItem {
|
|
|
69
76
|
// Structural properties
|
|
70
77
|
level?: number
|
|
71
78
|
style?: string
|
|
79
|
+
/**
|
|
80
|
+
* DocC reference role (e.g. `"symbol"`, `"collection"`, `"article"`).
|
|
81
|
+
* Used to decide whether a plain title should be rendered as a code span.
|
|
82
|
+
*/
|
|
83
|
+
role?: string
|
|
72
84
|
identifier?: string
|
|
73
85
|
identifiers?: string[]
|
|
74
86
|
url?: string
|
|
75
87
|
|
|
76
88
|
// Abstract content
|
|
77
89
|
abstract?: TextFragment[]
|
|
90
|
+
|
|
91
|
+
/** Present on reference entries for deprecated symbols. */
|
|
92
|
+
deprecated?: boolean
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
// ============================================================================
|
|
@@ -261,6 +276,10 @@ export interface Platform {
|
|
|
261
276
|
name: string
|
|
262
277
|
introducedAt: string
|
|
263
278
|
beta?: boolean
|
|
279
|
+
deprecated?: boolean
|
|
280
|
+
deprecatedAt?: string
|
|
281
|
+
message?: string
|
|
282
|
+
unavailable?: boolean
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
/**
|
|
@@ -297,6 +316,8 @@ export interface AppleDocJSON {
|
|
|
297
316
|
|
|
298
317
|
// Content
|
|
299
318
|
abstract?: Array<{ text: string; type: string }>
|
|
319
|
+
/** DocC deprecation notice shown at the top of deprecated symbol pages. */
|
|
320
|
+
deprecationSummary?: ContentItem[]
|
|
300
321
|
sections?: ContentItem[]
|
|
301
322
|
|
|
302
323
|
// Primary content sections
|
package/src/lib/url.ts
CHANGED
|
@@ -53,3 +53,38 @@ export function isValidAppleDocUrl(url: string): boolean {
|
|
|
53
53
|
|
|
54
54
|
return url.startsWith(APPLE_DOC_BASE_URL)
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derives a human-readable title from the last component of a documentation
|
|
59
|
+
* path or `doc://` identifier.
|
|
60
|
+
*
|
|
61
|
+
* @param identifier - The identifier or path (e.g., `doc://com.apple.SwiftUI/documentation/SwiftUI/View/body-8kl5o`)
|
|
62
|
+
* @returns The title, with any disambiguation suffix removed (e.g., `body`)
|
|
63
|
+
*/
|
|
64
|
+
export function extractTitleFromIdentifier(identifier: string): string {
|
|
65
|
+
const parts = identifier.split("/")
|
|
66
|
+
const lastPart = parts[parts.length - 1]
|
|
67
|
+
|
|
68
|
+
// Handle disambiguation suffixes (e.g., "body-8kl5o" -> "body", "init(exactly:)-63925" -> "init(exactly:)")
|
|
69
|
+
const disambiguationMatch = lastPart.match(/^(.+?)(?:-\w+)?$/)
|
|
70
|
+
if (disambiguationMatch) {
|
|
71
|
+
const baseName = disambiguationMatch[1]
|
|
72
|
+
|
|
73
|
+
// If it looks like a method signature (contains parentheses), preserve it
|
|
74
|
+
if (baseName.includes("(") && baseName.includes(")")) {
|
|
75
|
+
return baseName
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// For simple identifiers, convert camelCase to readable format
|
|
79
|
+
return baseName
|
|
80
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
81
|
+
.replace(/\s+/g, " ")
|
|
82
|
+
.trim()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback: convert camelCase to readable format
|
|
86
|
+
return lastPart
|
|
87
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
88
|
+
.replace(/\s+/g, " ")
|
|
89
|
+
.trim()
|
|
90
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Bot Auth (IETF Web Bot Auth working group) support.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Publish a JWKS at `/.well-known/http-message-signatures-directory`
|
|
7
|
+
* so receiving sites can look up the public key(s)
|
|
8
|
+
* used to verify our signatures.
|
|
9
|
+
* 2. Sign the requests sosumi.ai makes to external Swift-DocC hosts
|
|
10
|
+
* with RFC 9421 HTTP Message Signatures,
|
|
11
|
+
* attaching `Signature-Agent`, `Signature-Input`, and `Signature` headers
|
|
12
|
+
* so those sites can verify the traffic comes from us.
|
|
13
|
+
*
|
|
14
|
+
* The Ed25519 private key is provided out-of-band
|
|
15
|
+
* as the `WEB_BOT_AUTH_KEY` secret (a JSON Web Key).
|
|
16
|
+
* The matching public key served in the directory is derived from it,
|
|
17
|
+
* so no key material lives in the repository.
|
|
18
|
+
*
|
|
19
|
+
* See https://datatracker.ietf.org/wg/webbotauth/about/
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
directoryResponseHeaders,
|
|
24
|
+
HTTP_MESSAGE_SIGNATURES_DIRECTORY,
|
|
25
|
+
jwkToKeyID,
|
|
26
|
+
MediaType,
|
|
27
|
+
type Signer,
|
|
28
|
+
signatureHeaders,
|
|
29
|
+
} from "web-bot-auth"
|
|
30
|
+
import { helpers, signerFromJWK } from "web-bot-auth/crypto"
|
|
31
|
+
|
|
32
|
+
export interface WebBotAuthEnv {
|
|
33
|
+
/**
|
|
34
|
+
* Ed25519 private key as a JSON Web Key (JSON string).
|
|
35
|
+
* Provided as a secret.
|
|
36
|
+
*/
|
|
37
|
+
WEB_BOT_AUTH_KEY?: string
|
|
38
|
+
/** Origin advertised in the `Signature-Agent` header and hosting the directory. */
|
|
39
|
+
SIGNATURE_AGENT?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Path of the published key directory. */
|
|
43
|
+
export const DIRECTORY_PATH = HTTP_MESSAGE_SIGNATURES_DIRECTORY
|
|
44
|
+
|
|
45
|
+
/** Content type for the key directory response. */
|
|
46
|
+
export const DIRECTORY_MEDIA_TYPE = MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY
|
|
47
|
+
|
|
48
|
+
const DEFAULT_SIGNATURE_AGENT = "https://sosumi.ai"
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long an outbound request signature stays valid.
|
|
52
|
+
* Short, to limit replay.
|
|
53
|
+
*/
|
|
54
|
+
const OUTBOUND_SIGNATURE_TTL_MS = 5 * 60 * 1000
|
|
55
|
+
|
|
56
|
+
/** How long the directory self-signature stays valid. */
|
|
57
|
+
const DIRECTORY_SIGNATURE_TTL_MS = 60 * 60 * 1000
|
|
58
|
+
|
|
59
|
+
/** A public JSON Web Key as published in the directory (RFC 8037 Ed25519). */
|
|
60
|
+
export interface DirectoryKey {
|
|
61
|
+
kty?: string
|
|
62
|
+
crv?: string
|
|
63
|
+
x?: string
|
|
64
|
+
kid: string
|
|
65
|
+
use: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface WebBotAuthConfig {
|
|
69
|
+
signer: Signer
|
|
70
|
+
publicKey: DirectoryKey
|
|
71
|
+
agent: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cached, parsed configuration.
|
|
76
|
+
* The signing key is application-global (the same for every request),
|
|
77
|
+
* so it is memoized across requests in the isolate
|
|
78
|
+
* and only rebuilt when the underlying secret or agent changes.
|
|
79
|
+
*/
|
|
80
|
+
let cache: { key: string; agent: string; config: Promise<WebBotAuthConfig> } | null = null
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Prime the signing configuration from the environment.
|
|
84
|
+
* Call this once per request,
|
|
85
|
+
* before any outbound fetch or directory response is produced.
|
|
86
|
+
*/
|
|
87
|
+
export function configureWebBotAuth(env: WebBotAuthEnv): void {
|
|
88
|
+
const key = env.WEB_BOT_AUTH_KEY?.trim()
|
|
89
|
+
if (!key) {
|
|
90
|
+
cache = null
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const agent = env.SIGNATURE_AGENT?.trim() || DEFAULT_SIGNATURE_AGENT
|
|
95
|
+
if (cache && cache.key === key && cache.agent === agent) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
cache = { key, agent, config: buildConfig(key, agent) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Ed25519PrivateJwk {
|
|
103
|
+
kty: "OKP"
|
|
104
|
+
crv: "Ed25519"
|
|
105
|
+
x: string
|
|
106
|
+
d: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse and validate the signing secret as an Ed25519 OKP private JWK.
|
|
111
|
+
* Fails closed on anything malformed
|
|
112
|
+
* so a misconfigured `WEB_BOT_AUTH_KEY` never yields a broken directory key
|
|
113
|
+
* or unverifiable signatures.
|
|
114
|
+
*/
|
|
115
|
+
function parseSigningJwk(key: string): Ed25519PrivateJwk {
|
|
116
|
+
let jwk: JsonWebKey
|
|
117
|
+
try {
|
|
118
|
+
jwk = JSON.parse(key) as JsonWebKey
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new Error(`WEB_BOT_AUTH_KEY is not valid JSON: ${(error as Error).message}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
jwk.kty !== "OKP" ||
|
|
125
|
+
jwk.crv !== "Ed25519" ||
|
|
126
|
+
typeof jwk.x !== "string" ||
|
|
127
|
+
jwk.x.length === 0 ||
|
|
128
|
+
typeof jwk.d !== "string" ||
|
|
129
|
+
jwk.d.length === 0
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"WEB_BOT_AUTH_KEY must be an Ed25519 private JSON Web Key (kty=OKP, crv=Ed25519, with x and d).",
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function buildConfig(key: string, agent: string): Promise<WebBotAuthConfig> {
|
|
140
|
+
const jwk = parseSigningJwk(key)
|
|
141
|
+
const signer = await signerFromJWK(jwk)
|
|
142
|
+
const kid = await jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE)
|
|
143
|
+
|
|
144
|
+
// Publish only the public half of the key (never the `d` parameter).
|
|
145
|
+
const publicKey: DirectoryKey = {
|
|
146
|
+
kty: jwk.kty,
|
|
147
|
+
crv: jwk.crv,
|
|
148
|
+
x: jwk.x,
|
|
149
|
+
kid,
|
|
150
|
+
use: "sig",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { signer, publicKey, agent }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function currentConfig(): Promise<WebBotAuthConfig | null> {
|
|
157
|
+
const entry = cache
|
|
158
|
+
if (!entry) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return await entry.config
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error("web-bot-auth: failed to load signing key", error)
|
|
166
|
+
// Drop the failed config so the next request rebuilds it,
|
|
167
|
+
// rather than serving the cached rejection until the isolate restarts.
|
|
168
|
+
// Guard against clobbering a newer config set by a concurrent reconfigure.
|
|
169
|
+
if (cache === entry) {
|
|
170
|
+
cache = null
|
|
171
|
+
}
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Serialize a value as an RFC 8941 structured-field string (a quoted string). */
|
|
177
|
+
function structuredString(value: string): string {
|
|
178
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface DirectoryResponse {
|
|
182
|
+
body: { keys: DirectoryKey[] }
|
|
183
|
+
headers: Record<string, string>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Build the signed key directory response,
|
|
188
|
+
* or `null` when no key is configured.
|
|
189
|
+
*
|
|
190
|
+
* The response body is a JWKS
|
|
191
|
+
* and carries `Signature`/`Signature-Input` headers
|
|
192
|
+
* signing over `@authority` with `tag="http-message-signatures-directory"`,
|
|
193
|
+
* demonstrating control of the published key.
|
|
194
|
+
*/
|
|
195
|
+
export async function webBotAuthDirectory(requestUrl: string): Promise<DirectoryResponse | null> {
|
|
196
|
+
const config = await currentConfig()
|
|
197
|
+
if (!config) {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const created = new Date()
|
|
202
|
+
const expires = new Date(created.getTime() + DIRECTORY_SIGNATURE_TTL_MS)
|
|
203
|
+
const message = {
|
|
204
|
+
response: { status: 200, headers: {} as Record<string, string> },
|
|
205
|
+
request: { method: "GET", url: requestUrl, headers: {} as Record<string, string> },
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const signature = await directoryResponseHeaders(message, [config.signer], { created, expires })
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
body: { keys: [config.publicKey] },
|
|
212
|
+
headers: {
|
|
213
|
+
"Content-Type": DIRECTORY_MEDIA_TYPE,
|
|
214
|
+
Signature: signature.Signature,
|
|
215
|
+
"Signature-Input": signature["Signature-Input"],
|
|
216
|
+
"Cache-Control": "public, max-age=300, s-maxage=300",
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Produce Web Bot Auth headers for an outbound request,
|
|
223
|
+
* or `{}` when no key is configured (e.g. local development or the CLI).
|
|
224
|
+
* Signing failures degrade to an unsigned request
|
|
225
|
+
* rather than breaking the fetch.
|
|
226
|
+
*/
|
|
227
|
+
export async function webBotAuthHeaders(
|
|
228
|
+
method: string,
|
|
229
|
+
url: string | URL,
|
|
230
|
+
): Promise<Record<string, string>> {
|
|
231
|
+
const config = await currentConfig()
|
|
232
|
+
if (!config) {
|
|
233
|
+
return {}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const agent = structuredString(config.agent)
|
|
238
|
+
// `signature-agent` must be present on the message
|
|
239
|
+
// so it is covered by the signature;
|
|
240
|
+
// the same value is returned for the outbound request.
|
|
241
|
+
const headers = new Headers()
|
|
242
|
+
headers.set("Signature-Agent", agent)
|
|
243
|
+
|
|
244
|
+
const created = new Date()
|
|
245
|
+
const expires = new Date(created.getTime() + OUTBOUND_SIGNATURE_TTL_MS)
|
|
246
|
+
const signature = await signatureHeaders(
|
|
247
|
+
{ method, url: url.toString(), headers },
|
|
248
|
+
config.signer,
|
|
249
|
+
{ created, expires },
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"Signature-Agent": agent,
|
|
254
|
+
"Signature-Input": signature["Signature-Input"],
|
|
255
|
+
Signature: signature.Signature,
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error("web-bot-auth: failed to sign outbound request", error)
|
|
259
|
+
return {}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { zodToJsonSchema } from "zod-to-json-schema"
|
|
3
|
+
|
|
4
|
+
import { TOOL_DEFINITIONS } from "./mcp"
|
|
5
|
+
|
|
6
|
+
export interface WebMcpToolManifest {
|
|
7
|
+
name: string
|
|
8
|
+
title: string
|
|
9
|
+
description: string
|
|
10
|
+
inputSchema: Record<string, unknown>
|
|
11
|
+
readOnly: boolean
|
|
12
|
+
http: {
|
|
13
|
+
path?: string
|
|
14
|
+
pathFrom?: string
|
|
15
|
+
pathPrefix?: string
|
|
16
|
+
query?: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildWebMcpManifest(): WebMcpToolManifest[] {
|
|
21
|
+
return Object.values(TOOL_DEFINITIONS).map((def) => {
|
|
22
|
+
const jsonSchema = zodToJsonSchema(z.object(def.inputSchema), {
|
|
23
|
+
$refStrategy: "none",
|
|
24
|
+
})
|
|
25
|
+
const { $schema: _, ...inputSchema } = jsonSchema as Record<string, unknown> & {
|
|
26
|
+
$schema?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name: def.name,
|
|
31
|
+
title: def.title,
|
|
32
|
+
description: def.description,
|
|
33
|
+
inputSchema,
|
|
34
|
+
readOnly: def.annotations.readOnlyHint,
|
|
35
|
+
http: def.http,
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
package/wrangler.jsonc
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"assets": {
|
|
18
18
|
"binding": "ASSETS",
|
|
19
19
|
"directory": "./public",
|
|
20
|
-
"run_worker_first": ["/", "/.well-known/agent-skills/*"]
|
|
20
|
+
"run_worker_first": ["/", "/.well-known/agent-card.json", "/.well-known/agent-skills/*"]
|
|
21
21
|
},
|
|
22
22
|
"observability": {
|
|
23
23
|
"enabled": true
|