@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/dist/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { ServiceHandler, Signer } from '@kehto/runtime';
2
- import { NostrFilter, NostrEvent } from '@napplet/core';
3
- import { NotifySendMessage } from '@napplet/nub-notify';
4
- import { Theme, ThemeChangedMessage } from '@napplet/nub-theme';
2
+ import { NostrEvent, NappletMessage, NostrFilter, EventTemplate } from '@napplet/core';
3
+ import { MediaMetadata, MediaAction } from '@napplet/nub/media/types';
4
+ export { MediaAction } from '@napplet/nub/media/types';
5
+ import { NotifySendMessage } from '@napplet/nub/notify/types';
6
+ import { Theme, ThemeChangedMessage } from '@napplet/nub/theme/types';
7
+ import { ConfigSchemaErrorCode, ConfigValues, NappletConfigSchema } from '@napplet/nub/config/types';
8
+ export { a as CvmCloseMessage, b as CvmCloseResultMessage, c as CvmDiscoverMessage, d as CvmDiscoverQuery, e as CvmDiscoverResultMessage, f as CvmEventMessage, g as CvmInboundMessage, h as CvmOutboundMessage, i as CvmRequestMessage, j as CvmRequestOptions, k as CvmRequestResultMessage, l as CvmServer, m as CvmServerRef, n as CvmService, o as CvmServiceOptions, C as CvmTransport, p as CvmTransportError, M as McpContentBlock, q as McpMessage, r as McpTool, s as McpToolResult, t as createCvmService } from './cvm-service-CXcOVQ0m.js';
5
9
 
6
10
  /**
7
11
  * @kehto/services — Shared types for reference service implementations.
@@ -138,15 +142,6 @@ interface NotificationServiceOptions {
138
142
  */
139
143
  declare function createAudioService(options?: AudioServiceOptions): ServiceHandler;
140
144
 
141
- /**
142
- * notification-service.ts — Notification state registry as a ServiceHandler.
143
- *
144
- * Tracks notifications created by napplet windows. Shell hosts wire this
145
- * into the runtime via registerService('notifications', createNotificationService(opts)).
146
- * The shell host decides presentation (toast, badge, OS notification, etc.)
147
- * through the onChange callback. Browser-agnostic — no DOM, no window.
148
- */
149
-
150
145
  /**
151
146
  * Create a notification service handler.
152
147
  *
@@ -186,17 +181,53 @@ declare function createNotificationService(options?: NotificationServiceOptions)
186
181
  * - signer.nip04.encrypt/decrypt -> DELETED
187
182
  * - signer.nip44.encrypt/decrypt -> DELETED (shell encrypts internally
188
183
  * inside relay.publishEncrypted)
184
+ * - identity.decrypt -> ADDED in v1.8 as a shell-mediated decrypt request
185
+ * with class gate + typed error union.
189
186
  *
190
187
  * See REQUIREMENTS.md DEPS-03 (Phase 15 changelog).
191
188
  *
192
- * Handles 9 identity.* request types from @napplet/nub-identity. getPublicKey
189
+ * Handles 10 identity.* request types from @napplet/nub/identity. getPublicKey
193
190
  * and getRelays return real values sourced from hooks.auth.getSigner(); the
194
191
  * remaining 7 (getProfile/getFollows/getList/getZaps/getMutes/getBlocked/
195
- * getBadges) are stub-level each returns an empty default payload with the
196
- * spec-correct envelope shape. Host apps plug real backends via
197
- * runtime.registerService('identity', realHandler).
192
+ * getBadges) are stub-level; decrypt delegates to a host-supplied bridge.
193
+ * Host apps plug real backends via runtime.registerService('identity', realHandler).
198
194
  */
199
195
 
196
+ type IdentityDecryptErrorCode = 'class-forbidden' | 'signer-denied' | 'signer-unavailable' | 'decrypt-failed' | 'malformed-wrap' | 'impersonation' | 'unsupported-encryption' | 'policy-denied';
197
+ interface Rumor {
198
+ id: string;
199
+ pubkey: string;
200
+ created_at: number;
201
+ kind: number;
202
+ tags: string[][];
203
+ content: string;
204
+ }
205
+ interface IdentityDecryptMessage extends NappletMessage {
206
+ type: 'identity.decrypt';
207
+ id: string;
208
+ event: NostrEvent;
209
+ }
210
+ interface IdentityDecryptResultMessage extends NappletMessage {
211
+ type: 'identity.decrypt.result';
212
+ id: string;
213
+ rumor: Rumor;
214
+ sender: string;
215
+ }
216
+ interface IdentityDecryptErrorMessage extends NappletMessage {
217
+ type: 'identity.decrypt.error';
218
+ id: string;
219
+ error: IdentityDecryptErrorCode;
220
+ }
221
+ interface GiftWrapDecryptResult {
222
+ seal: NostrEvent;
223
+ rumor: Rumor;
224
+ }
225
+ interface HostDecryptBridge {
226
+ nip04Decrypt(senderPubkey: string, ciphertext: string): Promise<string>;
227
+ nip44Decrypt(senderPubkey: string, ciphertext: string): Promise<string>;
228
+ unwrapGiftWrap(wrap: NostrEvent): Promise<GiftWrapDecryptResult>;
229
+ }
230
+ type VerifyEvent = (event: NostrEvent) => boolean | Promise<boolean>;
200
231
  /**
201
232
  * Options for creating the identity service.
202
233
  *
@@ -215,14 +246,27 @@ interface IdentityServiceOptions {
215
246
  * availability can change dynamically.
216
247
  */
217
248
  getSigner: () => Signer | null;
249
+ /**
250
+ * Return the host decrypt bridge. Called only after outer event signature
251
+ * verification and encryption-mode detection succeed. Null means decrypt is
252
+ * unavailable while the rest of the identity service remains usable.
253
+ */
254
+ getDecryptor?: () => HostDecryptBridge | null;
255
+ /**
256
+ * Verify a received event before any decrypt attempt. Host shells should
257
+ * wire this to their canonical Nostr event verifier; tests and old hosts
258
+ * default to true for backward compatibility with the 9 read-only actions.
259
+ */
260
+ verifyEvent?: VerifyEvent;
218
261
  }
219
262
  /**
220
263
  * Create an identity service that handles NIP-5D identity.* envelope messages.
221
264
  *
222
- * Supports all 9 identity.* request types from @napplet/nub-identity. The two
265
+ * Supports all 10 identity.* request types from @napplet/nub/identity. The two
223
266
  * read-only nostr-info queries (getPublicKey, getRelays) resolve through the
224
267
  * caller-supplied signer; the remaining 7 return default/empty payloads with
225
268
  * spec-correct envelope shapes so napplets always receive a result envelope.
269
+ * identity.decrypt delegates to the host decrypt bridge.
226
270
  *
227
271
  * @param options - Identity service configuration (getSigner)
228
272
  * @returns A ServiceHandler ready for runtime.registerService('identity', handler)
@@ -328,15 +372,6 @@ interface RelayPoolServiceOptions {
328
372
  */
329
373
  declare function createRelayPoolService(options: RelayPoolServiceOptions): ServiceHandler;
330
374
 
