@kehto/services 0.2.0 → 0.5.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.js CHANGED
@@ -3,7 +3,7 @@ var AUDIO_SERVICE_VERSION = "1.0.0";
3
3
  function createAudioService(options) {
4
4
  const sources = /* @__PURE__ */ new Map();
5
5
  const onChange = options?.onChange;
6
- function notify() {
6
+ function notify2() {
7
7
  onChange?.(new Map(sources));
8
8
  }
9
9
  const descriptor = {
@@ -15,21 +15,22 @@ function createAudioService(options) {
15
15
  descriptor,
16
16
  handleMessage(windowId, message, send) {
17
17
  if (message.type !== "ifc.emit") return;
18
- const topic = message.topic;
18
+ const ifcMessage = message;
19
+ const topic = typeof ifcMessage.topic === "string" ? ifcMessage.topic : void 0;
19
20
  if (!topic?.startsWith("audio:")) return;
20
21
  const action = topic.slice(6);
21
- const payload = message.payload ?? {};
22
+ const payload = ifcMessage.payload && typeof ifcMessage.payload === "object" ? ifcMessage.payload : {};
22
23
  switch (action) {
23
24
  case "register": {
24
25
  const nappletClass = typeof payload.nappletClass === "string" ? payload.nappletClass : "";
25
26
  const title = typeof payload.title === "string" ? payload.title : "";
26
27
  sources.set(windowId, { windowId, nappletClass, title, muted: false });
27
- notify();
28
+ notify2();
28
29
  break;
29
30
  }
30
31
  case "unregister": {
31
32
  if (sources.delete(windowId)) {
32
- notify();
33
+ notify2();
33
34
  }
34
35
  break;
35
36
  }
@@ -39,7 +40,7 @@ function createAudioService(options) {
39
40
  if (typeof payload.title === "string") {
40
41
  source.title = payload.title;
41
42
  }
42
- notify();
43
+ notify2();
43
44
  break;
44
45
  }
45
46
  case "mute": {
@@ -48,7 +49,7 @@ function createAudioService(options) {
48
49
  const source = sources.get(targetWindowId);
49
50
  if (source) {
50
51
  source.muted = muted;
51
- notify();
52
+ notify2();
52
53
  }
53
54
  send({ type: "ifc.event", topic: "napplet:audio-muted", payload: { muted } });
54
55
  break;
@@ -59,7 +60,7 @@ function createAudioService(options) {
59
60
  },
60
61
  onWindowDestroyed(windowId) {
61
62
  if (sources.delete(windowId)) {
62
- notify();
63
+ notify2();
63
64
  }
64
65
  }
65
66
  };
@@ -73,42 +74,134 @@ function generateId() {
73
74
  idCounter++;
74
75
  return `notif-${Date.now()}-${idCounter}`;
75
76
  }
76
- function createNotificationService(options) {
77
- const notifications = /* @__PURE__ */ new Map();
78
- const onChange = options?.onChange;
79
- const maxPerWindow = options?.maxPerWindow ?? DEFAULT_MAX_PER_WINDOW;
80
- function getAllNotifications() {
81
- const all = [];
82
- for (const windowNotifs of notifications.values()) {
83
- all.push(...windowNotifs);
84
- }
85
- return all;
77
+ function getAllNotifications(store) {
78
+ const all = [];
79
+ for (const windowNotifs of store.notifications.values()) {
80
+ all.push(...windowNotifs);
86
81
  }
87
- function notify() {
88
- onChange?.(getAllNotifications());
82
+ return all;
83
+ }
84
+ function notify(store) {
85
+ store.onChange?.(getAllNotifications(store));
86
+ }
87
+ function getWindowNotifications(store, windowId) {
88
+ let list = store.notifications.get(windowId);
89
+ if (!list) {
90
+ list = [];
91
+ store.notifications.set(windowId, list);
92
+ }
93
+ return list;
94
+ }
95
+ function enforceLimit(store, list) {
96
+ while (list.length > store.maxPerWindow) {
97
+ list.shift();
89
98
  }
90
- function getWindowNotifications(windowId) {
91
- let list = notifications.get(windowId);
92
- if (!list) {
93
- list = [];
94
- notifications.set(windowId, list);
99
+ }
100
+ function findById(store, id) {
101
+ for (const [windowId, list] of store.notifications) {
102
+ const index = list.findIndex((n) => n.id === id);
103
+ if (index !== -1) {
104
+ return [windowId, list[index], index];
95
105
  }
96
- return list;
97
106
  }
98
- function enforceLimit(list) {
99
- while (list.length > maxPerWindow) {
100
- list.shift();
107
+ return void 0;
108
+ }
109
+ function createNotification(store, windowId, title, body) {
110
+ const notification = {
111
+ id: generateId(),
112
+ windowId,
113
+ title,
114
+ body,
115
+ read: false,
116
+ createdAt: Math.floor(Date.now() / 1e3)
117
+ };
118
+ const list = getWindowNotifications(store, windowId);
119
+ list.push(notification);
120
+ enforceLimit(store, list);
121
+ notify(store);
122
+ return notification;
123
+ }
124
+ function dismissNotification(store, id) {
125
+ const found = findById(store, id);
126
+ if (!found) return;
127
+ const [foundWindowId, , index] = found;
128
+ const list = store.notifications.get(foundWindowId);
129
+ if (!list) return;
130
+ list.splice(index, 1);
131
+ if (list.length === 0) store.notifications.delete(foundWindowId);
132
+ notify(store);
133
+ }
134
+ function markNotificationRead(store, id) {
135
+ const found = findById(store, id);
136
+ if (!found) return;
137
+ const [, notification] = found;
138
+ if (!notification.read) {
139
+ notification.read = true;
140
+ notify(store);
141
+ }
142
+ }
143
+ function handleNotifyEnvelope(store, windowId, action, msg, send) {
144
+ switch (action) {
145
+ case "create": {
146
+ const title = typeof msg.title === "string" ? msg.title : "";
147
+ const body = typeof msg.body === "string" ? msg.body : "";
148
+ const notification = createNotification(store, windowId, title, body);
149
+ send({ type: "notify.created", id: notification.id });
150
+ break;
151
+ }
152
+ case "dismiss": {
153
+ const notifId = typeof msg.notificationId === "string" ? msg.notificationId : "";
154
+ if (notifId) dismissNotification(store, notifId);
155
+ break;
156
+ }
157
+ case "read": {
158
+ const notifId = typeof msg.notificationId === "string" ? msg.notificationId : "";
159
+ if (notifId) markNotificationRead(store, notifId);
160
+ break;
101
161
  }
162
+ case "list": {
163
+ const windowNotifs = store.notifications.get(windowId) ?? [];
164
+ send({ type: "notify.listed", notifications: windowNotifs });
165
+ break;
166
+ }
167
+ default:
168
+ break;
102
169
  }
103
- function findById(id) {
104
- for (const [windowId, list] of notifications) {
105
- const index = list.findIndex((n) => n.id === id);
106
- if (index !== -1) {
107
- return [windowId, list[index], index];
108
- }
170
+ }
171
+ function handleIfcNotification(store, windowId, action, payload, send) {
172
+ switch (action) {
173
+ case "create": {
174
+ const title = typeof payload.title === "string" ? payload.title : "";
175
+ const body = typeof payload.body === "string" ? payload.body : "";
176
+ const notification = createNotification(store, windowId, title, body);
177
+ send({ type: "ifc.event", topic: "notifications:created", payload: { id: notification.id } });
178
+ break;
109
179
  }
110
- return void 0;
180
+ case "dismiss": {
181
+ const id = typeof payload.id === "string" ? payload.id : "";
182
+ if (id) dismissNotification(store, id);
183
+ break;
184
+ }
185
+ case "read": {
186
+ const id = typeof payload.id === "string" ? payload.id : "";
187
+ if (id) markNotificationRead(store, id);
188
+ break;
189
+ }
190
+ case "list": {
191
+ const windowNotifs = store.notifications.get(windowId) ?? [];
192
+ send({ type: "ifc.event", topic: "notifications:listed", payload: { notifications: windowNotifs } });
193
+ break;
194
+ }
195
+ default:
196
+ break;
111
197
  }
198
+ }
199
+ function createNotificationService(options) {
200
+ const store = {
201
+ notifications: /* @__PURE__ */ new Map(),
202
+ onChange: options?.onChange,
203
+ maxPerWindow: options?.maxPerWindow ?? DEFAULT_MAX_PER_WINDOW
204
+ };
112
205
  const descriptor = {
113
206
  name: "notifications",
114
207
  version: NOTIFICATION_SERVICE_VERSION,
@@ -119,132 +212,18 @@ function createNotificationService(options) {
119
212
  handleMessage(windowId, message, send) {
120
213
  const msg = message;
121
214
  if (message.type.startsWith("notify.")) {
122
- const action2 = message.type.slice(7);
123
- switch (action2) {
124
- case "create": {
125
- const title = typeof msg.title === "string" ? msg.title : "";
126
- const body = typeof msg.body === "string" ? msg.body : "";
127
- const id = generateId();
128
- const notification = {
129
- id,
130
- windowId,
131
- title,
132
- body,
133
- read: false,
134
- createdAt: Math.floor(Date.now() / 1e3)
135
- };
136
- const list = getWindowNotifications(windowId);
137
- list.push(notification);
138
- enforceLimit(list);
139
- notify();
140
- send({ type: "notify.created", id });
141
- break;
142
- }
143
- case "dismiss": {
144
- const notifId = typeof msg.notificationId === "string" ? msg.notificationId : "";
145
- if (!notifId) return;
146
- const found = findById(notifId);
147
- if (found) {
148
- const [foundWindowId, , index] = found;
149
- const list = notifications.get(foundWindowId);
150
- if (list) {
151
- list.splice(index, 1);
152
- if (list.length === 0) notifications.delete(foundWindowId);
153
- notify();
154
- }
155
- }
156
- break;
157
- }
158
- case "read": {
159
- const notifId = typeof msg.notificationId === "string" ? msg.notificationId : "";
160
- if (!notifId) return;
161
- const found = findById(notifId);
162
- if (found) {
163
- const [, notification] = found;
164
- if (!notification.read) {
165
- notification.read = true;
166
- notify();
167
- }
168
- }
169
- break;
170
- }
171
- case "list": {
172
- const windowNotifs = notifications.get(windowId) ?? [];
173
- send({ type: "notify.listed", notifications: windowNotifs });
174
- break;
175
- }
176
- default:
177
- break;
178
- }
215
+ handleNotifyEnvelope(store, windowId, message.type.slice(7), msg, send);
179
216
  return;
180
217
  }
181
218
  if (message.type !== "ifc.emit") return;
182
219
  const topic = msg.topic;
183
220
  if (!topic?.startsWith("notifications:")) return;
184
- const action = topic.slice(14);
185
221
  const payload = msg.payload ?? {};
186
- switch (action) {
187
- case "create": {
188
- const title = typeof payload.title === "string" ? payload.title : "";
189
- const body = typeof payload.body === "string" ? payload.body : "";
190
- const id = generateId();
191
- const notification = {
192
- id,
193
- windowId,
194
- title,
195
- body,
196
- read: false,
197
- createdAt: Math.floor(Date.now() / 1e3)
198
- };
199
- const list = getWindowNotifications(windowId);
200
- list.push(notification);
201
- enforceLimit(list);
202
- notify();
203
- send({ type: "ifc.event", topic: "notifications:created", payload: { id } });
204
- break;
205
- }
206
- case "dismiss": {
207
- const id = typeof payload.id === "string" ? payload.id : "";
208
- if (!id) return;
209
- const found = findById(id);
210
- if (found) {
211
- const [foundWindowId, , index] = found;
212
- const list = notifications.get(foundWindowId);
213
- if (list) {
214
- list.splice(index, 1);
215
- if (list.length === 0) {
216
- notifications.delete(foundWindowId);
217
- }
218
- notify();
219
- }
220
- }
221
- break;
222
- }
223
- case "read": {
224
- const id = typeof payload.id === "string" ? payload.id : "";
225
- if (!id) return;
226
- const found = findById(id);
227
- if (found) {
228
- const [, notification] = found;
229
- if (!notification.read) {
230
- notification.read = true;
231
- notify();
232
- }
233
- }
234
- break;
235
- }
236
- case "list": {
237
- const windowNotifs = notifications.get(windowId) ?? [];
238
- send({ type: "ifc.event", topic: "notifications:listed", payload: { notifications: windowNotifs } });
239
- break;
240
- }
241
- default:
242
- break;
243
- }
222
+ handleIfcNotification(store, windowId, topic.slice(14), payload, send);
244
223
  },
245
224
  onWindowDestroyed(windowId) {
246
- if (notifications.delete(windowId)) {
247
- notify();
225
+ if (store.notifications.delete(windowId)) {
226
+ notify(store);
248
227
  }
249
228
  }
250
229
  };
@@ -252,6 +231,156 @@ function createNotificationService(options) {
252
231
 
253
232
  // src/identity-service.ts
254
233
  var IDENTITY_SERVICE_VERSION = "1.0.0";
234
+ var DECRYPT_ERROR_CODES = [
235
+ "class-forbidden",
236
+ "signer-denied",
237
+ "signer-unavailable",
238
+ "decrypt-failed",
239
+ "malformed-wrap",
240
+ "impersonation",
241
+ "unsupported-encryption",
242
+ "policy-denied"
243
+ ];
244
+ var DECRYPT_ERROR_CODE_SET = new Set(DECRYPT_ERROR_CODES);
245
+ function isDecryptErrorCode(value) {
246
+ return typeof value === "string" && DECRYPT_ERROR_CODE_SET.has(value);
247
+ }
248
+ function normalizeDecryptError(error) {
249
+ if (isDecryptErrorCode(error)) return error;
250
+ if (typeof error === "object" && error !== null) {
251
+ const candidate = error;
252
+ if (isDecryptErrorCode(candidate.code)) return candidate.code;
253
+ if (isDecryptErrorCode(candidate.error)) return candidate.error;
254
+ if (isDecryptErrorCode(candidate.message)) return candidate.message;
255
+ }
256
+ return "decrypt-failed";
257
+ }
258
+ function sendDecryptError(id, error, send) {
259
+ const result = {
260
+ type: "identity.decrypt.error",
261
+ id,
262
+ error
263
+ };
264
+ send(result);
265
+ }
266
+ function isStringArrayArray(value) {
267
+ return Array.isArray(value) && value.every(
268
+ (tag) => Array.isArray(tag) && tag.every((part) => typeof part === "string")
269
+ );
270
+ }
271
+ function isNostrEvent(value) {
272
+ const event = value;
273
+ return typeof event === "object" && event !== null && typeof event.id === "string" && typeof event.pubkey === "string" && typeof event.created_at === "number" && typeof event.kind === "number" && isStringArrayArray(event.tags) && typeof event.content === "string" && typeof event.sig === "string";
274
+ }
275
+ function isRumor(value) {
276
+ const rumor = value;
277
+ return typeof rumor === "object" && rumor !== null && typeof rumor.id === "string" && typeof rumor.pubkey === "string" && typeof rumor.created_at === "number" && typeof rumor.kind === "number" && isStringArrayArray(rumor.tags) && typeof rumor.content === "string";
278
+ }
279
+ function isGiftWrapDecryptResult(value) {
280
+ const result = value;
281
+ return typeof result === "object" && result !== null && isNostrEvent(result.seal) && isRumor(result.rumor);
282
+ }
283
+ function firstDecodedByte(content) {
284
+ const trimmed = content.trim();
285
+ if (trimmed.length === 0) return null;
286
+ const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/");
287
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
288
+ try {
289
+ const decoded = atob(padded);
290
+ return decoded.length > 0 ? decoded.charCodeAt(0) : null;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+ function detectEncryptionMode(event) {
296
+ if (event.kind === 4) return "nip04";
297
+ if (event.kind === 1059) return "nip17";
298
+ if (event.kind === 14 || firstDecodedByte(event.content) === 2) {
299
+ return "nip44-direct";
300
+ }
301
+ return null;
302
+ }
303
+ function rumorFromSignedEvent(event, content) {
304
+ return {
305
+ id: event.id,
306
+ pubkey: event.pubkey,
307
+ kind: event.kind,
308
+ tags: event.tags,
309
+ created_at: event.created_at,
310
+ content
311
+ };
312
+ }
313
+ async function handleDecrypt(id, message, send, options) {
314
+ const event = message.event;
315
+ if (!isNostrEvent(event)) {
316
+ sendDecryptError(id, "malformed-wrap", send);
317
+ return;
318
+ }
319
+ const verifyEvent = options.verifyEvent ?? (() => true);
320
+ let verified;
321
+ try {
322
+ verified = await Promise.resolve(verifyEvent(event));
323
+ } catch {
324
+ sendDecryptError(id, "malformed-wrap", send);
325
+ return;
326
+ }
327
+ if (!verified) {
328
+ sendDecryptError(id, "malformed-wrap", send);
329
+ return;
330
+ }
331
+ const mode = detectEncryptionMode(event);
332
+ if (!mode) {
333
+ sendDecryptError(id, "unsupported-encryption", send);
334
+ return;
335
+ }
336
+ const decryptor = options.getDecryptor?.() ?? null;
337
+ if (!decryptor) {
338
+ sendDecryptError(id, "signer-unavailable", send);
339
+ return;
340
+ }
341
+ try {
342
+ if (mode === "nip04") {
343
+ const plaintext = await decryptor.nip04Decrypt(event.pubkey, event.content);
344
+ const result2 = {
345
+ type: "identity.decrypt.result",
346
+ id,
347
+ rumor: rumorFromSignedEvent(event, plaintext),
348
+ sender: event.pubkey
349
+ };
350
+ send(result2);
351
+ return;
352
+ }
353
+ if (mode === "nip44-direct") {
354
+ const plaintext = await decryptor.nip44Decrypt(event.pubkey, event.content);
355
+ const result2 = {
356
+ type: "identity.decrypt.result",
357
+ id,
358
+ rumor: rumorFromSignedEvent(event, plaintext),
359
+ sender: event.pubkey
360
+ };
361
+ send(result2);
362
+ return;
363
+ }
364
+ const unwrapped = await decryptor.unwrapGiftWrap(event);
365
+ if (!isGiftWrapDecryptResult(unwrapped)) {
366
+ sendDecryptError(id, "malformed-wrap", send);
367
+ return;
368
+ }
369
+ if (unwrapped.seal.pubkey !== unwrapped.rumor.pubkey) {
370
+ sendDecryptError(id, "impersonation", send);
371
+ return;
372
+ }
373
+ const result = {
374
+ type: "identity.decrypt.result",
375
+ id,
376
+ rumor: unwrapped.rumor,
377
+ sender: unwrapped.seal.pubkey
378
+ };
379
+ send(result);
380
+ } catch (error) {
381
+ sendDecryptError(id, normalizeDecryptError(error), send);
382
+ }
383
+ }
255
384
  function createIdentityService(options) {
256
385
  return {
257
386
  descriptor: {
@@ -264,6 +393,9 @@ function createIdentityService(options) {
264
393
  function sendError(typeBase, error) {
265
394
  send({ type: `${typeBase}.error`, id, error });
266
395
  }
396
+ function sendSignerError(typeBase, fallback, err) {
397
+ sendError(typeBase, err?.message ?? fallback);
398
+ }
267
399
  const signer = options.getSigner();
268
400
  switch (message.type) {
269
401
  case "identity.getPublicKey": {
@@ -283,12 +415,7 @@ function createIdentityService(options) {
283
415
  pubkey: pubkey ?? ""
284
416
  };
285
417
  send(result);
286
- }).catch((err) => {
287
- sendError(
288
- "identity.getPublicKey",
289
- err?.message ?? "getPublicKey failed"
290
- );
291
- });
418
+ }).catch((err) => sendSignerError("identity.getPublicKey", "getPublicKey failed", err));
292
419
  return;
293
420
  }
294
421
  case "identity.getRelays": {
@@ -303,12 +430,7 @@ function createIdentityService(options) {
303
430
  relays
304
431
  };
305
432
  send(result);
306
- }).catch((err) => {
307
- sendError(
308
- "identity.getRelays",
309
- err?.message ?? "getRelays failed"
310
- );
311
- });
433
+ }).catch((err) => sendSignerError("identity.getRelays", "getRelays failed", err));
312
434
  return;
313
435
  }
314
436
  case "identity.getProfile": {
@@ -374,6 +496,10 @@ function createIdentityService(options) {
374
496
  send(result);
375
497
  return;
376
498
  }
499
+ case "identity.decrypt": {
500
+ void handleDecrypt(id, message, send, options);
501
+ return;
502
+ }
377
503
  default:
378
504
  sendError(message.type, `Unknown identity method: ${message.type}`);
379
505
  }
@@ -395,10 +521,11 @@ function createRelayPoolService(options) {
395
521
  description: "Relay pool subscription and publishing"
396
522
  },
397
523
  handleMessage(windowId, message, send) {
524
+ const relayMessage = message;
398
525
  if (message.type === "relay.subscribe") {
399
- const subId = message.subId;
526
+ const subId = relayMessage.subId;
400
527
  if (typeof subId !== "string") return;
401
- const filters = message.filters;
528
+ const filters = Array.isArray(relayMessage.filters) ? relayMessage.filters : [];
402
529
  const subKey = `${windowId}:${subId}`;
403
530
  const existing = tracked.get(subKey);
404
531
  if (existing) {
@@ -433,7 +560,7 @@ function createRelayPoolService(options) {
433
560
  return;
434
561
  }
435
562
  if (message.type === "relay.close") {
436
- const subId = message.subId;
563
+ const subId = relayMessage.subId;
437
564
  if (typeof subId !== "string") return;
438
565
  const subKey = `${windowId}:${subId}`;
439
566
  const entry = tracked.get(subKey);
@@ -445,14 +572,14 @@ function createRelayPoolService(options) {
445
572
  return;
446
573
  }
447
574
  if (message.type === "relay.publish") {
448
- const event = message.event;
575
+ const event = relayMessage.event;
449
576
  if (event && typeof event === "object" && options.isAvailable()) {
450
577
  options.publish(event);
451
578
  }
452
579
  return;
453
580
  }
454
581
  if (message.type === "relay.publishEncrypted") {
455
- const event = message.event;
582
+ const event = relayMessage.event;
456
583
  if (event && typeof event === "object" && options.isAvailable()) {
457
584
  options.publish(event);
458
585
  }
@@ -481,10 +608,11 @@ function createCacheService(options) {
481
608
  description: "Local event cache (IndexedDB, worker relay, etc.)"
482
609
  },
483
610
  handleMessage(_windowId, message, send) {
611
+ const relayMessage = message;
484
612
  if (message.type === "relay.subscribe") {
485
- const subId = message.subId;
613
+ const subId = relayMessage.subId;
486
614
  if (typeof subId !== "string") return;
487
- const filters = message.filters;
615
+ const filters = Array.isArray(relayMessage.filters) ? relayMessage.filters : [];
488
616
  if (!options.isAvailable()) {
489
617
  send({ type: "relay.eose", subId });
490
618
  return;
@@ -500,7 +628,7 @@ function createCacheService(options) {
500
628
  return;
501
629
  }
502
630
  if (message.type === "relay.publish") {
503
- const event = message.event;
631
+ const event = relayMessage.event;
504
632
  if (event && typeof event === "object" && options.isAvailable()) {
505
633
  try {
506
634
  options.store(event);
@@ -537,6 +665,7 @@ function createCoordinatedRelay(options) {
537
665
  description: "Coordinated relay pool + cache with dedup and unified EOSE"
538
666
  },
539
667
  handleMessage(windowId, message, send) {
668
+ const relayMessage = message;
540
669
  if (message.type === "relay.subscribe") {
541
670
  let deliver2 = function(event) {
542
671
  if (tracked.seenIds.has(event.id)) return;
@@ -544,9 +673,9 @@ function createCoordinatedRelay(options) {
544
673
  if (subs.has(subKey)) send({ type: "relay.event", subId, event });
545
674
  };
546
675
  var deliver = deliver2;
547
- const subId = message.subId;
676
+ const subId = relayMessage.subId;
548
677
  if (typeof subId !== "string") return;
549
- const filters = message.filters;
678
+ const filters = Array.isArray(relayMessage.filters) ? relayMessage.filters : [];
550
679
  const subKey = `${windowId}:${subId}`;
551
680
  const existing = subs.get(subKey);
552
681
  if (existing) {
@@ -608,7 +737,7 @@ function createCoordinatedRelay(options) {
608
737
  return;
609
738
  }
610
739
  if (message.type === "relay.close") {
611
- const subId = message.subId;
740
+ const subId = relayMessage.subId;
612
741
  if (typeof subId !== "string") return;
613
742
  const subKey = `${windowId}:${subId}`;
614
743
  const entry = subs.get(subKey);
@@ -620,7 +749,7 @@ function createCoordinatedRelay(options) {
620
749
  return;
621
750
  }
622
751
  if (message.type === "relay.publish") {
623
- const event = message.event;
752
+ const event = relayMessage.event;
624
753
  if (!event || typeof event !== "object") return;
625
754
  if (options.relayPool.isAvailable()) {
626
755
  options.relayPool.publish(event);
@@ -648,31 +777,266 @@ function createCoordinatedRelay(options) {
648
777
  }
649
778
 
650
779
  // src/keys-service.ts
651
- var KEYS_SERVICE_VERSION = "1.0.0";
780
+ var KEYS_SERVICE_VERSION = "1.2.0";
781
+ var MODIFIER_ALIASES = {
782
+ ctrl: "ctrl",
783
+ control: "ctrl",
784
+ alt: "alt",
785
+ option: "alt",
786
+ shift: "shift",
787
+ meta: "meta",
788
+ cmd: "meta",
789
+ command: "meta",
790
+ win: "meta",
791
+ super: "meta"
792
+ };
793
+ function parseChord(chord) {
794
+ if (chord.length === 0) throw new Error("empty chord");
795
+ const parts = chord.split("+");
796
+ const out = { ctrl: false, alt: false, shift: false, meta: false, key: "" };
797
+ for (let i = 0; i < parts.length - 1; i++) {
798
+ const tok = parts[i].trim().toLowerCase();
799
+ if (tok.length === 0) continue;
800
+ const slot = MODIFIER_ALIASES[tok];
801
+ if (!slot) throw new Error(`unknown modifier: ${parts[i]}`);
802
+ out[slot] = true;
803
+ }
804
+ const keyTok = parts[parts.length - 1].trim();
805
+ if (keyTok.length === 0) throw new Error(`empty key in chord: ${chord}`);
806
+ out.key = keyTok.length === 1 ? keyTok.toUpperCase() : keyTok;
807
+ return out;
808
+ }
652
809
  function createKeysService(options = {}) {
653
810
  const descriptor = {
654
811
  name: "keys",
655
812
  version: KEYS_SERVICE_VERSION,
656
- description: "NIP-5D keys NUB reference handler (stub)"
813
+ description: options.hostBridge ? "NIP-5D keys NUB reference handler (host-bridge delegated)" : "NIP-5D keys NUB reference handler (document-level chord listener)"
814
+ };
815
+ function chordSpecKey(spec) {
816
+ return `${spec.ctrl}|${spec.alt}|${spec.shift}|${spec.meta}|${spec.key}`;
817
+ }
818
+ const reservedChordKeys = /* @__PURE__ */ new Set();
819
+ if (options.reservedChords) {
820
+ for (const chordStr of options.reservedChords) {
821
+ reservedChordKeys.add(chordSpecKey(parseChord(chordStr)));
822
+ }
823
+ }
824
+ function forwardKey(m) {
825
+ const k = m.key.length === 1 ? m.key.toUpperCase() : m.key;
826
+ return `${m.ctrl}|${m.alt}|${m.shift}|${m.meta}|${k}`;
827
+ }
828
+ function forwardPayload(m) {
829
+ return {
830
+ key: m.key,
831
+ code: m.code,
832
+ ctrlKey: m.ctrl,
833
+ altKey: m.alt,
834
+ shiftKey: m.shift,
835
+ metaKey: m.meta
836
+ };
837
+ }
838
+ function eventKey(ev) {
839
+ const k = ev.key.length === 1 ? ev.key.toUpperCase() : ev.key;
840
+ return `${ev.ctrlKey}|${ev.altKey}|${ev.shiftKey}|${ev.metaKey}|${k}`;
841
+ }
842
+ if (options.hostBridge) {
843
+ const bridge = options.hostBridge;
844
+ const bridgeWindowActions = /* @__PURE__ */ new Map();
845
+ const unsubscribeHandles = /* @__PURE__ */ new Map();
846
+ return {
847
+ descriptor,
848
+ handleMessage(windowId, message, send) {
849
+ switch (message.type) {
850
+ case "keys.forward": {
851
+ const m = message;
852
+ const reserved = reservedChordKeys.has(forwardKey(m));
853
+ options.onForward?.(forwardPayload(m));
854
+ if (reserved) {
855
+ return;
856
+ }
857
+ return;
858
+ }
859
+ case "keys.registerAction": {
860
+ const m = message;
861
+ if (m.action.defaultKey) {
862
+ try {
863
+ const unsubscribe = bridge.subscribe(m.action.defaultKey, (ev) => {
864
+ const e = ev;
865
+ if ("repeat" in e && e.repeat) return;
866
+ options.onForward?.({
867
+ key: e.key,
868
+ code: e.code,
869
+ ctrlKey: e.ctrlKey,
870
+ altKey: e.altKey,
871
+ shiftKey: e.shiftKey,
872
+ metaKey: e.metaKey
873
+ });
874
+ const payload = {
875
+ type: "keys.action",
876
+ actionId: m.action.id
877
+ };
878
+ send(payload);
879
+ });
880
+ unsubscribeHandles.set(m.action.id, unsubscribe);
881
+ if (!bridgeWindowActions.has(windowId)) bridgeWindowActions.set(windowId, /* @__PURE__ */ new Set());
882
+ bridgeWindowActions.get(windowId).add(m.action.id);
883
+ } catch (err) {
884
+ const id = m.id ?? "";
885
+ send({
886
+ type: "keys.registerAction.error",
887
+ id,
888
+ error: `bridge subscribe failed: ${err.message}`
889
+ });
890
+ return;
891
+ }
892
+ }
893
+ const result = {
894
+ type: "keys.registerAction.result",
895
+ id: m.id,
896
+ actionId: m.action.id,
897
+ ...m.action.defaultKey ? { binding: m.action.defaultKey } : {}
898
+ };
899
+ send(result);
900
+ return;
901
+ }
902
+ case "keys.unregisterAction": {
903
+ const m = message;
904
+ if (m.actionId) {
905
+ const unsubscribe = unsubscribeHandles.get(m.actionId);
906
+ if (unsubscribe) {
907
+ try {
908
+ unsubscribe();
909
+ } catch {
910
+ }
911
+ unsubscribeHandles.delete(m.actionId);
912
+ for (const [wid, set] of bridgeWindowActions.entries()) {
913
+ if (set.delete(m.actionId) && set.size === 0) bridgeWindowActions.delete(wid);
914
+ }
915
+ }
916
+ }
917
+ return;
918
+ }
919
+ default: {
920
+ const id = message.id ?? "";
921
+ send({
922
+ type: `${message.type}.error`,
923
+ id,
924
+ error: `Unknown keys method: ${message.type}`
925
+ });
926
+ return;
927
+ }
928
+ }
929
+ },
930
+ onWindowDestroyed(windowId) {
931
+ const actions = bridgeWindowActions.get(windowId);
932
+ if (!actions) return;
933
+ for (const actionId of actions) {
934
+ const unsubscribe = unsubscribeHandles.get(actionId);
935
+ if (unsubscribe) {
936
+ try {
937
+ unsubscribe();
938
+ } catch {
939
+ }
940
+ }
941
+ unsubscribeHandles.delete(actionId);
942
+ }
943
+ bridgeWindowActions.delete(windowId);
944
+ },
945
+ destroy() {
946
+ for (const unsubscribe of unsubscribeHandles.values()) {
947
+ try {
948
+ unsubscribe();
949
+ } catch {
950
+ }
951
+ }
952
+ unsubscribeHandles.clear();
953
+ bridgeWindowActions.clear();
954
+ }
955
+ };
956
+ }
957
+ const actionRegistry = /* @__PURE__ */ new Map();
958
+ const windowActions = /* @__PURE__ */ new Map();
959
+ const sendHandles = /* @__PURE__ */ new Map();
960
+ const target = options.listenerTarget ?? (typeof document !== "undefined" ? document : new EventTarget());
961
+ function chordMatches(spec, ev) {
962
+ if (spec.ctrl !== ev.ctrlKey) return false;
963
+ if (spec.alt !== ev.altKey) return false;
964
+ if (spec.shift !== ev.shiftKey) return false;
965
+ if (spec.meta !== ev.metaKey) return false;
966
+ const evKey = ev.key.length === 1 ? ev.key.toUpperCase() : ev.key;
967
+ return spec.key === evKey;
968
+ }
969
+ const listener = (rawEv) => {
970
+ const ev = rawEv;
971
+ if (ev.repeat) return;
972
+ const isReserved = reservedChordKeys.has(eventKey(ev));
973
+ let anyMatch = false;
974
+ for (const entry of actionRegistry.values()) {
975
+ if (chordMatches(entry.chord, ev)) {
976
+ anyMatch = true;
977
+ break;
978
+ }
979
+ }
980
+ if (isReserved || anyMatch) {
981
+ options.onForward?.({
982
+ key: ev.key,
983
+ code: ev.code,
984
+ ctrlKey: ev.ctrlKey,
985
+ altKey: ev.altKey,
986
+ shiftKey: ev.shiftKey,
987
+ metaKey: ev.metaKey
988
+ });
989
+ }
990
+ if (isReserved) return;
991
+ for (const [actionId, entry] of actionRegistry.entries()) {
992
+ if (chordMatches(entry.chord, ev)) {
993
+ const send = sendHandles.get(entry.windowId);
994
+ if (send) {
995
+ const payload = {
996
+ type: "keys.action",
997
+ actionId,
998
+ chord: entry.chord
999
+ };
1000
+ send(payload);
1001
+ }
1002
+ }
1003
+ }
657
1004
  };
1005
+ target.addEventListener("keydown", listener);
658
1006
  return {
659
1007
  descriptor,
660
- handleMessage(_windowId, message, send) {
1008
+ handleMessage(windowId, message, send) {
661
1009
  switch (message.type) {
662
1010
  case "keys.forward": {
663
1011
  const m = message;
664
- options.onForward?.({
665
- key: m.key,
666
- code: m.code,
667
- ctrlKey: m.ctrl,
668
- altKey: m.alt,
669
- shiftKey: m.shift,
670
- metaKey: m.meta
671
- });
1012
+ const reserved = reservedChordKeys.has(forwardKey(m));
1013
+ options.onForward?.(forwardPayload(m));
1014
+ if (reserved) return;
672
1015
  return;
673
1016
  }
674
1017
  case "keys.registerAction": {
675
1018
  const m = message;
1019
+ sendHandles.set(windowId, send);
1020
+ if (m.action.defaultKey) {
1021
+ try {
1022
+ const chord = parseChord(m.action.defaultKey);
1023
+ actionRegistry.set(m.action.id, {
1024
+ chord,
1025
+ chordString: m.action.defaultKey,
1026
+ windowId
1027
+ });
1028
+ if (!windowActions.has(windowId)) windowActions.set(windowId, /* @__PURE__ */ new Set());
1029
+ windowActions.get(windowId).add(m.action.id);
1030
+ } catch (err) {
1031
+ const id = m.id ?? "";
1032
+ send({
1033
+ type: "keys.registerAction.error",
1034
+ id,
1035
+ error: `invalid chord: ${err.message}`
1036
+ });
1037
+ return;
1038
+ }
1039
+ }
676
1040
  const result = {
677
1041
  type: "keys.registerAction.result",
678
1042
  id: m.id,
@@ -683,6 +1047,19 @@ function createKeysService(options = {}) {
683
1047
  return;
684
1048
  }
685
1049
  case "keys.unregisterAction": {
1050
+ const m = message;
1051
+ if (m.actionId && actionRegistry.has(m.actionId)) {
1052
+ const entry = actionRegistry.get(m.actionId);
1053
+ actionRegistry.delete(m.actionId);
1054
+ const set = windowActions.get(entry.windowId);
1055
+ if (set) {
1056
+ set.delete(m.actionId);
1057
+ if (set.size === 0) {
1058
+ windowActions.delete(entry.windowId);
1059
+ sendHandles.delete(entry.windowId);
1060
+ }
1061
+ }
1062
+ }
686
1063
  return;
687
1064
  }
688
1065
  default: {
@@ -696,66 +1073,382 @@ function createKeysService(options = {}) {
696
1073
  }
697
1074
  }
698
1075
  },
699
- onWindowDestroyed(_windowId) {
1076
+ onWindowDestroyed(windowId) {
1077
+ const actions = windowActions.get(windowId);
1078
+ if (actions) {
1079
+ for (const actionId of actions) actionRegistry.delete(actionId);
1080
+ windowActions.delete(windowId);
1081
+ }
1082
+ sendHandles.delete(windowId);
1083
+ },
1084
+ destroy() {
1085
+ target.removeEventListener("keydown", listener);
1086
+ actionRegistry.clear();
1087
+ windowActions.clear();
1088
+ sendHandles.clear();
1089
+ }
1090
+ };
1091
+ }
1092
+
1093
+ // src/browser-media-bridge.ts
1094
+ var SILENT_AUDIO_DATA_URL = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
1095
+ var DEFAULT_MEDIA_ACTIONS = ["play", "pause", "next", "prev", "seek"];
1096
+ var ACTION_MATRIX = [
1097
+ ["play", "play"],
1098
+ ["pause", "pause"],
1099
+ ["nexttrack", "next"],
1100
+ ["previoustrack", "prev"],
1101
+ ["seekto", "seek"]
1102
+ ];
1103
+ function createBrowserMediaBridge(opts = {}) {
1104
+ const ms = opts.mediaSessionTarget ?? (typeof navigator !== "undefined" && "mediaSession" in navigator ? navigator.mediaSession : null);
1105
+ const doc = opts.documentTarget !== void 0 ? opts.documentTarget : typeof document !== "undefined" ? document : null;
1106
+ let silentAudioEl = null;
1107
+ let activeSessionId = null;
1108
+ let sessionsActive = 0;
1109
+ const actionCallbacks = /* @__PURE__ */ new Set();
1110
+ function primeSilentAudio() {
1111
+ if (silentAudioEl || !doc) return;
1112
+ const el = doc.createElement("audio");
1113
+ el.src = SILENT_AUDIO_DATA_URL;
1114
+ el.loop = true;
1115
+ el.style.display = "none";
1116
+ el.setAttribute("data-kehto-silent-audio-prime", "true");
1117
+ doc.body.appendChild(el);
1118
+ void el.play().catch(() => {
1119
+ });
1120
+ silentAudioEl = el;
1121
+ }
1122
+ function teardownSilentAudio() {
1123
+ if (!silentAudioEl) return;
1124
+ try {
1125
+ silentAudioEl.pause();
1126
+ } catch {
1127
+ }
1128
+ try {
1129
+ silentAudioEl.remove();
1130
+ } catch {
1131
+ }
1132
+ silentAudioEl = null;
1133
+ }
1134
+ function applyActionHandlers(actions = DEFAULT_MEDIA_ACTIONS) {
1135
+ if (!ms) return;
1136
+ for (const [domAction, nubAction] of ACTION_MATRIX) {
1137
+ if (!actions.includes(nubAction)) {
1138
+ try {
1139
+ ms.setActionHandler(domAction, null);
1140
+ } catch {
1141
+ }
1142
+ continue;
1143
+ }
1144
+ ms.setActionHandler(domAction, (details) => {
1145
+ if (!activeSessionId) return;
1146
+ const value = nubAction === "seek" && typeof details?.seekTime === "number" ? details.seekTime : void 0;
1147
+ for (const cb of actionCallbacks) {
1148
+ cb(activeSessionId, nubAction, value);
1149
+ }
1150
+ });
1151
+ }
1152
+ }
1153
+ function writeMetadata(metadata) {
1154
+ if (!ms) return;
1155
+ if (!metadata) {
1156
+ ms.metadata = null;
1157
+ return;
1158
+ }
1159
+ const artwork = metadata.artwork?.url ? [{ src: metadata.artwork.url }] : void 0;
1160
+ const init = {
1161
+ title: metadata.title ?? "",
1162
+ artist: metadata.artist ?? "",
1163
+ album: metadata.album ?? "",
1164
+ ...artwork ? { artwork } : {}
1165
+ };
1166
+ try {
1167
+ const ctor = globalThis.MediaMetadata;
1168
+ ms.metadata = ctor ? new ctor(init) : init;
1169
+ } catch {
1170
+ ms.metadata = init;
1171
+ }
1172
+ }
1173
+ return {
1174
+ setMetadata(sessionId, metadata) {
1175
+ if (sessionId === activeSessionId) writeMetadata(metadata);
1176
+ },
1177
+ setPlaybackState(sessionId, state) {
1178
+ if (!ms || sessionId !== activeSessionId) return;
1179
+ ms.playbackState = state === "playing" ? "playing" : state === "paused" || state === "buffering" ? "paused" : "none";
1180
+ },
1181
+ onAction(callback) {
1182
+ actionCallbacks.add(callback);
1183
+ return () => {
1184
+ actionCallbacks.delete(callback);
1185
+ };
1186
+ },
1187
+ setActiveSession(sessionId, actions) {
1188
+ activeSessionId = sessionId;
1189
+ if (!sessionId) {
1190
+ if (ms) {
1191
+ ms.metadata = null;
1192
+ ms.playbackState = "none";
1193
+ for (const [domAction] of ACTION_MATRIX) {
1194
+ try {
1195
+ ms.setActionHandler(domAction, null);
1196
+ } catch {
1197
+ }
1198
+ }
1199
+ }
1200
+ return;
1201
+ }
1202
+ if (sessionsActive === 0) {
1203
+ primeSilentAudio();
1204
+ sessionsActive = 1;
1205
+ }
1206
+ applyActionHandlers(actions ?? DEFAULT_MEDIA_ACTIONS);
1207
+ },
1208
+ destroySession(_sessionId) {
1209
+ sessionsActive = Math.max(0, sessionsActive - 1);
1210
+ if (sessionsActive === 0) teardownSilentAudio();
700
1211
  }
701
1212
  };
702
1213
  }
703
1214
 
704
1215
  // src/media-service.ts
705
- var MEDIA_SERVICE_VERSION = "1.0.0";
1216
+ var MEDIA_SERVICE_VERSION = "1.1.0";
1217
+ function createMediaServiceState(options, bridge) {
1218
+ return {
1219
+ bridge,
1220
+ options,
1221
+ sessionRegistry: /* @__PURE__ */ new Map(),
1222
+ windowSessions: /* @__PURE__ */ new Map(),
1223
+ sendHandles: /* @__PURE__ */ new Map(),
1224
+ activeSessionId: null,
1225
+ touchCounter: 0,
1226
+ sessionCounter: 0
1227
+ };
1228
+ }
1229
+ function setActive(state, sessionId, actions) {
1230
+ state.activeSessionId = sessionId;
1231
+ state.bridge.setActiveSession?.(sessionId, actions);
1232
+ if (!sessionId) return;
1233
+ const entry = state.sessionRegistry.get(sessionId);
1234
+ if (!entry) return;
1235
+ if (entry.metadata) state.bridge.setMetadata(sessionId, entry.metadata);
1236
+ if (entry.state) state.bridge.setPlaybackState(sessionId, entry.state.status);
1237
+ }
1238
+ function promoteNextActiveOrClear(state) {
1239
+ if (state.sessionRegistry.size === 0) {
1240
+ setActive(state, null);
1241
+ return;
1242
+ }
1243
+ let latest = null;
1244
+ for (const entry of state.sessionRegistry.values()) {
1245
+ if (!latest || entry.lastTouched > latest.lastTouched) latest = entry;
1246
+ }
1247
+ setActive(state, latest ? latest.sessionId : null, latest?.actions);
1248
+ }
1249
+ function sendMediaCommand(state, sessionId, action, value) {
1250
+ const entry = state.sessionRegistry.get(sessionId);
1251
+ if (!entry) return;
1252
+ const send = state.sendHandles.get(entry.windowId);
1253
+ if (!send) return;
1254
+ const payload = {
1255
+ type: "media.command",
1256
+ sessionId,
1257
+ action,
1258
+ ...typeof value === "number" ? { value } : {}
1259
+ };
1260
+ send(payload);
1261
+ }
1262
+ function registerWindowSession(state, windowId, sessionId) {
1263
+ if (!state.windowSessions.has(windowId)) state.windowSessions.set(windowId, /* @__PURE__ */ new Set());
1264
+ state.windowSessions.get(windowId).add(sessionId);
1265
+ }
1266
+ function sendSessionCreateResult(send, id, fields) {
1267
+ send({
1268
+ type: "media.session.create.result",
1269
+ id: id ?? "",
1270
+ ...fields
1271
+ });
1272
+ }
1273
+ function isMediaPlaybackOwner(value) {
1274
+ return value === "shell" || value === "napplet";
1275
+ }
1276
+ function hasSourceRef(source) {
1277
+ if (!source) return false;
1278
+ if (typeof source.url === "string" && source.url.length > 0) return true;
1279
+ if (typeof source.blossomHash === "string" && source.blossomHash.length > 0) return true;
1280
+ if (source.nostr) {
1281
+ return Boolean(source.nostr.eventId || source.nostr.address);
1282
+ }
1283
+ return false;
1284
+ }
1285
+ function canonicalizeSessionId(state, windowId, preferredSessionId) {
1286
+ const trimmed = typeof preferredSessionId === "string" ? preferredSessionId.trim() : "";
1287
+ const hint = trimmed || `session-${++state.sessionCounter}`;
1288
+ if (!state.sessionRegistry.has(hint)) return hint;
1289
+ let next;
1290
+ do {
1291
+ next = `${windowId}:${hint}:${++state.sessionCounter}`;
1292
+ } while (state.sessionRegistry.has(next));
1293
+ return next;
1294
+ }
1295
+ function handleSessionCreate(state, windowId, message, send) {
1296
+ if (!isMediaPlaybackOwner(message.owner)) {
1297
+ sendSessionCreateResult(send, message.id, { error: "missing owner" });
1298
+ return;
1299
+ }
1300
+ if (message.owner === "shell") {
1301
+ if (!hasSourceRef(message.source)) {
1302
+ sendSessionCreateResult(send, message.id, { owner: "shell", error: "missing source" });
1303
+ return;
1304
+ }
1305
+ sendSessionCreateResult(send, message.id, { owner: "shell", error: "unsupported owner mode" });
1306
+ return;
1307
+ }
1308
+ state.sendHandles.set(windowId, send);
1309
+ const sessionId = canonicalizeSessionId(state, windowId, message.sessionId);
1310
+ const entry = {
1311
+ sessionId,
1312
+ windowId,
1313
+ owner: message.owner,
1314
+ source: message.source,
1315
+ metadata: message.metadata,
1316
+ state: void 0,
1317
+ actions: message.capabilities ?? DEFAULT_MEDIA_ACTIONS,
1318
+ lastTouched: ++state.touchCounter
1319
+ };
1320
+ state.sessionRegistry.set(sessionId, entry);
1321
+ registerWindowSession(state, windowId, sessionId);
1322
+ setActive(state, sessionId, entry.actions);
1323
+ state.options.onSessionCreate?.(windowId, sessionId, message.metadata);
1324
+ sendSessionCreateResult(send, message.id, { sessionId, owner: message.owner });
1325
+ }
1326
+ function handleSessionUpdate(state, windowId, message) {
1327
+ const entry = state.sessionRegistry.get(message.sessionId);
1328
+ if (entry) {
1329
+ entry.metadata = { ...entry.metadata, ...message.metadata };
1330
+ entry.lastTouched = ++state.touchCounter;
1331
+ if (entry.owner === "napplet" && message.sessionId === state.activeSessionId && entry.metadata) {
1332
+ state.bridge.setMetadata(message.sessionId, entry.metadata);
1333
+ }
1334
+ }
1335
+ state.options.onSessionUpdate?.(windowId, message.sessionId, message.metadata);
1336
+ }
1337
+ function handleSessionDestroy(state, windowId, message) {
1338
+ const entry = state.sessionRegistry.get(message.sessionId);
1339
+ if (entry) {
1340
+ state.sessionRegistry.delete(message.sessionId);
1341
+ const set = state.windowSessions.get(entry.windowId);
1342
+ if (set) {
1343
+ set.delete(message.sessionId);
1344
+ if (set.size === 0) state.windowSessions.delete(entry.windowId);
1345
+ }
1346
+ state.bridge.destroySession?.(message.sessionId);
1347
+ if (message.sessionId === state.activeSessionId) promoteNextActiveOrClear(state);
1348
+ }
1349
+ state.options.onSessionDestroy?.(windowId, message.sessionId);
1350
+ }
1351
+ function handleMediaState(state, windowId, message) {
1352
+ const entry = state.sessionRegistry.get(message.sessionId);
1353
+ if (entry?.owner === "napplet") {
1354
+ entry.state = {
1355
+ status: message.status,
1356
+ position: message.position,
1357
+ duration: message.duration,
1358
+ volume: message.volume
1359
+ };
1360
+ entry.lastTouched = ++state.touchCounter;
1361
+ if (state.activeSessionId !== message.sessionId) setActive(state, message.sessionId, entry.actions);
1362
+ else state.bridge.setPlaybackState(message.sessionId, message.status);
1363
+ }
1364
+ state.options.onState?.(windowId, message.sessionId, message);
1365
+ }
1366
+ function handleMediaCapabilities(state, windowId, message) {
1367
+ const entry = state.sessionRegistry.get(message.sessionId);
1368
+ if (entry?.owner === "napplet") {
1369
+ entry.actions = message.actions;
1370
+ entry.lastTouched = ++state.touchCounter;
1371
+ if (message.sessionId === state.activeSessionId) {
1372
+ state.bridge.setActiveSession?.(message.sessionId, entry.actions);
1373
+ }
1374
+ }
1375
+ state.options.onCapabilities?.(windowId, message.sessionId, message.actions);
1376
+ }
1377
+ function handleMediaMessage(state, windowId, message, send) {
1378
+ switch (message.type) {
1379
+ case "media.session.create":
1380
+ handleSessionCreate(state, windowId, message, send);
1381
+ return;
1382
+ case "media.session.update":
1383
+ handleSessionUpdate(state, windowId, message);
1384
+ return;
1385
+ case "media.session.destroy":
1386
+ handleSessionDestroy(state, windowId, message);
1387
+ return;
1388
+ case "media.state":
1389
+ handleMediaState(state, windowId, message);
1390
+ return;
1391
+ case "media.capabilities":
1392
+ handleMediaCapabilities(state, windowId, message);
1393
+ return;
1394
+ default: {
1395
+ const id = message.id ?? "";
1396
+ send({
1397
+ type: `${message.type}.error`,
1398
+ id,
1399
+ error: `Unknown media method: ${message.type}`
1400
+ });
1401
+ }
1402
+ }
1403
+ }
1404
+ function destroyWindowSessions(state, windowId) {
1405
+ const sessions = state.windowSessions.get(windowId);
1406
+ if (sessions) {
1407
+ const ownedActive = state.activeSessionId !== null && sessions.has(state.activeSessionId);
1408
+ for (const sessionId of sessions) {
1409
+ state.sessionRegistry.delete(sessionId);
1410
+ state.bridge.destroySession?.(sessionId);
1411
+ }
1412
+ state.windowSessions.delete(windowId);
1413
+ if (ownedActive) promoteNextActiveOrClear(state);
1414
+ }
1415
+ state.sendHandles.delete(windowId);
1416
+ }
1417
+ function destroyMediaState(state, unsubscribeAction) {
1418
+ unsubscribeAction();
1419
+ for (const sessionId of state.sessionRegistry.keys()) state.bridge.destroySession?.(sessionId);
1420
+ state.bridge.setActiveSession?.(null);
1421
+ state.sessionRegistry.clear();
1422
+ state.windowSessions.clear();
1423
+ state.sendHandles.clear();
1424
+ state.activeSessionId = null;
1425
+ state.touchCounter = 0;
1426
+ state.sessionCounter = 0;
1427
+ }
706
1428
  function createMediaService(options = {}) {
707
1429
  const descriptor = {
708
1430
  name: "media",
709
1431
  version: MEDIA_SERVICE_VERSION,
710
- description: "NIP-5D media NUB reference handler (stub)"
1432
+ description: options.hostBridge ? "NIP-5D media NUB reference handler (host-bridge delegated)" : "NIP-5D media NUB reference handler (navigator.mediaSession mirror)"
711
1433
  };
1434
+ const bridge = options.hostBridge ?? createBrowserMediaBridge({
1435
+ mediaSessionTarget: options.mediaSessionTarget,
1436
+ documentTarget: options.documentTarget
1437
+ });
1438
+ const state = createMediaServiceState(options, bridge);
1439
+ const unsubscribeAction = bridge.onAction((sessionId, action, value) => {
1440
+ sendMediaCommand(state, sessionId, action, value);
1441
+ });
712
1442
  return {
713
1443
  descriptor,
714
1444
  handleMessage(windowId, message, send) {
715
- switch (message.type) {
716
- case "media.session.create": {
717
- const m = message;
718
- options.onSessionCreate?.(windowId, m.sessionId, m.metadata);
719
- const result = {
720
- type: "media.session.create.result",
721
- id: m.id,
722
- sessionId: m.sessionId
723
- };
724
- send(result);
725
- return;
726
- }
727
- case "media.session.update": {
728
- const m = message;
729
- options.onSessionUpdate?.(windowId, m.sessionId ?? "", m.metadata);
730
- return;
731
- }
732
- case "media.session.destroy": {
733
- const m = message;
734
- options.onSessionDestroy?.(windowId, m.sessionId ?? "");
735
- return;
736
- }
737
- case "media.state": {
738
- const m = message;
739
- options.onState?.(windowId, m.sessionId ?? "", m);
740
- return;
741
- }
742
- case "media.capabilities": {
743
- const m = message;
744
- options.onCapabilities?.(windowId, m.sessionId ?? "", m.actions);
745
- return;
746
- }
747
- default: {
748
- const id = message.id ?? "";
749
- send({
750
- type: `${message.type}.error`,
751
- id,
752
- error: `Unknown media method: ${message.type}`
753
- });
754
- return;
755
- }
756
- }
1445
+ handleMediaMessage(state, windowId, message, send);
757
1446
  },
758
- onWindowDestroyed(_windowId) {
1447
+ onWindowDestroyed(windowId) {
1448
+ destroyWindowSessions(state, windowId);
1449
+ },
1450
+ destroy() {
1451
+ destroyMediaState(state, unsubscribeAction);
759
1452
  }
760
1453
  };
761
1454
  }
@@ -827,7 +1520,7 @@ var DEFAULT_THEME = {
827
1520
  primary: "#7aa2f7"
828
1521
  }
829
1522
  // fonts, background, title intentionally undefined — all optional per
830
- // @napplet/nub-theme Theme interface.
1523
+ // @napplet/nub/theme Theme interface.
831
1524
  };
832
1525
  function createThemeService(options = {}) {
833
1526
  let currentTheme = options.initialTheme ?? DEFAULT_THEME;
@@ -870,16 +1563,416 @@ function createThemeService(options = {}) {
870
1563
  }
871
1564
  return { handler, publishTheme, getCurrentTheme };
872
1565
  }
1566
+
1567
+ // src/config-service.ts
1568
+ var CONFIG_SERVICE_VERSION = "1.0.0";
1569
+ function validateCoreSubset(schema) {
1570
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
1571
+ return { ok: false, code: "invalid-schema", error: "schema root must be an object" };
1572
+ }
1573
+ const s = schema;
1574
+ if ("$ref" in s) {
1575
+ return { ok: false, code: "ref-not-allowed", error: "$ref is not permitted in the Core Subset" };
1576
+ }
1577
+ if ("pattern" in s) {
1578
+ return {
1579
+ ok: false,
1580
+ code: "pattern-not-allowed",
1581
+ error: "pattern is not permitted in the Core Subset"
1582
+ };
1583
+ }
1584
+ if ("oneOf" in s || "anyOf" in s || "allOf" in s || "not" in s) {
1585
+ return {
1586
+ ok: false,
1587
+ code: "invalid-schema",
1588
+ error: "oneOf/anyOf/allOf/not are not permitted in the Core Subset"
1589
+ };
1590
+ }
1591
+ if ("if" in s || "then" in s || "else" in s) {
1592
+ return {
1593
+ ok: false,
1594
+ code: "invalid-schema",
1595
+ error: "if/then/else are not permitted in the Core Subset"
1596
+ };
1597
+ }
1598
+ if (s.type !== "object") {
1599
+ return { ok: false, code: "invalid-schema", error: 'schema root must have type: "object"' };
1600
+ }
1601
+ const props = s.properties;
1602
+ if (props !== void 0 && (typeof props !== "object" || props === null)) {
1603
+ return { ok: false, code: "invalid-schema", error: "properties must be an object" };
1604
+ }
1605
+ if (props) {
1606
+ for (const [key, val] of Object.entries(props)) {
1607
+ if (typeof val !== "object" || val === null) {
1608
+ return {
1609
+ ok: false,
1610
+ code: "invalid-schema",
1611
+ error: `property "${key}" must be an object schema`
1612
+ };
1613
+ }
1614
+ const pv = val;
1615
+ const ALLOWED_TYPES = /* @__PURE__ */ new Set(["string", "number", "boolean", "array", "object"]);
1616
+ if (pv.type !== void 0 && !ALLOWED_TYPES.has(pv.type)) {
1617
+ return {
1618
+ ok: false,
1619
+ code: "invalid-schema",
1620
+ error: `property "${key}" must have type: string|number|boolean|array|object`
1621
+ };
1622
+ }
1623
+ }
1624
+ }
1625
+ return { ok: true };
1626
+ }
1627
+ function createConfigService(options) {
1628
+ const subscribers = /* @__PURE__ */ new Map();
1629
+ const descriptor = {
1630
+ name: "config",
1631
+ version: CONFIG_SERVICE_VERSION,
1632
+ description: "NUB-CONFIG reference service \u2014 shell-writes, napplet-reads configuration"
1633
+ };
1634
+ const handler = {
1635
+ descriptor,
1636
+ handleMessage(windowId, message, send) {
1637
+ switch (message.type) {
1638
+ case "config.get": {
1639
+ const m = message;
1640
+ const reply = {
1641
+ type: "config.values",
1642
+ id: m.id,
1643
+ values: options.getValues()
1644
+ };
1645
+ send(reply);
1646
+ return;
1647
+ }
1648
+ case "config.subscribe": {
1649
+ subscribers.set(windowId, send);
1650
+ const push = {
1651
+ type: "config.values",
1652
+ values: options.getValues()
1653
+ };
1654
+ send(push);
1655
+ options.onSubscribe?.(windowId);
1656
+ return;
1657
+ }
1658
+ case "config.unsubscribe": {
1659
+ subscribers.delete(windowId);
1660
+ options.onUnsubscribe?.(windowId);
1661
+ return;
1662
+ }
1663
+ case "config.registerSchema": {
1664
+ const m = message;
1665
+ const validation = options.registerSchema ? options.registerSchema(windowId, m.schema, m.version) : validateCoreSubset(m.schema);
1666
+ const result = validation.ok ? { type: "config.registerSchema.result", id: m.id, ok: true } : {
1667
+ type: "config.registerSchema.result",
1668
+ id: m.id,
1669
+ ok: false,
1670
+ code: validation.code,
1671
+ error: validation.error
1672
+ };
1673
+ send(result);
1674
+ return;
1675
+ }
1676
+ case "config.openSettings": {
1677
+ const m = message;
1678
+ options.openSettings?.(windowId, m.section);
1679
+ return;
1680
+ }
1681
+ default:
1682
+ return;
1683
+ }
1684
+ },
1685
+ onWindowDestroyed(windowId) {
1686
+ subscribers.delete(windowId);
1687
+ }
1688
+ };
1689
+ function publishValues(values) {
1690
+ const envelope = {
1691
+ type: "config.values",
1692
+ values
1693
+ };
1694
+ for (const send of subscribers.values()) {
1695
+ try {
1696
+ send(envelope);
1697
+ } catch {
1698
+ }
1699
+ }
1700
+ }
1701
+ return { handler, publishValues };
1702
+ }
1703
+
1704
+ // src/resource-service.ts
1705
+ var RESOURCE_SERVICE_VERSION = "1.0.0";
1706
+ function arrayBufferToBase64(buf) {
1707
+ const bytes = new Uint8Array(buf);
1708
+ const CHUNK = 32768;
1709
+ let binary = "";
1710
+ for (let i = 0; i < bytes.length; i += CHUNK) {
1711
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
1712
+ }
1713
+ return btoa(binary);
1714
+ }
1715
+ function assertResourceOptions(options) {
1716
+ if (typeof options?.fetch !== "function" || typeof options?.isOriginGranted !== "function" || typeof options?.getConnectGrants !== "function" || typeof options?.resolveIdentity !== "function") {
1717
+ throw new Error(
1718
+ "[RESOURCE-01 / H-03] createResourceService requires {fetch, isOriginGranted, getConnectGrants, resolveIdentity} \u2014 all four options are required from day one. The grants source (getConnectGrants) MUST be wired at construction time to prevent unguarded fetch proxying."
1719
+ );
1720
+ }
1721
+ }
1722
+ function trackRequest(state, requestId, windowId, controller) {
1723
+ state.inFlight.set(requestId, { controller, windowId });
1724
+ if (!state.perWindow.has(windowId)) {
1725
+ state.perWindow.set(windowId, /* @__PURE__ */ new Set());
1726
+ }
1727
+ state.perWindow.get(windowId).add(requestId);
1728
+ }
1729
+ function untrackRequest(state, requestId) {
1730
+ const entry = state.inFlight.get(requestId);
1731
+ if (entry) {
1732
+ state.inFlight.delete(requestId);
1733
+ state.perWindow.get(entry.windowId)?.delete(requestId);
1734
+ }
1735
+ }
1736
+ function sendResourceError(send, requestId, code, message) {
1737
+ send({
1738
+ type: "resource.bytes.error",
1739
+ requestId,
1740
+ code,
1741
+ message
1742
+ });
1743
+ }
1744
+ function parseResourceUrl(send, requestId, url) {
1745
+ try {
1746
+ return new URL(url);
1747
+ } catch {
1748
+ sendResourceError(send, requestId, "invalid-url", `invalid URL: ${url}`);
1749
+ return null;
1750
+ }
1751
+ }
1752
+ function collectResponseHeaders(response) {
1753
+ const headers = {};
1754
+ response.headers.forEach((value, key) => {
1755
+ headers[key] = value;
1756
+ });
1757
+ return headers;
1758
+ }
1759
+ async function handleBytes(options, state, windowId, msg, send) {
1760
+ const { requestId, url, init } = msg;
1761
+ const identity = options.resolveIdentity(windowId);
1762
+ if (!identity) {
1763
+ sendResourceError(send, requestId, "denied", "napplet identity not resolvable");
1764
+ return;
1765
+ }
1766
+ const parsedUrl = parseResourceUrl(send, requestId, url);
1767
+ if (!parsedUrl) return;
1768
+ const origin = parsedUrl.origin;
1769
+ const grants = options.getConnectGrants(identity.dTag, identity.aggregateHash);
1770
+ if (!options.isOriginGranted(origin, grants)) {
1771
+ sendResourceError(send, requestId, "denied", `origin ${origin} not granted`);
1772
+ return;
1773
+ }
1774
+ const controller = new AbortController();
1775
+ trackRequest(state, requestId, windowId, controller);
1776
+ try {
1777
+ const response = await options.fetch(url, {
1778
+ method: init?.method,
1779
+ headers: init?.headers ? { ...init.headers } : void 0,
1780
+ signal: controller.signal
1781
+ });
1782
+ const buffer = await response.arrayBuffer();
1783
+ send({
1784
+ type: "resource.bytes.result",
1785
+ requestId,
1786
+ status: response.status,
1787
+ headers: collectResponseHeaders(response),
1788
+ bodyBase64: arrayBufferToBase64(buffer)
1789
+ });
1790
+ } catch (err) {
1791
+ const isAbort = controller.signal.aborted || err instanceof Error && (err.name === "AbortError" || err.name === "DOMException");
1792
+ sendResourceError(
1793
+ send,
1794
+ requestId,
1795
+ isAbort ? "canceled" : "network-error",
1796
+ err instanceof Error ? err.message : String(err)
1797
+ );
1798
+ } finally {
1799
+ untrackRequest(state, requestId);
1800
+ }
1801
+ }
1802
+ function handleCancel(state, requestId) {
1803
+ const entry = state.inFlight.get(requestId);
1804
+ if (entry) {
1805
+ entry.controller.abort();
1806
+ }
1807
+ }
1808
+ function destroyWindowRequests(state, windowId) {
1809
+ const requestIds = state.perWindow.get(windowId);
1810
+ if (!requestIds) return;
1811
+ for (const requestId of requestIds) {
1812
+ const entry = state.inFlight.get(requestId);
1813
+ if (entry) {
1814
+ entry.controller.abort();
1815
+ state.inFlight.delete(requestId);
1816
+ }
1817
+ }
1818
+ state.perWindow.delete(windowId);
1819
+ }
1820
+ function createResourceService(options) {
1821
+ assertResourceOptions(options);
1822
+ const state = {
1823
+ inFlight: /* @__PURE__ */ new Map(),
1824
+ perWindow: /* @__PURE__ */ new Map()
1825
+ };
1826
+ const descriptor = {
1827
+ name: "resource",
1828
+ version: RESOURCE_SERVICE_VERSION,
1829
+ description: "NUB-RESOURCE reference service \u2014 shell-proxied authenticated fetch (RESOURCE-01..06)"
1830
+ };
1831
+ const handler = {
1832
+ descriptor,
1833
+ handleMessage(windowId, message, send) {
1834
+ switch (message.type) {
1835
+ case "resource.bytes": {
1836
+ const m = message;
1837
+ handleBytes(options, state, windowId, m, send).catch(() => {
1838
+ });
1839
+ return;
1840
+ }
1841
+ case "resource.cancel": {
1842
+ const m = message;
1843
+ handleCancel(state, m.requestId);
1844
+ return;
1845
+ }
1846
+ default:
1847
+ return;
1848
+ }
1849
+ },
1850
+ onWindowDestroyed(windowId) {
1851
+ destroyWindowRequests(state, windowId);
1852
+ }
1853
+ };
1854
+ return handler;
1855
+ }
1856
+
1857
+ // src/cvm-service.ts
1858
+ var CVM_SERVICE_VERSION = "1.0.0";
1859
+ var CVM_DESCRIPTOR = {
1860
+ name: "cvm",
1861
+ version: CVM_SERVICE_VERSION,
1862
+ description: "NAP-CVM ContextVM bridge \u2014 MCP over Nostr"
1863
+ };
1864
+ function createCvmService(options) {
1865
+ if (!options || typeof options.transport !== "object" || options.transport === null) {
1866
+ throw new Error("createCvmService: options.transport is required");
1867
+ }
1868
+ const { transport } = options;
1869
+ const sendByWindow = /* @__PURE__ */ new Map();
1870
+ const windowsByServer = /* @__PURE__ */ new Map();
1871
+ function openSession(windowId, server, send) {
1872
+ sendByWindow.set(windowId, send);
1873
+ let windows = windowsByServer.get(server.pubkey);
1874
+ if (!windows) {
1875
+ windows = /* @__PURE__ */ new Set();
1876
+ windowsByServer.set(server.pubkey, windows);
1877
+ }
1878
+ windows.add(windowId);
1879
+ }
1880
+ function closeSession(windowId, serverPubkey) {
1881
+ const windows = windowsByServer.get(serverPubkey);
1882
+ if (windows) {
1883
+ windows.delete(windowId);
1884
+ if (windows.size === 0) windowsByServer.delete(serverPubkey);
1885
+ }
1886
+ }
1887
+ const eventSub = transport.onEvent((server, message) => {
1888
+ const windows = windowsByServer.get(server.pubkey);
1889
+ if (!windows) return;
1890
+ for (const windowId of windows) {
1891
+ const send = sendByWindow.get(windowId);
1892
+ send?.({ type: "cvm.event", server, message });
1893
+ }
1894
+ });
1895
+ function handleDiscover(msg, send) {
1896
+ const m = msg;
1897
+ const id = m.id ?? "";
1898
+ void transport.discover(m.query).then((servers) => send({ type: "cvm.discover.result", id, servers })).catch(
1899
+ (err) => send({ type: "cvm.discover.result", id, servers: [], error: toErrorMessage(err) })
1900
+ );
1901
+ }
1902
+ function handleRequest(windowId, msg, send) {
1903
+ const m = msg;
1904
+ const id = m.id ?? "";
1905
+ if (!m.server || typeof m.server.pubkey !== "string" || m.server.pubkey.length === 0) {
1906
+ send({ type: "cvm.request.result", id, error: "server not found" });
1907
+ return;
1908
+ }
1909
+ if (!m.message || typeof m.message !== "object") {
1910
+ send({ type: "cvm.request.result", id, error: "unsupported method" });
1911
+ return;
1912
+ }
1913
+ openSession(windowId, m.server, send);
1914
+ void transport.request(m.server, m.message, m.options).then((message) => send({ type: "cvm.request.result", id, message })).catch(
1915
+ (err) => send({ type: "cvm.request.result", id, error: toErrorMessage(err) })
1916
+ );
1917
+ }
1918
+ function handleClose(windowId, msg, send) {
1919
+ const m = msg;
1920
+ const id = m.id ?? "";
1921
+ if (!m.server || typeof m.server.pubkey !== "string") {
1922
+ send({ type: "cvm.close.result", id, error: "server not found" });
1923
+ return;
1924
+ }
1925
+ closeSession(windowId, m.server.pubkey);
1926
+ void transport.close(m.server).then(() => send({ type: "cvm.close.result", id })).catch((err) => send({ type: "cvm.close.result", id, error: toErrorMessage(err) }));
1927
+ }
1928
+ return {
1929
+ descriptor: CVM_DESCRIPTOR,
1930
+ handleMessage(windowId, message, send) {
1931
+ switch (message.type) {
1932
+ case "cvm.discover":
1933
+ handleDiscover(message, send);
1934
+ return;
1935
+ case "cvm.request":
1936
+ handleRequest(windowId, message, send);
1937
+ return;
1938
+ case "cvm.close":
1939
+ handleClose(windowId, message, send);
1940
+ return;
1941
+ default:
1942
+ return;
1943
+ }
1944
+ },
1945
+ onWindowDestroyed(windowId) {
1946
+ sendByWindow.delete(windowId);
1947
+ for (const [pubkey, windows] of windowsByServer) {
1948
+ windows.delete(windowId);
1949
+ if (windows.size === 0) windowsByServer.delete(pubkey);
1950
+ }
1951
+ },
1952
+ dispose() {
1953
+ eventSub.close();
1954
+ }
1955
+ };
1956
+ }
1957
+ function toErrorMessage(err) {
1958
+ if (err instanceof Error) return err.message;
1959
+ if (typeof err === "string") return err;
1960
+ return "cvm request failed";
1961
+ }
873
1962
  export {
874
1963
  createAudioService,
1964
+ createBrowserMediaBridge,
875
1965
  createCacheService,
1966
+ createConfigService,
876
1967
  createCoordinatedRelay,
1968
+ createCvmService,
877
1969
  createIdentityService,
878
1970
  createKeysService,
879
1971
  createMediaService,
880
1972
  createNotificationService,
881
1973
  createNotifyService,
882
1974
  createRelayPoolService,
1975
+ createResourceService,
883
1976
  createThemeService
884
1977
  };
885
1978
  //# sourceMappingURL=index.js.map