@kehto/services 0.5.0 → 0.7.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.
@@ -94,4 +94,4 @@ declare function createNostrCvmTransport(options?: NostrCvmTransportOptions): Cv
94
94
  dispose(): void;
95
95
  };
96
96
 
97
- export { type CvmRelayPool, type NostrCvmTransportOptions, createNostrCvmTransport };
97
+ export { type CvmRelayPool, type CvmSubCloser, type NostrCvmTransportOptions, type NostrEventLike, type NostrFilterLike, createNostrCvmTransport };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cvm-nostr-transport.ts"],"sourcesContent":["/**\n * cvm-nostr-transport.ts — concrete ContextVM transport for NAP-CVM.\n *\n * Implements {@link CvmTransport} over Nostr, exactly as validated against live\n * ContextVM servers (e.g. Relatr):\n *\n * - MCP JSON-RPC messages ride in kind-25910 event `content`.\n * - Requests are CEP-4 gift-wrapped: the inner kind-25910 event is signed with\n * the shell's ephemeral client key, NIP-44-encrypted to the server, and\n * placed in a kind-21059 (ephemeral) / 1059 (regular) wrap signed by a fresh\n * random key, `p`-tagged to the server. Responses arrive the same way,\n * `p`-tagged to the client, and are correlated by the inner JSON-RPC `id`.\n * - Discovery reads kind-11316 (server) + kind-11317 (tools) announcements.\n *\n * Shipped on a separate entry (`@kehto/services/cvm-nostr-transport`) so the\n * `nostr-tools` dependency stays out of the core `@kehto/services` bundle.\n *\n * The client key is ephemeral and shell-owned: napplets never see keys, relay\n * sockets, or NIP-44 material (NAP-CVM §Security).\n *\n * @example\n * ```ts\n * import { createNostrCvmTransport } from '@kehto/services/cvm-nostr-transport';\n * const transport = createNostrCvmTransport({\n * defaultRelays: ['wss://relay.contextvm.org', 'wss://relay2.contextvm.org'],\n * });\n * ```\n */\n\nimport { SimplePool } from 'nostr-tools/pool';\nimport { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';\nimport * as nip44 from 'nostr-tools/nip44';\nimport type { Event as NostrToolsEvent, Filter as NostrToolsFilter } from 'nostr-tools';\n\nimport type { CvmTransport } from './cvm-service.js';\nimport type {\n CvmDiscoverQuery,\n CvmRequestOptions,\n CvmServer,\n CvmServerRef,\n McpMessage,\n} from './cvm-types.js';\n\n/** ContextVM unified transport event kind. */\nconst KIND_CVM = 25910;\n/** CEP-4 gift-wrap kinds: ephemeral (CEP-19) and regular. */\nconst KIND_GIFT_WRAP_EPHEMERAL = 21059;\nconst KIND_GIFT_WRAP_REGULAR = 1059;\n/** CEP-6 announcement kinds. */\nconst KIND_ANNOUNCE_SERVER = 11316;\nconst KIND_ANNOUNCE_TOOLS = 11317;\n\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_DISCOVER_TIMEOUT_MS = 6_000;\nconst MCP_PROTOCOL_VERSION = '2025-11-25';\nconst SEEN_WRAP_LIMIT = 512;\n\n/** Minimal signed Nostr event. */\ninterface NostrEventLike {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/** A Nostr REQ filter (subset). */\ninterface NostrFilterLike {\n kinds?: number[];\n authors?: string[];\n limit?: number;\n ['#p']?: string[];\n [key: string]: unknown;\n}\n\n/** Subscription handle returned by the relay pool. */\ninterface CvmSubCloser {\n close(): void;\n}\n\n/**\n * Minimal relay-pool surface used by this transport — structurally satisfied\n * by `nostr-tools` `SimplePool`. Injectable for testing.\n */\nexport interface CvmRelayPool {\n subscribe(\n relays: string[],\n filter: NostrFilterLike,\n params: { onevent?: (event: NostrEventLike) => void; oneose?: () => void },\n ): CvmSubCloser;\n publish(relays: string[], event: NostrEventLike): unknown;\n}\n\n/** Options for {@link createNostrCvmTransport}. */\nexport interface NostrCvmTransportOptions {\n /** Relays used when a server reference carries no relay hints. */\n defaultRelays?: string[];\n /** Default per-request timeout in milliseconds. */\n timeoutMs?: number;\n /** Whether to CEP-4 gift-wrap requests. Default true (most servers require it). */\n encrypt?: boolean;\n /** Use ephemeral (kind 21059) gift wraps when encrypting. Default true. */\n ephemeralWrap?: boolean;\n /** Relay pool to use. Defaults to a fresh `nostr-tools` `SimplePool`. */\n pool?: CvmRelayPool;\n /** Client secret key (32 bytes). Defaults to a generated ephemeral key. */\n clientSecretKey?: Uint8Array;\n /** Client info advertised during MCP `initialize`. */\n clientInfo?: { name: string; version: string };\n}\n\ntype EventHandler = (server: CvmServerRef, message: McpMessage) => void;\n\ninterface PendingRequest {\n resolve(message: McpMessage): void;\n reject(error: Error): void;\n timer: ReturnType<typeof setTimeout>;\n /** The caller's original JSON-RPC id, restored on the response. */\n originalId: string | number | undefined;\n}\n\ninterface ServerSession {\n relays: string[];\n initialized: boolean;\n initializing: Promise<void> | null;\n}\n\nlet correlationCounter = 0;\nfunction nextCorrelationId(): string {\n correlationCounter += 1;\n return `cvm-${correlationCounter}-${getPublicKey(generateSecretKey()).slice(0, 8)}`;\n}\n\nfunction randomizedPastTimestamp(): number {\n // NIP-59: randomize within the past two days to reduce timing metadata.\n const jitter = Math.floor(Math.random() * 172_800);\n return Math.floor(Date.now() / 1000) - jitter;\n}\n\nfunction tagValue(tags: string[][], name: string): string | undefined {\n return tags.find((tag) => tag[0] === name)?.[1];\n}\n\n/** Adapt a `nostr-tools` SimplePool to the {@link CvmRelayPool} surface. */\nfunction simplePoolAdapter(sp: SimplePool): CvmRelayPool {\n return {\n subscribe(relays, filter, params) {\n return sp.subscribe(relays, filter as NostrToolsFilter, {\n onevent: params.onevent,\n oneose: params.oneose,\n });\n },\n publish(relays, event) {\n return sp.publish(relays, event as NostrToolsEvent);\n },\n };\n}\n\n/**\n * Create a Nostr-backed ContextVM transport.\n *\n * @param options - Relay set, timeouts, encryption mode, and optional injected\n * pool/keys (the injected pool + key make the transport deterministic in tests).\n * @returns A {@link CvmTransport} plus a `dispose()` to tear down subscriptions.\n */\nexport function createNostrCvmTransport(\n options: NostrCvmTransportOptions = {},\n): CvmTransport & { dispose(): void } {\n const defaultRelays = options.defaultRelays ?? [];\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const encrypt = options.encrypt ?? true;\n const wrapKind = options.ephemeralWrap === false ? KIND_GIFT_WRAP_REGULAR : KIND_GIFT_WRAP_EPHEMERAL;\n const pool: CvmRelayPool = options.pool ?? simplePoolAdapter(new SimplePool());\n const clientSecretKey = options.clientSecretKey ?? generateSecretKey();\n const clientPubkey = getPublicKey(clientSecretKey);\n const clientInfo = options.clientInfo ?? { name: 'kehto-cvm', version: '1.0.0' };\n\n const sessions = new Map<string, ServerSession>();\n const pending = new Map<string, PendingRequest>();\n const eventHandlers = new Set<EventHandler>();\n const relayRefcount = new Map<string, number>();\n const seenWraps = new Set<string>();\n let inbound: CvmSubCloser | null = null;\n let subscribedRelays = '';\n\n function resolveRelays(server: CvmServerRef): string[] {\n const relays = server.relays && server.relays.length > 0 ? server.relays : defaultRelays;\n if (relays.length === 0) throw new Error('server not found');\n return [...new Set(relays)];\n }\n\n function holdRelays(relays: string[]): void {\n for (const url of relays) relayRefcount.set(url, (relayRefcount.get(url) ?? 0) + 1);\n refreshSubscription();\n }\n\n function releaseRelays(relays: string[]): void {\n for (const url of relays) {\n const count = (relayRefcount.get(url) ?? 0) - 1;\n if (count <= 0) relayRefcount.delete(url);\n else relayRefcount.set(url, count);\n }\n refreshSubscription();\n }\n\n function refreshSubscription(): void {\n const relays = [...relayRefcount.keys()].sort();\n const key = relays.join(',');\n if (key === subscribedRelays) return;\n inbound?.close();\n subscribedRelays = key;\n inbound = relays.length === 0\n ? null\n : pool.subscribe(\n relays,\n { kinds: encrypt ? [KIND_GIFT_WRAP_REGULAR, KIND_GIFT_WRAP_EPHEMERAL] : [KIND_CVM], ['#p']: [clientPubkey] },\n { onevent: handleInbound },\n );\n }\n\n function rememberWrap(id: string): boolean {\n if (seenWraps.has(id)) return false;\n seenWraps.add(id);\n if (seenWraps.size > SEEN_WRAP_LIMIT) {\n const oldest = seenWraps.values().next().value;\n if (oldest !== undefined) seenWraps.delete(oldest);\n }\n return true;\n }\n\n function handleInbound(event: NostrEventLike): void {\n if (!rememberWrap(event.id)) return;\n let serverPubkey: string;\n let mcp: McpMessage;\n try {\n if (encrypt) {\n const conversationKey = nip44.getConversationKey(clientSecretKey, event.pubkey);\n const inner = JSON.parse(nip44.decrypt(event.content, conversationKey)) as NostrEventLike;\n serverPubkey = inner.pubkey;\n mcp = JSON.parse(inner.content) as McpMessage;\n } else {\n serverPubkey = event.pubkey;\n mcp = JSON.parse(event.content) as McpMessage;\n }\n } catch {\n return; // not addressed to us / undecryptable / malformed — ignore.\n }\n\n const id = mcp.id;\n if (id != null && pending.has(String(id))) {\n const entry = pending.get(String(id))!;\n pending.delete(String(id));\n clearTimeout(entry.timer);\n entry.resolve({ ...mcp, id: entry.originalId });\n return;\n }\n // Uncorrelated server message (notification) → fan out as a CVM event.\n if (mcp.method !== undefined && sessions.has(serverPubkey)) {\n const server: CvmServerRef = { pubkey: serverPubkey };\n for (const handler of eventHandlers) handler(server, mcp);\n }\n }\n\n function publishMcp(server: CvmServerRef, relays: string[], message: McpMessage): void {\n const inner = finalizeEvent(\n { kind: KIND_CVM, created_at: Math.floor(Date.now() / 1000), tags: [['p', server.pubkey]], content: JSON.stringify(message) },\n clientSecretKey,\n ) as NostrEventLike;\n if (!encrypt) {\n pool.publish(relays, inner);\n return;\n }\n const wrapSecretKey = generateSecretKey();\n const conversationKey = nip44.getConversationKey(wrapSecretKey, server.pubkey);\n // Ephemeral wraps (kind 21059) are not stored; relays reject backdated\n // ephemeral events as \"expired\", so they MUST carry a current timestamp.\n // Regular wraps (kind 1059) are backdated per NIP-59 to blur timing metadata.\n const createdAt =\n wrapKind === KIND_GIFT_WRAP_EPHEMERAL ? Math.floor(Date.now() / 1000) : randomizedPastTimestamp();\n const wrap = finalizeEvent(\n {\n kind: wrapKind,\n created_at: createdAt,\n tags: [['p', server.pubkey]],\n content: nip44.encrypt(JSON.stringify(inner), conversationKey),\n },\n wrapSecretKey,\n ) as NostrEventLike;\n pool.publish(relays, wrap);\n }\n\n function sendCorrelated(\n server: CvmServerRef,\n relays: string[],\n message: McpMessage,\n timeout: number,\n ): Promise<McpMessage> {\n const correlationId = nextCorrelationId();\n const originalId = message.id;\n const outgoing: McpMessage = { ...message, id: correlationId };\n return new Promise<McpMessage>((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(correlationId);\n reject(new Error('relay timeout'));\n }, timeout);\n pending.set(correlationId, { resolve, reject, timer, originalId });\n try {\n publishMcp(server, relays, outgoing);\n } catch (err) {\n pending.delete(correlationId);\n clearTimeout(timer);\n reject(err instanceof Error ? err : new Error('publish failed'));\n }\n });\n }\n\n function getSession(server: CvmServerRef): ServerSession {\n let session = sessions.get(server.pubkey);\n if (!session) {\n const relays = resolveRelays(server);\n session = { relays, initialized: false, initializing: null };\n sessions.set(server.pubkey, session);\n holdRelays(relays);\n }\n return session;\n }\n\n async function ensureInitialized(server: CvmServerRef, session: ServerSession, timeout: number): Promise<void> {\n if (session.initialized) return;\n if (session.initializing) return session.initializing;\n session.initializing = (async () => {\n try {\n await sendCorrelated(\n server,\n session.relays,\n {\n jsonrpc: '2.0',\n id: 'init',\n method: 'initialize',\n params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo },\n },\n timeout,\n );\n // notifications/initialized completes the handshake; no response expected.\n publishMcp(server, session.relays, { jsonrpc: '2.0', method: 'notifications/initialized' });\n session.initialized = true;\n } catch {\n throw new Error('initialization failed');\n } finally {\n session.initializing = null;\n }\n })();\n return session.initializing;\n }\n\n return {\n async discover(query?: CvmDiscoverQuery): Promise<CvmServer[]> {\n const relays = query?.relays && query.relays.length > 0 ? query.relays : defaultRelays;\n if (relays.length === 0) return [];\n const announces = new Map<string, NostrEventLike>();\n const toolLists = new Map<string, NostrEventLike>();\n await new Promise<void>((resolve) => {\n const sub = pool.subscribe(\n relays,\n { kinds: [KIND_ANNOUNCE_SERVER, KIND_ANNOUNCE_TOOLS], limit: query?.limit ? query.limit * 4 : 100 },\n {\n onevent(event) {\n if (event.kind === KIND_ANNOUNCE_SERVER) announces.set(event.pubkey, event);\n else if (event.kind === KIND_ANNOUNCE_TOOLS) toolLists.set(event.pubkey, event);\n },\n oneose() { resolve(); },\n },\n );\n setTimeout(() => { sub.close(); resolve(); }, DEFAULT_DISCOVER_TIMEOUT_MS);\n });\n\n const servers: CvmServer[] = [];\n for (const [pubkey, event] of announces) {\n const name = tagValue(event.tags, 'name');\n const description = tagValue(event.tags, 'about');\n const server: CvmServer = {\n pubkey,\n relays: [...relays],\n ...(name ? { name } : {}),\n ...(description ? { description } : {}),\n paymentRequired: false,\n };\n const tools = toolLists.get(pubkey);\n if (tools) {\n const names = tools.tags.filter((tag) => tag[0] === 'i' && typeof tag[2] === 'string').map((tag) => tag[2]);\n if (names.length > 0) server.capabilities = names;\n }\n servers.push(server);\n }\n\n const search = query?.search?.toLowerCase();\n const filtered = search\n ? servers.filter((s) => `${s.name ?? ''} ${s.description ?? ''}`.toLowerCase().includes(search))\n : servers;\n return query?.limit ? filtered.slice(0, query.limit) : filtered;\n },\n\n async request(server: CvmServerRef, message: McpMessage, requestOptions?: CvmRequestOptions): Promise<McpMessage> {\n const session = getSession(server);\n const timeout = requestOptions?.timeoutMs ?? timeoutMs;\n if (requestOptions?.initialize) await ensureInitialized(server, session, timeout);\n return sendCorrelated(server, session.relays, message, timeout);\n },\n\n async close(server: CvmServerRef): Promise<void> {\n const session = sessions.get(server.pubkey);\n if (!session) return;\n sessions.delete(server.pubkey);\n releaseRelays(session.relays);\n },\n\n onEvent(handler: EventHandler): { close(): void } {\n eventHandlers.add(handler);\n return {\n close() {\n eventHandlers.delete(handler);\n },\n };\n },\n\n dispose(): void {\n inbound?.close();\n inbound = null;\n for (const entry of pending.values()) {\n clearTimeout(entry.timer);\n entry.reject(new Error('transport disposed'));\n }\n pending.clear();\n sessions.clear();\n relayRefcount.clear();\n eventHandlers.clear();\n subscribedRelays = '';\n },\n };\n}\n"],"mappings":";AA6BA,SAAS,kBAAkB;AAC3B,SAAS,eAAe,mBAAmB,oBAAoB;AAC/D,YAAY,WAAW;AAavB,IAAM,WAAW;AAEjB,IAAM,2BAA2B;AACjC,IAAM,yBAAyB;AAE/B,IAAM,uBAAuB;AAC7B,IAAM,sBAAsB;AAE5B,IAAM,qBAAqB;AAC3B,IAAM,8BAA8B;AACpC,IAAM,uBAAuB;AAC7B,IAAM,kBAAkB;AA0ExB,IAAI,qBAAqB;AACzB,SAAS,oBAA4B;AACnC,wBAAsB;AACtB,SAAO,OAAO,kBAAkB,IAAI,aAAa,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;AACnF;AAEA,SAAS,0BAAkC;AAEzC,QAAM,SAAS,KAAK,MAAM,KAAK,OAAO,IAAI,MAAO;AACjD,SAAO,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AACzC;AAEA,SAAS,SAAS,MAAkB,MAAkC;AACpE,SAAO,KAAK,KAAK,CAAC,QAAQ,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC;AAChD;AAGA,SAAS,kBAAkB,IAA8B;AACvD,SAAO;AAAA,IACL,UAAU,QAAQ,QAAQ,QAAQ;AAChC,aAAO,GAAG,UAAU,QAAQ,QAA4B;AAAA,QACtD,SAAS,OAAO;AAAA,QAChB,QAAQ,OAAO;AAAA,MACjB,CAAC;AAAA,IACH;AAAA,IACA,QAAQ,QAAQ,OAAO;AACrB,aAAO,GAAG,QAAQ,QAAQ,KAAwB;AAAA,IACpD;AAAA,EACF;AACF;AASO,SAAS,wBACd,UAAoC,CAAC,GACD;AACpC,QAAM,gBAAgB,QAAQ,iBAAiB,CAAC;AAChD,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAMA,WAAU,QAAQ,WAAW;AACnC,QAAM,WAAW,QAAQ,kBAAkB,QAAQ,yBAAyB;AAC5E,QAAM,OAAqB,QAAQ,QAAQ,kBAAkB,IAAI,WAAW,CAAC;AAC7E,QAAM,kBAAkB,QAAQ,mBAAmB,kBAAkB;AACrE,QAAM,eAAe,aAAa,eAAe;AACjD,QAAM,aAAa,QAAQ,cAAc,EAAE,MAAM,aAAa,SAAS,QAAQ;AAE/E,QAAM,WAAW,oBAAI,IAA2B;AAChD,QAAM,UAAU,oBAAI,IAA4B;AAChD,QAAM,gBAAgB,oBAAI,IAAkB;AAC5C,QAAM,gBAAgB,oBAAI,IAAoB;AAC9C,QAAM,YAAY,oBAAI,IAAY;AAClC,MAAI,UAA+B;AACnC,MAAI,mBAAmB;AAEvB,WAAS,cAAc,QAAgC;AACrD,UAAM,SAAS,OAAO,UAAU,OAAO,OAAO,SAAS,IAAI,OAAO,SAAS;AAC3E,QAAI,OAAO,WAAW,EAAG,OAAM,IAAI,MAAM,kBAAkB;AAC3D,WAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAAA,EAC5B;AAEA,WAAS,WAAW,QAAwB;AAC1C,eAAW,OAAO,OAAQ,eAAc,IAAI,MAAM,cAAc,IAAI,GAAG,KAAK,KAAK,CAAC;AAClF,wBAAoB;AAAA,EACtB;AAEA,WAAS,cAAc,QAAwB;AAC7C,eAAW,OAAO,QAAQ;AACxB,YAAM,SAAS,cAAc,IAAI,GAAG,KAAK,KAAK;AAC9C,UAAI,SAAS,EAAG,eAAc,OAAO,GAAG;AAAA,UACnC,eAAc,IAAI,KAAK,KAAK;AAAA,IACnC;AACA,wBAAoB;AAAA,EACtB;AAEA,WAAS,sBAA4B;AACnC,UAAM,SAAS,CAAC,GAAG,cAAc,KAAK,CAAC,EAAE,KAAK;AAC9C,UAAM,MAAM,OAAO,KAAK,GAAG;AAC3B,QAAI,QAAQ,iBAAkB;AAC9B,aAAS,MAAM;AACf,uBAAmB;AACnB,cAAU,OAAO,WAAW,IACxB,OACA,KAAK;AAAA,MACH;AAAA,MACA,EAAE,OAAOA,WAAU,CAAC,wBAAwB,wBAAwB,IAAI,CAAC,QAAQ,GAAG,CAAC,IAAI,GAAG,CAAC,YAAY,EAAE;AAAA,MAC3G,EAAE,SAAS,cAAc;AAAA,IAC3B;AAAA,EACN;AAEA,WAAS,aAAa,IAAqB;AACzC,QAAI,UAAU,IAAI,EAAE,EAAG,QAAO;AAC9B,cAAU,IAAI,EAAE;AAChB,QAAI,UAAU,OAAO,iBAAiB;AACpC,YAAM,SAAS,UAAU,OAAO,EAAE,KAAK,EAAE;AACzC,UAAI,WAAW,OAAW,WAAU,OAAO,MAAM;AAAA,IACnD;AACA,WAAO;AAAA,EACT;AAEA,WAAS,cAAc,OAA6B;AAClD,QAAI,CAAC,aAAa,MAAM,EAAE,EAAG;AAC7B,QAAI;AACJ,QAAI;AACJ,QAAI;AACF,UAAIA,UAAS;AACX,cAAM,kBAAwB,yBAAmB,iBAAiB,MAAM,MAAM;AAC9E,cAAM,QAAQ,KAAK,MAAY,cAAQ,MAAM,SAAS,eAAe,CAAC;AACtE,uBAAe,MAAM;AACrB,cAAM,KAAK,MAAM,MAAM,OAAO;AAAA,MAChC,OAAO;AACL,uBAAe,MAAM;AACrB,cAAM,KAAK,MAAM,MAAM,OAAO;AAAA,MAChC;AAAA,IACF,QAAQ;AACN;AAAA,IACF;AAEA,UAAM,KAAK,IAAI;AACf,QAAI,MAAM,QAAQ,QAAQ,IAAI,OAAO,EAAE,CAAC,GAAG;AACzC,YAAM,QAAQ,QAAQ,IAAI,OAAO,EAAE,CAAC;AACpC,cAAQ,OAAO,OAAO,EAAE,CAAC;AACzB,mBAAa,MAAM,KAAK;AACxB,YAAM,QAAQ,EAAE,GAAG,KAAK,IAAI,MAAM,WAAW,CAAC;AAC9C;AAAA,IACF;AAEA,QAAI,IAAI,WAAW,UAAa,SAAS,IAAI,YAAY,GAAG;AAC1D,YAAM,SAAuB,EAAE,QAAQ,aAAa;AACpD,iBAAW,WAAW,cAAe,SAAQ,QAAQ,GAAG;AAAA,IAC1D;AAAA,EACF;AAEA,WAAS,WAAW,QAAsB,QAAkB,SAA2B;AACrF,UAAM,QAAQ;AAAA,MACZ,EAAE,MAAM,UAAU,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,GAAG,MAAM,CAAC,CAAC,KAAK,OAAO,MAAM,CAAC,GAAG,SAAS,KAAK,UAAU,OAAO,EAAE;AAAA,MAC5H;AAAA,IACF;AACA,QAAI,CAACA,UAAS;AACZ,WAAK,QAAQ,QAAQ,KAAK;AAC1B;AAAA,IACF;AACA,UAAM,gBAAgB,kBAAkB;AACxC,UAAM,kBAAwB,yBAAmB,eAAe,OAAO,MAAM;AAI7E,UAAM,YACJ,aAAa,2BAA2B,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI,wBAAwB;AAClG,UAAM,OAAO;AAAA,MACX;AAAA,QACE,MAAM;AAAA,QACN,YAAY;AAAA,QACZ,MAAM,CAAC,CAAC,KAAK,OAAO,MAAM,CAAC;AAAA,QAC3B,SAAe,cAAQ,KAAK,UAAU,KAAK,GAAG,eAAe;AAAA,MAC/D;AAAA,MACA;AAAA,IACF;AACA,SAAK,QAAQ,QAAQ,IAAI;AAAA,EAC3B;AAEA,WAAS,eACP,QACA,QACA,SACA,SACqB;AACrB,UAAM,gBAAgB,kBAAkB;AACxC,UAAM,aAAa,QAAQ;AAC3B,UAAM,WAAuB,EAAE,GAAG,SAAS,IAAI,cAAc;AAC7D,WAAO,IAAI,QAAoB,CAAC,SAAS,WAAW;AAClD,YAAM,QAAQ,WAAW,MAAM;AAC7B,gBAAQ,OAAO,aAAa;AAC5B,eAAO,IAAI,MAAM,eAAe,CAAC;AAAA,MACnC,GAAG,OAAO;AACV,cAAQ,IAAI,eAAe,EAAE,SAAS,QAAQ,OAAO,WAAW,CAAC;AACjE,UAAI;AACF,mBAAW,QAAQ,QAAQ,QAAQ;AAAA,MACrC,SAAS,KAAK;AACZ,gBAAQ,OAAO,aAAa;AAC5B,qBAAa,KAAK;AAClB,eAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,gBAAgB,CAAC;AAAA,MACjE;AAAA,IACF,CAAC;AAAA,EACH;AAEA,WAAS,WAAW,QAAqC;AACvD,QAAI,UAAU,SAAS,IAAI,OAAO,MAAM;AACxC,QAAI,CAAC,SAAS;AACZ,YAAM,SAAS,cAAc,MAAM;AACnC,gBAAU,EAAE,QAAQ,aAAa,OAAO,cAAc,KAAK;AAC3D,eAAS,IAAI,OAAO,QAAQ,OAAO;AACnC,iBAAW,MAAM;AAAA,IACnB;AACA,WAAO;AAAA,EACT;AAEA,iBAAe,kBAAkB,QAAsB,SAAwB,SAAgC;AAC7G,QAAI,QAAQ,YAAa;AACzB,QAAI,QAAQ,aAAc,QAAO,QAAQ;AACzC,YAAQ,gBAAgB,YAAY;AAClC,UAAI;AACF,cAAM;AAAA,UACJ;AAAA,UACA,QAAQ;AAAA,UACR;AAAA,YACE,SAAS;AAAA,YACT,IAAI;AAAA,YACJ,QAAQ;AAAA,YACR,QAAQ,EAAE,iBAAiB,sBAAsB,cAAc,CAAC,GAAG,WAAW;AAAA,UAChF;AAAA,UACA;AAAA,QACF;AAEA,mBAAW,QAAQ,QAAQ,QAAQ,EAAE,SAAS,OAAO,QAAQ,4BAA4B,CAAC;AAC1F,gBAAQ,cAAc;AAAA,MACxB,QAAQ;AACN,cAAM,IAAI,MAAM,uBAAuB;AAAA,MACzC,UAAE;AACA,gBAAQ,eAAe;AAAA,MACzB;AAAA,IACF,GAAG;AACH,WAAO,QAAQ;AAAA,EACjB;AAEA,SAAO;AAAA,IACL,MAAM,SAAS,OAAgD;AAC7D,YAAM,SAAS,OAAO,UAAU,MAAM,OAAO,SAAS,IAAI,MAAM,SAAS;AACzE,UAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AACjC,YAAM,YAAY,oBAAI,IAA4B;AAClD,YAAM,YAAY,oBAAI,IAA4B;AAClD,YAAM,IAAI,QAAc,CAAC,YAAY;AACnC,cAAM,MAAM,KAAK;AAAA,UACf;AAAA,UACA,EAAE,OAAO,CAAC,sBAAsB,mBAAmB,GAAG,OAAO,OAAO,QAAQ,MAAM,QAAQ,IAAI,IAAI;AAAA,UAClG;AAAA,YACE,QAAQ,OAAO;AACb,kBAAI,MAAM,SAAS,qBAAsB,WAAU,IAAI,MAAM,QAAQ,KAAK;AAAA,uBACjE,MAAM,SAAS,oBAAqB,WAAU,IAAI,MAAM,QAAQ,KAAK;AAAA,YAChF;AAAA,YACA,SAAS;AAAE,sBAAQ;AAAA,YAAG;AAAA,UACxB;AAAA,QACF;AACA,mBAAW,MAAM;AAAE,cAAI,MAAM;AAAG,kBAAQ;AAAA,QAAG,GAAG,2BAA2B;AAAA,MAC3E,CAAC;AAED,YAAM,UAAuB,CAAC;AAC9B,iBAAW,CAAC,QAAQ,KAAK,KAAK,WAAW;AACvC,cAAM,OAAO,SAAS,MAAM,MAAM,MAAM;AACxC,cAAM,cAAc,SAAS,MAAM,MAAM,OAAO;AAChD,cAAM,SAAoB;AAAA,UACxB;AAAA,UACA,QAAQ,CAAC,GAAG,MAAM;AAAA,UAClB,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,UACvB,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,UACrC,iBAAiB;AAAA,QACnB;AACA,cAAM,QAAQ,UAAU,IAAI,MAAM;AAClC,YAAI,OAAO;AACT,gBAAM,QAAQ,MAAM,KAAK,OAAO,CAAC,QAAQ,IAAI,CAAC,MAAM,OAAO,OAAO,IAAI,CAAC,MAAM,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC;AAC1G,cAAI,MAAM,SAAS,EAAG,QAAO,eAAe;AAAA,QAC9C;AACA,gBAAQ,KAAK,MAAM;AAAA,MACrB;AAEA,YAAM,SAAS,OAAO,QAAQ,YAAY;AAC1C,YAAM,WAAW,SACb,QAAQ,OAAO,CAAC,MAAM,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,eAAe,EAAE,GAAG,YAAY,EAAE,SAAS,MAAM,CAAC,IAC7F;AACJ,aAAO,OAAO,QAAQ,SAAS,MAAM,GAAG,MAAM,KAAK,IAAI;AAAA,IACzD;AAAA,IAEA,MAAM,QAAQ,QAAsB,SAAqB,gBAAyD;AAChH,YAAM,UAAU,WAAW,MAAM;AACjC,YAAM,UAAU,gBAAgB,aAAa;AAC7C,UAAI,gBAAgB,WAAY,OAAM,kBAAkB,QAAQ,SAAS,OAAO;AAChF,aAAO,eAAe,QAAQ,QAAQ,QAAQ,SAAS,OAAO;AAAA,IAChE;AAAA,IAEA,MAAM,MAAM,QAAqC;AAC/C,YAAM,UAAU,SAAS,IAAI,OAAO,MAAM;AAC1C,UAAI,CAAC,QAAS;AACd,eAAS,OAAO,OAAO,MAAM;AAC7B,oBAAc,QAAQ,MAAM;AAAA,IAC9B;AAAA,IAEA,QAAQ,SAA0C;AAChD,oBAAc,IAAI,OAAO;AACzB,aAAO;AAAA,QACL,QAAQ;AACN,wBAAc,OAAO,OAAO;AAAA,QAC9B;AAAA,MACF;AAAA,IACF;AAAA,IAEA,UAAgB;AACd,eAAS,MAAM;AACf,gBAAU;AACV,iBAAW,SAAS,QAAQ,OAAO,GAAG;AACpC,qBAAa,MAAM,KAAK;AACxB,cAAM,OAAO,IAAI,MAAM,oBAAoB,CAAC;AAAA,MAC9C;AACA,cAAQ,MAAM;AACd,eAAS,MAAM;AACf,oBAAc,MAAM;AACpB,oBAAc,MAAM;AACpB,yBAAmB;AAAA,IACrB;AAAA,EACF;AACF;","names":["encrypt"]}
1
+ {"version":3,"sources":["../src/cvm-nostr-transport.ts"],"sourcesContent":["/**\n * cvm-nostr-transport.ts — concrete ContextVM transport for NAP-CVM.\n *\n * Implements {@link CvmTransport} over Nostr, exactly as validated against live\n * ContextVM servers (e.g. Relatr):\n *\n * - MCP JSON-RPC messages ride in kind-25910 event `content`.\n * - Requests are CEP-4 gift-wrapped: the inner kind-25910 event is signed with\n * the shell's ephemeral client key, NIP-44-encrypted to the server, and\n * placed in a kind-21059 (ephemeral) / 1059 (regular) wrap signed by a fresh\n * random key, `p`-tagged to the server. Responses arrive the same way,\n * `p`-tagged to the client, and are correlated by the inner JSON-RPC `id`.\n * - Discovery reads kind-11316 (server) + kind-11317 (tools) announcements.\n *\n * Shipped on a separate entry (`@kehto/services/cvm-nostr-transport`) so the\n * `nostr-tools` dependency stays out of the core `@kehto/services` bundle.\n *\n * The client key is ephemeral and shell-owned: napplets never see keys, relay\n * sockets, or NIP-44 material (NAP-CVM §Security).\n *\n * @example\n * ```ts\n * import { createNostrCvmTransport } from '@kehto/services/cvm-nostr-transport';\n * const transport = createNostrCvmTransport({\n * defaultRelays: ['wss://relay.contextvm.org', 'wss://relay2.contextvm.org'],\n * });\n * ```\n */\n\nimport { SimplePool } from 'nostr-tools/pool';\nimport { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';\nimport * as nip44 from 'nostr-tools/nip44';\nimport type { Event as NostrToolsEvent, Filter as NostrToolsFilter } from 'nostr-tools';\n\nimport type { CvmTransport } from './cvm-service.js';\nimport type {\n CvmDiscoverQuery,\n CvmRequestOptions,\n CvmServer,\n CvmServerRef,\n McpMessage,\n} from './cvm-types.js';\n\n/** ContextVM unified transport event kind. */\nconst KIND_CVM = 25910;\n/** CEP-4 gift-wrap kinds: ephemeral (CEP-19) and regular. */\nconst KIND_GIFT_WRAP_EPHEMERAL = 21059;\nconst KIND_GIFT_WRAP_REGULAR = 1059;\n/** CEP-6 announcement kinds. */\nconst KIND_ANNOUNCE_SERVER = 11316;\nconst KIND_ANNOUNCE_TOOLS = 11317;\n\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_DISCOVER_TIMEOUT_MS = 6_000;\nconst MCP_PROTOCOL_VERSION = '2025-11-25';\nconst SEEN_WRAP_LIMIT = 512;\n\n/** Minimal signed Nostr event. */\nexport interface NostrEventLike {\n id: string;\n pubkey: string;\n created_at: number;\n kind: number;\n tags: string[][];\n content: string;\n sig: string;\n}\n\n/** A Nostr REQ filter (subset). */\nexport interface NostrFilterLike {\n kinds?: number[];\n authors?: string[];\n limit?: number;\n ['#p']?: string[];\n [key: string]: unknown;\n}\n\n/** Subscription handle returned by the relay pool. */\nexport interface CvmSubCloser {\n close(): void;\n}\n\n/**\n * Minimal relay-pool surface used by this transport — structurally satisfied\n * by `nostr-tools` `SimplePool`. Injectable for testing.\n */\nexport interface CvmRelayPool {\n subscribe(\n relays: string[],\n filter: NostrFilterLike,\n params: { onevent?: (event: NostrEventLike) => void; oneose?: () => void },\n ): CvmSubCloser;\n publish(relays: string[], event: NostrEventLike): unknown;\n}\n\n/** Options for {@link createNostrCvmTransport}. */\nexport interface NostrCvmTransportOptions {\n /** Relays used when a server reference carries no relay hints. */\n defaultRelays?: string[];\n /** Default per-request timeout in milliseconds. */\n timeoutMs?: number;\n /** Whether to CEP-4 gift-wrap requests. Default true (most servers require it). */\n encrypt?: boolean;\n /** Use ephemeral (kind 21059) gift wraps when encrypting. Default true. */\n ephemeralWrap?: boolean;\n /** Relay pool to use. Defaults to a fresh `nostr-tools` `SimplePool`. */\n pool?: CvmRelayPool;\n /** Client secret key (32 bytes). Defaults to a generated ephemeral key. */\n clientSecretKey?: Uint8Array;\n /** Client info advertised during MCP `initialize`. */\n clientInfo?: { name: string; version: string };\n}\n\ntype EventHandler = (server: CvmServerRef, message: McpMessage) => void;\n\ninterface PendingRequest {\n resolve(message: McpMessage): void;\n reject(error: Error): void;\n timer: ReturnType<typeof setTimeout>;\n /** The caller's original JSON-RPC id, restored on the response. */\n originalId: string | number | undefined;\n}\n\ninterface ServerSession {\n relays: string[];\n initialized: boolean;\n initializing: Promise<void> | null;\n}\n\nlet correlationCounter = 0;\nfunction nextCorrelationId(): string {\n correlationCounter += 1;\n return `cvm-${correlationCounter}-${getPublicKey(generateSecretKey()).slice(0, 8)}`;\n}\n\nfunction randomizedPastTimestamp(): number {\n // NIP-59: randomize within the past two days to reduce timing metadata.\n const jitter = Math.floor(Math.random() * 172_800);\n return Math.floor(Date.now() / 1000) - jitter;\n}\n\nfunction tagValue(tags: string[][], name: string): string | undefined {\n return tags.find((tag) => tag[0] === name)?.[1];\n}\n\n/** Adapt a `nostr-tools` SimplePool to the {@link CvmRelayPool} surface. */\nfunction simplePoolAdapter(sp: SimplePool): CvmRelayPool {\n return {\n subscribe(relays, filter, params) {\n return sp.subscribe(relays, filter as NostrToolsFilter, {\n onevent: params.onevent,\n oneose: params.oneose,\n });\n },\n publish(relays, event) {\n return sp.publish(relays, event as NostrToolsEvent);\n },\n };\n}\n\n/**\n * Create a Nostr-backed ContextVM transport.\n *\n * @param options - Relay set, timeouts, encryption mode, and optional injected\n * pool/keys (the injected pool + key make the transport deterministic in tests).\n * @returns A {@link CvmTransport} plus a `dispose()` to tear down subscriptions.\n */\nexport function createNostrCvmTransport(\n options: NostrCvmTransportOptions = {},\n): CvmTransport & { dispose(): void } {\n const defaultRelays = options.defaultRelays ?? [];\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const encrypt = options.encrypt ?? true;\n const wrapKind = options.ephemeralWrap === false ? KIND_GIFT_WRAP_REGULAR : KIND_GIFT_WRAP_EPHEMERAL;\n const pool: CvmRelayPool = options.pool ?? simplePoolAdapter(new SimplePool());\n const clientSecretKey = options.clientSecretKey ?? generateSecretKey();\n const clientPubkey = getPublicKey(clientSecretKey);\n const clientInfo = options.clientInfo ?? { name: 'kehto-cvm', version: '1.0.0' };\n\n const sessions = new Map<string, ServerSession>();\n const pending = new Map<string, PendingRequest>();\n const eventHandlers = new Set<EventHandler>();\n const relayRefcount = new Map<string, number>();\n const seenWraps = new Set<string>();\n let inbound: CvmSubCloser | null = null;\n let subscribedRelays = '';\n\n function resolveRelays(server: CvmServerRef): string[] {\n const relays = server.relays && server.relays.length > 0 ? server.relays : defaultRelays;\n if (relays.length === 0) throw new Error('server not found');\n return [...new Set(relays)];\n }\n\n function holdRelays(relays: string[]): void {\n for (const url of relays) relayRefcount.set(url, (relayRefcount.get(url) ?? 0) + 1);\n refreshSubscription();\n }\n\n function releaseRelays(relays: string[]): void {\n for (const url of relays) {\n const count = (relayRefcount.get(url) ?? 0) - 1;\n if (count <= 0) relayRefcount.delete(url);\n else relayRefcount.set(url, count);\n }\n refreshSubscription();\n }\n\n function refreshSubscription(): void {\n const relays = [...relayRefcount.keys()].sort();\n const key = relays.join(',');\n if (key === subscribedRelays) return;\n inbound?.close();\n subscribedRelays = key;\n inbound = relays.length === 0\n ? null\n : pool.subscribe(\n relays,\n { kinds: encrypt ? [KIND_GIFT_WRAP_REGULAR, KIND_GIFT_WRAP_EPHEMERAL] : [KIND_CVM], ['#p']: [clientPubkey] },\n { onevent: handleInbound },\n );\n }\n\n function rememberWrap(id: string): boolean {\n if (seenWraps.has(id)) return false;\n seenWraps.add(id);\n if (seenWraps.size > SEEN_WRAP_LIMIT) {\n const oldest = seenWraps.values().next().value;\n if (oldest !== undefined) seenWraps.delete(oldest);\n }\n return true;\n }\n\n function handleInbound(event: NostrEventLike): void {\n if (!rememberWrap(event.id)) return;\n let serverPubkey: string;\n let mcp: McpMessage;\n try {\n if (encrypt) {\n const conversationKey = nip44.getConversationKey(clientSecretKey, event.pubkey);\n const inner = JSON.parse(nip44.decrypt(event.content, conversationKey)) as NostrEventLike;\n serverPubkey = inner.pubkey;\n mcp = JSON.parse(inner.content) as McpMessage;\n } else {\n serverPubkey = event.pubkey;\n mcp = JSON.parse(event.content) as McpMessage;\n }\n } catch {\n return; // not addressed to us / undecryptable / malformed — ignore.\n }\n\n const id = mcp.id;\n if (id != null && pending.has(String(id))) {\n const entry = pending.get(String(id))!;\n pending.delete(String(id));\n clearTimeout(entry.timer);\n entry.resolve({ ...mcp, id: entry.originalId });\n return;\n }\n // Uncorrelated server message (notification) → fan out as a CVM event.\n if (mcp.method !== undefined && sessions.has(serverPubkey)) {\n const server: CvmServerRef = { pubkey: serverPubkey };\n for (const handler of eventHandlers) handler(server, mcp);\n }\n }\n\n function publishMcp(server: CvmServerRef, relays: string[], message: McpMessage): void {\n const inner = finalizeEvent(\n { kind: KIND_CVM, created_at: Math.floor(Date.now() / 1000), tags: [['p', server.pubkey]], content: JSON.stringify(message) },\n clientSecretKey,\n ) as NostrEventLike;\n if (!encrypt) {\n pool.publish(relays, inner);\n return;\n }\n const wrapSecretKey = generateSecretKey();\n const conversationKey = nip44.getConversationKey(wrapSecretKey, server.pubkey);\n // Ephemeral wraps (kind 21059) are not stored; relays reject backdated\n // ephemeral events as \"expired\", so they MUST carry a current timestamp.\n // Regular wraps (kind 1059) are backdated per NIP-59 to blur timing metadata.\n const createdAt =\n wrapKind === KIND_GIFT_WRAP_EPHEMERAL ? Math.floor(Date.now() / 1000) : randomizedPastTimestamp();\n const wrap = finalizeEvent(\n {\n kind: wrapKind,\n created_at: createdAt,\n tags: [['p', server.pubkey]],\n content: nip44.encrypt(JSON.stringify(inner), conversationKey),\n },\n wrapSecretKey,\n ) as NostrEventLike;\n pool.publish(relays, wrap);\n }\n\n function sendCorrelated(\n server: CvmServerRef,\n relays: string[],\n message: McpMessage,\n timeout: number,\n ): Promise<McpMessage> {\n const correlationId = nextCorrelationId();\n const originalId = message.id;\n const outgoing: McpMessage = { ...message, id: correlationId };\n return new Promise<McpMessage>((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(correlationId);\n reject(new Error('relay timeout'));\n }, timeout);\n pending.set(correlationId, { resolve, reject, timer, originalId });\n try {\n publishMcp(server, relays, outgoing);\n } catch (err) {\n pending.delete(correlationId);\n clearTimeout(timer);\n reject(err instanceof Error ? err : new Error('publish failed'));\n }\n });\n }\n\n function getSession(server: CvmServerRef): ServerSession {\n let session = sessions.get(server.pubkey);\n if (!session) {\n const relays = resolveRelays(server);\n session = { relays, initialized: false, initializing: null };\n sessions.set(server.pubkey, session);\n holdRelays(relays);\n }\n return session;\n }\n\n async function ensureInitialized(server: CvmServerRef, session: ServerSession, timeout: number): Promise<void> {\n if (session.initialized) return;\n if (session.initializing) return session.initializing;\n session.initializing = (async () => {\n try {\n await sendCorrelated(\n server,\n session.relays,\n {\n jsonrpc: '2.0',\n id: 'init',\n method: 'initialize',\n params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo },\n },\n timeout,\n );\n // notifications/initialized completes the handshake; no response expected.\n publishMcp(server, session.relays, { jsonrpc: '2.0', method: 'notifications/initialized' });\n session.initialized = true;\n } catch {\n throw new Error('initialization failed');\n } finally {\n session.initializing = null;\n }\n })();\n return session.initializing;\n }\n\n return {\n async discover(query?: CvmDiscoverQuery): Promise<CvmServer[]> {\n const relays = query?.relays && query.relays.length > 0 ? query.relays : defaultRelays;\n if (relays.length === 0) return [];\n const announces = new Map<string, NostrEventLike>();\n const toolLists = new Map<string, NostrEventLike>();\n await new Promise<void>((resolve) => {\n const sub = pool.subscribe(\n relays,\n { kinds: [KIND_ANNOUNCE_SERVER, KIND_ANNOUNCE_TOOLS], limit: query?.limit ? query.limit * 4 : 100 },\n {\n onevent(event) {\n if (event.kind === KIND_ANNOUNCE_SERVER) announces.set(event.pubkey, event);\n else if (event.kind === KIND_ANNOUNCE_TOOLS) toolLists.set(event.pubkey, event);\n },\n oneose() { resolve(); },\n },\n );\n setTimeout(() => { sub.close(); resolve(); }, DEFAULT_DISCOVER_TIMEOUT_MS);\n });\n\n const servers: CvmServer[] = [];\n for (const [pubkey, event] of announces) {\n const name = tagValue(event.tags, 'name');\n const description = tagValue(event.tags, 'about');\n const server: CvmServer = {\n pubkey,\n relays: [...relays],\n ...(name ? { name } : {}),\n ...(description ? { description } : {}),\n paymentRequired: false,\n };\n const tools = toolLists.get(pubkey);\n if (tools) {\n const names = tools.tags.filter((tag) => tag[0] === 'i' && typeof tag[2] === 'string').map((tag) => tag[2]);\n if (names.length > 0) server.capabilities = names;\n }\n servers.push(server);\n }\n\n const search = query?.search?.toLowerCase();\n const filtered = search\n ? servers.filter((s) => `${s.name ?? ''} ${s.description ?? ''}`.toLowerCase().includes(search))\n : servers;\n return query?.limit ? filtered.slice(0, query.limit) : filtered;\n },\n\n async request(server: CvmServerRef, message: McpMessage, requestOptions?: CvmRequestOptions): Promise<McpMessage> {\n const session = getSession(server);\n const timeout = requestOptions?.timeoutMs ?? timeoutMs;\n if (requestOptions?.initialize) await ensureInitialized(server, session, timeout);\n return sendCorrelated(server, session.relays, message, timeout);\n },\n\n async close(server: CvmServerRef): Promise<void> {\n const session = sessions.get(server.pubkey);\n if (!session) return;\n sessions.delete(server.pubkey);\n releaseRelays(session.relays);\n },\n\n onEvent(handler: EventHandler): { close(): void } {\n eventHandlers.add(handler);\n return {\n close() {\n eventHandlers.delete(handler);\n },\n };\n },\n\n dispose(): void {\n inbound?.close();\n inbound = null;\n for (const entry of pending.values()) {\n clearTimeout(entry.timer);\n entry.reject(new Error('transport disposed'));\n }\n pending.clear();\n sessions.clear();\n relayRefcount.clear();\n eventHandlers.clear();\n subscribedRelays = '';\n },\n };\n}\n"],"mappings":"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","names":["encrypt"]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ServiceHandler, Signer } from '@kehto/runtime';
2
- import { NostrEvent, NappletMessage, NostrFilter } from '@napplet/core';
2
+ import { NostrEvent, NappletMessage, NostrFilter, EventTemplate } from '@napplet/core';
3
3
  import { MediaMetadata, MediaAction } from '@napplet/nub/media/types';
4
4
  export { MediaAction } from '@napplet/nub/media/types';
5
5
  import { NotifySendMessage } from '@napplet/nub/notify/types';
@@ -1532,4 +1532,500 @@ type ResourceService = ServiceHandler;
1532
1532
  */
1533
1533
  declare function createResourceService(options: ResourceServiceOptions): ResourceService;
1534
1534
 
1535
- export { type AudioServiceOptions, type AudioSource, type CacheServiceOptions, type ConfigSchemaValidation, type ConfigService, type ConfigServiceOptions, type CoordinatedRelayOptions, type GiftWrapDecryptResult, type HostCacheBridge, type HostDecryptBridge, type HostKeyEvent, type HostKeysBridge, type HostMediaBridge, type IdentityDecryptErrorCode, type IdentityDecryptErrorMessage, type IdentityDecryptMessage, type IdentityDecryptResultMessage, type IdentityServiceOptions, type KeysServiceOptions, type MediaMetadataLike, type MediaPlaybackOwner, type MediaServiceOptions, type MediaSessionCreateOptions, type MediaSessionTarget, type MediaSourceRef, type Notification, type NotificationServiceOptions, type NotifyServiceOptions, type RelayPoolServiceOptions, type ResourceService, type ResourceServiceOptions, type Rumor, type ThemeService, type ThemeServiceOptions, type VerifyEvent, createAudioService, createBrowserMediaBridge, createCacheService, createConfigService, createCoordinatedRelay, createIdentityService, createKeysService, createMediaService, createNotificationService, createNotifyService, createRelayPoolService, createResourceService, createThemeService };
1535
+ /**
1536
+ * outbox-service.ts — NAP-OUTBOX (outbox-aware relay routing) reference service.
1537
+ *
1538
+ * Shell-side handler for the NAP-OUTBOX wire protocol. It is a pure envelope
1539
+ * router: it validates `outbox.*` envelopes, delegates the actual relay
1540
+ * discovery / routing / dedup / publish-fanout work to an injected
1541
+ * {@link OutboxRouter}, and posts the correlated result / lifecycle messages
1542
+ * (echoing the request `id` or `subId`) back to the napplet.
1543
+ *
1544
+ * The router is injected (options-as-bridge) so this service has no Nostr
1545
+ * dependency and is fully unit-testable. A concrete relay-pool-backed router
1546
+ * ships alongside as {@link createRelayPoolOutboxRouter}.
1547
+ *
1548
+ * ──────────────────────────── Responsibilities ────────────────────────────
1549
+ * Inbound: outbox.query, outbox.subscribe, outbox.close, outbox.publish,
1550
+ * outbox.resolveRelays
1551
+ * Outbound: outbox.query.result, outbox.event, outbox.eose, outbox.closed,
1552
+ * outbox.publish.result, outbox.resolveRelays.result
1553
+ *
1554
+ * The shell owns relay discovery, routing, fallback, deduplication, signature
1555
+ * validation, signing, and publish fanout policy — all of which live behind
1556
+ * the {@link OutboxRouter}. This service only marshals the wire protocol.
1557
+ *
1558
+ * @example
1559
+ * ```ts
1560
+ * import { createOutboxService, createRelayPoolOutboxRouter } from '@kehto/services';
1561
+ *
1562
+ * const router = createRelayPoolOutboxRouter({ relayPool, loadRelayLists, fallbackRelays });
1563
+ * runtime.registerService('outbox', createOutboxService({ router }));
1564
+ * ```
1565
+ *
1566
+ * @packageDocumentation
1567
+ */
1568
+
1569
+ /**
1570
+ * Relay-selection strategy:
1571
+ * - `outbox` — query/publish via author write relays (the outbox model)
1572
+ * - `inbox` — query/publish via recipient read relays (the inbox model)
1573
+ * - `auto` — let the shell choose per its policy and relay intelligence
1574
+ */
1575
+ type OutboxStrategy = 'outbox' | 'inbox' | 'auto';
1576
+ /** Options for a one-shot outbox query. */
1577
+ interface OutboxQueryOptions {
1578
+ /** Explicit author hints (augment/override authors derived from filters). */
1579
+ authors?: string[];
1580
+ /** Relay hints; treated as a hint subject to shell validation, not a bypass. */
1581
+ relays?: string[];
1582
+ /** Relay-selection strategy. */
1583
+ strategy?: OutboxStrategy;
1584
+ /** Maximum events to collect. */
1585
+ limit?: number;
1586
+ /** Wall-clock budget for the query, in milliseconds. */
1587
+ timeoutMs?: number;
1588
+ }
1589
+ /** Options for a live outbox subscription. */
1590
+ interface OutboxSubscribeOptions extends OutboxQueryOptions {
1591
+ /** Keep the subscription open for real-time events after EOSE. */
1592
+ live?: boolean;
1593
+ }
1594
+ /** Options for an outbox publish. */
1595
+ interface OutboxPublishOptions {
1596
+ /** Relay hints; treated as a hint subject to shell validation. */
1597
+ relays?: string[];
1598
+ /** Recipient authors whose inbox relays should be included for directed events. */
1599
+ targetAuthors?: string[];
1600
+ /** Relay-selection strategy. */
1601
+ strategy?: OutboxStrategy;
1602
+ }
1603
+ /** A read/write target for relay-plan resolution. */
1604
+ interface OutboxTarget {
1605
+ /** Authors to resolve relays for. */
1606
+ authors?: string[];
1607
+ /** Single pubkey to resolve relays for. */
1608
+ pubkey?: string;
1609
+ /** Whether the plan is for reading (their write relays) or writing (their read relays). */
1610
+ direction?: 'read' | 'write';
1611
+ /** Relay-selection strategy. */
1612
+ strategy?: OutboxStrategy;
1613
+ }
1614
+ /** The relay plan the shell would use for a target. */
1615
+ interface OutboxRelayPlan {
1616
+ /** Resolved relay URLs. */
1617
+ relays: string[];
1618
+ /** Where the plan came from. */
1619
+ source: 'nip65' | 'cache' | 'policy' | 'fallback';
1620
+ /** Authors for which no relay list could be resolved. */
1621
+ missingAuthors?: string[];
1622
+ }
1623
+ /** Outcome of an outbox query, as returned by the {@link OutboxRouter}. */
1624
+ interface OutboxResult {
1625
+ /** Deduplicated, signature-validated events. */
1626
+ events: NostrEvent[];
1627
+ /** Map of event id -> relay URLs where the shell observed the event. */
1628
+ relays: Record<string, string[]>;
1629
+ /** True when some relay lists or connections failed and results are partial. */
1630
+ incomplete?: boolean;
1631
+ /** Error reason when the query could not complete. */
1632
+ error?: string;
1633
+ }
1634
+ /** Outcome of an outbox publish, as returned by the {@link OutboxRouter}. */
1635
+ interface OutboxPublishResult {
1636
+ /** Whether the publish succeeded on at least the required relays. */
1637
+ ok: boolean;
1638
+ /** The signed event returned by the shell. */
1639
+ event?: NostrEvent;
1640
+ /** The published event id. */
1641
+ eventId?: string;
1642
+ /** Map of relay URL -> per-relay publish success. */
1643
+ relays?: Record<string, boolean>;
1644
+ /** Error reason when the publish failed. */
1645
+ error?: string;
1646
+ }
1647
+ /** Sink an {@link OutboxRouter} streams subscription lifecycle through. */
1648
+ interface OutboxSubscriptionSink {
1649
+ /** Deliver a matching event; `relay` is the relay it was observed on, when known. */
1650
+ event(event: NostrEvent, relay?: string): void;
1651
+ /** Signal end-of-stored-events. */
1652
+ eose(): void;
1653
+ /** Signal that the subscription was closed upstream; `reason` is optional. */
1654
+ closed(reason?: string): void;
1655
+ }
1656
+ /** Handle to a router-owned subscription. */
1657
+ interface OutboxRouterSubscription {
1658
+ /** Stop the subscription and release its relay connections. */
1659
+ close(): void;
1660
+ }
1661
+ /**
1662
+ * Abstract outbox router. Implementors own relay discovery (NIP-65 / NIP-66),
1663
+ * routing, fallback, deduplication, signature validation, signing, and publish
1664
+ * fanout. The service translates wire envelopes into these calls and back.
1665
+ */
1666
+ interface OutboxRouter {
1667
+ /** Resolve relays, query them, dedup by id, validate signatures, collect events. */
1668
+ query(filters: NostrFilter[], options?: OutboxQueryOptions): Promise<OutboxResult>;
1669
+ /** Open a live outbox-aware subscription, streaming through `sink`. */
1670
+ subscribe(filters: NostrFilter[], options: OutboxSubscribeOptions | undefined, sink: OutboxSubscriptionSink): OutboxRouterSubscription;
1671
+ /** Sign `template` and fan it out to the relevant write/inbox relays. */
1672
+ publish(template: EventTemplate, options?: OutboxPublishOptions): Promise<OutboxPublishResult>;
1673
+ /** Return the relay plan the shell would use for a read/write target. */
1674
+ resolveRelays(target: OutboxTarget): Promise<OutboxRelayPlan>;
1675
+ }
1676
+ /** Options for {@link createOutboxService}. */
1677
+ interface OutboxServiceOptions {
1678
+ /** The outbox router the shell uses to reach relays. Required. */
1679
+ router: OutboxRouter;
1680
+ }
1681
+ /**
1682
+ * Create the NAP-OUTBOX service handler.
1683
+ *
1684
+ * @param options - Must provide an {@link OutboxRouter}.
1685
+ * @returns A `ServiceHandler` ready for `runtime.registerService('outbox', handler)`.
1686
+ * @throws If `options.router` is missing.
1687
+ */
1688
+ declare function createOutboxService(options: OutboxServiceOptions): ServiceHandler;
1689
+
1690
+ /**
1691
+ * relay-pool-outbox-router.ts — concrete {@link OutboxRouter} backed by a relay pool.
1692
+ *
1693
+ * Implements the outbox-model routing that NAP-OUTBOX centralizes so napplets
1694
+ * don't each reinvent it: derive authors, resolve their NIP-65 relays, fan a
1695
+ * per-relay subscription out across the plan, deduplicate by event id (while
1696
+ * recording every relay an event was observed on), validate signatures, and —
1697
+ * for publish — sign the template and fan it out to the relevant write/inbox
1698
+ * relays.
1699
+ *
1700
+ * NIP-65 relay-list *fetching* is the host's concern (it may come from a
1701
+ * kind-10002 cache, a NIP-66 indexer via `@kehto/nip/66`, or a live query), so
1702
+ * it is injected via {@link RelayPoolOutboxRouterOptions.loadRelayLists}. The
1703
+ * relay pool, signer, and signature verifier are injected too — keeping this
1704
+ * router browser-agnostic and unit-testable with mocks.
1705
+ *
1706
+ * Relay-selection model (per the outbox model):
1707
+ * - reading an author's events → their **write** relays (where they publish)
1708
+ * - writing to reach an author → their **read** relays (their inbox)
1709
+ *
1710
+ * `strategy` overrides the direction default: `outbox` forces write relays,
1711
+ * `inbox` forces read relays, `auto` (default) follows the read/write direction.
1712
+ *
1713
+ * @example
1714
+ * ```ts
1715
+ * import { createOutboxService, createRelayPoolOutboxRouter } from '@kehto/services';
1716
+ *
1717
+ * const router = createRelayPoolOutboxRouter({
1718
+ * relayPool: myOutboxPool,
1719
+ * loadRelayLists: (pubkeys) => relayListCache.getMany(pubkeys),
1720
+ * fallbackRelays: ['wss://relay.damus.io', 'wss://nos.lol'],
1721
+ * signEvent: (tmpl) => signer.signEvent(tmpl),
1722
+ * verifyEvent: (ev) => verifyEvent(ev),
1723
+ * });
1724
+ * runtime.registerService('outbox', createOutboxService({ router }));
1725
+ * ```
1726
+ *
1727
+ * @packageDocumentation
1728
+ */
1729
+
1730
+ /** A NIP-65 relay list for a single pubkey. */
1731
+ interface RelayListEntry {
1732
+ /** Relays the author reads from (their inbox). */
1733
+ read: string[];
1734
+ /** Relays the author writes to (where their events land). */
1735
+ write: string[];
1736
+ }
1737
+ /**
1738
+ * Relay pool contract the router drives. Implementors adapt their pool library
1739
+ * (nostr-tools SimplePool, applesauce-relay, etc.). Unlike the lower-level
1740
+ * relay NUB pool, both methods take an explicit relay-URL set so the router
1741
+ * controls outbox routing and can attribute events to the relay they arrived on.
1742
+ */
1743
+ interface OutboxRelayPool {
1744
+ /**
1745
+ * Subscribe to `filters` on exactly `relayUrls`. The callback receives each
1746
+ * matching event or the literal `'EOSE'` once stored events are exhausted.
1747
+ * Returns a handle to cancel the subscription.
1748
+ */
1749
+ subscribe(filters: NostrFilter[], relayUrls: string[], callback: (item: NostrEvent | 'EOSE') => void): {
1750
+ unsubscribe(): void;
1751
+ };
1752
+ /**
1753
+ * Publish `event` to `relayUrls`. May return a per-relay success map; a
1754
+ * `void`/missing return is treated as optimistic success on every target.
1755
+ */
1756
+ publish(event: NostrEvent, relayUrls: string[]): Promise<Record<string, boolean>> | Record<string, boolean> | void;
1757
+ /** Whether the relay pool is connected and able to handle requests. */
1758
+ isAvailable(): boolean;
1759
+ }
1760
+ /** Options for {@link createRelayPoolOutboxRouter}. */
1761
+ interface RelayPoolOutboxRouterOptions {
1762
+ /** Relay pool the router subscribes/publishes through. Required. */
1763
+ relayPool: OutboxRelayPool;
1764
+ /**
1765
+ * Resolve NIP-65 relay lists for a set of pubkeys. Pubkeys with no known
1766
+ * list are simply omitted from the returned map (they become `missingAuthors`).
1767
+ */
1768
+ loadRelayLists(pubkeys: string[]): Promise<Map<string, RelayListEntry>> | Map<string, RelayListEntry>;
1769
+ /** Relays to fall back to when NIP-65 data is absent, stale, or empty. Required. */
1770
+ fallbackRelays: string[];
1771
+ /**
1772
+ * Sign a template before publish (shell-mediated; napplets never sign). When
1773
+ * omitted, `publish` resolves with `{ ok: false, error: 'publish denied' }`.
1774
+ */
1775
+ signEvent?(template: EventTemplate): Promise<NostrEvent>;
1776
+ /**
1777
+ * Validate an event signature before delivering it to a napplet. May be sync
1778
+ * or async. Defaults to accepting every event (host pools often pre-verify).
1779
+ */
1780
+ verifyEvent?(event: NostrEvent): Promise<boolean> | boolean;
1781
+ /**
1782
+ * Gate relay URLs (e.g. block private-network hosts). Defaults to allowing
1783
+ * only `ws://` / `wss://` URLs — `options.relays` hints pass through this too.
1784
+ */
1785
+ isRelayAllowed?(url: string): boolean;
1786
+ /** Default query timeout when `options.timeoutMs` is unset. Default 4000ms. */
1787
+ defaultTimeoutMs?: number;
1788
+ }
1789
+ /**
1790
+ * Create a relay-pool-backed {@link OutboxRouter}.
1791
+ *
1792
+ * @param options - Relay pool, NIP-65 loader, fallback relays, and optional
1793
+ * signer / verifier / relay gate / timeout.
1794
+ * @returns An {@link OutboxRouter} for {@link createOutboxService}.
1795
+ * @throws If `relayPool`, `loadRelayLists`, or `fallbackRelays` are missing.
1796
+ */
1797
+ declare function createRelayPoolOutboxRouter(options: RelayPoolOutboxRouterOptions): OutboxRouter;
1798
+
1799
+ /**
1800
+ * upload-service.ts — NAP-UPLOAD (shell-mediated file/blob upload) reference service.
1801
+ *
1802
+ * Shell-side handler for the NAP-UPLOAD wire protocol. It is a pure envelope
1803
+ * router: it validates `upload.*` envelopes, delegates the actual byte transfer
1804
+ * (server selection, rail authorization signing, the HTTP upload) to an injected
1805
+ * {@link Uploader}, and posts the correlated result / status messages back to the
1806
+ * napplet.
1807
+ *
1808
+ * The uploader is injected (options-as-bridge) so this service has no transport
1809
+ * or Nostr dependency and is fully unit-testable. NAP-UPLOAD is deliberately
1810
+ * abstract over the backend — the runtime decides *how* it uploads (NIP-96,
1811
+ * Blossom, …). A concrete HTTP-backed uploader ships alongside as
1812
+ * {@link createHttpUploader}.
1813
+ *
1814
+ * ──────────────────────────── Responsibilities ────────────────────────────
1815
+ * Inbound: upload.upload, upload.status
1816
+ * Outbound: upload.upload.result, upload.status.result, upload.status.changed
1817
+ *
1818
+ * The service owns the `uploadId` (generated per request, scoped to the
1819
+ * requesting napplet), tracks the latest {@link UploadStatus} per upload for
1820
+ * `upload.status` queries, and cleans up on window teardown. The shell owns
1821
+ * consent, policy, server selection, signing, and the HTTP upload — all behind
1822
+ * the {@link Uploader}.
1823
+ *
1824
+ * @example
1825
+ * ```ts
1826
+ * import { createUploadService, createHttpUploader } from '@kehto/services';
1827
+ *
1828
+ * const uploader = createHttpUploader({ rails: { nip96: { servers } }, signEvent });
1829
+ * runtime.registerService('upload', createUploadService({ uploader }));
1830
+ * ```
1831
+ *
1832
+ * @packageDocumentation
1833
+ */
1834
+
1835
+ /**
1836
+ * Storage rail. `nip96` (NIP-96 HTTP file storage) and `blossom` (Blossom blob
1837
+ * storage) are the first concrete backends; the open string keeps the API
1838
+ * stable as shells add rails (torrents, usenet, …).
1839
+ */
1840
+ type UploadRail = 'nip96' | 'blossom' | (string & {});
1841
+ /** Lifecycle state of an upload. */
1842
+ type UploadState = 'pending' | 'uploading' | 'complete' | 'failed' | 'cancelled';
1843
+ /** Pixel dimensions of an uploaded image/video. */
1844
+ interface UploadDimensions {
1845
+ width: number;
1846
+ height: number;
1847
+ }
1848
+ /**
1849
+ * A napplet's upload request. `data` crosses the postMessage boundary by
1850
+ * structured clone — shells never require base64 encoding.
1851
+ */
1852
+ interface UploadRequest {
1853
+ /** Storage rail; omit to let the shell pick a configured default. */
1854
+ rail?: UploadRail;
1855
+ /** The bytes to upload. */
1856
+ data: ArrayBuffer | Blob;
1857
+ /** MIME type; inferred from `data` when omitted. */
1858
+ mimeType?: string;
1859
+ /** Suggested filename. */
1860
+ filename?: string;
1861
+ /** Alt text / description for the file event. */
1862
+ caption?: string;
1863
+ /** Request the server not re-encode the file (NIP-96 `no_transform`). */
1864
+ noTransform?: boolean;
1865
+ /** Rail-specific or shell-specific extra metadata. */
1866
+ metadata?: Record<string, unknown>;
1867
+ }
1868
+ /** A single Nostr tag (NIP-94 / imeta entries are arrays of strings). */
1869
+ type NostrTag = string[];
1870
+ /** The result of an upload. */
1871
+ interface UploadResult {
1872
+ /** Whether the upload succeeded (or is progressing) vs failed/cancelled. */
1873
+ ok: boolean;
1874
+ /** Shell-generated id, scoped to the requesting napplet. */
1875
+ uploadId: string;
1876
+ /** Current lifecycle state. */
1877
+ status: UploadState;
1878
+ /** The rail the shell used. */
1879
+ rail: UploadRail;
1880
+ /** Primary download URL. */
1881
+ url?: string;
1882
+ /** Mirrors / alternative server URLs. */
1883
+ fallbackUrls?: string[];
1884
+ /** Hash of the stored blob (NIP-94 `x`). */
1885
+ sha256?: string;
1886
+ /** Hash before server transforms (NIP-94 `ox`). */
1887
+ originalSha256?: string;
1888
+ /** Size in bytes. */
1889
+ size?: number;
1890
+ /** Stored MIME type. */
1891
+ mimeType?: string;
1892
+ /** Image/video dimensions when known. */
1893
+ dimensions?: UploadDimensions;
1894
+ /** Blurhash placeholder when known. */
1895
+ blurhash?: string;
1896
+ /** Ready-to-attach NIP-94 / imeta tags. */
1897
+ nip94?: NostrTag[];
1898
+ /** Error reason when the upload failed or was cancelled. */
1899
+ error?: string;
1900
+ }
1901
+ /** A status snapshot for an upload, including progress counters. */
1902
+ interface UploadStatus extends UploadResult {
1903
+ /** Bytes sent so far (while uploading). */
1904
+ bytesSent?: number;
1905
+ /** Total bytes to send. */
1906
+ bytesTotal?: number;
1907
+ /** Unix ms timestamp of this status. */
1908
+ updatedAt: number;
1909
+ }
1910
+ /**
1911
+ * Context handed to an {@link Uploader} for a single upload. Carries the
1912
+ * service-owned `uploadId` and a sink for streaming progress / state changes.
1913
+ */
1914
+ interface UploaderContext {
1915
+ /** The service-generated upload id (authoritative; scoped to the napplet). */
1916
+ uploadId: string;
1917
+ /** The napplet window that requested the upload. */
1918
+ windowId: string;
1919
+ /**
1920
+ * Push a status update (progress, or a transition to complete/failed). The
1921
+ * service stamps `uploadId` and `updatedAt` before forwarding to the napplet
1922
+ * as `upload.status.changed`, and records it as the latest tracked status.
1923
+ */
1924
+ onStatus(status: UploadStatus): void;
1925
+ }
1926
+ /**
1927
+ * Abstract upload backend. Implementors own server selection, rail
1928
+ * authorization signing (NIP-98 for NIP-96, kind 24242 for Blossom), the HTTP
1929
+ * upload, and integrity-hash reporting. The service translates wire envelopes
1930
+ * into these calls and back. A concrete reference implementation ships as
1931
+ * {@link createHttpUploader}.
1932
+ */
1933
+ interface Uploader {
1934
+ /** Upload `request.data`, streaming progress through `ctx.onStatus`. */
1935
+ upload(request: UploadRequest, ctx: UploaderContext): Promise<UploadResult>;
1936
+ /** Optional: resolve the latest status for an upload the service is not tracking. */
1937
+ status?(uploadId: string): Promise<UploadStatus | undefined>;
1938
+ /** Optional: abort an in-flight upload (called on window teardown). */
1939
+ cancel?(uploadId: string): void;
1940
+ }
1941
+ /** Options for {@link createUploadService}. */
1942
+ interface UploadServiceOptions {
1943
+ /** The upload backend the shell uses. Required. */
1944
+ uploader: Uploader;
1945
+ /** Generate an upload id; defaults to `crypto.randomUUID()`. */
1946
+ generateId?: () => string;
1947
+ /** Current time in unix ms; defaults to `Date.now()`. */
1948
+ now?: () => number;
1949
+ }
1950
+ /**
1951
+ * Create the NAP-UPLOAD service handler.
1952
+ *
1953
+ * @param options - Must provide an {@link Uploader}.
1954
+ * @returns A `ServiceHandler` ready for `runtime.registerService('upload', handler)`.
1955
+ * @throws If `options.uploader` is missing.
1956
+ */
1957
+ declare function createUploadService(options: UploadServiceOptions): ServiceHandler;
1958
+
1959
+ /**
1960
+ * http-uploader.ts — NAP-UPLOAD concrete HTTP-backed {@link Uploader}.
1961
+ *
1962
+ * The reference upload backend for {@link createUploadService}. Implements two
1963
+ * storage rails over HTTP:
1964
+ *
1965
+ * - **NIP-96** — signs a NIP-98 (kind 27235) HTTP-auth event, POSTs the file as
1966
+ * `multipart/form-data`, and maps the returned NIP-94 event tags into an
1967
+ * {@link UploadResult}.
1968
+ * - **Blossom** — signs a kind 24242 authorization event, PUTs the raw bytes to
1969
+ * `<server>/upload`, and maps the returned blob descriptor.
1970
+ *
1971
+ * Signing (`signEvent`) and transport (`fetch`) are injected so the uploader
1972
+ * carries no Nostr or network dependency and is fully unit-testable. The shell
1973
+ * holds the signing key and never exposes it to napplets — the uploader only
1974
+ * receives a signing callback. Server URLs are shell configuration, not napplet
1975
+ * input: a napplet may *hint* a rail, but never a server.
1976
+ *
1977
+ * The configured server URL is used directly as the upload endpoint (the
1978
+ * NIP-96 `api_url` / Blossom base). Hosts that need `.well-known` discovery can
1979
+ * resolve it before constructing the uploader.
1980
+ *
1981
+ * @example
1982
+ * ```ts
1983
+ * const uploader = createHttpUploader({
1984
+ * rails: { nip96: { servers: ['https://nostr.build/api/v2/nip96/upload'] } },
1985
+ * signEvent: (tmpl) => signer.signEvent(tmpl),
1986
+ * });
1987
+ * runtime.registerService('upload', createUploadService({ uploader }));
1988
+ * ```
1989
+ *
1990
+ * @packageDocumentation
1991
+ */
1992
+
1993
+ /** Per-rail server configuration. The first server is the primary endpoint. */
1994
+ interface RailServerConfig {
1995
+ /** Ordered server endpoint URLs; index 0 is primary. */
1996
+ servers: string[];
1997
+ }
1998
+ /** Storage rails this uploader can serve. */
1999
+ interface HttpUploaderRails {
2000
+ /** NIP-96 HTTP file storage. */
2001
+ nip96?: RailServerConfig;
2002
+ /** Blossom blob storage. */
2003
+ blossom?: RailServerConfig;
2004
+ }
2005
+ /** Signs an event template on the user's behalf (shell holds the key). */
2006
+ type SignEvent = (template: EventTemplate) => Promise<NostrEvent>;
2007
+ /** Options for {@link createHttpUploader}. */
2008
+ interface HttpUploaderOptions {
2009
+ /** Configured rails + their servers. */
2010
+ rails: HttpUploaderRails;
2011
+ /** Rail to use when a request omits one; defaults to the first configured rail. */
2012
+ defaultRail?: UploadRail;
2013
+ /** Signs NIP-98 / Blossom auth events. Required. */
2014
+ signEvent: SignEvent;
2015
+ /** Fetch implementation; defaults to the global `fetch`. */
2016
+ fetch?: typeof fetch;
2017
+ /** Hex SHA-256 of the payload bytes; defaults to Web Crypto. */
2018
+ digestSha256?: (bytes: Uint8Array) => Promise<string>;
2019
+ /** Unix *seconds* clock for event timestamps; defaults to `Date.now()/1000`. */
2020
+ now?: () => number;
2021
+ }
2022
+ /**
2023
+ * Create the reference HTTP {@link Uploader} (NIP-96 + Blossom rails).
2024
+ *
2025
+ * @param options - Rails, server config, and the injected `signEvent`.
2026
+ * @returns An {@link Uploader} for `createUploadService({ uploader })`.
2027
+ * @throws If `options.signEvent` is missing.
2028
+ */
2029
+ declare function createHttpUploader(options: HttpUploaderOptions): Uploader;
2030
+
2031
+ export { type AudioServiceOptions, type AudioSource, type CacheServiceOptions, type ConfigSchemaValidation, type ConfigService, type ConfigServiceOptions, type CoordinatedRelayOptions, type GiftWrapDecryptResult, type HostCacheBridge, type HostDecryptBridge, type HostKeyEvent, type HostKeysBridge, type HostMediaBridge, type HttpUploaderOptions, type HttpUploaderRails, type IdentityDecryptErrorCode, type IdentityDecryptErrorMessage, type IdentityDecryptMessage, type IdentityDecryptResultMessage, type IdentityServiceOptions, type KeysServiceOptions, type MediaMetadataLike, type MediaPlaybackOwner, type MediaServiceOptions, type MediaSessionCreateOptions, type MediaSessionTarget, type MediaSourceRef, type NostrTag, type Notification, type NotificationServiceOptions, type NotifyServiceOptions, type OutboxPublishOptions, type OutboxPublishResult, type OutboxQueryOptions, type OutboxRelayPlan, type OutboxRelayPool, type OutboxResult, type OutboxRouter, type OutboxRouterSubscription, type OutboxServiceOptions, type OutboxStrategy, type OutboxSubscribeOptions, type OutboxSubscriptionSink, type OutboxTarget, type RailServerConfig, type RelayListEntry, type RelayPoolOutboxRouterOptions, type RelayPoolServiceOptions, type ResourceService, type ResourceServiceOptions, type Rumor, type SignEvent, type ThemeService, type ThemeServiceOptions, type UploadDimensions, type UploadRail, type UploadRequest, type UploadResult, type UploadServiceOptions, type UploadState, type UploadStatus, type Uploader, type UploaderContext, type VerifyEvent, createAudioService, createBrowserMediaBridge, createCacheService, createConfigService, createCoordinatedRelay, createHttpUploader, createIdentityService, createKeysService, createMediaService, createNotificationService, createNotifyService, createOutboxService, createRelayPoolOutboxRouter, createRelayPoolService, createResourceService, createThemeService, createUploadService };