331
- /**
332
- * cache-service.ts — Local event cache as a ServiceHandler.
333
- *
334
- * Wraps an existing cache implementation (query, store, isAvailable)
335
- * as a ServiceHandler that receives relay NUB envelope messages. Cache
336
- * subscriptions are one-shot queries — relay.subscribe triggers a query
337
- * and immediate EOSE, unlike relay pool subscriptions which stay open.
338
- */
339
-
340
375
  /**
341
376
  * Options for creating a cache service.
342
377
  *
@@ -370,6 +405,31 @@ interface CacheServiceOptions {
370
405
  */
371
406
  isAvailable(): boolean;
372
407
  }
408
+ /**
409
+ * @kehto/services cross-package naming-parity alias for {@link CacheServiceOptions}.
410
+ *
411
+ * `HostCacheBridge` matches the v1.4 `HostKeysBridge` / `HostMediaBridge`
412
+ * convention — it is a pure type alias for `CacheServiceOptions`, NOT a new
413
+ * type. Existing consumers of `CacheServiceOptions` continue to work
414
+ * unchanged; new consumers may prefer `HostCacheBridge` for consistency
415
+ * with the other Host*Bridge names in `@kehto/services`.
416
+ *
417
+ * Anti-feature note (PITFALLS.md M-02): `CacheServiceOptions` MUST remain
418
+ * the primary export. This alias is additive; do not rename or delete
419
+ * `CacheServiceOptions` when other Host*Bridge names eventually
420
+ * stabilize.
421
+ *
422
+ * @example
423
+ * ```ts
424
+ * import type { HostCacheBridge } from '@kehto/services';
425
+ * const cache: HostCacheBridge = {
426
+ * query: (filters) => myIndexedDB.query(filters),
427
+ * store: (event) => myIndexedDB.store(event),
428
+ * isAvailable: () => true,
429
+ * };
430
+ * ```
431
+ */
432
+ type HostCacheBridge = CacheServiceOptions;
373
433
  /**
374
434
  * Create a cache service that wraps an existing cache implementation
375
435
  * as a ServiceHandler.
@@ -395,17 +455,6 @@ interface CacheServiceOptions {
395
455
  */
396
456
  declare function createCacheService(options: CacheServiceOptions): ServiceHandler;
397
457
 
398
- /**
399
- * coordinated-relay.ts — Composite relay + cache ServiceHandler.
400
- *
401
- * Combines relay pool and cache into a single service that handles
402
- * relay.subscribe by querying both sources, deduplicating events by ID,
403
- * and sending a unified EOSE after both sources complete.
404
- *
405
- * This is a convenience helper for shell implementors. Those who need
406
- * custom coordination can write their own composite service.
407
- */
408
-
409
458
  /**
410
459
  * Options for creating a coordinated relay service.
411
460
  *
@@ -468,27 +517,126 @@ interface CoordinatedRelayOptions {
468
517
  declare function createCoordinatedRelay(options: CoordinatedRelayOptions): ServiceHandler;
469
518
 
470
519
  /**
471
- * keys-service.ts — NIP-5D keys NUB reference service (stub-level).
520
+ * keys-service.ts — NIP-5D keys NUB reference document-level listener implementation.
472
521
  *
473
- * Handles the 3 napplet -> shell request types from @napplet/nub-keys:
522
+ * Handles the 3 napplet -> shell request types from @napplet/nub/keys:
474
523
  * - keys.forward -> invokes options.onForward (hotkey passthrough, fire-and-forget)
475
- * - keys.registerAction -> echoes { actionId, binding: action.defaultKey? } as .result
476
- * - keys.unregisterAction -> fire-and-forget (no envelope)
477
- *
478
- * Stub-level: no real keyboard listener, no binding persistence. Host apps
479
- * wire a real backend via runtime.registerService('keys', realHandler).
480
- *
481
- * Field-name translation: @napplet/nub-keys uses the compact
524
+ * - keys.registerAction -> parses action.defaultKey into a chord spec, stores the
525
+ * subscription in an in-memory registry keyed by actionId,
526
+ * tracks windowId ownership so onWindowDestroyed can auto-
527
+ * unsubscribe, and echoes { actionId, binding } as .result
528
+ * - keys.unregisterAction -> removes the subscription; fire-and-forget (no envelope)
529
+ *
530
+ * Real listener: on service construction the handler attaches a single
531
+ * `keydown` listener to `options.listenerTarget` (default: `document`). Each
532
+ * keydown is matched against the chord-subscription registry; matches invoke
533
+ * `options.onForward` with the DOM-shape payload AND push a canonical
534
+ * `keys.action` envelope back to the owning napplet via the per-window `send`
535
+ * callback captured at `keys.registerAction` time. Subscriptions persist
536
+ * across messages; `onWindowDestroyed(windowId)` drops all subscriptions owned
537
+ * by the destroyed window as well as its cached `send` handle.
538
+ *
539
+ * On each document keydown matching a registered action, the service
540
+ * additionally emits a `keys.action` envelope to the action's owning napplet
541
+ * via the per-window `send` callback — this is the canonical @napplet/nub/keys
542
+ * surface the SDK's `keys.onAction(...)` helper consumes. The shape is a
543
+ * superset of `KeysActionMessage`: `{ type, actionId, chord }` where `chord`
544
+ * is the parsed `{ ctrl, alt, shift, meta, key }` struct (extension field;
545
+ * base shape unchanged, downstream SDKs that only read `{ type, actionId }`
546
+ * ignore `chord` silently).
547
+ *
548
+ * Field-name translation: @napplet/nub/keys uses the compact
482
549
  * { ctrl, alt, shift, meta } form on the wire; the shell's HotkeyHooks
483
550
  * (packages/shell/src/types.ts) expects the DOM-compatible
484
551
  * { ctrlKey, altKey, shiftKey, metaKey } form. This service performs the
485
552
  * translation so callers of `onForward` see the DOM shape.
486
553
  *
487
- * Shell -> napplet push envelopes (`keys.bindings`, `keys.action`) are
488
- * NOT emitted by this service they are the shell-side keys forwarder's
489
- * responsibility (DRIFT-SHELL-06, tracked under Plan 12-11 / future phase).
554
+ * Shell -> napplet push envelopes `keys.bindings` remain the shell-side keys
555
+ * forwarder's responsibility (DRIFT-SHELL-06, tracked under Plan 12-11 /
556
+ * future phase); `keys.action` is emitted here per Plan 26-01.
490
557
  */
491
558
 
