@kehto/services 0.2.0 → 0.6.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 CHANGED
@@ -1,6 +1,10 @@
1
1
  # @kehto/services
2
2
 
3
- Reference service handlers for the napplet protocol — audio, notifications, identity, relay pool, cache, keys (stub), media (stub), notify, theme.
3
+ Reference service handlers for the napplet protocol — audio, notifications, identity, relay pool, cache, keys, media, notify, theme.
4
+
5
+ > **Alpha status:** Kehto is an early runtime implementation for a draft NIP-5D
6
+ > protocol. NUB contracts and service envelopes are not final; treat these
7
+ > handlers as reference implementations for the current draft.
4
8
 
5
9
  ## Install
6
10
 
@@ -14,11 +18,11 @@ pnpm add @kehto/services
14
18
 
15
19
  Host apps wire services into the runtime via `runtime.registerService(name, handler)`. The services are browser-agnostic — they have no DOM dependency. Browser-specific behaviors (audio element pool, OS notifications) are delivered through host-supplied callbacks.
16
20
 
17
- Canonical v1.2 posture:
21
+ Current draft posture:
18
22
 
19
23
  - The v1.1 signer service is deleted outright. Its responsibilities split into two: read-only identity lookups go through `createIdentityService` (`getPublicKey`, `getRelays`, `getProfile`, `getFollows`, `getList`, `getZaps`, `getMutes`, `getBlocked`, `getBadges`); signing happens inside the shell as part of `relay.publish` / `relay.publishEncrypted` and is never exposed to napplets.
20
- - `createKeysService` and `createMediaService` are stub-only in v1.3 they accept the canonical envelopes and return well-formed responses, but real host backends (OS keybinding registration, audio/video playback control) must be plugged in by the host app in future milestones.
21
- - `createNotifyService` (NIP-5D `notify.*` NUB) coexists with the legacy `createNotificationService` (ifc-emit `notifications:*` channel). Both may be registered simultaneously until the legacy handler is retired.
24
+ - `createKeysService` and `createMediaService` ship real reference backends as of v1.4 (see the dedicated sections below). `createKeysService` attaches a document-level `keydown` listener by default and delivers `keys.action` push envelopes to registered napplets; `createMediaService` mirrors session metadata and playback state to `navigator.mediaSession` and emits `media.command` push envelopes on OS transport events. Both accept a host-bridge option (`HostKeysBridge` / `HostMediaBridge`) so Electron / Tauri / native shells can swap in OS-level backends without re-implementing the wire-protocol bookkeeping.
25
+ - `createNotifyService` (NIP-5D `notify.*` NUB) coexists with the legacy `createNotificationService` (ifc-emit `notifications:*` channel). Both may be registered simultaneously while hosts migrate.
22
26
 
23
27
  ## Quick Start
24
28
 
@@ -45,40 +49,338 @@ runtime.registerService(
45
49
  );
