@kehto/runtime 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.js CHANGED
@@ -1,5 +1,12 @@
1
1
  // src/enforce.ts
2
+ import { ALL_CAPABILITIES } from "@kehto/acl/capabilities";
2
3
  import { resolveCapabilitiesNub } from "@kehto/acl";
4
+ var CLASS_CAPABILITY_ALLOWLIST = Object.freeze({
5
+ "class-1": new Set(ALL_CAPABILITIES),
6
+ "class-2": new Set(ALL_CAPABILITIES.filter(
7
+ (c) => c !== "relay:write" && c !== "identity:decrypt" && c !== "outbox:write"
8
+ ))
9
+ });
3
10
  function createEnforceGate(config) {
4
11
  const { checkAcl, resolveIdentity, onAclCheck } = config;
5
12
  return function enforce(pubkey, capability, message) {
@@ -9,10 +16,11 @@ function createEnforceGate(config) {
9
16
  const allowed = checkAcl(pubkey, dTag, aggregateHash, capability);
10
17
  const identity = { pubkey, dTag, hash: aggregateHash };
11
18
  const decision = allowed ? "allow" : "deny";
19
+ const reason = allowed ? "allowed" : "capability-missing";
12
20
  if (onAclCheck) {
13
- onAclCheck({ identity, capability, decision, message });
21
+ onAclCheck({ identity, capability, decision, message, reason });
14
22
  }
15
- return { allowed, capability };
23
+ return { allowed, capability, reason };
16
24
  };
17
25
  }
18
26
  function createNubEnforceGate(config) {
@@ -21,13 +29,31 @@ function createNubEnforceGate(config) {
21
29
  const entry = resolveIdentityByWindowId(windowId);
22
30
  const dTag = entry?.dTag ?? "";
23
31
  const aggregateHash = entry?.aggregateHash ?? "";
32
+ const nappletClass = entry?.class ?? null;
33
+ if (nappletClass !== null) {
34
+ const allowlist = CLASS_CAPABILITY_ALLOWLIST[nappletClass];
35
+ if (!allowlist || !allowlist.has(capability)) {
36
+ const identity2 = { pubkey: "", dTag, hash: aggregateHash };
37
+ if (onAclCheck) {
38
+ onAclCheck({
39
+ identity: identity2,
40
+ capability,
41
+ decision: "deny",
42
+ message,
43
+ reason: "class-forbidden"
44
+ });
45
+ }
46
+ return { allowed: false, capability, reason: "class-forbidden" };
47
+ }
48
+ }
24
49
  const allowed = checkAcl("", dTag, aggregateHash, capability);
25
50
  const identity = { pubkey: "", dTag, hash: aggregateHash };
26
51
  const decision = allowed ? "allow" : "deny";
52
+ const reason = allowed ? "allowed" : "capability-missing";
27
53
  if (onAclCheck) {
28
- onAclCheck({ identity, capability, decision, message });
54
+ onAclCheck({ identity, capability, decision, message, reason });
29
55
  }
30
- return { allowed, capability };
56
+ return { allowed, capability, reason };
31
57
  };
32
58
  }
33
59
  function formatDenialReason(capability) {
@@ -117,7 +143,7 @@ import {
117
143
  CAP_HOTKEY_FORWARD,
118
144
  CAP_STATE_READ,
119
145
  CAP_STATE_WRITE,
120
- CAP_ALL
146
+ toKey
121
147
  } from "@kehto/acl";
122
148
  var CAP_IDENTITY_READ = 1 << 5;
123
149
  var CAP_KEYS_BIND = 1 << 6;
@@ -126,6 +152,12 @@ var CAP_MEDIA_CONTROL = 1 << 10;
126
152
  var CAP_NOTIFY_SEND = 1 << 11;
127
153
  var CAP_NOTIFY_CHANNEL = 1 << 12;
128
154
  var CAP_THEME_READ = 1 << 13;
155
+ var CAP_CONFIG_READ = 1 << 14;
156
+ var CAP_RESOURCE_FETCH = 1 << 15;
157
+ var CAP_IDENTITY_DECRYPT = 1 << 16;
158
+ var CAP_CVM_CALL = 1 << 17;
159
+ var CAP_OUTBOX_READ = 1 << 18;
160
+ var CAP_OUTBOX_WRITE = 1 << 19;
129
161
  var CAP_MAP = {
130
162
  "relay:read": CAP_RELAY_READ,
131
163
  "relay:write": CAP_RELAY_WRITE,
@@ -140,8 +172,15 @@ var CAP_MAP = {
140
172
  "media:control": CAP_MEDIA_CONTROL,
141
173
  "notify:send": CAP_NOTIFY_SEND,
142
174
  "notify:channel": CAP_NOTIFY_CHANNEL,
143
- "theme:read": CAP_THEME_READ
175
+ "theme:read": CAP_THEME_READ,
176
+ "config:read": CAP_CONFIG_READ,
177
+ "resource:fetch": CAP_RESOURCE_FETCH,
178
+ "identity:decrypt": CAP_IDENTITY_DECRYPT,
179
+ "cvm:call": CAP_CVM_CALL,
180
+ "outbox:read": CAP_OUTBOX_READ,
181
+ "outbox:write": CAP_OUTBOX_WRITE
144
182
  };
183
+ var RUNTIME_CAP_ALL = Object.values(CAP_MAP).reduce((bits, bit) => bits | bit, 0);
145
184
  function capToBit(cap) {
146
185
  return CAP_MAP[cap] ?? 0;
147
186
  }
@@ -157,6 +196,11 @@ function toIdentity(pubkey, dTag, hash) {
157
196
  }
158
197
  function createAclState(persistence, defaultPolicy = "permissive") {
159
198
  let state = createState(defaultPolicy);
199
+ function ensureRuntimeDefaultEntry(id) {
200
+ if (state.defaultPolicy !== "permissive") return;
201
+ if (state.entries[toKey(id)]) return;
202
+ state = grant(state, id, RUNTIME_CAP_ALL);
203
+ }
160
204
  return {
161
205
  check(pubkey, dTag, aggregateHash, capability) {
162
206
  const id = toIdentity(pubkey, dTag, aggregateHash);
@@ -164,23 +208,27 @@ function createAclState(persistence, defaultPolicy = "permissive") {
164
208
  },
165
209
  grant(pubkey, dTag, aggregateHash, capability) {
166
210
  const id = toIdentity(pubkey, dTag, aggregateHash);
211
+ ensureRuntimeDefaultEntry(id);
167
212
  state = grant(state, id, capToBit(capability));
168
213
  },
169
214
  revoke(pubkey, dTag, aggregateHash, capability) {
170
215
  const id = toIdentity(pubkey, dTag, aggregateHash);
216
+ ensureRuntimeDefaultEntry(id);
171
217
  state = revoke(state, id, capToBit(capability));
172
218
  },
173
219
  block(pubkey, dTag, aggregateHash) {
174
220
  const id = toIdentity(pubkey, dTag, aggregateHash);
221
+ ensureRuntimeDefaultEntry(id);
175
222
  state = block(state, id);
176
223
  },
177
224
  unblock(pubkey, dTag, aggregateHash) {
178
225
  const id = toIdentity(pubkey, dTag, aggregateHash);
226
+ ensureRuntimeDefaultEntry(id);
179
227
  state = unblock(state, id);
180
228
  },
181
229
  isBlocked(pubkey, dTag, aggregateHash) {
182
230
  const id = toIdentity(pubkey, dTag, aggregateHash);
183
- return !check(state, id, CAP_ALL) && this.getEntry(pubkey, dTag, aggregateHash)?.blocked === true;
231
+ return !check(state, id, RUNTIME_CAP_ALL) && this.getEntry(pubkey, dTag, aggregateHash)?.blocked === true;
184
232
  },
185
233
  getEntry(pubkey, dTag, aggregateHash) {
186
234
  const id = toIdentity(pubkey, dTag, aggregateHash);
@@ -195,8 +243,7 @@ function createAclState(persistence, defaultPolicy = "permissive") {
195
243
  };
196
244
  },
197
245
  getAllEntries() {
198
- return Object.entries(state.entries).map(([key, entry]) => {
199
- const parts = key.split(":");
246
+ return Object.entries(state.entries).map(([, entry]) => {
200
247
  return {
201
248
  pubkey: "",
202
249
  capabilities: bitsToCapabilities(entry.caps),
@@ -391,22 +438,21 @@ function createEventBuffer(sendToNapplet, sessionRegistry, enforce, subscription
391
438
 
392
439
  // src/runtime.ts
393
440
  import { createDispatch } from "@napplet/core";
394
- import { ALL_CAPABILITIES } from "@kehto/acl/capabilities";
395
441
 
396
442
  // src/service-dispatch.ts
397
443
  function routeServiceMessage(windowId, message, services, sendToNapplet) {
398
- const send = (msg) => sendToNapplet(windowId, msg);
399
444
  const domain = message.type.split(".")[0];
400
445
  const handler = services[domain];
401
446
  if (handler) {
402
- handler.handleMessage(windowId, message, send);
447
+ handler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
403
448
  return true;
404
449
  }
405
- if (message.type === "ifc.emit" && typeof message.topic === "string") {
406
- const prefix = message.topic.split(":")[0];
450
+ const ifcMessage = message;
451
+ if (message.type === "ifc.emit" && typeof ifcMessage.topic === "string") {
452
+ const prefix = ifcMessage.topic.split(":")[0];
407
453
  const ifcHandler = services[prefix];
408
454
  if (ifcHandler) {
409
- ifcHandler.handleMessage(windowId, message, send);
455
+ ifcHandler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
410
456
  return true;
411
457
  }
412
458
  }
@@ -421,6 +467,492 @@ function notifyServiceWindowDestroyed(windowId, services) {
421
467
  }
422
468
  }
423
469
 
470
+ // src/relay-handler.ts
471
+ function createRelayHandler(context) {
472
+ return function handleRelayMessage(windowId, msg) {
473
+ const m = msg;
474
+ const dotIdx = msg.type.indexOf(".");
475
+ const action = msg.type.slice(dotIdx + 1);
476
+ switch (action) {
477
+ case "subscribe":
478
+ handleRelaySubscribe(context, windowId, msg, m);
479
+ return;
480
+ case "close":
481
+ handleRelayClose(context, windowId, msg, m);
482
+ return;
483
+ case "publish":
484
+ handleRelayPublish(context, windowId, msg, m);
485
+ return;
486
+ case "publishEncrypted":
487
+ handleRelayPublishEncrypted(context, windowId, msg);
488
+ return;
489
+ case "query":
490
+ handleRelayQuery(context, windowId, m);
491
+ return;
492
+ default:
493
+ return;
494
+ }
495
+ };
496
+ }
497
+ function relayServiceFrom(context) {
498
+ return context.serviceRegistry["relay"] ?? context.serviceRegistry["relay-pool"];
499
+ }
500
+ function isShellKindQuery(filters) {
501
+ return filters.length > 0 && filters.every((filter) => filter.kinds?.every((kind) => kind >= 29e3 && kind < 3e4));
502
+ }
503
+ function handleRelaySubscribe(context, windowId, msg, m) {
504
+ const { eventBuffer, hooks, serviceRegistry, subscriptions } = context;
505
+ const subId = m.subId ?? "";
506
+ const filters = m.filters ?? [];
507
+ if (!subId) return;
508
+ const subKey = `${windowId}:${subId}`;
509
+ subscriptions.set(subKey, { windowId, filters });
510
+ const seenIds = /* @__PURE__ */ new Set();
511
+ function deliver(event) {
512
+ if (seenIds.has(event.id)) return;
513
+ seenIds.add(event.id);
514
+ if (subscriptions.has(subKey)) {
515
+ hooks.sendToNapplet(windowId, { type: "relay.event", subId, event });
516
+ }
517
+ }
518
+ for (const bufferedEvent of eventBuffer.getBufferedEvents()) {
519
+ if (matchesAnyFilter(bufferedEvent, filters)) deliver(bufferedEvent);
520
+ }
521
+ const isShellKind = isShellKindQuery(filters);
522
+ const relayService = relayServiceFrom(context);
523
+ const cacheService = !serviceRegistry["relay"] ? serviceRegistry["cache"] : void 0;
524
+ if (!isShellKind && relayService) {
525
+ relayService.handleMessage(windowId, msg, (resp) => {
526
+ if (!subscriptions.has(subKey)) return;
527
+ hooks.sendToNapplet(windowId, resp);
528
+ });
529
+ if (cacheService) {
530
+ cacheService.handleMessage(windowId, msg, (resp) => {
531
+ if (!subscriptions.has(subKey)) return;
532
+ hooks.sendToNapplet(windowId, resp);
533
+ });
534
+ }
535
+ return;
536
+ }
537
+ deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, isShellKind, deliver);
538
+ }
539
+ function deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, isShellKind, deliver) {
540
+ const { hooks } = context;
541
+ const cache = hooks.cache;
542
+ if (cache?.isAvailable() && !isShellKind) {
543
+ cache.query(filters).then((cachedEvents) => {
544
+ for (const event of cachedEvents) deliver(event);
545
+ }).catch(() => {
546
+ });
547
+ }
548
+ const pool = hooks.relayPool;
549
+ if (!pool?.isAvailable() && !isShellKind) {
550
+ hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
551
+ return;
552
+ }
553
+ if (!pool?.isAvailable() || isShellKind) return;
554
+ const relayUrls = pool.selectRelayTier(filters);
555
+ let eoseSent = false;
556
+ const eoseFallbackTimer = setTimeout(() => {
557
+ if (!eoseSent) {
558
+ eoseSent = true;
559
+ hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
560
+ }
561
+ }, 15e3);
562
+ const subscription = pool.subscribe(filters, (item) => {
563
+ if (item === "EOSE") {
564
+ clearTimeout(eoseFallbackTimer);
565
+ if (!eoseSent) {
566
+ eoseSent = true;
567
+ hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
568
+ }
569
+ return;
570
+ }
571
+ deliver(item);
572
+ if (cache?.isAvailable() && !isShellKind) {
573
+ try {
574
+ cache.store(item);
575
+ } catch {
576
+ return;
577
+ }
578
+ }
579
+ }, relayUrls);
580
+ pool.trackSubscription(subKey, () => {
581
+ clearTimeout(eoseFallbackTimer);
582
+ subscription.unsubscribe();
583
+ });
584
+ }
585
+ function handleRelayClose(context, windowId, msg, m) {
586
+ const { hooks, subscriptions } = context;
587
+ const subId = m.subId ?? "";
588
+ if (!subId) return;
589
+ const subKey = `${windowId}:${subId}`;
590
+ subscriptions.delete(subKey);
591
+ const relayService = relayServiceFrom(context);
592
+ if (relayService) relayService.handleMessage(windowId, msg, () => {
593
+ });
594
+ hooks.relayPool?.untrackSubscription(subKey);
595
+ hooks.sendToNapplet(windowId, { type: "relay.closed", subId, message: "" });
596
+ }
597
+ function handleRelayPublish(context, windowId, msg, m) {
598
+ const { eventBuffer, hooks, replayDetector } = context;
599
+ const event = m.event;
600
+ const id = m.id ?? "";
601
+ if (!event || typeof event !== "object") {
602
+ hooks.sendToNapplet(windowId, { type: "relay.publish.error", id, error: "invalid event" });
603
+ return;
604
+ }
605
+ const replayResult = replayDetector.check(event);
606
+ if (replayResult !== null) {
607
+ hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: replayResult });
608
+ return;
609
+ }
610
+ const relayService = relayServiceFrom(context);
611
+ if (relayService) {
612
+ relayService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
613
+ } else if (hooks.relayPool?.isAvailable()) {
614
+ hooks.relayPool.publish(event);
615
+ hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: true });
616
+ } else {
617
+ hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: "no relay pool available" });
618
+ }
619
+ eventBuffer.bufferAndDeliver(event, windowId);
620
+ }
621
+ function handleRelayPublishEncrypted(context, windowId, msg) {
622
+ const { hooks } = context;
623
+ const id = msg.id ?? "";
624
+ const eventTemplate = msg.event;
625
+ const peMsg = msg;
626
+ const recipient = peMsg.recipient ?? "";
627
+ const encryption = peMsg.encryption ?? "nip44";
628
+ const replyPe = (ok, extra = {}) => {
629
+ hooks.sendToNapplet(windowId, { type: "relay.publishEncrypted.result", id, ok, ...extra });
630
+ };
631
+ if (!recipient) {
632
+ replyPe(false, { error: "missing recipient" });
633
+ return;
634
+ }
635
+ if (encryption !== "nip44" && encryption !== "nip04") {
636
+ replyPe(false, { error: `unsupported encryption scheme: ${encryption}` });
637
+ return;
638
+ }
639
+ const peSigner = hooks.auth.getSigner();
640
+ if (!peSigner) {
641
+ replyPe(false, { error: "no signer configured" });
642
+ return;
643
+ }
644
+ if (!eventTemplate || typeof eventTemplate !== "object") {
645
+ replyPe(false, { error: "invalid event template" });
646
+ return;
647
+ }
648
+ publishEncrypted(context, windowId, id, recipient, encryption, eventTemplate, replyPe);
649
+ }
650
+ function publishEncrypted(context, windowId, id, recipient, encryption, eventTemplate, replyPe) {
651
+ const { eventBuffer, hooks } = context;
652
+ const peSigner = hooks.auth.getSigner();
653
+ if (!peSigner) return;
654
+ (async () => {
655
+ try {
656
+ const plaintext = String(eventTemplate.content ?? "");
657
+ const ciphertext = encryption === "nip44" ? await peSigner.nip44?.encrypt(recipient, plaintext) ?? "" : await peSigner.nip04?.encrypt(recipient, plaintext) ?? "";
658
+ const eventWithCiphertext = { ...eventTemplate, content: ciphertext };
659
+ const signed = await peSigner.signEvent?.(eventWithCiphertext);
660
+ if (!signed) {
661
+ replyPe(false, { error: "signEvent returned null" });
662
+ return;
663
+ }
664
+ publishSignedEncrypted(context, windowId, id, signed, replyPe);
665
+ try {
666
+ eventBuffer.bufferAndDeliver(signed, windowId);
667
+ } catch {
668
+ return;
669
+ }
670
+ } catch (err) {
671
+ replyPe(false, { error: err?.message ?? "encryption failed" });
672
+ }
673
+ })();
674
+ }
675
+ function publishSignedEncrypted(context, windowId, id, signed, replyPe) {
676
+ const { hooks } = context;
677
+ const relayService = relayServiceFrom(context);
678
+ if (!relayService) {
679
+ if (hooks.relayPool?.isAvailable()) {
680
+ hooks.relayPool.publish(signed);
681
+ replyPe(true, { event: signed, eventId: signed.id });
682
+ } else {
683
+ replyPe(false, { error: "no relay pool available" });
684
+ }
685
+ return;
686
+ }
687
+ const publishMsg = { type: "relay.publish", id, event: signed };
688
+ let replied = false;
689
+ relayService.handleMessage(windowId, publishMsg, (resp) => {
690
+ if (replied) return;
691
+ const r = resp;
692
+ if (typeof r.type !== "string" || !r.type.startsWith("relay.publish")) return;
693
+ const okVal = r.ok ?? r.accepted ?? false;
694
+ replied = true;
695
+ const publishResult = { event: signed, eventId: signed.id };
696
+ if (!okVal) publishResult.error = r.error ?? r.message ?? "publish failed";
697
+ replyPe(okVal, publishResult);
698
+ });
699
+ if (!replied) {
700
+ replied = true;
701
+ replyPe(true, { event: signed, eventId: signed.id });
702
+ }
703
+ }
704
+ function handleRelayQuery(context, windowId, m) {
705
+ const id = m.id ?? "";
706
+ const filters = m.filters ?? [];
707
+ let count = 0;
708
+ for (const event of context.eventBuffer.getBufferedEvents()) {
709
+ if (matchesAnyFilter(event, filters)) count++;
710
+ }
711
+ context.hooks.sendToNapplet(windowId, { type: "relay.query.result", id, count });
712
+ }
713
+
714
+ // src/identity-handler.ts
715
+ function createIdentityHandler(context) {
716
+ return function handleIdentityMessage(windowId, msg) {
717
+ const { hooks, serviceRegistry } = context;
718
+ const identityService = serviceRegistry["identity"];
719
+ if (identityService) {
720
+ identityService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
721
+ return;
722
+ }
723
+ const id = msg.id ?? "";
724
+ const action = msg.type.slice("identity.".length);
725
+ const signer = hooks.auth.getSigner();
726
+ const sendError = (error) => {
727
+ hooks.sendToNapplet(windowId, { type: `${msg.type}.error`, id, error });
728
+ };
729
+ const sendResult = (payload) => {
730
+ hooks.sendToNapplet(windowId, { type: `${msg.type}.result`, id, ...payload });
731
+ };
732
+ switch (action) {
733
+ case "getPublicKey":
734
+ if (!signer) {
735
+ sendResult({ pubkey: "" });
736
+ return;
737
+ }
738
+ Promise.resolve(signer.getPublicKey?.()).then((pubkey) => sendResult({ pubkey: pubkey ?? "" })).catch((err) => sendError(err?.message ?? "getPublicKey failed"));
739
+ return;
740
+ case "getRelays":
741
+ if (!signer) {
742
+ sendError("no signer configured");
743
+ return;
744
+ }
745
+ Promise.resolve(signer.getRelays?.() ?? {}).then((relays) => sendResult({ relays })).catch((err) => sendError(err?.message ?? "getRelays failed"));
746
+ return;
747
+ case "getProfile":
748
+ sendResult({ profile: null });
749
+ return;
750
+ case "getFollows":
751
+ sendResult({ pubkeys: [] });
752
+ return;
753
+ case "getList":
754
+ sendResult({ entries: [] });
755
+ return;
756
+ case "getZaps":
757
+ sendResult({ zaps: [] });
758
+ return;
759
+ case "getMutes":
760
+ sendResult({ pubkeys: [] });
761
+ return;
762
+ case "getBlocked":
763
+ sendResult({ pubkeys: [] });
764
+ return;
765
+ case "getBadges":
766
+ sendResult({ badges: [] });
767
+ return;
768
+ default:
769
+ sendError(`Unknown identity action: ${action}`);
770
+ }
771
+ };
772
+ }
773
+
774
+ // src/ifc-handler.ts
775
+ function createIfcRuntime(hooks, sessionRegistry) {
776
+ const state = {
777
+ subscriptions: /* @__PURE__ */ new Map(),
778
+ channels: /* @__PURE__ */ new Map(),
779
+ channelsByWindow: /* @__PURE__ */ new Map()
780
+ };
781
+ return {
782
+ handleMessage(windowId, msg) {
783
+ handleIfcMessage(state, hooks, sessionRegistry, windowId, msg);
784
+ },
785
+ destroyWindow(windowId) {
786
+ removeWindowChannels(state, hooks, windowId);
787
+ removeWindowSubscriptions(state, windowId);
788
+ },
789
+ clear() {
790
+ state.subscriptions.clear();
791
+ state.channels.clear();
792
+ state.channelsByWindow.clear();
793
+ }
794
+ };
795
+ }
796
+ function addChannel(state, channelId, peerA, peerB) {
797
+ state.channels.set(channelId, { channelId, peerA, peerB });
798
+ for (const windowId of [peerA, peerB]) {
799
+ let set = state.channelsByWindow.get(windowId);
800
+ if (!set) {
801
+ set = /* @__PURE__ */ new Set();
802
+ state.channelsByWindow.set(windowId, set);
803
+ }
804
+ set.add(channelId);
805
+ }
806
+ }
807
+ function removeChannel(state, channelId) {
808
+ const channel = state.channels.get(channelId);
809
+ if (!channel) return;
810
+ state.channels.delete(channelId);
811
+ for (const windowId of [channel.peerA, channel.peerB]) {
812
+ const set = state.channelsByWindow.get(windowId);
813
+ if (!set) continue;
814
+ set.delete(channelId);
815
+ if (set.size === 0) state.channelsByWindow.delete(windowId);
816
+ }
817
+ }
818
+ function peerOf(state, channelId, self) {
819
+ const channel = state.channels.get(channelId);
820
+ if (!channel) return null;
821
+ if (channel.peerA === self) return channel.peerB;
822
+ if (channel.peerB === self) return channel.peerA;
823
+ return null;
824
+ }
825
+ function resolveTarget(sessionRegistry, target) {
826
+ if (sessionRegistry.getEntryByWindowId(target)) return target;
827
+ const entries = sessionRegistry.getAllEntries();
828
+ const byPubkey = entries.find((entry) => entry.pubkey === target);
829
+ return byPubkey?.windowId ?? null;
830
+ }
831
+ function handleIfcMessage(state, hooks, sessionRegistry, windowId, msg) {
832
+ const m = msg;
833
+ const dotIdx = msg.type.indexOf(".");
834
+ const action = msg.type.slice(dotIdx + 1);
835
+ switch (action) {
836
+ case "emit":
837
+ handleEmit(state, hooks, windowId, m);
838
+ return;
839
+ case "subscribe":
840
+ handleSubscribe(state, hooks, windowId, m);
841
+ return;
842
+ case "unsubscribe":
843
+ handleUnsubscribe(state, windowId, m);
844
+ return;
845
+ case "channel.open":
846
+ handleChannelOpen(state, hooks, sessionRegistry, windowId, m);
847
+ return;
848
+ case "channel.emit":
849
+ handleChannelEmit(state, hooks, windowId, m);
850
+ return;
851
+ case "channel.broadcast":
852
+ handleChannelBroadcast(state, hooks, windowId, m);
853
+ return;
854
+ case "channel.list":
855
+ handleChannelList(state, hooks, windowId, m);
856
+ return;
857
+ case "channel.close":
858
+ handleChannelClose(state, hooks, windowId, m);
859
+ return;
860
+ default:
861
+ return;
862
+ }
863
+ }
864
+ function handleEmit(state, hooks, windowId, m) {
865
+ const topic = m.topic ?? "";
866
+ if (!topic) return;
867
+ const subscribers = state.subscriptions.get(topic);
868
+ if (!subscribers) return;
869
+ for (const subscriberWindowId of subscribers) {
870
+ if (subscriberWindowId !== windowId) {
871
+ hooks.sendToNapplet(subscriberWindowId, { type: "ifc.event", topic, payload: m.payload, sender: windowId });
872
+ }
873
+ }
874
+ }
875
+ function handleSubscribe(state, hooks, windowId, m) {
876
+ const id = m.id ?? "";
877
+ const topic = m.topic ?? "";
878
+ if (!topic) {
879
+ hooks.sendToNapplet(windowId, { type: "ifc.subscribe.result", id, error: "missing topic" });
880
+ return;
881
+ }
882
+ let subscriptions = state.subscriptions.get(topic);
883
+ if (!subscriptions) {
884
+ subscriptions = /* @__PURE__ */ new Set();
885
+ state.subscriptions.set(topic, subscriptions);
886
+ }
887
+ subscriptions.add(windowId);
888
+ hooks.sendToNapplet(windowId, { type: "ifc.subscribe.result", id });
889
+ }
890
+ function handleUnsubscribe(state, windowId, m) {
891
+ const topic = m.topic ?? "";
892
+ if (!topic) return;
893
+ const subscriptions = state.subscriptions.get(topic);
894
+ if (!subscriptions) return;
895
+ subscriptions.delete(windowId);
896
+ if (subscriptions.size === 0) state.subscriptions.delete(topic);
897
+ }
898
+ function handleChannelOpen(state, hooks, sessionRegistry, windowId, m) {
899
+ const id = m.id ?? "";
900
+ const peerWindow = resolveTarget(sessionRegistry, m.target ?? "");
901
+ if (!peerWindow) {
902
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.open.result", id, error: "target not found" });
903
+ return;
904
+ }
905
+ const channelId = hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 32);
906
+ addChannel(state, channelId, windowId, peerWindow);
907
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.open.result", id, channelId, peer: peerWindow });
908
+ }
909
+ function handleChannelEmit(state, hooks, windowId, m) {
910
+ const peer = peerOf(state, m.channelId ?? "", windowId);
911
+ if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.event", channelId: m.channelId ?? "", sender: windowId, payload: m.payload });
912
+ }
913
+ function handleChannelBroadcast(state, hooks, windowId, m) {
914
+ const channels = state.channelsByWindow.get(windowId);
915
+ if (!channels) return;
916
+ for (const channelId of channels) {
917
+ const peer = peerOf(state, channelId, windowId);
918
+ if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.event", channelId, sender: windowId, payload: m.payload });
919
+ }
920
+ }
921
+ function handleChannelList(state, hooks, windowId, m) {
922
+ const channels = [];
923
+ const set = state.channelsByWindow.get(windowId);
924
+ if (set) {
925
+ for (const channelId of set) {
926
+ const peer = peerOf(state, channelId, windowId);
927
+ if (peer) channels.push({ id: channelId, peer });
928
+ }
929
+ }
930
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.list.result", id: m.id ?? "", channels });
931
+ }
932
+ function handleChannelClose(state, hooks, windowId, m) {
933
+ const channelId = m.channelId ?? "";
934
+ const peer = peerOf(state, channelId, windowId);
935
+ if (!peer) return;
936
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.closed", channelId });
937
+ hooks.sendToNapplet(peer, { type: "ifc.channel.closed", channelId });
938
+ removeChannel(state, channelId);
939
+ }
940
+ function removeWindowSubscriptions(state, windowId) {
941
+ for (const [topic, subscriptions] of state.subscriptions) {
942
+ subscriptions.delete(windowId);
943
+ if (subscriptions.size === 0) state.subscriptions.delete(topic);
944
+ }
945
+ }
946
+ function removeWindowChannels(state, hooks, windowId) {
947
+ const channelIds = state.channelsByWindow.get(windowId);
948
+ if (!channelIds) return;
949
+ for (const channelId of Array.from(channelIds)) {
950
+ const peer = peerOf(state, channelId, windowId);
951
+ if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.closed", channelId });
952
+ removeChannel(state, channelId);
953
+ }
954
+ }
955
+
424
956
  // src/state-handler.ts
425
957
  function scopedKey(dTag, aggregateHash, userKey) {
426
958
  return `napplet-state:${dTag}:${aggregateHash}:${userKey}`;
@@ -450,7 +982,7 @@ function handleStorageNub(windowId, msg, sendToNapplet, sessionRegistry, aclStat
450
982
  sendToNapplet(windowId, { type: `${msg.type}.result`, id, ...payload });
451
983
  }
452
984
  function sendErrorNub(error) {
453
- sendToNapplet(windowId, { type: `${msg.type}.error`, id, error });
985
+ sendToNapplet(windowId, { type: `${msg.type}.result`, id, error });
454
986
  }
455
987
  const entry = sessionRegistry.getEntryByWindowId(windowId);
456
988
  if (!entry) {
@@ -509,7 +1041,7 @@ function handleStorageNub(windowId, msg, sendToNapplet, sessionRegistry, aclStat
509
1041
  break;
510
1042
  }
511
1043
  case "clear": {
512
- sendErrorNub("storage.clear is not in @napplet/nub-storage; action not supported");
1044
+ sendErrorNub("storage.clear is not in @napplet/nub/storage; action not supported");
513
1045
  break;
514
1046
  }
515
1047
  case "keys": {
@@ -533,14 +1065,133 @@ function cleanupNappState(pubkey, dTag, aggregateHash, statePersistence) {
533
1065
  statePersistence.clear(legacyPrefix);
534
1066
  }
535
1067
 
1068
+ // src/domain-handlers.ts
1069
+ var THEME_FALLBACK_DEFAULT = {
1070
+ colors: { background: "#0a0a0a", text: "#e0e0e0", primary: "#7aa2f7" }
1071
+ };
1072
+ function createRuntimeDomainHandlers(context) {
1073
+ return {
1074
+ storage: (windowId, msg) => handleStorageMessage(context, windowId, msg),
1075
+ media: (windowId, msg) => handleMediaMessage(context, windowId, msg),
1076
+ keys: (windowId, msg) => handleKeysMessage(context, windowId, msg),
1077
+ notify: (windowId, msg) => handleNotifyMessage(context, windowId, msg),
1078
+ theme: (windowId, msg) => handleThemeMessage(context, windowId, msg),
1079
+ config: (windowId, msg) => handleServiceOnlyMessage(context, "config", windowId, msg),
1080
+ resource: (windowId, msg) => handleServiceOnlyMessage(context, "resource", windowId, msg),
1081
+ cvm: (windowId, msg) => handleServiceOnlyMessage(context, "cvm", windowId, msg),
1082
+ outbox: (windowId, msg) => handleServiceOnlyMessage(context, "outbox", windowId, msg)
1083
+ };
1084
+ }
1085
+ function handleStorageMessage(context, windowId, msg) {
1086
+ const { aclState, hooks, sessionRegistry } = context;
1087
+ handleStorageNub(windowId, msg, hooks.sendToNapplet, sessionRegistry, aclState, hooks.statePersistence);
1088
+ }
1089
+ function handleMediaMessage(context, windowId, msg) {
1090
+ const { hooks, serviceRegistry } = context;
1091
+ const mediaService = serviceRegistry["media"];
1092
+ if (mediaService) {
1093
+ mediaService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1094
+ return;
1095
+ }
1096
+ if (msg.type === "media.session.create") {
1097
+ const m = msg;
1098
+ if (m.owner !== "napplet" && m.owner !== "shell") {
1099
+ hooks.sendToNapplet(windowId, {
1100
+ type: "media.session.create.result",
1101
+ id: m.id ?? "",
1102
+ error: "missing owner"
1103
+ });
1104
+ return;
1105
+ }
1106
+ if (m.owner === "shell") {
1107
+ hooks.sendToNapplet(windowId, {
1108
+ type: "media.session.create.result",
1109
+ id: m.id ?? "",
1110
+ owner: "shell",
1111
+ error: "unsupported owner mode"
1112
+ });
1113
+ return;
1114
+ }
1115
+ hooks.sendToNapplet(windowId, {
1116
+ type: "media.session.create.result",
1117
+ id: m.id ?? "",
1118
+ sessionId: m.sessionId ?? "",
1119
+ owner: m.owner
1120
+ });
1121
+ }
1122
+ }
1123
+ function handleKeysMessage(context, windowId, msg) {
1124
+ const { hooks, serviceRegistry } = context;
1125
+ const keysService = serviceRegistry["keys"];
1126
+ if (keysService) {
1127
+ keysService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1128
+ return;
1129
+ }
1130
+ if (msg.type === "keys.forward") {
1131
+ forwardHotkey(hooks, msg);
1132
+ return;
1133
+ }
1134
+ if (msg.type === "keys.registerAction") sendRegisterActionResult(hooks, windowId, msg);
1135
+ }
1136
+ function forwardHotkey(hooks, msg) {
1137
+ const m = msg;
1138
+ hooks.hotkeys.executeHotkeyFromForward({
1139
+ key: m.key ?? "",
1140
+ code: m.code ?? "",
1141
+ ctrlKey: !!m.ctrl,
1142
+ altKey: !!m.alt,
1143
+ shiftKey: !!m.shift,
1144
+ metaKey: !!m.meta
1145
+ });
1146
+ }
1147
+ function sendRegisterActionResult(hooks, windowId, msg) {
1148
+ const m = msg;
1149
+ hooks.sendToNapplet(windowId, {
1150
+ type: "keys.registerAction.result",
1151
+ id: m.id ?? "",
1152
+ actionId: m.action?.id ?? "",
1153
+ ...m.action?.defaultKey ? { binding: m.action.defaultKey } : {}
1154
+ });
1155
+ }
1156
+ function handleNotifyMessage(context, windowId, msg) {
1157
+ const { hooks, serviceRegistry } = context;
1158
+ const notifyService = serviceRegistry["notify"];
1159
+ if (notifyService) {
1160
+ notifyService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1161
+ return;
1162
+ }
1163
+ if (msg.type === "notify.send") {
1164
+ const m = msg;
1165
+ hooks.sendToNapplet(windowId, { type: "notify.send.result", id: m.id ?? "", notificationId: `shell-${Date.now()}` });
1166
+ } else if (msg.type === "notify.permission.request") {
1167
+ const m = msg;
1168
+ hooks.sendToNapplet(windowId, { type: "notify.permission.result", id: m.id ?? "", granted: true });
1169
+ }
1170
+ }
1171
+ function handleThemeMessage(context, windowId, msg) {
1172
+ const { hooks, serviceRegistry } = context;
1173
+ const themeService = serviceRegistry["theme"];
1174
+ if (themeService) {
1175
+ themeService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1176
+ return;
1177
+ }
1178
+ if (msg.type === "theme.get") {
1179
+ const m = msg;
1180
+ hooks.sendToNapplet(windowId, {
1181
+ type: "theme.get.result",
1182
+ id: m.id ?? "",
1183
+ theme: THEME_FALLBACK_DEFAULT
1184
+ });
1185
+ }
1186
+ }
1187
+ function handleServiceOnlyMessage(context, name, windowId, msg) {
1188
+ const service = context.serviceRegistry[name];
1189
+ if (!service) return;
1190
+ service.handleMessage(windowId, msg, (resp) => context.hooks.sendToNapplet(windowId, resp));
1191
+ }
1192
+
536
1193
  // src/runtime.ts
537
- function createRuntime(hooks) {
538
- const subscriptions = /* @__PURE__ */ new Map();
539
- const ifcSubscriptions = /* @__PURE__ */ new Map();
540
- const ifcChannels = /* @__PURE__ */ new Map();
541
- const ifcChannelsByWindow = /* @__PURE__ */ new Map();
542
- let _consentHandler = null;
543
- const serviceRegistry = { ...hooks.services ?? {} };
1194
+ function createRegisteredServices(serviceRegistry) {
544
1195
  const registeredServices = /* @__PURE__ */ new Map();
545
1196
  for (const [name, handler] of Object.entries(serviceRegistry)) {
546
1197
  registeredServices.set(name, {
@@ -549,824 +1200,37 @@ function createRuntime(hooks) {
549
1200
  description: handler.descriptor.description
550
1201
  });
551
1202
  }
552
- const undeclaredServiceConsents = /* @__PURE__ */ new Set();
553
- const sessionRegistry = createSessionRegistry(hooks.onPendingUpdate);
554
- const aclState = createAclState(hooks.aclPersistence);
555
- const manifestCache = createManifestCache(hooks.manifestPersistence);
556
- const replayDetector = createReplayDetector(
557
- hooks.getConfigOverrides ? () => hooks.getConfigOverrides().replayWindowSeconds : void 0
558
- );
559
- const enforce = createEnforceGate({
560
- checkAcl: (pubkey, dTag, aggregateHash, capability) => aclState.check(pubkey, dTag, aggregateHash, capability),
561
- resolveIdentity: (pubkey) => {
562
- const entry = sessionRegistry.getEntry(pubkey);
563
- return entry ? { dTag: entry.dTag, aggregateHash: entry.aggregateHash } : void 0;
564
- },
565
- onAclCheck: hooks.onAclCheck
566
- });
567
- const enforceNub = createNubEnforceGate({
568
- checkAcl: (pubkey, dTag, aggregateHash, capability) => aclState.check(pubkey, dTag, aggregateHash, capability),
569
- resolveIdentityByWindowId: (windowId) => {
570
- const entry = sessionRegistry.getEntryByWindowId(windowId);
571
- return entry ? { dTag: entry.dTag, aggregateHash: entry.aggregateHash } : void 0;
572
- },
573
- onAclCheck: hooks.onAclCheck
574
- });
575
- const eventBuffer = createEventBuffer(
576
- hooks.sendToNapplet,
577
- sessionRegistry,
578
- enforce,
579
- subscriptions,
580
- hooks.getConfigOverrides ? () => hooks.getConfigOverrides().ringBufferSize ?? RING_BUFFER_SIZE : void 0
581
- );
582
- aclState.load();
583
- manifestCache.load();
584
- function checkCompatibility(requires, windowId, eventId) {
585
- if (requires.length === 0) return true;
586
- const available = Array.from(registeredServices.values());
587
- const registeredNames = new Set(registeredServices.keys());
588
- const missing = requires.filter((name) => !registeredNames.has(name));
589
- const compatible = missing.length === 0;
590
- if (!compatible) {
591
- const report = { available, missing, compatible };
592
- hooks.onCompatibilityIssue?.(report);
593
- if (hooks.strictMode) {
594
- hooks.sendToNapplet(windowId, [
595
- "OK",
596
- eventId,
597
- false,
598
- `blocked: missing required services: ${missing.join(", ")}`
599
- ]);
600
- return false;
601
- }
602
- }
603
- return true;
604
- }
605
- function checkUndeclaredService(windowId, pubkey, serviceName, event, onApproved) {
606
- if (!registeredServices.has(serviceName)) return true;
607
- const nappletPubkey = sessionRegistry.getPubkey(windowId);
608
- if (!nappletPubkey) return true;
609
- const nappletEntry = sessionRegistry.getEntry(nappletPubkey);
610
- if (!nappletEntry) return true;
611
- const requires = manifestCache.getRequires(nappletEntry.pubkey, nappletEntry.dTag);
612
- if (requires.includes(serviceName)) return true;
613
- const consentKey = `${windowId}:${serviceName}`;
614
- if (undeclaredServiceConsents.has(consentKey)) return true;
615
- if (_consentHandler) {
616
- _consentHandler({
617
- type: "undeclared-service",
618
- windowId,
619
- pubkey,
620
- event,
621
- serviceName,
622
- resolve: (allowed) => {
623
- if (allowed) {
624
- undeclaredServiceConsents.add(consentKey);
625
- onApproved();
626
- }
627
- }
628
- });
629
- return false;
630
- }
631
- return false;
632
- }
633
- function handleHotkeyForward(event) {
634
- const keyData = {
635
- key: event.tags?.find((t) => t[0] === "key")?.[1] ?? "",
636
- code: event.tags?.find((t) => t[0] === "code")?.[1] ?? "",
637
- ctrlKey: event.tags?.find((t) => t[0] === "ctrl")?.[1] === "1",
638
- altKey: event.tags?.find((t) => t[0] === "alt")?.[1] === "1",
639
- shiftKey: event.tags?.find((t) => t[0] === "shift")?.[1] === "1",
640
- metaKey: event.tags?.find((t) => t[0] === "meta")?.[1] === "1"
641
- };
642
- hooks.hotkeys.executeHotkeyFromForward(keyData);
643
- }
644
- function handleShellCommand(event, windowId, topic) {
645
- function sendOk(success, reason) {
646
- hooks.sendToNapplet(windowId, ["OK", event.id, success, reason]);
647
- }
648
- function sendInterPaneReply(replyTopic, content) {
649
- const responseEvent = {
650
- kind: 29e3,
651
- // IPC_PEER — inlined numeric after Phase 24 shim deletion
652
- pubkey: "",
653
- created_at: Math.floor(Date.now() / 1e3),
654
- tags: [["t", replyTopic]],
655
- content,
656
- id: "",
657
- sig: ""
658
- };
659
- hooks.sendToNapplet(windowId, ["EVENT", "__shell__", responseEvent]);
660
- sendOk(true, "");
661
- }
662
- switch (topic) {
663
- case "shell:acl-get": {
664
- const aclEntries = aclState.getAllEntries();
665
- const nappletEntries = sessionRegistry.getAllEntries();
666
- const nappletInfoMap = {};
667
- for (const e of nappletEntries) nappletInfoMap[e.pubkey] = { type: e.type, registeredAt: e.registeredAt };
668
- const merged = [...aclEntries];
669
- for (const e of nappletEntries) {
670
- if (!merged.find((a) => a.pubkey === e.pubkey)) {
671
- merged.push({ pubkey: e.pubkey, capabilities: [...ALL_CAPABILITIES], blocked: false });
672
- }
673
- }
674
- const display = merged.map((e) => ({
675
- ...e,
676
- type: nappletInfoMap[e.pubkey]?.type ?? "unknown",
677
- registeredAt: nappletInfoMap[e.pubkey]?.registeredAt ?? 0
678
- }));
679
- sendInterPaneReply("shell:acl-current", JSON.stringify({ entries: display }));
680
- break;
681
- }
682
- case "shell:acl-revoke":
683
- case "shell:acl-grant":
684
- case "shell:acl-block":
685
- case "shell:acl-unblock": {
686
- const pk = event.tags?.find((t) => t[0] === "pubkey")?.[1];
687
- const cap = event.tags?.find((t) => t[0] === "cap")?.[1];
688
- if (!pk) {
689
- sendOk(false, "error: missing pubkey tag");
690
- break;
691
- }
692
- const ne = sessionRegistry.getEntry(pk);
693
- if (topic === "shell:acl-revoke" && cap) aclState.revoke(pk, ne?.dTag ?? "", ne?.aggregateHash ?? "", cap);
694
- else if (topic === "shell:acl-grant" && cap) aclState.grant(pk, ne?.dTag ?? "", ne?.aggregateHash ?? "", cap);
695
- else if (topic === "shell:acl-block") aclState.block(pk, ne?.dTag ?? "", ne?.aggregateHash ?? "");
696
- else if (topic === "shell:acl-unblock") aclState.unblock(pk, ne?.dTag ?? "", ne?.aggregateHash ?? "");
697
- aclState.persist();
698
- const ae = aclState.getEntry(pk, ne?.dTag ?? "", ne?.aggregateHash ?? "");
699
- sendInterPaneReply("shell:acl-current", JSON.stringify({ entries: ae ? [ae] : [] }));
700
- break;
701
- }
702
- case "shell:relay-get":
703
- sendInterPaneReply("shell:relay-current", JSON.stringify(hooks.relayConfig.getRelayConfig()));
704
- break;
705
- case "shell:relay-add": {
706
- const tier = event.tags?.find((t) => t[0] === "tier")?.[1];
707
- const url = event.tags?.find((t) => t[0] === "url")?.[1];
708
- if (tier && url) {
709
- hooks.relayConfig.addRelay(tier, url);
710
- sendInterPaneReply("shell:relay-current", JSON.stringify(hooks.relayConfig.getRelayConfig()));
711
- } else sendOk(false, "error: missing tier/url");
712
- break;
713
- }
714
- case "shell:relay-remove": {
715
- const tier = event.tags?.find((t) => t[0] === "tier")?.[1];
716
- const url = event.tags?.find((t) => t[0] === "url")?.[1];
717
- if (tier && url) {
718
- hooks.relayConfig.removeRelay(tier, url);
719
- sendInterPaneReply("shell:relay-current", JSON.stringify(hooks.relayConfig.getRelayConfig()));
720
- } else sendOk(false, "error: missing tier/url");
721
- break;
722
- }
723
- case "shell:relay-nip66":
724
- sendInterPaneReply("shell:relay-nip66-data", JSON.stringify(hooks.relayConfig.getNip66Suggestions()));
725
- break;
726
- case "shell:relay-scoped-connect": {
727
- const url = event.tags?.find((t) => t[0] === "url")?.[1];
728
- const subId = event.tags?.find((t) => t[0] === "sub-id")?.[1];
729
- const filtersTag = event.tags?.find((t) => t[0] === "filters")?.[1];
730
- if (!url || !subId || !filtersTag) {
731
- sendOk(false, "error: missing tags");
732
- break;
733
- }
734
- try {
735
- const filters = JSON.parse(filtersTag);
736
- hooks.relayPool?.openScopedRelay(windowId, url, subId, filters, hooks.sendToNapplet);
737
- sendOk(true, "");
738
- } catch {
739
- sendOk(false, "error: invalid filters");
740
- }
741
- break;
742
- }
743
- case "shell:relay-scoped-close":
744
- hooks.relayPool?.closeScopedRelay(windowId);
745
- sendOk(true, "");
746
- break;
747
- case "shell:relay-scoped-publish": {
748
- const et = event.tags?.find((t) => t[0] === "event")?.[1];
749
- if (!et) {
750
- sendOk(false, "error: missing event tag");
751
- break;
752
- }
753
- try {
754
- const signed = JSON.parse(et);
755
- const ok = hooks.relayPool?.publishToScopedRelay(windowId, signed) ?? false;
756
- sendOk(ok, ok ? "" : "error: no active scoped relay");
757
- } catch {
758
- sendOk(false, "error: invalid event JSON");
759
- }
760
- break;
761
- }
762
- case "shell:create-window": {
763
- try {
764
- const payload = JSON.parse(event.content);
765
- if (!payload.title || !payload.class) {
766
- sendOk(false, "error: requires title and class");
767
- break;
768
- }
769
- const id = hooks.windowManager.createWindow({ title: payload.title, class: payload.class, iframeSrc: payload.iframeSrc });
770
- sendOk(!!id, id ? "" : "error: window creation failed");
771
- } catch {
772
- sendOk(false, "error: invalid JSON");
773
- }
774
- break;
775
- }
776
- case "shell:send-dm": {
777
- if (hooks.dm) {
778
- const corrId = event.tags?.find((t) => t[0] === "id")?.[1] ?? "";
779
- const recipient = event.tags?.find((t) => t[0] === "p")?.[1];
780
- let message;
781
- try {
782
- message = JSON.parse(event.content).message;
783
- } catch {
784
- }
785
- if (!recipient || !message) {
786
- sendOk(false, "error: missing recipient or message");
787
- break;
788
- }
789
- hooks.dm.sendDm(recipient, message).then((result) => {
790
- const payload = result.success ? { success: true, ...result.eventId ? { eventId: result.eventId } : {} } : { success: false, error: result.error ?? "unknown error" };
791
- const response = {
792
- kind: 29e3,
793
- // IPC_PEER — inlined numeric after Phase 24 shim deletion
794
- pubkey: "",
795
- created_at: Math.floor(Date.now() / 1e3),
796
- tags: [["t", "shell:send-dm-result"], ["id", corrId]],
797
- content: JSON.stringify(payload),
798
- id: "",
799
- sig: ""
800
- };
801
- hooks.sendToNapplet(windowId, ["EVENT", "__shell__", response]);
802
- sendOk(result.success, result.success ? "" : `error: ${result.error}`);
803
- }).catch(() => {
804
- sendOk(false, "error: DM send failed");
805
- });
806
- } else sendOk(false, "error: DM hooks not configured");
807
- break;
808
- }
809
- default:
810
- sendOk(true, "");
811
- break;
812
- }
813
- }
814
- function handleRelayMessage(windowId, msg) {
815
- const m = msg;
816
- const dotIdx = msg.type.indexOf(".");
817
- const action = msg.type.slice(dotIdx + 1);
818
- switch (action) {
819
- case "subscribe": {
820
- let deliver2 = function(event) {
821
- if (seenIds.has(event.id)) return;
822
- seenIds.add(event.id);
823
- if (subscriptions.has(subKey)) {
824
- hooks.sendToNapplet(windowId, { type: "relay.event", subId, event });
825
- }
826
- };
827
- var deliver = deliver2;
828
- const subId = m.subId ?? "";
829
- const filters = m.filters ?? [];
830
- if (!subId) return;
831
- const subKey = `${windowId}:${subId}`;
832
- subscriptions.set(subKey, { windowId, filters });
833
- const seenIds = /* @__PURE__ */ new Set();
834
- for (const bufferedEvent of eventBuffer.getBufferedEvents()) {
835
- if (matchesAnyFilter(bufferedEvent, filters)) deliver2(bufferedEvent);
836
- }
837
- const isShellKind = filters.length > 0 && filters.every((f) => f.kinds?.every((k) => k >= 29e3 && k < 3e4));
838
- if (!isShellKind) {
839
- const relayService = serviceRegistry["relay"] ?? serviceRegistry["relay-pool"];
840
- const cacheService = !serviceRegistry["relay"] ? serviceRegistry["cache"] : void 0;
841
- if (relayService) {
842
- relayService.handleMessage(windowId, msg, (resp) => {
843
- if (!subscriptions.has(subKey)) return;
844
- hooks.sendToNapplet(windowId, resp);
845
- });
846
- if (cacheService) cacheService.handleMessage(windowId, msg, (resp) => {
847
- if (!subscriptions.has(subKey)) return;
848
- hooks.sendToNapplet(windowId, resp);
849
- });
850
- return;
851
- }
852
- }
853
- const cache = hooks.cache;
854
- if (cache?.isAvailable() && !isShellKind) {
855
- cache.query(filters).then((cachedEvents) => {
856
- for (const event of cachedEvents) deliver2(event);
857
- }).catch(() => {
858
- });
859
- }
860
- const pool = hooks.relayPool;
861
- if (pool?.isAvailable() && !isShellKind) {
862
- const relayUrls = pool.selectRelayTier(filters);
863
- let eoseSent = false;
864
- const eoseFallbackTimer = setTimeout(() => {
865
- if (!eoseSent) {
866
- eoseSent = true;
867
- hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
868
- }
869
- }, 15e3);
870
- const subscription = pool.subscribe(filters, (item) => {
871
- if (item === "EOSE") {
872
- clearTimeout(eoseFallbackTimer);
873
- if (!eoseSent) {
874
- eoseSent = true;
875
- hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
876
- }
877
- return;
878
- }
879
- deliver2(item);
880
- if (cache?.isAvailable() && !isShellKind) {
881
- try {
882
- cache.store(item);
883
- } catch {
884
- }
885
- }
886
- }, relayUrls);
887
- pool.trackSubscription(subKey, () => {
888
- clearTimeout(eoseFallbackTimer);
889
- subscription.unsubscribe();
890
- });
891
- } else if (!isShellKind) {
892
- hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
893
- }
894
- break;
895
- }
896
- case "close": {
897
- const subId = m.subId ?? "";
898
- if (!subId) return;
899
- const subKey = `${windowId}:${subId}`;
900
- subscriptions.delete(subKey);
901
- const relayService = serviceRegistry["relay"] ?? serviceRegistry["relay-pool"];
902
- if (relayService) {
903
- relayService.handleMessage(windowId, msg, () => {
904
- });
905
- }
906
- hooks.relayPool?.untrackSubscription(subKey);
907
- hooks.sendToNapplet(windowId, { type: "relay.closed", subId, message: "" });
908
- break;
909
- }
910
- case "publish": {
911
- const event = m.event;
912
- const id = m.id ?? "";
913
- if (!event || typeof event !== "object") {
914
- hooks.sendToNapplet(windowId, { type: "relay.publish.error", id, error: "invalid event" });
915
- return;
916
- }
917
- const replayResult = replayDetector.check(event);
918
- if (replayResult !== null) {
919
- hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: replayResult });
920
- return;
921
- }
922
- const relayService = serviceRegistry["relay"] ?? serviceRegistry["relay-pool"];
923
- if (relayService) {
924
- relayService.handleMessage(windowId, msg, (resp) => {
925
- hooks.sendToNapplet(windowId, resp);
926
- });
927
- } else if (hooks.relayPool?.isAvailable()) {
928
- hooks.relayPool.publish(event);
929
- hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: true });
930
- } else {
931
- hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: "no relay pool available" });
932
- }
933
- eventBuffer.bufferAndDeliver(event, windowId);
934
- break;
935
- }
936
- // Shell-mediated encryption path (NUB-08 / SH-C03). Napplets hand the
937
- // shell a plaintext EventTemplate plus a recipient pubkey; the shell
938
- // encrypts via its own signer (nip44 default, nip04 opt-in), signs,
939
- // publishes, and returns a relay.publishEncrypted.result. The signer's
940
- // nip04/nip44 primitives are SHELL-INTERNAL — no napplet-visible
941
- // message surface reaches them (SignerProxy was deleted in Plan 12-01).
942
- case "publishEncrypted": {
943
- let replyPe2 = function(ok, extra = {}) {
944
- hooks.sendToNapplet(windowId, {
945
- type: "relay.publishEncrypted.result",
946
- id,
947
- ok,
948
- ...extra
949
- });
950
- };
951
- var replyPe = replyPe2;
952
- const id = m.id ?? "";
953
- const eventTemplate = m.event;
954
- const peMsg = msg;
955
- const recipient = peMsg.recipient ?? "";
956
- const encryption = peMsg.encryption ?? "nip44";
957
- if (!recipient) {
958
- replyPe2(false, { error: "missing recipient" });
959
- break;
960
- }
961
- if (encryption !== "nip44" && encryption !== "nip04") {
962
- replyPe2(false, { error: `unsupported encryption scheme: ${encryption}` });
963
- break;
964
- }
965
- const peSigner = hooks.auth.getSigner();
966
- if (!peSigner) {
967
- replyPe2(false, { error: "no signer configured" });
968
- break;
969
- }
970
- if (!eventTemplate || typeof eventTemplate !== "object") {
971
- replyPe2(false, { error: "invalid event template" });
972
- break;
973
- }
974
- (async () => {
975
- try {
976
- const plaintext = String(eventTemplate.content ?? "");
977
- const ciphertext = encryption === "nip44" ? await peSigner.nip44?.encrypt(recipient, plaintext) ?? "" : await peSigner.nip04?.encrypt(recipient, plaintext) ?? "";
978
- const eventWithCiphertext = { ...eventTemplate, content: ciphertext };
979
- const signed = await peSigner.signEvent?.(eventWithCiphertext);
980
- if (!signed) {
981
- replyPe2(false, { error: "signEvent returned null" });
982
- return;
983
- }
984
- const relayService = serviceRegistry["relay"] ?? serviceRegistry["relay-pool"];
985
- if (relayService) {
986
- const publishMsg = { type: "relay.publish", id, event: signed };
987
- let replied = false;
988
- relayService.handleMessage(windowId, publishMsg, (resp) => {
989
- if (replied) return;
990
- const r = resp;
991
- if (typeof r.type === "string" && r.type.startsWith("relay.publish")) {
992
- const okVal = r.ok ?? r.accepted ?? false;
993
- replied = true;
994
- replyPe2(okVal, {
995
- event: signed,
996
- eventId: signed.id,
997
- ...okVal ? {} : { error: r.error ?? r.message ?? "publish failed" }
998
- });
999
- }
1000
- });
1001
- if (!replied) {
1002
- replied = true;
1003
- replyPe2(true, { event: signed, eventId: signed.id });
1004
- }
1005
- } else if (hooks.relayPool?.isAvailable()) {
1006
- hooks.relayPool.publish(signed);
1007
- replyPe2(true, { event: signed, eventId: signed.id });
1008
- } else {
1009
- replyPe2(false, { error: "no relay pool available" });
1010
- }
1011
- try {
1012
- eventBuffer.bufferAndDeliver(signed, windowId);
1013
- } catch {
1014
- }
1015
- } catch (err) {
1016
- replyPe2(false, { error: err?.message ?? "encryption failed" });
1017
- }
1018
- })();
1019
- break;
1020
- }
1021
- case "query": {
1022
- const id = m.id ?? "";
1023
- const filters = m.filters ?? [];
1024
- let count = 0;
1025
- for (const event of eventBuffer.getBufferedEvents()) {
1026
- if (matchesAnyFilter(event, filters)) count++;
1027
- }
1028
- hooks.sendToNapplet(windowId, { type: "relay.query.result", id, count });
1029
- break;
1030
- }
1031
- default:
1032
- break;
1033
- }
1034
- }
1035
- function handleIdentityMessage(windowId, msg) {
1036
- const identityService = serviceRegistry["identity"];
1037
- if (identityService) {
1038
- identityService.handleMessage(windowId, msg, (resp) => {
1039
- hooks.sendToNapplet(windowId, resp);
1040
- });
1041
- return;
1042
- }
1043
- const id = msg.id ?? "";
1044
- const action = msg.type.slice("identity.".length);
1045
- const signer = hooks.auth.getSigner();
1046
- function sendError(error) {
1047
- hooks.sendToNapplet(windowId, { type: `${msg.type}.error`, id, error });
1048
- }
1049
- function sendResult(payload) {
1050
- hooks.sendToNapplet(windowId, { type: `${msg.type}.result`, id, ...payload });
1051
- }
1052
- switch (action) {
1053
- case "getPublicKey": {
1054
- if (!signer) {
1055
- sendError("no signer configured");
1056
- return;
1057
- }
1058
- Promise.resolve(signer.getPublicKey?.()).then((pubkey) => sendResult({ pubkey })).catch((err) => sendError(err?.message ?? "getPublicKey failed"));
1059
- return;
1060
- }
1061
- case "getRelays": {
1062
- if (!signer) {
1063
- sendError("no signer configured");
1064
- return;
1065
- }
1066
- Promise.resolve(signer.getRelays?.() ?? {}).then((relays) => sendResult({ relays })).catch((err) => sendError(err?.message ?? "getRelays failed"));
1067
- return;
1068
- }
1069
- case "getProfile":
1070
- sendResult({ profile: null });
1071
- return;
1072
- case "getFollows":
1073
- sendResult({ pubkeys: [] });
1074
- return;
1075
- case "getList":
1076
- sendResult({ entries: [] });
1077
- return;
1078
- case "getZaps":
1079
- sendResult({ zaps: [] });
1080
- return;
1081
- case "getMutes":
1082
- sendResult({ pubkeys: [] });
1083
- return;
1084
- case "getBlocked":
1085
- sendResult({ pubkeys: [] });
1086
- return;
1087
- case "getBadges":
1088
- sendResult({ badges: [] });
1089
- return;
1090
- default:
1091
- sendError(`Unknown identity action: ${action}`);
1092
- }
1093
- }
1094
- function handleStorageMessage(windowId, msg) {
1095
- handleStorageNub(windowId, msg, hooks.sendToNapplet, sessionRegistry, aclState, hooks.statePersistence);
1096
- }
1097
- function ifcAddChannel(channelId, peerA, peerB) {
1098
- ifcChannels.set(channelId, { channelId, peerA, peerB });
1099
- for (const w of [peerA, peerB]) {
1100
- let set = ifcChannelsByWindow.get(w);
1101
- if (!set) {
1102
- set = /* @__PURE__ */ new Set();
1103
- ifcChannelsByWindow.set(w, set);
1104
- }
1105
- set.add(channelId);
1106
- }
1107
- }
1108
- function ifcRemoveChannel(channelId) {
1109
- const ch = ifcChannels.get(channelId);
1110
- if (!ch) return;
1111
- ifcChannels.delete(channelId);
1112
- for (const w of [ch.peerA, ch.peerB]) {
1113
- const set = ifcChannelsByWindow.get(w);
1114
- if (set) {
1115
- set.delete(channelId);
1116
- if (set.size === 0) ifcChannelsByWindow.delete(w);
1117
- }
1118
- }
1119
- }
1120
- function ifcPeerOf(channelId, self) {
1121
- const ch = ifcChannels.get(channelId);
1122
- if (!ch) return null;
1123
- if (ch.peerA === self) return ch.peerB;
1124
- if (ch.peerB === self) return ch.peerA;
1125
- return null;
1126
- }
1127
- function ifcGenerateChannelId() {
1128
- return hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 32);
1129
- }
1130
- function ifcResolveTarget(target) {
1131
- if (sessionRegistry.getEntryByWindowId(target)) return target;
1132
- const entries = sessionRegistry.getAllEntries();
1133
- const byPubkey = entries.find((e) => e.pubkey === target);
1134
- return byPubkey?.windowId ?? null;
1135
- }
1136
- function handleIfcMessage(windowId, msg) {
1137
- const m = msg;
1138
- const dotIdx = msg.type.indexOf(".");
1139
- const action = msg.type.slice(dotIdx + 1);
1140
- switch (action) {
1141
- case "emit": {
1142
- const topic = m.topic ?? "";
1143
- const payload = m.payload;
1144
- if (!topic) return;
1145
- const subscribers = ifcSubscriptions.get(topic);
1146
- if (subscribers) {
1147
- for (const subscriberWindowId of subscribers) {
1148
- if (subscriberWindowId === windowId) continue;
1149
- hooks.sendToNapplet(subscriberWindowId, { type: "ifc.event", topic, payload, sender: windowId });
1150
- }
1151
- }
1152
- return;
1153
- }
1154
- case "subscribe": {
1155
- const id = m.id ?? "";
1156
- const topic = m.topic ?? "";
1157
- if (!topic) {
1158
- hooks.sendToNapplet(windowId, { type: "ifc.subscribe.result", id, error: "missing topic" });
1159
- return;
1160
- }
1161
- let subs = ifcSubscriptions.get(topic);
1162
- if (!subs) {
1163
- subs = /* @__PURE__ */ new Set();
1164
- ifcSubscriptions.set(topic, subs);
1165
- }
1166
- subs.add(windowId);
1167
- hooks.sendToNapplet(windowId, { type: "ifc.subscribe.result", id });
1168
- return;
1169
- }
1170
- case "unsubscribe": {
1171
- const topic = m.topic ?? "";
1172
- if (!topic) return;
1173
- const subs = ifcSubscriptions.get(topic);
1174
- if (subs) {
1175
- subs.delete(windowId);
1176
- if (subs.size === 0) ifcSubscriptions.delete(topic);
1177
- }
1178
- return;
1179
- }
1180
- case "channel.open": {
1181
- const id = m.id ?? "";
1182
- const target = m.target ?? "";
1183
- const peerWindow = ifcResolveTarget(target);
1184
- if (!peerWindow) {
1185
- hooks.sendToNapplet(windowId, { type: "ifc.channel.open.result", id, error: "target not found" });
1186
- return;
1187
- }
1188
- const channelId = ifcGenerateChannelId();
1189
- ifcAddChannel(channelId, windowId, peerWindow);
1190
- hooks.sendToNapplet(windowId, { type: "ifc.channel.open.result", id, channelId, peer: peerWindow });
1191
- return;
1192
- }
1193
- case "channel.emit": {
1194
- const channelId = m.channelId ?? "";
1195
- const peer = ifcPeerOf(channelId, windowId);
1196
- if (!peer) return;
1197
- hooks.sendToNapplet(peer, { type: "ifc.channel.event", channelId, sender: windowId, payload: m.payload });
1198
- return;
1199
- }
1200
- case "channel.broadcast": {
1201
- const channels = ifcChannelsByWindow.get(windowId);
1202
- if (!channels) return;
1203
- for (const channelId of channels) {
1204
- const peer = ifcPeerOf(channelId, windowId);
1205
- if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.event", channelId, sender: windowId, payload: m.payload });
1206
- }
1207
- return;
1208
- }
1209
- case "channel.list": {
1210
- const id = m.id ?? "";
1211
- const channels = [];
1212
- const set = ifcChannelsByWindow.get(windowId);
1213
- if (set) {
1214
- for (const channelId of set) {
1215
- const peer = ifcPeerOf(channelId, windowId);
1216
- if (peer) channels.push({ id: channelId, peer });
1217
- }
1218
- }
1219
- hooks.sendToNapplet(windowId, { type: "ifc.channel.list.result", id, channels });
1220
- return;
1221
- }
1222
- case "channel.close": {
1223
- const channelId = m.channelId ?? "";
1224
- const peer = ifcPeerOf(channelId, windowId);
1225
- if (!peer) return;
1226
- hooks.sendToNapplet(windowId, { type: "ifc.channel.closed", channelId });
1227
- hooks.sendToNapplet(peer, { type: "ifc.channel.closed", channelId });
1228
- ifcRemoveChannel(channelId);
1229
- return;
1230
- }
1231
- default:
1232
- return;
1233
- }
1234
- }
1235
- function handleMediaMessage(windowId, msg) {
1236
- const mediaService = serviceRegistry["media"];
1237
- if (mediaService) {
1238
- mediaService.handleMessage(windowId, msg, (resp) => {
1239
- hooks.sendToNapplet(windowId, resp);
1240
- });
1241
- return;
1242
- }
1243
- if (msg.type === "media.session.create") {
1244
- const m = msg;
1245
- hooks.sendToNapplet(windowId, {
1246
- type: "media.session.create.result",
1247
- id: m.id ?? "",
1248
- sessionId: m.sessionId ?? ""
1249
- });
1250
- }
1251
- }
1252
- function handleKeysMessage(windowId, msg) {
1253
- const keysService = serviceRegistry["keys"];
1254
- if (keysService) {
1255
- keysService.handleMessage(windowId, msg, (resp) => {
1256
- hooks.sendToNapplet(windowId, resp);
1257
- });
1258
- return;
1259
- }
1260
- if (msg.type === "keys.forward") {
1261
- const m = msg;
1262
- hooks.hotkeys.executeHotkeyFromForward({
1263
- key: m.key ?? "",
1264
- code: m.code ?? "",
1265
- ctrlKey: !!m.ctrl,
1266
- altKey: !!m.alt,
1267
- shiftKey: !!m.shift,
1268
- metaKey: !!m.meta
1269
- });
1270
- return;
1271
- }
1272
- if (msg.type === "keys.registerAction") {
1273
- const m = msg;
1274
- hooks.sendToNapplet(windowId, {
1275
- type: "keys.registerAction.result",
1276
- id: m.id ?? "",
1277
- actionId: m.action?.id ?? "",
1278
- ...m.action?.defaultKey ? { binding: m.action.defaultKey } : {}
1279
- });
1280
- return;
1281
- }
1282
- }
1283
- function handleNotifyMessage(windowId, msg) {
1284
- const notifyService = serviceRegistry["notify"];
1285
- if (notifyService) {
1286
- notifyService.handleMessage(windowId, msg, (resp) => {
1287
- hooks.sendToNapplet(windowId, resp);
1288
- });
1289
- return;
1290
- }
1291
- if (msg.type === "notify.send") {
1292
- const m = msg;
1293
- hooks.sendToNapplet(windowId, {
1294
- type: "notify.send.result",
1295
- id: m.id ?? "",
1296
- notificationId: `shell-${Date.now()}`
1297
- });
1298
- } else if (msg.type === "notify.permission.request") {
1299
- const m = msg;
1300
- hooks.sendToNapplet(windowId, {
1301
- type: "notify.permission.result",
1302
- id: m.id ?? "",
1303
- granted: true
1304
- });
1305
- }
1306
- }
1307
- const THEME_FALLBACK_DEFAULT = {
1308
- colors: { background: "#0a0a0a", text: "#e0e0e0", primary: "#7aa2f7" }
1309
- };
1310
- function handleThemeMessage(windowId, msg) {
1311
- const themeService = serviceRegistry["theme"];
1312
- if (themeService) {
1313
- themeService.handleMessage(windowId, msg, (resp) => {
1314
- hooks.sendToNapplet(windowId, resp);
1315
- });
1316
- return;
1317
- }
1318
- if (msg.type === "theme.get") {
1319
- const m = msg;
1320
- hooks.sendToNapplet(windowId, {
1321
- type: "theme.get.result",
1322
- id: m.id ?? "",
1323
- theme: THEME_FALLBACK_DEFAULT
1324
- });
1325
- }
1326
- }
1203
+ return registeredServices;
1204
+ }
1205
+ function createNubEnvelopeDispatcher(handlers) {
1327
1206
  let currentWindowId = null;
1328
1207
  const nubDispatch = createDispatch();
1329
- const relayAdapter = (msg) => {
1330
- if (currentWindowId === null) return;
1331
- handleRelayMessage(currentWindowId, msg);
1332
- };
1333
- const identityAdapter = (msg) => {
1334
- if (currentWindowId === null) return;
1335
- handleIdentityMessage(currentWindowId, msg);
1336
- };
1337
- const keysAdapter = (msg) => {
1338
- if (currentWindowId === null) return;
1339
- handleKeysMessage(currentWindowId, msg);
1340
- };
1341
- const mediaAdapter = (msg) => {
1342
- if (currentWindowId === null) return;
1343
- handleMediaMessage(currentWindowId, msg);
1344
- };
1345
- const notifyAdapter = (msg) => {
1346
- if (currentWindowId === null) return;
1347
- handleNotifyMessage(currentWindowId, msg);
1348
- };
1349
- const storageAdapter = (msg) => {
1350
- if (currentWindowId === null) return;
1351
- handleStorageMessage(currentWindowId, msg);
1352
- };
1353
- const ifcAdapter = (msg) => {
1354
- if (currentWindowId === null) return;
1355
- handleIfcMessage(currentWindowId, msg);
1208
+ const adapt = (handler) => (msg) => {
1209
+ if (currentWindowId !== null) handler(currentWindowId, msg);
1356
1210
  };
1357
- const themeAdapter = (msg) => {
1358
- if (currentWindowId === null) return;
1359
- handleThemeMessage(currentWindowId, msg);
1211
+ nubDispatch.registerNub("relay", adapt(handlers.relay));
1212
+ nubDispatch.registerNub("identity", adapt(handlers.identity));
1213
+ nubDispatch.registerNub("keys", adapt(handlers.keys));
1214
+ nubDispatch.registerNub("media", adapt(handlers.media));
1215
+ nubDispatch.registerNub("notify", adapt(handlers.notify));
1216
+ nubDispatch.registerNub("storage", adapt(handlers.storage));
1217
+ nubDispatch.registerNub("ifc", adapt(handlers.ifc));
1218
+ nubDispatch.registerNub("theme", adapt(handlers.theme));
1219
+ nubDispatch.registerNub("config", adapt(handlers.config));
1220
+ nubDispatch.registerNub("resource", adapt(handlers.resource));
1221
+ nubDispatch.registerNub("cvm", adapt(handlers.cvm));
1222
+ nubDispatch.registerNub("outbox", adapt(handlers.outbox));
1223
+ return (windowId, envelope) => {
1224
+ currentWindowId = windowId;
1225
+ try {
1226
+ nubDispatch.dispatch(envelope);
1227
+ } finally {
1228
+ currentWindowId = null;
1229
+ }
1360
1230
  };
1361
- nubDispatch.registerNub("relay", relayAdapter);
1362
- nubDispatch.registerNub("identity", identityAdapter);
1363
- nubDispatch.registerNub("keys", keysAdapter);
1364
- nubDispatch.registerNub("media", mediaAdapter);
1365
- nubDispatch.registerNub("notify", notifyAdapter);
1366
- nubDispatch.registerNub("storage", storageAdapter);
1367
- nubDispatch.registerNub("ifc", ifcAdapter);
1368
- nubDispatch.registerNub("theme", themeAdapter);
1369
- function handleMessage(windowId, msg) {
1231
+ }
1232
+ function createMessageHandler(hooks, enforceNub, dispatchNubEnvelope) {
1233
+ return (windowId, msg) => {
1370
1234
  if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
1371
1235
  const envelope = msg;
1372
1236
  const dotIdx = envelope.type.indexOf(".");
@@ -1376,47 +1240,62 @@ function createRuntime(hooks) {
1376
1240
  const result = enforceNub(windowId, caps.senderCap, envelope);
1377
1241
  if (!result.allowed) {
1378
1242
  const id = envelope.id ?? "";
1379
- hooks.sendToNapplet(windowId, { type: `${envelope.type}.error`, id, error: formatDenialReason(result.capability) });
1243
+ const isIdentityDecrypt = envelope.type === "identity.decrypt";
1244
+ const isStorageEnvelope = envelope.type.startsWith("storage.");
1245
+ const error = isIdentityDecrypt ? result.reason === "class-forbidden" ? "class-forbidden" : "policy-denied" : formatDenialReason(result.capability);
1246
+ const type = isStorageEnvelope ? `${envelope.type}.result` : `${envelope.type}.error`;
1247
+ hooks.sendToNapplet(windowId, { type, id, error });
1380
1248
  return;
1381
1249
  }
1382
1250
  }
1383
- currentWindowId = windowId;
1384
- try {
1385
- nubDispatch.dispatch(envelope);
1386
- } finally {
1387
- currentWindowId = null;
1388
- }
1389
- }
1390
- const runtimeInstance = {
1391
- handleMessage,
1251
+ dispatchNubEnvelope(windowId, envelope);
1252
+ };
1253
+ }
1254
+ function createInjectedEvent(hooks, topic, payload) {
1255
+ const uuid = hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 64).padEnd(64, "0");
1256
+ return {
1257
+ id: uuid,
1258
+ pubkey: "0".repeat(64),
1259
+ created_at: Math.floor(Date.now() / 1e3),
1260
+ kind: 29e3,
1261
+ tags: [["t", topic]],
1262
+ content: JSON.stringify(payload),
1263
+ sig: "0".repeat(128)
1264
+ };
1265
+ }
1266
+ function createRuntimeInstance(context) {
1267
+ const {
1268
+ aclState,
1269
+ eventBuffer,
1270
+ hooks,
1271
+ ifcRuntime,
1272
+ manifestCache,
1273
+ registeredServices,
1274
+ replayDetector,
1275
+ serviceRegistry,
1276
+ sessionRegistry,
1277
+ subscriptions
1278
+ } = context;
1279
+ const undeclaredServiceConsents = /* @__PURE__ */ new Set();
1280
+ let consentHandler = null;
1281
+ return {
1282
+ handleMessage: context.handleMessage,
1392
1283
  injectEvent(topic, payload) {
1393
- const uuid = hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 64).padEnd(64, "0");
1394
- const event = {
1395
- id: uuid,
1396
- pubkey: "0".repeat(64),
1397
- created_at: Math.floor(Date.now() / 1e3),
1398
- kind: 29e3,
1399
- // IPC_PEER — inlined numeric after Phase 24 shim deletion
1400
- tags: [["t", topic]],
1401
- content: JSON.stringify(payload),
1402
- sig: "0".repeat(128)
1403
- };
1404
- eventBuffer.bufferAndDeliver(event, null);
1284
+ eventBuffer.bufferAndDeliver(createInjectedEvent(hooks, topic, payload), null);
1405
1285
  },
1406
1286
  destroy() {
1407
1287
  manifestCache.persist();
1408
1288
  aclState.persist();
1409
1289
  replayDetector.clear();
1410
1290
  subscriptions.clear();
1411
- ifcSubscriptions.clear();
1412
- ifcChannels.clear();
1413
- ifcChannelsByWindow.clear();
1291
+ ifcRuntime.clear();
1414
1292
  eventBuffer.clear();
1415
1293
  registeredServices.clear();
1416
1294
  undeclaredServiceConsents.clear();
1417
1295
  },
1418
1296
  registerConsentHandler(handler) {
1419
- _consentHandler = handler;
1297
+ consentHandler = handler;
1298
+ void consentHandler;
1420
1299
  },
1421
1300
  registerService(name, handler) {
1422
1301
  serviceRegistry[name] = handler;
@@ -1437,20 +1316,7 @@ function createRuntime(hooks) {
1437
1316
  hooks.relayPool?.untrackSubscription(key);
1438
1317
  }
1439
1318
  }
1440
- for (const [topic, subs] of ifcSubscriptions) {
1441
- subs.delete(windowId);
1442
- if (subs.size === 0) ifcSubscriptions.delete(topic);
1443
- }
1444
- const channelIds = ifcChannelsByWindow.get(windowId);
1445
- if (channelIds) {
1446
- for (const channelId of [...channelIds]) {
1447
- const peer = ifcPeerOf(channelId, windowId);
1448
- if (peer) {
1449
- hooks.sendToNapplet(peer, { type: "ifc.channel.closed", channelId });
1450
- }
1451
- ifcRemoveChannel(channelId);
1452
- }
1453
- }
1319
+ ifcRuntime.destroyWindow(windowId);
1454
1320
  notifyServiceWindowDestroyed(windowId, serviceRegistry);
1455
1321
  },
1456
1322
  get sessionRegistry() {
@@ -1463,7 +1329,64 @@ function createRuntime(hooks) {
1463
1329
  return manifestCache;
1464
1330
  }
1465
1331
  };
1466
- return runtimeInstance;
1332
+ }
1333
+ function createRuntime(hooks) {
1334
+ const subscriptions = /* @__PURE__ */ new Map();
1335
+ const serviceRegistry = { ...hooks.services };
1336
+ const registeredServices = createRegisteredServices(serviceRegistry);
1337
+ const sessionRegistry = createSessionRegistry(hooks.onPendingUpdate);
1338
+ const aclState = createAclState(hooks.aclPersistence);
1339
+ const manifestCache = createManifestCache(hooks.manifestPersistence);
1340
+ const replayDetector = createReplayDetector(
1341
+ hooks.getConfigOverrides ? () => hooks.getConfigOverrides().replayWindowSeconds : void 0
1342
+ );
1343
+ const enforce = createEnforceGate({
1344
+ checkAcl: (pubkey, dTag, aggregateHash, capability) => aclState.check(pubkey, dTag, aggregateHash, capability),
1345
+ resolveIdentity: (pubkey) => {
1346
+ const entry = sessionRegistry.getEntry(pubkey);
1347
+ return entry ? { dTag: entry.dTag, aggregateHash: entry.aggregateHash } : void 0;
1348
+ },
1349
+ onAclCheck: hooks.onAclCheck
1350
+ });
1351
+ const enforceNub = createNubEnforceGate({
1352
+ checkAcl: (pubkey, dTag, aggregateHash, capability) => aclState.check(pubkey, dTag, aggregateHash, capability),
1353
+ resolveIdentityByWindowId: (windowId) => {
1354
+ const entry = sessionRegistry.getEntryByWindowId(windowId);
1355
+ return entry ? { dTag: entry.dTag, aggregateHash: entry.aggregateHash, class: entry.class } : void 0;
1356
+ },
1357
+ onAclCheck: hooks.onAclCheck
1358
+ });
1359
+ const eventBuffer = createEventBuffer(
1360
+ hooks.sendToNapplet,
1361
+ sessionRegistry,
1362
+ enforce,
1363
+ subscriptions,
1364
+ hooks.getConfigOverrides ? () => hooks.getConfigOverrides().ringBufferSize ?? RING_BUFFER_SIZE : void 0
1365
+ );
1366
+ aclState.load();
1367
+ manifestCache.load();
1368
+ const ifcRuntime = createIfcRuntime(hooks, sessionRegistry);
1369
+ const domainHandlers = createRuntimeDomainHandlers({ hooks, serviceRegistry, sessionRegistry, aclState });
1370
+ const dispatchNubEnvelope = createNubEnvelopeDispatcher({
1371
+ relay: createRelayHandler({ hooks, serviceRegistry, subscriptions, eventBuffer, replayDetector }),
1372
+ identity: createIdentityHandler({ hooks, serviceRegistry }),
1373
+ ifc: ifcRuntime.handleMessage,
1374
+ ...domainHandlers
1375
+ });
1376
+ const handleMessage = createMessageHandler(hooks, enforceNub, dispatchNubEnvelope);
1377
+ return createRuntimeInstance({
1378
+ hooks,
1379
+ serviceRegistry,
1380
+ registeredServices,
1381
+ replayDetector,
1382
+ subscriptions,
1383
+ eventBuffer,
1384
+ ifcRuntime,
1385
+ sessionRegistry,
1386
+ aclState,
1387
+ manifestCache,
1388
+ handleMessage
1389
+ });
1467
1390
  }
1468
1391
 
1469
1392
  // src/index.ts