559
+ /**
560
+ * Minimal structural subset of the DOM `KeyboardEvent` exposed to
561
+ * `HostKeysBridge` subscribe callbacks. DOM `KeyboardEvent` satisfies this
562
+ * structurally with no adapter needed. OS-bridge impls (Electron, Tauri —
563
+ * out of v1.4 scope) synthesize this from native key events.
564
+ */
565
+ interface HostKeyEvent {
566
+ key: string;
567
+ code: string;
568
+ ctrlKey: boolean;
569
+ altKey: boolean;
570
+ shiftKey: boolean;
571
+ metaKey: boolean;
572
+ /** True for OS autorepeat; the service filters these by default. */
573
+ repeat?: boolean;
574
+ }
575
+ /**
576
+ * Host-bridge contract for pluggable keyboard backends.
577
+ *
578
+ * The browser reference implementation (the default {@link createKeysService}
579
+ * behaviour when `hostBridge` is omitted) registers a `document`-level keydown
580
+ * listener and satisfies this interface structurally — it exposes
581
+ * `subscribe(chord, callback) => unsubscribe` semantics but omits the two
582
+ * OS-level optional fields (browsers cannot register global hotkeys without
583
+ * privileged APIs).
584
+ *
585
+ * Host apps (Electron, Tauri) implement this interface in their own code and
586
+ * pass it via `createKeysService({ hostBridge: myBridge })` — the service
587
+ * then delegates subscription lifecycle to the bridge and remains browser-free.
588
+ *
589
+ * Reference implementations for Electron / Tauri are explicitly out of v1.4
590
+ * scope and live in host-app examples / follow-up milestones (see
591
+ * REQUIREMENTS.md "Future Requirements").
592
+ *
593
+ * @example
594
+ * ```ts
595
+ * // Host-app pseudocode (Electron main-process relay):
596
+ * const electronBridge: HostKeysBridge = {
597
+ * subscribe(chord, cb) {
598
+ * const handle = globalShortcut.register(chord, () => cb({ key: '', code: '', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false }));
599
+ * return () => globalShortcut.unregister(chord);
600
+ * },
601
+ * registerGlobalHotkey: (chord) => globalShortcut.register(chord, () => {}),
602
+ * onGlobalHotkey: (cb) => globalHotkeyBridge.on('global-hotkey', (_, chord) => cb(chord)),
603
+ * };
604
+ *
605
+ * const keys = createKeysService({ hostBridge: electronBridge });
606
+ * runtime.registerService('keys', keys);
607
+ * ```
608
+ */
609
+ interface HostKeysBridge {
610
+ /**
611
+ * Subscribe a callback to a chord. Returns an unsubscribe handle.
612
+ *
613
+ * Implementations MUST:
614
+ * - invoke `callback` exactly once per matching chord event (implementations
615
+ * are responsible for any OS-autorepeat filtering)
616
+ * - invoke `callback` synchronously during the event delivery
617
+ * - accept the string chord format documented by @napplet/nub/keys
618
+ * (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`)
619
+ */
620
+ subscribe(chord: string, callback: (event: KeyboardEvent | HostKeyEvent) => void): () => void;
621
+ /**
622
+ * Optional: register an OS-level global hotkey (works even when the host
623
+ * window is not focused). Returns true on success, false if the chord
624
+ * cannot be registered (e.g. already claimed by another app).
625
+ *
626
+ * Omitted by the browser reference implementation — browsers cannot
627
+ * register OS-level global hotkeys without privileged APIs. Electron
628
+ * (`globalShortcut`) and Tauri (`GlobalShortcut`) provide this.
629
+ */
630
+ registerGlobalHotkey?(chord: string): boolean;
631
+ /**
632
+ * Optional: subscribe to OS-level global hotkey events (regardless of
633
+ * focus). Returns an unsubscribe handle.
634
+ *
635
+ * Omitted by the browser reference implementation. See
636
+ * {@link HostKeysBridge.registerGlobalHotkey}.
637
+ */
638
+ onGlobalHotkey?(callback: (chord: string) => void): () => void;
639
+ }
492
640
  /**
493
641
  * Options for creating a keys service via createKeysService().
494
642
  *
@@ -504,7 +652,8 @@ declare function createCoordinatedRelay(options: CoordinatedRelayOptions): Servi
504
652
  */
505
653
  interface KeysServiceOptions {
506
654
  /**
507
- * Called on keys.forward. Receives the DOM-style field names
655
+ * Called on `keys.forward` (napplet-forwarded chord) AND on document keydown
656
+ * matching a registered action. Receives the DOM-style field names
508
657
  * (ctrlKey/altKey/shiftKey/metaKey) to match the shell's HotkeyHooks
509
658
  * contract. The service translates from the wire shape
510
659
  * ({ ctrl, alt, shift, meta }) before invoking this callback.
@@ -517,17 +666,62 @@ interface KeysServiceOptions {
517
666
  shiftKey: boolean;
518
667
  metaKey: boolean;
519
668
  }) => void;
669
+ /**
670
+ * EventTarget to attach the default keydown listener to. Defaults to the
671
+ * global `document` when running in a DOM environment, else an isolated
672
+ * `new EventTarget()` (SSR / Node-test safe). Passing a fresh
673
+ * `new EventTarget()` is useful for unit tests. Mirrors the pattern used
674
+ * by `@kehto/shell`'s `createKeysForwarder`.
675
+ *
676
+ * Ignored when `hostBridge` is provided — the bridge owns subscription
677
+ * lifecycle and no document listener is attached.
678
+ */
679
+ listenerTarget?: EventTarget;
680
+ /**
681
+ * Optional pluggable backend for chord subscription. When provided, the
682
+ * service delegates `keys.registerAction` → `bridge.subscribe(chord, cb)`
683
+ * and stores the returned unsubscribe handle keyed on `actionId`. The
684
+ * default document-listener path is NOT attached when `hostBridge` is
685
+ * provided — the bridge is authoritative. See {@link HostKeysBridge}.
686
+ */
687
+ hostBridge?: HostKeysBridge;
688
+ /**
689
+ * Optional set of shell-reserved chords. Strings in the `@napplet/nub/keys`
690
+ * wire format (e.g. `'Ctrl+Shift+K'`, `'Cmd+P'`). When a napplet sends
691
+ * `keys.forward` with a chord matching this set — or when a document
692
+ * keydown matches a reserved chord — the service invokes `onForward`
693
+ * (or the `hostBridge`-registered handler) but suppresses the
694
+ * `keys.action` push to any napplet that registered the same chord via
695
+ * `keys.registerAction`. Precedence: reserved > registered. The shell
696
+ * WANTS the forward — that is why it reserved the chord.
697
+ *
698
+ * Normalized once at service construction via the same chord parser used
699
+ * for `action.defaultKey` — `'Ctrl+K'` / `'Control+k'` / `'ctrl+K'` all
700
+ * match. Static; no runtime mutation. For dynamic reservation see the
701
+ * deferred `HostKeysBridge.reserveAbsolute(chords)` extension.
702
+ *
703
+ * @example
704
+ * ```ts
705
+ * const keys = createKeysService({
706
+ * reservedChords: ['Ctrl+Alt+T', 'Super+Space'],
707
+ * onForward: (event) => wm.dispatch(event),
708
+ * });
709
+ * ```
710
+ */
711
+ reservedChords?: ReadonlyArray<string>;
520
712
  }
521
713
  /**
522
714
  * Create a keys service handler.
523
715
  *
524
- * The service is stub-level: it dispatches the 3 napplet -> shell keys
525
- * request types from @napplet/nub-keys, responds with spec-correct
526
- * envelopes, and defers real keyboard behavior to an optional
527
- * onForward callback. No binding persistence, no real keyboard listener.
716
+ * Attaches a single `keydown` listener to `options.listenerTarget`
717
+ * (default `document`). Matching chord subscriptions invoke `options.onForward`
718
+ * with a DOM-shape payload AND push a `keys.action` envelope back to the
719
+ * owning napplet via the per-window `send` callback captured at
720
+ * `keys.registerAction` time. Returns a `ServiceHandler` augmented with a
721
+ * `destroy()` method that detaches the listener and clears all registries.
528
722
  *
529
- * @param options - Optional configuration (onForward callback)
530
- * @returns A ServiceHandler to register with the runtime
723
+ * @param options - Optional configuration (onForward callback, listenerTarget)
724
+ * @returns A ServiceHandler (with `destroy()`) to register with the runtime
531
725
  *
532
726
  * @example
533
727
  * ```ts
@@ -538,32 +732,229 @@ interface KeysServiceOptions {
538
732
  * });
539
733
  *
540
734
  * runtime.registerService('keys', keys);
735
+ * // Later, on shell teardown:
736
+ * keys.destroy();
541
737
  * ```
542
738
  */
