@nshipster/sosumi 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/package.json +1 -1
- package/public/SKILL.md +1 -0
- package/public/index.html +52 -5
- package/public/llms.txt +17 -2
- package/public/page.css +104 -0
- package/public/privacy.html +111 -0
- package/public/robots.txt +2 -0
- package/public/sitemap.xml +9 -0
- package/public/support.html +65 -0
- package/public/terms.html +73 -0
- package/src/index.ts +205 -10
- package/src/lib/a2a.ts +676 -5
- package/src/lib/ard.ts +77 -0
- package/src/lib/auth.ts +105 -0
- package/src/lib/did.ts +40 -0
- package/src/lib/hig/render.ts +1 -1
- package/src/lib/hig/types.ts +1 -1
- package/src/lib/identity.ts +4 -0
- package/src/lib/mcp.ts +13 -2
- package/src/lib/reference/render.ts +47 -2
- package/src/lib/types.ts +11 -0
- package/src/lib/webbotauth.ts +19 -120
- package/wrangler.jsonc +14 -2
package/src/lib/ard.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { PUBLISHER_DID, PUBLISHER_DOMAIN } from "./identity"
|
|
2
|
+
import { SKILL_NAME } from "./skill"
|
|
3
|
+
|
|
4
|
+
export const AI_CATALOG_MEDIA_TYPE = "application/ai-catalog+json"
|
|
5
|
+
|
|
6
|
+
export interface AiCatalogEntry {
|
|
7
|
+
identifier: string
|
|
8
|
+
displayName: string
|
|
9
|
+
type: string
|
|
10
|
+
url: string
|
|
11
|
+
description: string
|
|
12
|
+
representativeQueries: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AiCatalog {
|
|
16
|
+
specVersion: string
|
|
17
|
+
host: {
|
|
18
|
+
displayName: string
|
|
19
|
+
identifier: string
|
|
20
|
+
documentationUrl: string
|
|
21
|
+
}
|
|
22
|
+
entries: AiCatalogEntry[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build the ARD capability manifest for the origin serving the request. */
|
|
26
|
+
export function buildAiCatalog(origin: string): AiCatalog {
|
|
27
|
+
return {
|
|
28
|
+
specVersion: "1.0",
|
|
29
|
+
host: {
|
|
30
|
+
displayName: PUBLISHER_DOMAIN,
|
|
31
|
+
identifier: PUBLISHER_DID,
|
|
32
|
+
documentationUrl: `${origin}/`,
|
|
33
|
+
},
|
|
34
|
+
entries: [
|
|
35
|
+
{
|
|
36
|
+
identifier: `urn:air:${PUBLISHER_DOMAIN}:server:mcp`,
|
|
37
|
+
displayName: "Sosumi MCP Server",
|
|
38
|
+
type: "application/mcp-server-card+json",
|
|
39
|
+
url: `${origin}/.well-known/mcp/server-card.json`,
|
|
40
|
+
description:
|
|
41
|
+
"Searches and fetches Apple Developer documentation, Human Interface Guidelines, WWDC transcripts, and public Swift-DocC pages.",
|
|
42
|
+
representativeQueries: [
|
|
43
|
+
"Search Apple Developer documentation for URLSession",
|
|
44
|
+
"Fetch the SwiftUI View documentation as Markdown",
|
|
45
|
+
"Get the transcript for a WWDC session",
|
|
46
|
+
"Read a public Swift-DocC documentation page",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
identifier: `urn:air:${PUBLISHER_DOMAIN}:agent:documentation`,
|
|
51
|
+
displayName: "Sosumi Documentation Agent",
|
|
52
|
+
type: "application/a2a-agent-card+json",
|
|
53
|
+
url: `${origin}/.well-known/agent-card.json`,
|
|
54
|
+
description:
|
|
55
|
+
"An A2A agent for searching and fetching Apple and Swift-DocC documentation as Markdown.",
|
|
56
|
+
representativeQueries: [
|
|
57
|
+
"Find Apple documentation about Swift actors",
|
|
58
|
+
"Fetch /documentation/swiftui/view as Markdown",
|
|
59
|
+
"Fetch the transcript for /videos/play/wwdc2021/10133",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
identifier: `urn:air:${PUBLISHER_DOMAIN}:skill:${SKILL_NAME}`,
|
|
64
|
+
displayName: "Sosumi Agent Skill",
|
|
65
|
+
type: 'text/markdown; profile="urn:air:agent-skills"',
|
|
66
|
+
url: `${origin}/.well-known/agent-skills/${SKILL_NAME}/SKILL.md`,
|
|
67
|
+
description:
|
|
68
|
+
"Instructions for using Sosumi to research Apple APIs, design guidance, videos, and Swift-DocC documentation.",
|
|
69
|
+
representativeQueries: [
|
|
70
|
+
"Research an Apple framework API before writing code",
|
|
71
|
+
"Look up current SwiftUI documentation",
|
|
72
|
+
"Find Apple's design guidance for an interface",
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jwkToKeyID, type Signer } from "web-bot-auth"
|
|
2
|
+
import { helpers, signerFromJWK } from "web-bot-auth/crypto"
|
|
3
|
+
|
|
4
|
+
/** The public half of the application's Ed25519 signing key. */
|
|
5
|
+
export interface PublicSigningKey {
|
|
6
|
+
kty: "OKP"
|
|
7
|
+
crv: "Ed25519"
|
|
8
|
+
x: string
|
|
9
|
+
kid: string
|
|
10
|
+
use: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SigningKeyConfig {
|
|
14
|
+
signer: Signer
|
|
15
|
+
publicKey: PublicSigningKey
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Ed25519PrivateJwk {
|
|
19
|
+
kty: "OKP"
|
|
20
|
+
crv: "Ed25519"
|
|
21
|
+
x: string
|
|
22
|
+
d: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cache: { key: string; config: Promise<SigningKeyConfig> } | null = null
|
|
26
|
+
|
|
27
|
+
/** Configure the application-wide signing key from a serialized private JWK. */
|
|
28
|
+
export function configureSigningKey(serializedKey?: string): void {
|
|
29
|
+
const key = serializedKey?.trim()
|
|
30
|
+
if (!key) {
|
|
31
|
+
cache = null
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (cache?.key === key) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
cache = { key, config: buildSigningKeyConfig(key) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Return the configured signer and public key, or null if unavailable. */
|
|
43
|
+
export async function currentSigningKey(): Promise<SigningKeyConfig | null> {
|
|
44
|
+
const entry = cache
|
|
45
|
+
if (!entry) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
return await entry.config
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("auth: failed to load signing key", error)
|
|
53
|
+
if (cache === entry) {
|
|
54
|
+
cache = null
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return a copy of the configured public signing key, if available. */
|
|
61
|
+
export async function currentPublicSigningKey(): Promise<PublicSigningKey | null> {
|
|
62
|
+
const config = await currentSigningKey()
|
|
63
|
+
return config ? { ...config.publicKey } : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseSigningJwk(key: string): Ed25519PrivateJwk {
|
|
67
|
+
let jwk: JsonWebKey
|
|
68
|
+
try {
|
|
69
|
+
jwk = JSON.parse(key) as JsonWebKey
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new Error(`WEB_BOT_AUTH_KEY is not valid JSON: ${(error as Error).message}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
jwk.kty !== "OKP" ||
|
|
76
|
+
jwk.crv !== "Ed25519" ||
|
|
77
|
+
typeof jwk.x !== "string" ||
|
|
78
|
+
jwk.x.length === 0 ||
|
|
79
|
+
typeof jwk.d !== "string" ||
|
|
80
|
+
jwk.d.length === 0
|
|
81
|
+
) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"WEB_BOT_AUTH_KEY must be an Ed25519 private JSON Web Key (kty=OKP, crv=Ed25519, with x and d).",
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function buildSigningKeyConfig(key: string): Promise<SigningKeyConfig> {
|
|
91
|
+
const jwk = parseSigningJwk(key)
|
|
92
|
+
const signer = await signerFromJWK(jwk)
|
|
93
|
+
const kid = await jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
signer,
|
|
97
|
+
publicKey: {
|
|
98
|
+
kty: jwk.kty,
|
|
99
|
+
crv: jwk.crv,
|
|
100
|
+
x: jwk.x,
|
|
101
|
+
kid,
|
|
102
|
+
use: "sig",
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/lib/did.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { currentPublicSigningKey } from "./auth"
|
|
2
|
+
import { PUBLISHER_DID, PUBLISHER_ORIGIN } from "./identity"
|
|
3
|
+
|
|
4
|
+
export const DID_DOCUMENT_MEDIA_TYPE = "application/did+ld+json"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build the did:web document for Sosumi's stable publisher identity.
|
|
8
|
+
* The verification method reuses the public half of the application signing
|
|
9
|
+
* key, which is configured out of band and never committed. Returns `null`
|
|
10
|
+
* rather than publishing an unverifiable identity when that key is unavailable.
|
|
11
|
+
*/
|
|
12
|
+
export async function buildDidDocument() {
|
|
13
|
+
const publicKey = await currentPublicSigningKey()
|
|
14
|
+
if (!publicKey) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const verificationMethodId = `${PUBLISHER_DID}#${publicKey.kid}`
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
|
|
22
|
+
id: PUBLISHER_DID,
|
|
23
|
+
alsoKnownAs: [PUBLISHER_ORIGIN],
|
|
24
|
+
verificationMethod: [
|
|
25
|
+
{
|
|
26
|
+
id: verificationMethodId,
|
|
27
|
+
type: "JsonWebKey2020",
|
|
28
|
+
controller: PUBLISHER_DID,
|
|
29
|
+
publicKeyJwk: {
|
|
30
|
+
kty: publicKey.kty,
|
|
31
|
+
crv: publicKey.crv,
|
|
32
|
+
x: publicKey.x,
|
|
33
|
+
kid: publicKey.kid,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
authentication: [verificationMethodId],
|
|
38
|
+
assertionMethod: [verificationMethodId],
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/lib/hig/render.ts
CHANGED
|
@@ -516,7 +516,7 @@ function renderHIGTocItems(items: HIGTocItem[], headingLevel: number): string {
|
|
|
516
516
|
let markdown = ""
|
|
517
517
|
|
|
518
518
|
for (const item of items) {
|
|
519
|
-
if (item.type === "module" || item.type === "symbol") {
|
|
519
|
+
if (item.type === "module" || item.type === "symbol" || item.type === "collection") {
|
|
520
520
|
// Ensure blank line before heading when preceding content was a list
|
|
521
521
|
if (markdown && !markdown.endsWith("\n\n")) {
|
|
522
522
|
markdown += "\n"
|
package/src/lib/hig/types.ts
CHANGED
package/src/lib/mcp.ts
CHANGED
|
@@ -4,16 +4,27 @@ import { z } from "zod"
|
|
|
4
4
|
import type { ExternalPolicyEnv } from "./external"
|
|
5
5
|
import { fetchExternalDocumentationMarkdown } from "./external"
|
|
6
6
|
import { fetchHIGPageData, renderHIGFromJSON } from "./hig"
|
|
7
|
+
import { PUBLISHER_DOMAIN } from "./identity"
|
|
7
8
|
import { fetchJSONData, renderFromJSON } from "./reference"
|
|
8
9
|
import { searchAppleDeveloperDocs } from "./search"
|
|
9
10
|
import { generateAppleDocUrl, normalizeDocumentationPath } from "./url"
|
|
10
11
|
import { fetchVideoTranscriptMarkdown } from "./video"
|
|
11
12
|
|
|
12
13
|
export const MCP_SERVER_INFO = {
|
|
13
|
-
name:
|
|
14
|
+
name: PUBLISHER_DOMAIN,
|
|
14
15
|
version: "1.0.0",
|
|
15
16
|
} as const
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Guidance for clients, kept out of the documentation itself
|
|
20
|
+
* so that fetched pages stay 1:1 with Apple's content.
|
|
21
|
+
*/
|
|
22
|
+
export const MCP_SERVER_INSTRUCTIONS =
|
|
23
|
+
"Sosumi returns Apple's documentation as Markdown. " +
|
|
24
|
+
'The conformance conditions in Apple\'s data, such as "Conforms when `Element` conforms to `Hashable`.", ' +
|
|
25
|
+
"can be missing or wrong. " +
|
|
26
|
+
"When a condition matters, read the declaration in the module's `.swiftinterface` file in the SDK."
|
|
27
|
+
|
|
17
28
|
export interface ToolHttpBinding {
|
|
18
29
|
path?: string
|
|
19
30
|
pathFrom?: string
|
|
@@ -114,7 +125,7 @@ export const TOOL_DEFINITIONS = {
|
|
|
114
125
|
} satisfies Record<string, ToolMeta>
|
|
115
126
|
|
|
116
127
|
export function createMcpServer(externalPolicyEnv: ExternalPolicyEnv = {}) {
|
|
117
|
-
const server = new McpServer(MCP_SERVER_INFO)
|
|
128
|
+
const server = new McpServer(MCP_SERVER_INFO, { instructions: MCP_SERVER_INSTRUCTIONS })
|
|
118
129
|
|
|
119
130
|
const search = TOOL_DEFINITIONS.searchAppleDocumentation
|
|
120
131
|
server.registerTool(
|
|
@@ -614,7 +614,13 @@ function renderRelationships(
|
|
|
614
614
|
markdown += `## ${rel.title}\n\n`
|
|
615
615
|
for (const id of rel.identifiers) {
|
|
616
616
|
const { title, url } = resolveLink(id, variants, references, externalOrigin)
|
|
617
|
-
|
|
617
|
+
const condition = renderCondition(
|
|
618
|
+
references?.[id],
|
|
619
|
+
"conformancePrefix",
|
|
620
|
+
references,
|
|
621
|
+
externalOrigin,
|
|
622
|
+
)
|
|
623
|
+
markdown += `- [${title}](${url})${condition ? ` ${condition}` : ""}\n`
|
|
618
624
|
}
|
|
619
625
|
markdown += "\n"
|
|
620
626
|
}
|
|
@@ -623,6 +629,30 @@ function renderRelationships(
|
|
|
623
629
|
return markdown
|
|
624
630
|
}
|
|
625
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Render the condition on a reference, or an empty string when it has none.
|
|
634
|
+
* Relationship sections use the conformance prefix
|
|
635
|
+
* ("Conforms when `Element` conforms to `Hashable`."),
|
|
636
|
+
* and topic and see also sections use the availability prefix
|
|
637
|
+
* ("Available when `Element` conforms to `Hashable`.").
|
|
638
|
+
*/
|
|
639
|
+
function renderCondition(
|
|
640
|
+
reference: ContentItem | undefined,
|
|
641
|
+
prefixKey: "conformancePrefix" | "availabilityPrefix",
|
|
642
|
+
references?: Record<string, ContentItem>,
|
|
643
|
+
externalOrigin?: string,
|
|
644
|
+
): string {
|
|
645
|
+
const constraints = reference?.conformance?.constraints
|
|
646
|
+
if (!constraints?.length) {
|
|
647
|
+
return ""
|
|
648
|
+
}
|
|
649
|
+
const prefix = reference?.conformance?.[prefixKey] ?? []
|
|
650
|
+
const inlineContent = prefix.length
|
|
651
|
+
? [...prefix, { type: "text", text: " " }, ...constraints]
|
|
652
|
+
: constraints
|
|
653
|
+
return renderInlineContent(inlineContent, references, 0, externalOrigin).trim()
|
|
654
|
+
}
|
|
655
|
+
|
|
626
656
|
/**
|
|
627
657
|
* Render topic sections
|
|
628
658
|
*/
|
|
@@ -652,6 +682,15 @@ function renderTopicSections(
|
|
|
652
682
|
if (abstract) {
|
|
653
683
|
markdown += ` ${abstract}`
|
|
654
684
|
}
|
|
685
|
+
const condition = renderCondition(
|
|
686
|
+
references?.[id],
|
|
687
|
+
"availabilityPrefix",
|
|
688
|
+
references,
|
|
689
|
+
externalOrigin,
|
|
690
|
+
)
|
|
691
|
+
if (condition) {
|
|
692
|
+
markdown += ` ${condition}`
|
|
693
|
+
}
|
|
655
694
|
markdown += "\n"
|
|
656
695
|
}
|
|
657
696
|
markdown += "\n"
|
|
@@ -730,7 +769,13 @@ function renderSeeAlso(
|
|
|
730
769
|
markdown += `## ${section.title}\n\n`
|
|
731
770
|
for (const id of section.identifiers) {
|
|
732
771
|
const { title, url } = resolveLink(id, variants, references, externalOrigin)
|
|
733
|
-
|
|
772
|
+
const condition = renderCondition(
|
|
773
|
+
references?.[id],
|
|
774
|
+
"availabilityPrefix",
|
|
775
|
+
references,
|
|
776
|
+
externalOrigin,
|
|
777
|
+
)
|
|
778
|
+
markdown += `- [${title}](${url})${condition ? ` ${condition}` : ""}\n`
|
|
734
779
|
}
|
|
735
780
|
markdown += "\n"
|
|
736
781
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -90,6 +90,17 @@ export interface ContentItem {
|
|
|
90
90
|
|
|
91
91
|
/** Present on reference entries for deprecated symbols. */
|
|
92
92
|
deprecated?: boolean
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Present on reference entries for conditional conformances and members
|
|
96
|
+
* (e.g. "Conforms when `Element` conforms to `Hashable`."
|
|
97
|
+
* or "Available when `Element` conforms to `Hashable`.").
|
|
98
|
+
*/
|
|
99
|
+
conformance?: {
|
|
100
|
+
conformancePrefix?: ContentItem[]
|
|
101
|
+
availabilityPrefix?: ContentItem[]
|
|
102
|
+
constraints?: ContentItem[]
|
|
103
|
+
}
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
// ============================================================================
|
package/src/lib/webbotauth.ts
CHANGED
|
@@ -22,12 +22,11 @@
|
|
|
22
22
|
import {
|
|
23
23
|
directoryResponseHeaders,
|
|
24
24
|
HTTP_MESSAGE_SIGNATURES_DIRECTORY,
|
|
25
|
-
jwkToKeyID,
|
|
26
25
|
MediaType,
|
|
27
|
-
type Signer,
|
|
28
26
|
signatureHeaders,
|
|
29
27
|
} from "web-bot-auth"
|
|
30
|
-
import {
|
|
28
|
+
import { configureSigningKey, currentSigningKey, type PublicSigningKey } from "./auth"
|
|
29
|
+
import { PUBLISHER_ORIGIN } from "./identity"
|
|
31
30
|
|
|
32
31
|
export interface WebBotAuthEnv {
|
|
33
32
|
/**
|
|
@@ -45,7 +44,7 @@ export const DIRECTORY_PATH = HTTP_MESSAGE_SIGNATURES_DIRECTORY
|
|
|
45
44
|
/** Content type for the key directory response. */
|
|
46
45
|
export const DIRECTORY_MEDIA_TYPE = MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY
|
|
47
46
|
|
|
48
|
-
const DEFAULT_SIGNATURE_AGENT =
|
|
47
|
+
const DEFAULT_SIGNATURE_AGENT = PUBLISHER_ORIGIN
|
|
49
48
|
|
|
50
49
|
/**
|
|
51
50
|
* How long an outbound request signature stays valid.
|
|
@@ -56,28 +55,7 @@ const OUTBOUND_SIGNATURE_TTL_MS = 5 * 60 * 1000
|
|
|
56
55
|
/** How long the directory self-signature stays valid. */
|
|
57
56
|
const DIRECTORY_SIGNATURE_TTL_MS = 60 * 60 * 1000
|
|
58
57
|
|
|
59
|
-
|
|
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
|
|
58
|
+
let signatureAgent: string | null = null
|
|
81
59
|
|
|
82
60
|
/**
|
|
83
61
|
* Prime the signing configuration from the environment.
|
|
@@ -86,91 +64,8 @@ let cache: { key: string; agent: string; config: Promise<WebBotAuthConfig> } | n
|
|
|
86
64
|
*/
|
|
87
65
|
export function configureWebBotAuth(env: WebBotAuthEnv): void {
|
|
88
66
|
const key = env.WEB_BOT_AUTH_KEY?.trim()
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
}
|
|
67
|
+
configureSigningKey(key)
|
|
68
|
+
signatureAgent = key ? env.SIGNATURE_AGENT?.trim() || DEFAULT_SIGNATURE_AGENT : null
|
|
174
69
|
}
|
|
175
70
|
|
|
176
71
|
/** Serialize a value as an RFC 8941 structured-field string (a quoted string). */
|
|
@@ -179,7 +74,7 @@ function structuredString(value: string): string {
|
|
|
179
74
|
}
|
|
180
75
|
|
|
181
76
|
export interface DirectoryResponse {
|
|
182
|
-
body: { keys:
|
|
77
|
+
body: { keys: PublicSigningKey[] }
|
|
183
78
|
headers: Record<string, string>
|
|
184
79
|
}
|
|
185
80
|
|
|
@@ -193,8 +88,8 @@ export interface DirectoryResponse {
|
|
|
193
88
|
* demonstrating control of the published key.
|
|
194
89
|
*/
|
|
195
90
|
export async function webBotAuthDirectory(requestUrl: string): Promise<DirectoryResponse | null> {
|
|
196
|
-
const
|
|
197
|
-
if (!
|
|
91
|
+
const signingKey = await currentSigningKey()
|
|
92
|
+
if (!signingKey) {
|
|
198
93
|
return null
|
|
199
94
|
}
|
|
200
95
|
|
|
@@ -205,10 +100,13 @@ export async function webBotAuthDirectory(requestUrl: string): Promise<Directory
|
|
|
205
100
|
request: { method: "GET", url: requestUrl, headers: {} as Record<string, string> },
|
|
206
101
|
}
|
|
207
102
|
|
|
208
|
-
const signature = await directoryResponseHeaders(message, [
|
|
103
|
+
const signature = await directoryResponseHeaders(message, [signingKey.signer], {
|
|
104
|
+
created,
|
|
105
|
+
expires,
|
|
106
|
+
})
|
|
209
107
|
|
|
210
108
|
return {
|
|
211
|
-
body: { keys: [
|
|
109
|
+
body: { keys: [signingKey.publicKey] },
|
|
212
110
|
headers: {
|
|
213
111
|
"Content-Type": DIRECTORY_MEDIA_TYPE,
|
|
214
112
|
Signature: signature.Signature,
|
|
@@ -228,13 +126,14 @@ export async function webBotAuthHeaders(
|
|
|
228
126
|
method: string,
|
|
229
127
|
url: string | URL,
|
|
230
128
|
): Promise<Record<string, string>> {
|
|
231
|
-
const
|
|
232
|
-
|
|
129
|
+
const signingKey = await currentSigningKey()
|
|
130
|
+
const agentValue = signatureAgent
|
|
131
|
+
if (!signingKey || !agentValue) {
|
|
233
132
|
return {}
|
|
234
133
|
}
|
|
235
134
|
|
|
236
135
|
try {
|
|
237
|
-
const agent = structuredString(
|
|
136
|
+
const agent = structuredString(agentValue)
|
|
238
137
|
// `signature-agent` must be present on the message
|
|
239
138
|
// so it is covered by the signature;
|
|
240
139
|
// the same value is returned for the outbound request.
|
|
@@ -245,7 +144,7 @@ export async function webBotAuthHeaders(
|
|
|
245
144
|
const expires = new Date(created.getTime() + OUTBOUND_SIGNATURE_TTL_MS)
|
|
246
145
|
const signature = await signatureHeaders(
|
|
247
146
|
{ method, url: url.toString(), headers },
|
|
248
|
-
|
|
147
|
+
signingKey.signer,
|
|
249
148
|
{ created, expires },
|
|
250
149
|
)
|
|
251
150
|
|
package/wrangler.jsonc
CHANGED
|
@@ -17,12 +17,24 @@
|
|
|
17
17
|
"assets": {
|
|
18
18
|
"binding": "ASSETS",
|
|
19
19
|
"directory": "./public",
|
|
20
|
-
"run_worker_first": [
|
|
20
|
+
"run_worker_first": [
|
|
21
|
+
"/",
|
|
22
|
+
"/.well-known/agent-card.json",
|
|
23
|
+
"/.well-known/agent-skills/*",
|
|
24
|
+
"/.well-known/mcp-registry-auth",
|
|
25
|
+
"/.well-known/security.txt",
|
|
26
|
+
"/message:send",
|
|
27
|
+
"/message:stream",
|
|
28
|
+
"/tasks",
|
|
29
|
+
"/tasks/*",
|
|
30
|
+
"/extendedAgentCard"
|
|
31
|
+
]
|
|
21
32
|
},
|
|
22
33
|
"observability": {
|
|
23
34
|
"enabled": true
|
|
24
35
|
},
|
|
25
36
|
"vars": {
|
|
26
|
-
"NODE_ENV": "production"
|
|
37
|
+
"NODE_ENV": "production",
|
|
38
|
+
"MCP_REGISTRY_PUBLIC_KEY": "sL6l9eXq+RN7+VuAVWZGnSacjsgix0TVW5FDLYlBUpg="
|
|
27
39
|
}
|
|
28
40
|
}
|