@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/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 (May 2026)
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
- // - The current backend is a plain JSON POST API on Apple's MSC infrastructure
25
- const APPLE_SEARCH_SERVICE_URL = "https://devintserv.msc.sbz.apple.com/api/v1/search"
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/json",
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
- const data = await readSearchResponseJson(response)
69
- const rawResults = Array.isArray(data.results) ? (data.results as unknown[]) : []
70
- return extractSearchResults(rawResults)
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
- async function readSearchResponseJson(response: Response): Promise<JsonRecord> {
130
+ function parseJson(text: string): unknown {
74
131
  try {
75
- const data = await response.json()
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
- return result ? [result] : []
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
- if (!isJsonRecord(item)) {
167
+ const record = unwrapResultValue(item)
168
+ if (!record || !isJsonRecord(record.metadata)) {
91
169
  return null
92
170
  }
93
171
 
94
- const documentation = extractMetadataRecord(item.documentation)
95
- if (documentation) {
96
- const title = stringValue(documentation.title)
97
- const url = stringValue(documentation.permalink)
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(documentation.description) ?? "",
106
- breadcrumbs: splitHierarchy(stringValue(documentation.hierarchy)),
107
- tags: compactStrings([stringValue(documentation.kind)]),
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
- const developer = extractMetadataRecord(item.developer)
113
- if (developer) {
114
- const title = firstString(developer.titles)
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(developer.descriptions) ?? "",
124
- breadcrumbs: compactStrings([firstString(developer.projectNames)]),
201
+ description: firstString(metadata.descriptions) ?? "",
202
+ breadcrumbs: compactStrings([firstString(metadata.projectNames)]),
125
203
  tags: compactStrings([
126
- firstString(developer.itemTypes),
127
- firstString(developer.deliveryLanguageCodes),
204
+ firstString(metadata.itemTypes),
205
+ firstString(metadata.deliveryLanguageCodes),
128
206
  ]),
129
- type: (firstString(developer.itemTypes) ?? "developer").toLowerCase(),
207
+ type: (firstString(metadata.itemTypes) ?? "developer").toLowerCase(),
130
208
  }
131
209
  }
132
210
 
133
- const devsite = extractMetadataRecord(item.devsite)
134
- if (devsite) {
135
- const title = stringValue(devsite.title)
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(devsite.description) ?? "",
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
- function extractMetadataRecord(container: unknown): JsonRecord | null {
173
- if (!isJsonRecord(container)) {
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
- const metadata = container.metadata
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