543
- declare function createKeysService(options?: KeysServiceOptions): ServiceHandler;
739
+ declare function createKeysService(options?: KeysServiceOptions): ServiceHandler & {
740
+ destroy(): void;
741
+ };
742
+
743
+ /** Minimal subset of navigator.mediaSession the browser bridge depends on. Makes the bridge
744
+ * Node/test-safe: unit tests pass a MockMediaSession via mediaSessionTarget.
745
+ * The handler parameter uses `details?` (optional) so both the real DOM impl
746
+ * (which always passes an object) and test mocks that omit details both satisfy
747
+ * this type structurally. */
748
+ type MediaSessionTarget = {
749
+ metadata: MediaMetadataLike | null;
750
+ playbackState: 'none' | 'playing' | 'paused';
751
+ setActionHandler(action: string, handler: ((details?: {
752
+ action?: string;
753
+ seekTime?: number;
754
+ }) => void) | null): void;
755
+ };
756
+ /** Structural subset of the DOM MediaMetadata class — assignable from a plain object
757
+ * with title/artist/album/artwork fields. The browser impl uses `new MediaMetadata({...})`;
758
+ * tests can pass a plain object. */
759
+ type MediaMetadataLike = {
760
+ title?: string;
761
+ artist?: string;
762
+ album?: string;
763
+ artwork?: unknown;
764
+ };
765
+ /**
766
+ * Reference browser implementation of {@link HostMediaBridge}.
767
+ *
768
+ * Mirrors metadata to `navigator.mediaSession.metadata` (via the DOM
769
+ * `MediaMetadata` constructor when available; plain-object fallback in test
770
+ * envs). Mirrors playback state to `navigator.mediaSession.playbackState`
771
+ * with the canonical mapping: 'playing' maps to 'playing', 'paused' maps to
772
+ * 'paused', 'buffering' maps to 'paused', 'stopped' maps to 'none'. Installs
773
+ * `setActionHandler` callbacks for play/pause/nexttrack/previoustrack/seekto
774
+ * that fan into the onAction subscriber with the mapped `MediaAction` literal
775
+ * (and `value` from `details.seekTime` for seekto). When `setActiveSession` is
776
+ * called with a non-null `actions` parameter, only the declared actions get
777
+ * active handlers — the remaining are cleared (matching the capabilities
778
+ * narrowing behavior of Plan 27-01's inline implementation).
779
+ *
780
+ * Installs a silent-audio prime (4 kHz silent WAV data URL) when the first
781
+ * session becomes active (setActiveSession called with a non-null sessionId) —
782
+ * browsers refuse to render OS media controls without a playing audio element.
783
+ * Removes the element when destroySession brings the active session count to
784
+ * zero.
785
+ *
786
+ * @param opts.mediaSessionTarget - Override navigator.mediaSession (tests).
787
+ * @param opts.documentTarget - Override document (tests; pass null to disable silent-audio prime).
788
+ */
789
+ declare function createBrowserMediaBridge(opts?: {
790
+ mediaSessionTarget?: MediaSessionTarget;
791
+ documentTarget?: Document | null;
792
+ }): HostMediaBridge;
544
793
 
545
794
  /**
546
- * media-service.ts — NIP-5D media NUB reference service (stub-level).
795
+ * media-service.ts — NIP-5D media NUB reference service (navigator.mediaSession
796
+ * reference implementation).
547
797
  *
548
- * Handles 5 napplet -> shell request types from @napplet/nub-media:
798
+ * Handles the napplet-owned subset of @napplet/nub/media:
549
799
  * media.session.create (result), media.session.update, media.session.destroy,
550
800
  * media.state, media.capabilities.
551
801
  *
552
- * Stub-level: no real MediaSession API integration. Host apps wire real
553
- * playback backends via runtime.registerService('media', realHandler).
802
+ * HostMediaBridge contract: {@link HostMediaBridge} defines the pluggable backend
803
+ * contract for metadata/state mirroring + action routing. The browser reference
804
+ * implementation is {@link createBrowserMediaBridge} (mirrors to navigator.mediaSession
805
+ * with setActionHandler matrix). When no hostBridge option is passed, createMediaService
806
+ * internally uses createBrowserMediaBridge as the default — behavior is identical
807
+ * to the Plan 27-01 single-path implementation.
808
+ *
809
+ * navigator.mediaSession mirroring: on session.create the browser bridge mirrors the
810
+ * napplet-supplied metadata to navigator.mediaSession.metadata via new MediaMetadata()
811
+ * and installs setActionHandler callbacks for the 5 OS transport actions
812
+ * (play / pause / nexttrack / previoustrack / seekto). Each callback emits a canonical
813
+ * media.command envelope to the owning napplet — that is the @napplet/nub/media
814
+ * MediaCommandMessage shape consumed by the SDK's mediaOnCommand() helper.
815
+ *
816
+ * NAP-MEDIA now distinguishes napplet-owned playback from shell-owned
817
+ * playback. This reference backend supports `owner: "napplet"` because it
818
+ * mirrors a napplet's own media element to navigator.mediaSession. It rejects
819
+ * `owner: "shell"` until a host bridge provides policy-checked source fetching
820
+ * and playback.
821
+ *
822
+ * media.command push: when the OS user clicks a media control (hardware key, lock-screen
823
+ * transport, OS media overlay), the bridge's onAction callback fires. The service looks
824
+ * up the active session's owning napplet, invokes the per-window send callback captured
825
+ * at session.create time, and delivers:
826
+ * { type: 'media.command', sessionId, action, value? }
827
+ * where action is the nub-media MediaAction literal (play|pause|next|prev|seek) and
828
+ * value is the seekTime (seconds) for seek.
829
+ *
830
+ * Silent-audio prime: the browser bridge creates a hidden <audio> element with a
831
+ * 4 kHz silent WAV data URL and plays it when the first session becomes active
832
+ * (setActiveSession with a non-null sessionId). Without a playing audio element in
833
+ * the host page, most browsers refuse to render OS media controls. The element is
834
+ * cleaned up when the last session is destroyed (via bridge.destroySession).
835
+ *
836
+ * Multi-session registry with last-active-wins semantics: every session.create is
837
+ * tracked in a Map<sessionId, SessionEntry>; any media.state report promotes that
838
+ * session to active. The active session's metadata and playback state are reflected
839
+ * via bridge.setMetadata / bridge.setPlaybackState. When the active session is
840
+ * destroyed, the next-most-recently-touched session is promoted automatically.
554
841
  *
555
842
  * Note: this is SEPARATE from packages/services/src/audio-service.ts, which
556
843
  * is the legacy ifc-topic-based audio source registry (audio:* topic events
557
- * over ifc.emit). media-service is the canonical @napplet/nub-media NIP-5D
844
+ * over ifc.emit). media-service is the canonical @napplet/nub/media NIP-5D
558
845
  * path and they coexist — audio-service continues to track audio sources for
559
846
  * shell UI, while media-service handles the NUB protocol envelope surface.
560
847
  *
561
- * Shell -> Napplet push types (media.command, media.controls) are handled
562
- * by the shell adapter separately out of scope for this service.
848
+ * Shell -> Napplet push types (media.command) are emitted here when
849
+ * bridge.onAction callbacks fire this is the canonical Phase 27 real backend.
563
850
  */