46
50
  ```
47
51
 
52
+ ## Keys Service
53
+
54
+ Reference keyboard / chord backend for the `keys.*` NIP-5D NUB. By default attaches a single `document`-level `keydown` listener that matches incoming events against registered chord subscriptions and delivers a `keys.action` push envelope back to the owning napplet. Implement the [`HostKeysBridge`](#hostkeysbridge-interface) interface to swap in OS-level backends (Electron `globalShortcut`, Tauri `GlobalShortcut`).
55
+
56
+ ### Factory
57
+
58
+ ```ts
59
+ import { createKeysService } from '@kehto/services';
60
+
61
+ export function createKeysService(options?: KeysServiceOptions): ServiceHandler & { destroy(): void };
62
+ ```
63
+
64
+ `destroy()` detaches the document listener (or the bridge's unsubscribe handles) and clears all subscription registries. Call on shell teardown.
65
+
66
+ ### KeysServiceOptions
67
+
68
+ | Field | Type | Description |
69
+ |-------|------|-------------|
70
+ | `onForward` | `(event: { key, code, ctrlKey, altKey, shiftKey, metaKey }) => void` | Called on `keys.forward` envelopes AND on matching document keydowns. DOM-shape payload (the service translates from the wire-format `{ ctrl, alt, shift, meta }` before invoking this callback). |
71
+ | `listenerTarget` | `EventTarget` | Defaults to `document`. Pass a fresh `new EventTarget()` in unit tests to isolate the listener. Ignored when `hostBridge` is provided. |
72
+ | `hostBridge` | `HostKeysBridge` | Pluggable OS-bridge. When provided, the service delegates `keys.registerAction` to `bridge.subscribe(chord, cb)` and the default document listener is NOT attached. |
73
+ | `reservedChords` | `ReadonlyArray<string>` | Optional set of shell-reserved chords (wire-format strings like `'Ctrl+Shift+K'`, `'Cmd+P'`). When a napplet forwards a reserved chord via `keys.forward` OR a document keydown matches a reserved chord, `onForward` (or the `hostBridge` handler) fires but `keys.action` is NOT dispatched to any napplet that registered the same chord via `keys.registerAction`. Precedence: **reserved > registered**. Normalized once at construction via the same parser used for `action.defaultKey`. See [Reserved Chords](#reserved-chords). |
74
+
75
+ ### HostKeysBridge interface
76
+
77
+ Copy the contract verbatim for host-app implementers. OS-level bridges implement `subscribe` at minimum; the two optional fields enable global-hotkey registration (works even when the host window is unfocused).
78
+
79
+ ```ts
80
+ export interface HostKeysBridge {
81
+ /**
82
+ * Subscribe a callback to a chord. Returns an unsubscribe handle.
83
+ *
84
+ * Implementations MUST:
85
+ * - invoke `callback` exactly once per matching chord event (implementations
86
+ * are responsible for any OS-autorepeat filtering)
87
+ * - invoke `callback` synchronously during the event delivery
88
+ * - accept the string chord format documented by @napplet/nub/keys
89
+ * (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`)
90
+ */
91
+ subscribe(chord: string, callback: (event: KeyboardEvent | HostKeyEvent) => void): () => void;
92
+
93
+ /**
94
+ * Optional: register an OS-level global hotkey (works even when the host
95
+ * window is not focused). Returns true on success, false if the chord
96
+ * cannot be registered (e.g. already claimed by another app).
97
+ *
98
+ * Omitted by the browser reference implementation — browsers cannot
99
+ * register OS-level global hotkeys without privileged APIs. Electron
100
+ * (`globalShortcut`) and Tauri (`GlobalShortcut`) provide this.
101
+ */
102
+ registerGlobalHotkey?(chord: string): boolean;
103
+
104
+ /**
105
+ * Optional: subscribe to OS-level global hotkey events (regardless of
106
+ * focus). Returns an unsubscribe handle.
107
+ *
108
+ * Omitted by the browser reference implementation. See
109
+ * {@link HostKeysBridge.registerGlobalHotkey}.
110
+ */
111
+ onGlobalHotkey?(callback: (chord: string) => void): () => void;
112
+ }
113
+ ```
114
+
115
+ ### Usage
116
+
117
+ Default browser path — the reference document-level chord listener:
118
+
119
+ ```ts
120
+ import { createKeysService } from '@kehto/services';
121
+
122
+ const keys = createKeysService({
123
+ onForward: (event) => {
124
+ // DOM-shape payload: { key, code, ctrlKey, altKey, shiftKey, metaKey }
125
+ hotkeyDispatcher.dispatch(event);
126
+ },
127
+ });
128
+
129
+ runtime.registerService('keys', keys);
130
+ // On shell teardown:
131
+ keys.destroy();
132
+ ```
133
+
134
+ Custom bridge path — swap in Electron's `globalShortcut`:
135
+
136
+ ```ts
137
+ import { createKeysService, type HostKeysBridge } from '@kehto/services';
138
+ import { globalShortcut } from 'electron';
139
+
140
+ const electronBridge: HostKeysBridge = {
141
+ subscribe(chord, cb) {
142
+ globalShortcut.register(chord, () => cb({
143
+ key: '', code: '',
144
+ ctrlKey: false, altKey: false, shiftKey: false, metaKey: false,
145
+ } as KeyboardEvent));
146
+ return () => globalShortcut.unregister(chord);
147
+ },
148
+ };
149
+
150
+ runtime.registerService('keys', createKeysService({ hostBridge: electronBridge }));
151
+ ```
152
+
153
+ ### When to plug a custom bridge
154
+
155
+ Plug a `HostKeysBridge` when the default document listener is insufficient: Electron or Tauri apps that need to register OS-level global hotkeys (chords delivered even when the host window is not focused), native shells that route chords through a platform-specific hotkey manager (macOS Carbon, Linux X11 grab, Windows RegisterHotKey), or test harnesses that inject synthetic events through a controlled `EventTarget`. The bridge owns subscription lifecycle; the service retains per-window bookkeeping (so `onWindowDestroyed` cleanup stays identical across paths).
156
+
157
+ See the demo: [`apps/playground/napplets/hotkey-chord/src/main.ts`](../../apps/playground/napplets/hotkey-chord/src/main.ts) (the Phase 26 end-to-end exemplar — uses `@napplet/sdk` `keys.registerAction` + `keys.onAction` against the real backend).
158
+
159
+ ### Reserved Chords
160
+
161
+ Shell-reserved chords let a host application (window manager, launcher shell, tiling WM) claim specific chords for its own dispatch regardless of what napplets subscribe to. Declare the reserved set once at service construction via the `reservedChords` option on [`KeysServiceOptions`](#keysserviceoptions):
162
+
163
+ ```ts
164
+ import { createKeysService } from '@kehto/services';
165
+
166
+ const keys = createKeysService({
167
+ reservedChords: [
168
+ 'Ctrl+Alt+T', // launcher
169
+ 'Super+Space', // workspace switch
170
+ 'Ctrl+Shift+Q', // window close
171
+ ],
172
+ onForward: (event) => {
173
+ // The shell's WM dispatcher — fires for reserved chords regardless of
174
+ // which napplet (if any) tried to register them.
175
+ wmLauncher.dispatch(event);
176
+ },
177
+ });
178
+
179
+ runtime.registerService('keys', keys);
180
+ ```
181
+
182
+ **Precedence contract: reserved > registered.** When a napplet forwards a chord via `keys.forward` — or when the default document keydown listener matches a chord registered by a napplet via `keys.registerAction` — the service consults the reserved set first:
183
+
184
+ - If the chord IS reserved: `onForward` (or the `hostBridge`-registered handler) fires exactly once. No `keys.action` envelope is dispatched to any napplet, even if the napplet registered the identical chord. This is intentional — the shell WANTS the forward; that is why it reserved the chord.
185
+ - If the chord is NOT reserved: legacy behavior. `onForward` fires AND every napplet whose registered action matches receives a `keys.action` envelope via its captured `send` handle.
186
+
187
+ Reserved chords are normalized at service construction via the same parser used for `action.defaultKey`, so `'Ctrl+Shift+K'`, `'Control+shift+k'`, and `'ctrl+Shift+K'` all match the same chord. Modifier aliases (`Cmd` / `Command` / `Win` / `Super` → meta; `Control` → ctrl; `Option` → alt) are recognized case-insensitively.
188
+
189
+ **WM-launcher integration example:**
190
+
191
+ ```ts
192
+ // Shell-side: declare every WM-absolute chord at boot.
193
+ const keys = createKeysService({
194
+ reservedChords: Object.keys(wmChordMap), // e.g. ['Super+1', 'Super+2', ..., 'Ctrl+Alt+T']
195
+ onForward: (event) => {
196
+ const chordStr = chordStringFromEvent(event);
197
+ const action = wmChordMap[chordStr];
198
+ if (action) action.execute();
199
+ },
200
+ });
201
+ runtime.registerService('keys', keys);
202
+
203
+ // Napplet-side (hotkey-chord napplet, for example): perfectly free to register
204
+ // `Ctrl+Shift+K` via keys.registerAction. If Ctrl+Shift+K is NOT in the shell's
205
+ // reservedChords, the napplet receives keys.action as normal. If a shell later
206
+ // adds Ctrl+Shift+K to its reserved set (e.g. because it now binds the chord
207
+ // to a WM action), the napplet's registration is silently suppressed for that
208
+ // chord — the shell is authoritative.
209
+ ```
210
+
211
+ **Dynamic reservation is out of scope for v1.6.** If a downstream shell needs runtime updates to the reserved set (e.g. "reservation depends on which workspace is active"), open an issue referencing `HostKeysBridge.reserveAbsolute(chords)` — the deferred extension shape. Until then, `reservedChords` is static at service construction.
212
+
213
+ **OS-level global hotkeys remain a separate concern.** `reservedChords` operates at the service layer — the chord must still reach the host window's focus (or be forwarded via `keys.forward`). For OS-level reservation (chord fires even when the host window is unfocused), implement [`HostKeysBridge.registerGlobalHotkey`](#hostkeysbridge-interface) in your bridge — reserved chords and global hotkeys compose orthogonally.
214
+
215
+ ## Media Service
216
+
217
+ Reference media backend for the `media.*` NIP-5D NUB. By default mirrors session metadata + playback state to `navigator.mediaSession` via the DOM `MediaSession` API and installs `setActionHandler` callbacks that emit `media.command` push envelopes on OS transport events (play / pause / next / previous / seek). Implement the [`HostMediaBridge`](#hostmediabridge-interface) interface to swap in native backends (Electron bridge, MPRIS on Linux, MediaRemote on macOS).
218
+
219
+ ### Factory
220
+
221
+ ```ts
222
+ import { createMediaService } from '@kehto/services';
223
+
224
+ export function createMediaService(options?: MediaServiceOptions): ServiceHandler & { destroy(): void };
225
+ ```
226
+
227
+ `destroy()` tears down the active bridge (removes `setActionHandler` listeners, removes the silent-audio prime element in the browser reference implementation) and clears the session registry.
228
+
229
+ ### MediaServiceOptions
230
+
231
+ | Field | Type | Description |
232
+ |-------|------|-------------|
233
+ | `onSessionCreate` | `(windowId, sessionId, metadata?) => void` | Called when a napplet creates a session. |
234
+ | `onState` | `(windowId, sessionId, state) => void` | Called on `media.state` updates — high-frequency; keep handler work minimal. |
235
+ | `onSessionDestroy` | `(windowId, sessionId) => void` | Called when a napplet destroys a session. |
236
+ | `onSessionUpdate` | `(windowId, sessionId, metadata) => void` | Called when a napplet updates session metadata. |
237
+ | `onCapabilities` | `(windowId, sessionId, actions) => void` | Called when a napplet declares capabilities for a session. |
238
+ | `mediaSessionTarget` | `MediaSessionTarget` | Overrides `navigator.mediaSession` (used by the default bridge only). Pass a `MockMediaSession` in unit tests. Ignored when `hostBridge` is provided. |
239
+ | `documentTarget` | `Document \| null` | Overrides `document` (used by the default bridge only). Set to `null` to disable the silent-audio prime in unit tests. Ignored when `hostBridge` is provided. |
240
+ | `hostBridge` | `HostMediaBridge` | Pluggable backend. When provided, the service delegates setMetadata / setPlaybackState / onAction to the bridge and skips `navigator.mediaSession` entirely. |
241
+
242
+ ### HostMediaBridge interface
243
+
244
+ Copy the contract verbatim for host-app implementers. Native bridges implement `setMetadata` + `setPlaybackState` + `onAction` at minimum; the two optional fields cover active-session switching and per-session teardown.
245
+
246
+ ```ts
247
+ export interface HostMediaBridge {
248
+ /**
249
+ * Set the metadata displayed on the OS transport surface for a session.
250
+ * Called on session.create (with initial metadata) and on session.update
251
+ * (with merged metadata) whenever the session is the active session.
252
+ * Implementations MUST be idempotent.
253
+ */
254
+ setMetadata(sessionId: string, metadata: MediaMetadata): void;
255
+
256
+ /**
257
+ * Set the playback state for a session. Called on media.state reports
258
+ * whenever the session is the active session. State strings match
259
+ * nub-media MediaState.status exactly. Implementations MUST be idempotent.
260
+ */
261
+ setPlaybackState(sessionId: string, state: 'playing' | 'paused' | 'stopped' | 'buffering'): void;
262
+
263
+ /**
264
+ * Subscribe to OS-level action events (user clicks play/pause/seek/next/prev
265
+ * on the transport surface). Returns an unsubscribe handle.
266
+ *
267
+ * The callback receives `(sessionId, action, value?)`. `sessionId` is the
268
+ * bridge's currently-active session (the browser impl tracks this internally
269
+ * via setActionHandler-at-fire-time; native impls track via setActiveSession).
270
+ * `value` is populated for `action === 'seek'` (seek target in seconds) and
271
+ * for `action === 'volume'` (0.0-1.0). The service dispatches the resulting
272
+ * `media.command` envelope to the owning napplet of that session.
273
+ */
274
+ onAction(callback: (sessionId: string, action: MediaAction, value?: number) => void): () => void;
275
+
276
+ /**
277
+ * Optional: notify the bridge that the active session has changed. The
278
+ * browser reference impl uses this to switch which session's metadata/state
279
+ * is mirrored to the singleton navigator.mediaSession and to install (or
280
+ * clear) action handlers for the session's declared capabilities.
281
+ *
282
+ * The optional `actions` parameter carries the session's declared capability
283
+ * set so the bridge can narrow which OS transport buttons are active. When
284
+ * omitted, the bridge applies its default set. Native OS bridges that track
285
+ * active-session state internally may omit this field entirely.
286
+ */
287
+ setActiveSession?(sessionId: string | null, actions?: readonly MediaAction[]): void;
288
+
289
+ /**
290
+ * Optional: tear down per-session resources. The browser reference impl
291
+ * uses this to remove the silent-audio prime element when the last session
292
+ * is destroyed. Bridges that need no per-session teardown may omit this field.
293
+ */
294
+ destroySession?(sessionId: string): void;
295
+ }
296
+ ```
297
+
298
+ ### Usage
299
+
300
+ Default browser path — the reference `navigator.mediaSession` mirror:
301
+
302
+ ```ts
303
+ import { createMediaService } from '@kehto/services';
304
+
305
+ const media = createMediaService({
306
+ onSessionCreate: (windowId, sessionId, metadata) => {
307
+ console.log(`[${windowId}] created session ${sessionId}`, metadata);
308
+ },
309
+ onState: (windowId, sessionId, state) => {
310
+ nowPlaying.update(windowId, state);
311
+ },
312
+ });
313
+
314
+ runtime.registerService('media', media);
315
+ // On shell teardown:
316
+ media.destroy();
317
+ ```
318
+
319
+ Custom bridge path — swap in an Electron host bridge:
320
+
321
+ ```ts
322
+ import { createMediaService, type HostMediaBridge, type MediaAction } from '@kehto/services';
323
+ import { mediaBridge } from './electron-media-bridge';
324
+
325
+ const electronBridge: HostMediaBridge = {
326
+ setMetadata(sessionId, md) {
327
+ mediaBridge.sendMetadata({ sessionId, md });
328
+ },
329
+ setPlaybackState(sessionId, state) {
330
+ mediaBridge.sendPlaybackState({ sessionId, state });
331
+ },
332
+ onAction(cb) {
333
+ const handler = (_: unknown, msg: { sessionId: string; action: MediaAction; value?: number }) =>
334
+ cb(msg.sessionId, msg.action, msg.value);
335
+ mediaBridge.onAction(handler);
336
+ return () => mediaBridge.offAction(handler);
337
+ },
338
+ };
339
+
340
+ runtime.registerService('media', createMediaService({ hostBridge: electronBridge }));
341
+ ```
342
+
343
+ ### When to plug a custom bridge
344
+
345
+ Plug a `HostMediaBridge` when `navigator.mediaSession` is insufficient: Electron apps that need to route transport events through the main process (lock-screen integration on Windows, Now Playing integration on macOS), Linux shells that speak MPRIS over D-Bus, native mobile wrappers that forward to AVPlayer / ExoPlayer, or test harnesses that record action events without touching the DOM. The bridge owns metadata/state mirroring and OS action routing; the service retains per-session bookkeeping (sessionRegistry + per-window send handles) so `media.command` dispatch semantics stay identical across paths.
346
+
347
+ See the demo: [`apps/playground/napplets/media-controller/src/main.ts`](../../apps/playground/napplets/media-controller/src/main.ts) (the Phase 27 end-to-end exemplar — uses `@napplet/nub/media` `mediaCreateSession({ owner: 'napplet', ... })` + `mediaReportState` + `mediaOnCommand` against the real backend).
348
+
48
349
  ## Public API
49
350
 
50
- Each factory returns a `ServiceHandler` registrable via `runtime.registerService()`. The bullets below note the canonical NIP-5D domain the handler owns and the ACL capability napplets need in order to reach it.
351
+ Each factory returns a `ServiceHandler` registrable via `runtime.registerService()`. The bullets below note the current NIP-5D domain the handler owns and the ACL capability napplets need in order to reach it.
51
352
 
52
353
  ### Identity NUB
53
- - [`createIdentityService`](../../docs/api/functions/_kehto_services.createIdentityService.html) — `identity.*` reads (`identity:read`). No signing surface; shell mediates signing internally.
354
+ - `createIdentityService` — `identity.*` reads (`identity:read`). No signing surface; shell mediates signing internally.
54
355
 
55
356
  ### Notify NUB
56
- - [`createNotifyService`](../../docs/api/functions/_kehto_services.createNotifyService.html) — canonical `notify.*` envelopes (`notify:send` / `notify:channel`).
57
- - [`createNotificationService`](../../docs/api/functions/_kehto_services.createNotificationService.html) — legacy ifc-emit `notifications:*` channel; coexists with `createNotifyService` until retired.
357
+ - `createNotifyService` — canonical `notify.*` envelopes (`notify:send` / `notify:channel`).
358
+ - `createNotificationService` — legacy ifc-emit `notifications:*` channel; coexists with `createNotifyService` until retired.
58
359
 
59
360
  ### Relay NUB
60
- - [`createRelayPoolService`](../../docs/api/functions/_kehto_services.createRelayPoolService.html) — `relay.publish`, `relay.publishEncrypted`, `relay.subscribe` fan-out (`relay:read` / `relay:write`).
61
- - [`createCacheService`](../../docs/api/functions/_kehto_services.createCacheService.html) — offline event cache (`cache:read` / `cache:write`).
62
- - [`createCoordinatedRelay`](../../docs/api/functions/_kehto_services.createCoordinatedRelay.html) — composite service that bundles relay-pool + cache with read-through behavior.
361
+ - `createRelayPoolService` — `relay.publish`, `relay.publishEncrypted`, `relay.subscribe` fan-out (`relay:read` / `relay:write`).
362
+ - `createCacheService` — offline event cache (`cache:read` / `cache:write`).
363
+ - `createCoordinatedRelay` — composite service that bundles relay-pool + cache with read-through behavior.
63
364
 
64
- ### Keys NUB (stub in v1.3)
65
- - [`createKeysService`](../../docs/api/functions/_kehto_services.createKeysService.html) — `keys.bind/unbind/bindings` stub (`keys:bind` / `keys:forward`). Plug a real backend via the `onBind`/`onForward` hooks when the host supports OS key registration.
365
+ ### Keys NUB
366
+ - `createKeysService` — `keys.registerAction` / `keys.unregisterAction` / `keys.forward` + `keys.action` push envelopes (`keys:forward`). Document-level chord listener by default; implement the `HostKeysBridge` interface to swap in Electron / Tauri / OS-level backends. See [Keys Service](#keys-service) for the full contract.
66
367
 
67
- ### Media NUB (stub in v1.3)
68
- - [`createMediaService`](../../docs/api/functions/_kehto_services.createMediaService.html) `media.*` playback/transport stub (`media:control`). Plug a real media backend via the service options.
368
+ ### Media NUB
369
+ - `createMediaService` — owner-aware `media.session.create` / `update` / `destroy` / `media.state` / `media.capabilities` + `media.command` push envelopes (`media:control`). Napplet-owned sessions mirror to `navigator.mediaSession` by default; shell-owned creates are rejected until a host playback/fetch bridge is supplied. Implement the `HostMediaBridge` interface to swap in native backends. See [Media Service](#media-service) for the full contract.
69
370
 
70
371
  ### Theme NUB
71
- - [`createThemeService`](../../docs/api/functions/_kehto_services.createThemeService.html) — `theme.get` + `theme.changed` fan-out (`theme:read`). Returns a `ThemeService` with `publishTheme()` / `setTheme()` utilities for host-side updates.
372
+ - `createThemeService` — `theme.get` + `theme.changed` fan-out (`theme:read`). Returns a `ThemeService` with `publishTheme()` / `setTheme()` utilities for host-side updates.
72
373
 
73
374
  ### Audio (legacy ifc-emit)
74
- - [`createAudioService`](../../docs/api/functions/_kehto_services.createAudioService.html) — `audio:*` ifc-emit topic handler. Browser-agnostic registry of per-window audio sources; host wires `onChange` to update transport UI.
375
+ - `createAudioService` — `audio:*` ifc-emit topic handler. Browser-agnostic registry of per-window audio sources; host wires `onChange` to update transport UI.
75
376
 
76
377
  ### Types
77
378
  `AudioSource`, `AudioServiceOptions`, `Notification`, `NotificationServiceOptions`, `IdentityServiceOptions`, `RelayPoolServiceOptions`, `CacheServiceOptions`, `CoordinatedRelayOptions`, `KeysServiceOptions`, `MediaServiceOptions`, `NotifyServiceOptions`, `ThemeServiceOptions`, `ThemeService`.
78
379
 
79
380
  ## API Reference
80
381
 
81
- Full API reference: [docs/api/@kehto/services/](../../docs/api/modules/_kehto_services.html) (generated via `pnpm docs:api`).
382
+ Full package docs: [`docs/packages/services.md`](../../docs/packages/services.md).
383
+ Generated API module: `docs/api/modules/_kehto_services.html` (run `pnpm docs:api`).
82
384
 
83
385
  ## License
84
386
 
@@ -0,0 +1,97 @@
1
+ import { C as CvmTransport } from './cvm-service-CXcOVQ0m.js';
2
+ import '@kehto/runtime';
3
+
4
+ /**
5
+ * cvm-nostr-transport.ts — concrete ContextVM transport for NAP-CVM.
6
+ *
7
+ * Implements {@link CvmTransport} over Nostr, exactly as validated against live
8
+ * ContextVM servers (e.g. Relatr):
9
+ *
10
+ * - MCP JSON-RPC messages ride in kind-25910 event `content`.
11
+ * - Requests are CEP-4 gift-wrapped: the inner kind-25910 event is signed with
12
+ * the shell's ephemeral client key, NIP-44-encrypted to the server, and
13
+ * placed in a kind-21059 (ephemeral) / 1059 (regular) wrap signed by a fresh
14
+ * random key, `p`-tagged to the server. Responses arrive the same way,
15
+ * `p`-tagged to the client, and are correlated by the inner JSON-RPC `id`.
16
+ * - Discovery reads kind-11316 (server) + kind-11317 (tools) announcements.
17
+ *
18
+ * Shipped on a separate entry (`@kehto/services/cvm-nostr-transport`) so the
19
+ * `nostr-tools` dependency stays out of the core `@kehto/services` bundle.
20
+ *
21
+ * The client key is ephemeral and shell-owned: napplets never see keys, relay
22
+ * sockets, or NIP-44 material (NAP-CVM §Security).
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { createNostrCvmTransport } from '@kehto/services/cvm-nostr-transport';
27
+ * const transport = createNostrCvmTransport({
28
+ * defaultRelays: ['wss://relay.contextvm.org', 'wss://relay2.contextvm.org'],
29
+ * });
30
+ * ```
31
+ */
32
+
33
+ /** Minimal signed Nostr event. */
34
+ interface NostrEventLike {
35
+ id: string;
36
+ pubkey: string;
37
+ created_at: number;
38
+ kind: number;
39
+ tags: string[][];
40
+ content: string;
41
+ sig: string;
42
+ }
43
+ /** A Nostr REQ filter (subset). */
44
+ interface NostrFilterLike {
45
+ kinds?: number[];
46
+ authors?: string[];
47
+ limit?: number;
48
+ ['#p']?: string[];
49
+ [key: string]: unknown;
50
+ }
51
+ /** Subscription handle returned by the relay pool. */
52
+ interface CvmSubCloser {
53
+ close(): void;
54
+ }
55
+ /**
56
+ * Minimal relay-pool surface used by this transport — structurally satisfied
57
+ * by `nostr-tools` `SimplePool`. Injectable for testing.
58
+ */
59
+ interface CvmRelayPool {
60
+ subscribe(relays: string[], filter: NostrFilterLike, params: {
61
+ onevent?: (event: NostrEventLike) => void;
62
+ oneose?: () => void;
63
+ }): CvmSubCloser;
64
+ publish(relays: string[], event: NostrEventLike): unknown;
65
+ }
66
+ /** Options for {@link createNostrCvmTransport}. */
67
+ interface NostrCvmTransportOptions {
68
+ /** Relays used when a server reference carries no relay hints. */
69
+ defaultRelays?: string[];
70
+ /** Default per-request timeout in milliseconds. */
71
+ timeoutMs?: number;
72
+ /** Whether to CEP-4 gift-wrap requests. Default true (most servers require it). */
73
+ encrypt?: boolean;
74
+ /** Use ephemeral (kind 21059) gift wraps when encrypting. Default true. */
75
+ ephemeralWrap?: boolean;
76
+ /** Relay pool to use. Defaults to a fresh `nostr-tools` `SimplePool`. */
77
+ pool?: CvmRelayPool;
78
+ /** Client secret key (32 bytes). Defaults to a generated ephemeral key. */
79
+ clientSecretKey?: Uint8Array;
80
+ /** Client info advertised during MCP `initialize`. */
81
+ clientInfo?: {
82
+ name: string;
83
+ version: string;
84
+ };
85
+ }
86
+ /**
87
+ * Create a Nostr-backed ContextVM transport.
88
+ *
89
+ * @param options - Relay set, timeouts, encryption mode, and optional injected
90
+ * pool/keys (the injected pool + key make the transport deterministic in tests).
91
+ * @returns A {@link CvmTransport} plus a `dispose()` to tear down subscriptions.
92
+ */
93
+ declare function createNostrCvmTransport(options?: NostrCvmTransportOptions): CvmTransport & {
94
+ dispose(): void;
95
+ };
96
+
97
+ export { type CvmRelayPool, type CvmSubCloser, type NostrCvmTransportOptions, type NostrEventLike, type NostrFilterLike, createNostrCvmTransport };