564
851
 
852
+ type MediaPlaybackOwner = 'shell' | 'napplet';
853
+ interface MediaSourceRef {
854
+ url?: string;
855
+ blossomHash?: string;
856
+ nostr?: {
857
+ eventId?: string;
858
+ address?: string;
859
+ relays?: string[];
860
+ };
861
+ mimeType?: string;
862
+ }
863
+ interface MediaSessionCreateOptions {
864
+ owner: MediaPlaybackOwner;
865
+ sessionId?: string;
866
+ source?: MediaSourceRef;
867
+ metadata?: MediaMetadata;
868
+ capabilities?: MediaAction[];
869
+ autoplay?: boolean;
870
+ live?: boolean;
871
+ }
872
+ /**
873
+ * Host-bridge contract for pluggable media backends.
874
+ *
875
+ * The browser reference implementation ({@link createBrowserMediaBridge}) mirrors
876
+ * napplet-reported metadata/state to `navigator.mediaSession` and installs
877
+ * `setActionHandler` callbacks that fan into the bridge's onAction subscribers.
878
+ * It satisfies this interface with all 5 fields implemented (setActiveSession
879
+ * switches the active session and optionally re-applies action-handler narrowing;
880
+ * destroySession tears down the silent-audio prime on last-session teardown).
881
+ *
882
+ * Host apps (Electron, Tauri) implement this interface in their own code and
883
+ * pass it via `createMediaService({ hostBridge: myBridge })` — the service
884
+ * then delegates metadata/state mirroring + action routing to the bridge and
885
+ * remains browser-free. Session-ownership bookkeeping (which windowId owns
886
+ * which sessionId, which send callback routes media.command back to which
887
+ * napplet) stays in the service layer — that's wire-protocol concern, not a
888
+ * bridge concern.
889
+ *
890
+ * Reference implementations for Electron / Tauri are explicitly out of v1.4
891
+ * scope and live in host-app examples / follow-up milestones (see
892
+ * REQUIREMENTS.md "Future Requirements").
893
+ *
894
+ * @example
895
+ * ```ts
896
+ * // Host-app pseudocode (Electron main-process relay):
897
+ * const electronBridge: HostMediaBridge = {
898
+ * setMetadata(sessionId, md) { mediaBridge.sendMetadata({ sessionId, md }); },
899
+ * setPlaybackState(sessionId, state) { mediaBridge.sendPlaybackState({ sessionId, state }); },
900
+ * onAction(cb) {
901
+ * const handler = (_: unknown, msg: { sessionId: string; action: MediaAction; value?: number }) =>
902
+ * cb(msg.sessionId, msg.action, msg.value);
903
+ * mediaBridge.onAction(handler);
904
+ * return () => mediaBridge.offAction(handler);
905
+ * },
906
+ * };
907
+ * const media = createMediaService({ hostBridge: electronBridge });
908
+ * runtime.registerService('media', media);
909
+ * ```
910
+ */
911
+ interface HostMediaBridge {
912
+ /**
913
+ * Set the metadata displayed on the OS transport surface for a session.
914
+ * Called on session.create (with initial metadata) and on session.update
915
+ * (with merged metadata) whenever the session is the active session.
916
+ * Implementations MUST be idempotent.
917
+ */
918
+ setMetadata(sessionId: string, metadata: MediaMetadata): void;
919
+ /**
920
+ * Set the playback state for a session. Called on media.state reports
921
+ * whenever the session is the active session. State strings match
922
+ * nub-media MediaState.status exactly. Implementations MUST be idempotent.
923
+ */
924
+ setPlaybackState(sessionId: string, state: 'playing' | 'paused' | 'stopped' | 'buffering'): void;
925
+ /**
926
+ * Subscribe to OS-level action events (user clicks play/pause/seek/next/prev
927
+ * on the transport surface). Returns an unsubscribe handle.
928
+ *
929
+ * The callback receives `(sessionId, action, value?)`. `sessionId` is the
930
+ * bridge's currently-active session (the browser impl tracks this internally
931
+ * via setActionHandler-at-fire-time; native impls track via setActiveSession).
932
+ * `value` is populated for `action === 'seek'` (seek target in seconds) and
933
+ * for `action === 'volume'` (0.0-1.0). The service dispatches the resulting
934
+ * `media.command` envelope to the owning napplet of that session.
935
+ */
936
+ onAction(callback: (sessionId: string, action: MediaAction, value?: number) => void): () => void;
937
+ /**
938
+ * Optional: notify the bridge that the active session has changed. The
939
+ * browser reference impl uses this to switch which session's metadata/state
940
+ * is mirrored to the singleton navigator.mediaSession and to install (or
941
+ * clear) action handlers for the session's declared capabilities.
942
+ *
943
+ * The optional `actions` parameter carries the session's declared capability
944
+ * set so the bridge can narrow which OS transport buttons are active. When
945
+ * omitted, the bridge applies its default set. Native OS bridges that track
946
+ * active-session state internally may omit this field entirely.
947
+ */
948
+ setActiveSession?(sessionId: string | null, actions?: readonly MediaAction[]): void;
949
+ /**
950
+ * Optional: tear down per-session resources. The browser reference impl
951
+ * uses this to remove the silent-audio prime element when the last session
952
+ * is destroyed. Bridges that need no per-session teardown may omit this field.
953
+ */
954
+ destroySession?(sessionId: string): void;
955
+ }
565
956
  /**
566
- * Optional host callbacks for the stub media service.
957
+ * Optional host callbacks for the media service.
567
958
  *
568
959
  * Host shells can hook session creation and state updates to drive
569
960
  * their own UI (e.g., now-playing banner) without replacing the
@@ -594,12 +985,32 @@ interface MediaServiceOptions {
594
985
  onSessionUpdate?: (windowId: string, sessionId: string, metadata: unknown) => void;
595
986
  /** Called when a napplet declares capabilities for a session. */
596
987
  onCapabilities?: (windowId: string, sessionId: string, actions: unknown) => void;
988
+ /**
989
+ * MediaSession target override (used by default browser bridge only).
990
+ * Defaults to `navigator.mediaSession` when running in a browser. Pass a
991
+ * MockMediaSession in unit tests. Ignored when `hostBridge` is provided.
992
+ */
993
+ mediaSessionTarget?: MediaSessionTarget;
994
+ /**
995
+ * DOM document override (used by default browser bridge only).
996
+ * Defaults to `document` when available. Set to null to disable the silent-audio
997
+ * prime entirely — useful in unit tests. Ignored when `hostBridge` is provided.
998
+ */
999
+ documentTarget?: Document | null;
1000
+ /**
1001
+ * Optional pluggable backend for metadata/state mirroring + OS action handling.
1002
+ * When provided, the service delegates setMetadata / setPlaybackState / onAction
1003
+ * to the bridge and skips navigator.mediaSession entirely. When omitted, the
1004
+ * service internally uses {@link createBrowserMediaBridge} as the default.
1005
+ * See {@link HostMediaBridge}.
1006
+ */
1007
+ hostBridge?: HostMediaBridge;
597
1008
  }
598
1009
  /**
599
- * Create a stub-level media NUB service handler.
1010
+ * Create a media NUB service handler with navigator.mediaSession integration.
600
1011
  *
601
1012
  * Implements the 5 napplet->shell media.* request types defined in
602
- * `@napplet/nub-media`. Only `media.session.create` produces a reply
1013
+ * `@napplet/nub/media`. Only `media.session.create` produces a reply
603
1014
  * envelope (`media.session.create.result`) — the remaining four
604
1015
  * (`session.update`, `session.destroy`, `state`, `capabilities`) are
605
1016
  * fire-and-forget per the NUB spec.
@@ -607,8 +1018,10 @@ interface MediaServiceOptions {
607
1018
  * Unknown `media.*` actions produce a `<type>.error` envelope so
608
1019
  * napplets are never left hanging on a malformed request.
609
1020
  *
610
- * @param options - Optional host callbacks for session lifecycle + state
611
- * @returns A ServiceHandler to register with the runtime
1021
+ * @param options - Optional host callbacks for session lifecycle + state, plus
1022
+ * mediaSessionTarget and documentTarget for test injection, and an optional
1023
+ * hostBridge for native-OS media backend delegation.
1024
+ * @returns A ServiceHandler (with `destroy()`) to register with the runtime
612
1025
  *
613
1026
  * @example
614
1027
  * ```ts
@@ -616,14 +1029,18 @@ interface MediaServiceOptions {
616
1029
  *
617
1030
  * const media = createMediaService();
618
1031
  * runtime.registerService('media', media);
1032
+ * // Later, on shell teardown:
1033
+ * media.destroy();
619
1034
  * ```
620
1035
  */
621
- declare function createMediaService(options?: MediaServiceOptions): ServiceHandler;
1036
+ declare function createMediaService(options?: MediaServiceOptions): ServiceHandler & {
1037
+ destroy(): void;
1038
+ };
622
1039
 
623
1040
  /**
624
1041
  * notify-service.ts — NIP-5D notify NUB reference service (stub-level).
625
1042
  *
626
- * Handles the 5 napplet -> shell request types from `@napplet/nub-notify`:
1043
+ * Handles the 5 napplet -> shell request types from `@napplet/nub/notify`:
627
1044
  * - `notify.send` -> `notify.send.result` (shell-assigned id)
628
1045
  * - `notify.dismiss` -> fire-and-forget
629
1046
  * - `notify.badge` -> fire-and-forget
@@ -638,9 +1055,9 @@ declare function createMediaService(options?: MediaServiceOptions): ServiceHandl
638
1055
  * `packages/services/src/notification-service.ts`, which is the legacy
639
1056
  * ifc-topic-based notification registry (operates on `ifc.emit` topics
640
1057
  * under `notifications:*`). `notify-service.ts` is the canonical
641
- * `@napplet/nub-notify` NIP-5D path and lives alongside the legacy module.
1058
+ * `@napplet/nub/notify` NIP-5D path and lives alongside the legacy module.
642
1059
  * If the host app registers this service via
643
- * `runtime.registerService('notify', ...)`, @napplet/nub-notify messages
1060
+ * `runtime.registerService('notify', ...)`, @napplet/nub/notify messages
644
1061
  * land here; the legacy ifc-emit topic path remains untouched.
645
1062
  *
646
1063
  * Shell -> napplet push messages (`notify.action`, `notify.clicked`,
@@ -684,7 +1101,7 @@ interface NotifyServiceOptions {
684
1101
  /**
685
1102
  * Create a stub-level notify service handler.
686
1103
  *
687
- * Answers the 5 napplet->shell request types from `@napplet/nub-notify`.
1104
+ * Answers the 5 napplet->shell request types from `@napplet/nub/notify`.
688
1105
  * Does NOT implement a real backend (no DOM Notification API, no channel
689
1106
  * registry, no permission prompt). Host apps replace this via
690
1107
  * `runtime.registerService('notify', realHandler)` when a real backend is
@@ -706,7 +1123,7 @@ declare function createNotifyService(options?: NotifyServiceOptions): ServiceHan
706
1123
  /**
707
1124
  * theme-service.ts — NIP-5D theme NUB reference service.
708
1125
  *
709
- * Handles the single napplet->shell request type from @napplet/nub-theme:
1126
+ * Handles the single napplet->shell request type from @napplet/nub/theme:
710
1127
  * - `theme.get` -> `theme.get.result { theme }` (current theme)
711
1128
  *
712
1129
  * Exposes a host-facing `publishTheme(theme)` handle that:
@@ -807,4 +1224,576 @@ interface ThemeService {
807
1224
  */
808
1225
  declare function createThemeService(options?: ThemeServiceOptions): ThemeService;
809
1226
 
810
- export { type AudioServiceOptions, type AudioSource, type CacheServiceOptions, type CoordinatedRelayOptions, type IdentityServiceOptions, type KeysServiceOptions, type MediaServiceOptions, type Notification, type NotificationServiceOptions, type NotifyServiceOptions, type RelayPoolServiceOptions, type ThemeService, type ThemeServiceOptions, createAudioService, createCacheService, createCoordinatedRelay, createIdentityService, createKeysService, createMediaService, createNotificationService, createNotifyService, createRelayPoolService, createThemeService };
1227
+ /**
1228
+ * config-service.ts — NUB-CONFIG reference service (9th NUB domain, v1.7 Phase 39).
1229
+ *
1230
+ * Shell-side reference implementation for the canonical NUB-CONFIG wire
1231
+ * protocol (`@napplet/nub/config`, published at `^0.3.0`). Handles the full
1232
+ * 8-message discriminated union: 5 napplet→shell request types + 3
1233
+ * shell→napplet result/push types.
1234
+ *
1235
+ * ──────────────────────────── SCOPE BOUNDARY (CONFIG-04) ─────────────────────────
1236
+ * NUB-CONFIG is **shell-managed per-napplet configuration**. Napplets observe
1237
+ * values via `config.get` (one-shot) or `config.subscribe` (snapshot + live
1238
+ * push). The shell is the **sole writer** — there is intentionally **NO**
1239
+ * `config.set` wire message. Napplets cannot mutate configuration values;
1240
+ * the shell owns persistence and the update flow.
1241
+ *
1242
+ * Do NOT use this service as a general key-value store. NUB-STORAGE
1243
+ * (`state:read` / `state:write`) remains the general KV surface. Using
1244
+ * NUB-CONFIG to store e.g. `{ lastScrollPosition: 420 }` is an anti-pattern
1245
+ * (H-07 in PITFALLS.md) — such state belongs in NUB-STORAGE.
1246
+ * ──────────────────────────────────────────────────────────────────────────────────
1247
+ *
1248
+ * Host integration: provide `getValues()` returning the current
1249
+ * `ConfigValues` snapshot. Call the returned `publishValues(newValues)`
1250
+ * whenever the configuration changes — the service fans the new snapshot
1251
+ * out to every napplet that has an active `config.subscribe`.
1252
+ *
1253
+ * Optional: provide `registerSchema` to accept napplet-declared schemas at
1254
+ * runtime (the ref impl does a minimal shape check using the Core Subset
1255
+ * validator; use `ajv` in host impls that need strict draft-07 conformance).
1256
+ * Provide `openSettings` to open a shell-side UI for the napplet (no
1257
+ * response envelope — fire-and-forget UI hook).
1258
+ *
1259
+ * @example
1260
+ * ```ts
1261
+ * import { createConfigService } from '@kehto/services';
1262
+ *
1263
+ * const configFixtures = { theme: 'dark', density: 'compact', recentSearches: [] };
1264
+ * const config = createConfigService({
1265
+ * getValues: () => ({ ...configFixtures }),
1266
+ * });
1267
+ * runtime.registerService('config', config.handler);
1268
+ *
1269
+ * // Later, when shell-side values change:
1270
+ * configFixtures.theme = 'light';
1271
+ * config.publishValues({ ...configFixtures });
1272
+ * ```
1273
+ */
1274
+
1275
+ /**
1276
+ * Shape returned by a successful `registerSchema` result (ok=true) or a
1277
+ * rejection (ok=false + code + error). Mirrors the wire envelope fields.
1278
+ */
1279
+ type ConfigSchemaValidation = {
1280
+ ok: true;
1281
+ } | {
1282
+ ok: false;
1283
+ code: ConfigSchemaErrorCode;
1284
+ error: string;
1285
+ };
1286
+ /**
1287
+ * Configuration options for `createConfigService` (options-as-bridge
1288
+ * per v1.6 Decision 18).
1289
+ *
1290
+ * @example
1291
+ * ```ts
1292
+ * const config = createConfigService({
1293
+ * getValues: () => ({ theme: 'dark', density: 'compact' }),
1294
+ * openSettings: (windowId, section) => showSettingsPanel(windowId, section),
1295
+ * });
1296
+ * ```
1297
+ */
1298
+ interface ConfigServiceOptions {
1299
+ /**
1300
+ * Returns the current configuration values snapshot.
1301
+ * Called on every `config.get` and at every `config.subscribe` initial push.
1302
+ * Implementations should return a fresh object (not a mutable reference).
1303
+ */
1304
+ getValues(): ConfigValues;
1305
+ /**
1306
+ * Optional: receive notification when a napplet subscribes to config updates.
1307
+ * Fire-and-forget — the service tracks the subscription internally regardless.
1308
+ */
1309
+ onSubscribe?: (windowId: string) => void;
1310
+ /**
1311
+ * Optional: receive notification when a napplet unsubscribes.
1312
+ */
1313
+ onUnsubscribe?: (windowId: string) => void;
1314
+ /**
1315
+ * Optional: validate and store a napplet-provided schema.
1316
+ *
1317
+ * If omitted, the ref impl runs its own Core Subset check (hand-coded
1318
+ * validator; 30-50 lines) and returns ok/reject. Hosts that need strict
1319
+ * draft-07 conformance should provide an ajv-backed implementation.
1320
+ *
1321
+ * Return shape mirrors `config.registerSchema.result` wire envelope
1322
+ * (minus the `id` — the dispatch layer correlates).
1323
+ */
1324
+ registerSchema?: (windowId: string, schema: NappletConfigSchema, version: number | undefined) => ConfigSchemaValidation;
1325
+ /**
1326
+ * Optional: open the shell-side settings UI for this napplet.
1327
+ * Fire-and-forget — no response envelope per the wire spec.
1328
+ * If omitted, `config.openSettings` is silently dropped (D10 allows
1329
+ * the config-demo napplet to function without a settings UI).
1330
+ */
1331
+ openSettings?: (windowId: string, section: string | undefined) => void;
1332
+ }
1333
+ /**
1334
+ * NUB-CONFIG reference service bundle — `handler` to register with the
1335
+ * runtime, `publishValues` for the host app to push updates live to all
1336
+ * subscribed napplets.
1337
+ */
1338
+ interface ConfigService {
1339
+ /** Register this with the runtime via `runtime.registerService('config', handler)`. */
1340
+ handler: ServiceHandler;
1341
+ /**
1342
+ * Broadcast a new values snapshot to every napplet with an active
1343
+ * `config.subscribe`. Each subscriber receives a `config.values` envelope
1344
+ * with no `id` (push form per wire spec — absence of `id` distinguishes
1345
+ * push from correlated `config.get` response).
1346
+ *
1347
+ * @param values - The new configuration snapshot (full object, not a diff)
1348
+ */
1349
+ publishValues(values: ConfigValues): void;
1350
+ }
1351
+ /**
1352
+ * Create a NUB-CONFIG reference service.
1353
+ *
1354
+ * Shell-writes, napplet-reads. Handles the full `@napplet/nub/config` wire
1355
+ * protocol: `config.get` (correlated snapshot), `config.subscribe` /
1356
+ * `config.unsubscribe` (live push stream), `config.registerSchema` (optional
1357
+ * schema registration + Core Subset validation), `config.openSettings`
1358
+ * (optional UI deep-link, fire-and-forget).
1359
+ *
1360
+ * Returns a `ConfigService` bundle: `{ handler, publishValues }`.
1361
+ * Register `handler` with the runtime; call `publishValues(newValues)` from
1362
+ * the shell whenever config state changes.
1363
+ *
1364
+ * @param options - Host-supplied implementation hooks (options-as-bridge,
1365
+ * v1.6 Decision 18). `getValues` is required; all other fields are optional.
1366
+ * @returns A ConfigService bundle.
1367
+ *
1368
+ * @see ConfigServiceOptions for the options shape.
1369
+ * @see packages/services/src/theme-service.ts for the sibling pattern.
1370
+ * @see SCOPE BOUNDARY comment at the top of this file re: NUB-STORAGE separation.
1371
+ *
1372
+ * @example
1373
+ * ```ts
1374
+ * import { createConfigService } from '@kehto/services';
1375
+ *
1376
+ * const config = createConfigService({
1377
+ * getValues: () => ({ theme: 'dark', density: 'compact' }),
1378
+ * openSettings: (windowId, section) => openSettingsUI(section),
1379
+ * });
1380
+ * runtime.registerService('config', config.handler);
1381
+ *
1382
+ * // Push a live update to all subscribers:
1383
+ * config.publishValues({ theme: 'light', density: 'compact' });
1384
+ * ```
1385
+ */
1386
+ declare function createConfigService(options: ConfigServiceOptions): ConfigService;
1387
+
1388
+ /**
1389
+ * resource-service.ts — NUB-RESOURCE reference service (10th NUB domain, v1.7 Phase 40).
1390
+ *
1391
+ * Shell-side reference implementation for the canonical NUB-RESOURCE wire
1392
+ * protocol (`internal-resource.ts` in @kehto/shell/src/types; kehto-internal
1393
+ * model per PROJECT.md Decision #31 — diverges from upstream `@napplet/nub/
1394
+ * resource` in field names + error vocabulary). Handles the canonical
1395
+ * 4-message protocol:
1396
+ * Inbound: resource.bytes, resource.cancel
1397
+ * Outbound: resource.bytes.result, resource.bytes.error
1398
+ *
1399
+ * ──────────────────────── SCOPE BOUNDARY (RESOURCE-01) ────────────────────────
1400
+ * NUB-RESOURCE is an **authenticated fetch proxy** — read-only, atomic.
1401
+ *
1402
+ * This service is NOT responsible for:
1403
+ * - Streaming / chunked responses (host-app concern)
1404
+ * - Response caching / conditional requests (host-app concern)
1405
+ * - Upload / POST body construction (NUB-RESOURCE v1.7 is read-only)
1406
+ * - Redirect limits, MIME sniffing, SVG rasterization (host-fetch concern)
1407
+ * - Private-IP blocking, SSRF mitigation (host-provided-fetch responsibility)
1408
+ *
1409
+ * These belong to the host-app's `fetch` implementation per D7 and
1410
+ * SHELL-RESOURCE-POLICY.md (Phase 40 Plan 40-03). Kehto ships a reference
1411
+ * service; production hardening is the host app's concern.
1412
+ * ──────────────────────────────────────────────────────────────────────────────
1413
+ *
1414
+ * Host integration: provide `fetch`, `isOriginGranted`, `getConnectGrants`,
1415
+ * and `resolveIdentity`. ALL FOUR are required from day one (H-03 prevention).
1416
+ *
1417
+ * @example
1418
+ * ```ts
1419
+ * import { createResourceService } from '@kehto/services';
1420
+ *
1421
+ * const resourceSvc = createResourceService({
1422
+ * fetch: (url, init) => globalThis.fetch(url, init),
1423
+ * isOriginGranted: (origin, grants) => grants.includes(origin),
1424
+ * getConnectGrants: (dTag, hash) => connectStore.getOrigins(dTag, hash),
1425
+ * resolveIdentity: (windowId) => sessionRegistry.getEntryByWindowId(windowId) ?? null,
1426
+ * });
1427
+ * runtime.registerService('resource', resourceSvc);
1428
+ * ```
1429
+ */
1430
+
1431
+ /**
1432
+ * Options for `createResourceService` (options-as-bridge per v1.6 Decision 18).
1433
+ *
1434
+ * ALL FOUR fields are required. The factory throws at construction if any is
1435
+ * missing — H-03 prevention: the grants source (`getConnectGrants`) MUST be
1436
+ * wired from day one so there is no window where resource requests bypass the
1437
+ * grant check.
1438
+ *
1439
+ * @see PITFALLS.md:228 (H-03) — grants-source coupling must be present at construction
1440
+ */
1441
+ interface ResourceServiceOptions {
1442
+ /**
1443
+ * Host-supplied fetch implementation. Receives the URL, a partial init
1444
+ * (method, headers, signal), and must return a `Response`-compatible promise.
1445
+ *
1446
+ * The host's `fetch` is the ONLY place to implement redirect limits, MIME
1447
+ * sniffing, SVG rasterization, private-IP / SSRF blocking, etc.
1448
+ * This service does NOT filter: it proxies transparently.
1449
+ *
1450
+ * @param url - The URL from the resource.bytes request
1451
+ * @param init - Method, headers (from napplet), and an AbortSignal
1452
+ */
1453
+ fetch(url: string, init: {
1454
+ method?: string;
1455
+ headers?: Record<string, string>;
1456
+ signal: AbortSignal;
1457
+ }): Promise<Response>;
1458
+ /**
1459
+ * Returns true if `origin` is present in `grants` (the list returned by
1460
+ * `getConnectGrants` for the napplet's dTag + aggregateHash).
1461
+ *
1462
+ * The reference implementation is simply `grants.includes(origin)`. Host apps
1463
+ * may provide normalized-origin comparison if needed.
1464
+ *
1465
+ * @param origin - Parsed origin of the requested URL (scheme + host + port)
1466
+ * @param grants - Readonly list from getConnectGrants for this napplet identity
1467
+ */
1468
+ isOriginGranted(origin: string, grants: readonly string[]): boolean;
1469
+ /**
1470
+ * Returns the list of allowed fetch origins for the given napplet identity.
1471
+ * Called on every `resource.bytes` request — must be synchronous and fast.
1472
+ *
1473
+ * Typically wraps `connectStore.getOrigins(dTag, aggregateHash)` from
1474
+ * @kehto/shell.
1475
+ *
1476
+ * H-03 prevention: REQUIRED from day one — factory throws on construction
1477
+ * if omitted.
1478
+ *
1479
+ * @param dTag - The napplet's d-tag (from session registry)
1480
+ * @param aggregateHash - The napplet's aggregate hash (from session registry)
1481
+ */
1482
+ getConnectGrants(dTag: string, aggregateHash: string): readonly string[];
1483
+ /**
1484
+ * Resolve a windowId to the napplet's identity (dTag + aggregateHash).
1485
+ * Returns null if the window is not in the session registry.
1486
+ *
1487
+ * Typically wraps `sessionRegistry.getEntryByWindowId(windowId)`.
1488
+ *
1489
+ * @param windowId - The iframe window identifier
1490
+ */
1491
+ resolveIdentity(windowId: string): {
1492
+ dTag: string;
1493
+ aggregateHash: string;
1494
+ } | null;
1495
+ }
1496
+ /**
1497
+ * Type alias for the ServiceHandler returned by `createResourceService`.
1498
+ * Exported for host apps that need to type-annotate the handler reference.
1499
+ */
1500
+ type ResourceService = ServiceHandler;
1501
+ /**
1502
+ * Create a NUB-RESOURCE reference service.
1503
+ *
1504
+ * Implements canonical 4-message protocol: `resource.bytes` (napplet → shell
1505
+ * fetch request), `resource.cancel` (napplet → shell in-flight cancellation),
1506
+ * `resource.bytes.result` (shell → napplet success), `resource.bytes.error`
1507
+ * (shell → napplet failure/denial/cancel).
1508
+ *
1509
+ * On-construction guard (H-03 prevention): all four options are validated at
1510
+ * factory call time. If any is missing, the factory throws immediately with a
1511
+ * message containing `[RESOURCE-01 / H-03]` so misconfigured shell apps fail
1512
+ * loudly at startup rather than silently at first dispatch.
1513
+ *
1514
+ * Returns a `ServiceHandler` (no `publishValues`-style surface — resource has
1515
+ * no shell-initiated push beyond the response/error path).
1516
+ *
1517
+ * @param options - REQUIRED: fetch, isOriginGranted, getConnectGrants, resolveIdentity
1518
+ * @returns ServiceHandler to register via `runtime.registerService('resource', handler)`
1519
+ *
1520
+ * @example
1521
+ * ```ts
1522
+ * import { createResourceService } from '@kehto/services';
1523
+ *
1524
+ * const svc = createResourceService({
1525
+ * fetch: (url, init) => globalThis.fetch(url, init),
1526
+ * isOriginGranted: (origin, grants) => grants.includes(origin),
1527
+ * getConnectGrants: (dTag, hash) => connectStore.getOrigins(dTag, hash),
1528
+ * resolveIdentity: (windowId) => sessionRegistry.getEntryByWindowId(windowId) ?? null,
1529
+ * });
1530
+ * runtime.registerService('resource', svc);
1531
+ * ```
1532
+ */
1533
+ declare function createResourceService(options: ResourceServiceOptions): ResourceService;
1534
+
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
+ 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 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 RelayListEntry, type RelayPoolOutboxRouterOptions, type RelayPoolServiceOptions, type ResourceService, type ResourceServiceOptions, type Rumor, type ThemeService, type ThemeServiceOptions, type VerifyEvent, createAudioService, createBrowserMediaBridge, createCacheService, createConfigService, createCoordinatedRelay, createIdentityService, createKeysService, createMediaService, createNotificationService, createNotifyService, createOutboxService, createRelayPoolOutboxRouter, createRelayPoolService, createResourceService, createThemeService };