@kehto/runtime 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
@@ -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"
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,10 @@ 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;
129
159
  var CAP_MAP = {
130
160
  "relay:read": CAP_RELAY_READ,
131
161
  "relay:write": CAP_RELAY_WRITE,
@@ -140,8 +170,13 @@ var CAP_MAP = {
140
170
  "media:control": CAP_MEDIA_CONTROL,
141
171
  "notify:send": CAP_NOTIFY_SEND,
142
172
  "notify:channel": CAP_NOTIFY_CHANNEL,
143
- "theme:read": CAP_THEME_READ
173
+ "theme:read": CAP_THEME_READ,
174
+ "config:read": CAP_CONFIG_READ,
175
+ "resource:fetch": CAP_RESOURCE_FETCH,
176
+ "identity:decrypt": CAP_IDENTITY_DECRYPT,
177
+ "cvm:call": CAP_CVM_CALL
144
178
  };
179
+ var RUNTIME_CAP_ALL = Object.values(CAP_MAP).reduce((bits, bit) => bits | bit, 0);
145
180
  function capToBit(cap) {
146
181
  return CAP_MAP[cap] ?? 0;
147
182
  }
@@ -157,6 +192,11 @@ function toIdentity(pubkey, dTag, hash) {
157
192
  }
158
193
  function createAclState(persistence, defaultPolicy = "permissive") {
159
194
  let state = createState(defaultPolicy);
195
+ function ensureRuntimeDefaultEntry(id) {
196
+ if (state.defaultPolicy !== "permissive") return;
197
+ if (state.entries[toKey(id)]) return;
198
+ state = grant(state, id, RUNTIME_CAP_ALL);
199
+ }
160
200
  return {
161
201
  check(pubkey, dTag, aggregateHash, capability) {
162
202
  const id = toIdentity(pubkey, dTag, aggregateHash);
@@ -164,23 +204,27 @@ function createAclState(persistence, defaultPolicy = "permissive") {
164
204
  },
165
205
  grant(pubkey, dTag, aggregateHash, capability) {
166
206
  const id = toIdentity(pubkey, dTag, aggregateHash);
207
+ ensureRuntimeDefaultEntry(id);
167
208
  state = grant(state, id, capToBit(capability));
168
209
  },
169
210
  revoke(pubkey, dTag, aggregateHash, capability) {
170
211
  const id = toIdentity(pubkey, dTag, aggregateHash);
212
+ ensureRuntimeDefaultEntry(id);
171
213
  state = revoke(state, id, capToBit(capability));
172
214
  },
173
215
  block(pubkey, dTag, aggregateHash) {
174
216
  const id = toIdentity(pubkey, dTag, aggregateHash);
217
+ ensureRuntimeDefaultEntry(id);
175
218
  state = block(state, id);
176
219
  },
177
220
  unblock(pubkey, dTag, aggregateHash) {
178
221
  const id = toIdentity(pubkey, dTag, aggregateHash);
222
+ ensureRuntimeDefaultEntry(id);
179
223
  state = unblock(state, id);
180
224
  },
181
225
  isBlocked(pubkey, dTag, aggregateHash) {
182
226
  const id = toIdentity(pubkey, dTag, aggregateHash);
183
- return !check(state, id, CAP_ALL) && this.getEntry(pubkey, dTag, aggregateHash)?.blocked === true;
227
+ return !check(state, id, RUNTIME_CAP_ALL) && this.getEntry(pubkey, dTag, aggregateHash)?.blocked === true;
184
228
  },
185
229
  getEntry(pubkey, dTag, aggregateHash) {
186
230
  const id = toIdentity(pubkey, dTag, aggregateHash);
@@ -195,8 +239,7 @@ function createAclState(persistence, defaultPolicy = "permissive") {
195
239
  };
196
240
  },
197
241
  getAllEntries() {
198
- return Object.entries(state.entries).map(([key, entry]) => {
199
- const parts = key.split(":");
242
+ return Object.entries(state.entries).map(([, entry]) => {
200
243
  return {
201
244
  pubkey: "",
202
245
  capabilities: bitsToCapabilities(entry.caps),
@@ -391,22 +434,21 @@ function createEventBuffer(sendToNapplet, sessionRegistry, enforce, subscription
391
434
 
392
435
  // src/runtime.ts
393
436
  import { createDispatch } from "@napplet/core";
394
- import { ALL_CAPABILITIES } from "@kehto/acl/capabilities";
395
437
 
396
438
  // src/service-dispatch.ts
397
439
  function routeServiceMessage(windowId, message, services, sendToNapplet) {
398
- const send = (msg) => sendToNapplet(windowId, msg);
399
440
  const domain = message.type.split(".")[0];
400
441
  const handler = services[domain];
401
442
  if (handler) {
402
- handler.handleMessage(windowId, message, send);
443
+ handler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
403
444
  return true;
404
445
  }
405
- if (message.type === "ifc.emit" && typeof message.topic === "string") {
406
- const prefix = message.topic.split(":")[0];
446
+ const ifcMessage = message;
447
+ if (message.type === "ifc.emit" && typeof ifcMessage.topic === "string") {
448
+ const prefix = ifcMessage.topic.split(":")[0];
407
449
  const ifcHandler = services[prefix];
408
450
  if (ifcHandler) {
409
- ifcHandler.handleMessage(windowId, message, send);
451
+ ifcHandler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
410
452
  return true;
411
453
  }
412
454
  }
@@ -421,6 +463,492 @@ function notifyServiceWindowDestroyed(windowId, services) {
421
463
  }
422
464
  }
423
465
 
466
+ // src/relay-handler.ts
467
+ function createRelayHandler(context) {
468
+ return function handleRelayMessage(windowId, msg) {
469
+ const m = msg;
470
+ const dotIdx = msg.type.indexOf(".");
471
+ const action = msg.type.slice(dotIdx + 1);
472
+ switch (action) {
473
+ case "subscribe":
474
+ handleRelaySubscribe(context, windowId, msg, m);
475
+ return;
476
+ case "close":
477
+ handleRelayClose(context, windowId, msg, m);
478
+ return;
479
+ case "publish":
480
+ handleRelayPublish(context, windowId, msg, m);
481
+ return;
482
+ case "publishEncrypted":
483
+ handleRelayPublishEncrypted(context, windowId, msg);
484
+ return;
485
+ case "query":
486
+ handleRelayQuery(context, windowId, m);
487
+ return;
488
+ default:
489
+ return;
490
+ }
491
+ };
492
+ }
493
+ function relayServiceFrom(context) {
494
+ return context.serviceRegistry["relay"] ?? context.serviceRegistry["relay-pool"];
495
+ }
496
+ function isShellKindQuery(filters) {
497
+ return filters.length > 0 && filters.every((filter) => filter.kinds?.every((kind) => kind >= 29e3 && kind < 3e4));
498
+ }
499
+ function handleRelaySubscribe(context, windowId, msg, m) {
500
+ const { eventBuffer, hooks, serviceRegistry, subscriptions } = context;
501
+ const subId = m.subId ?? "";
502
+ const filters = m.filters ?? [];
503
+ if (!subId) return;
504
+ const subKey = `${windowId}:${subId}`;
505
+ subscriptions.set(subKey, { windowId, filters });
506
+ const seenIds = /* @__PURE__ */ new Set();
507
+ function deliver(event) {
508
+ if (seenIds.has(event.id)) return;
509
+ seenIds.add(event.id);
510
+ if (subscriptions.has(subKey)) {
511
+ hooks.sendToNapplet(windowId, { type: "relay.event", subId, event });
512
+ }
513
+ }
514
+ for (const bufferedEvent of eventBuffer.getBufferedEvents()) {
515
+ if (matchesAnyFilter(bufferedEvent, filters)) deliver(bufferedEvent);
516
+ }
517
+ const isShellKind = isShellKindQuery(filters);
518
+ const relayService = relayServiceFrom(context);
519
+ const cacheService = !serviceRegistry["relay"] ? serviceRegistry["cache"] : void 0;
520
+ if (!isShellKind && relayService) {
521
+ relayService.handleMessage(windowId, msg, (resp) => {
522
+ if (!subscriptions.has(subKey)) return;
523
+ hooks.sendToNapplet(windowId, resp);
524
+ });
525
+ if (cacheService) {
526
+ cacheService.handleMessage(windowId, msg, (resp) => {
527
+ if (!subscriptions.has(subKey)) return;
528
+ hooks.sendToNapplet(windowId, resp);
529
+ });
530
+ }
531
+ return;
532
+ }
533
+ deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, isShellKind, deliver);
534
+ }
535
+ function deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, isShellKind, deliver) {
536
+ const { hooks } = context;
537
+ const cache = hooks.cache;
538
+ if (cache?.isAvailable() && !isShellKind) {
539
+ cache.query(filters).then((cachedEvents) => {
540
+ for (const event of cachedEvents) deliver(event);
541
+ }).catch(() => {
542
+ });
543
+ }
544
+ const pool = hooks.relayPool;
545
+ if (!pool?.isAvailable() && !isShellKind) {
546
+ hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
547
+ return;
548
+ }
549
+ if (!pool?.isAvailable() || isShellKind) return;
550
+ const relayUrls = pool.selectRelayTier(filters);
551
+ let eoseSent = false;
552
+ const eoseFallbackTimer = setTimeout(() => {
553
+ if (!eoseSent) {
554
+ eoseSent = true;
555
+ hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
556
+ }
557
+ }, 15e3);
558
+ const subscription = pool.subscribe(filters, (item) => {
559
+ if (item === "EOSE") {
560
+ clearTimeout(eoseFallbackTimer);
561
+ if (!eoseSent) {
562
+ eoseSent = true;
563
+ hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
564
+ }
565
+ return;
566
+ }
567
+ deliver(item);
568
+ if (cache?.isAvailable() && !isShellKind) {
569
+ try {
570
+ cache.store(item);
571
+ } catch {
572
+ return;
573
+ }
574
+ }
575
+ }, relayUrls);
576
+ pool.trackSubscription(subKey, () => {
577
+ clearTimeout(eoseFallbackTimer);
578
+ subscription.unsubscribe();
579
+ });
580
+ }
581
+ function handleRelayClose(context, windowId, msg, m) {
582
+ const { hooks, subscriptions } = context;
583
+ const subId = m.subId ?? "";
584
+ if (!subId) return;
585
+ const subKey = `${windowId}:${subId}`;
586
+ subscriptions.delete(subKey);
587
+ const relayService = relayServiceFrom(context);
588
+ if (relayService) relayService.handleMessage(windowId, msg, () => {
589
+ });
590
+ hooks.relayPool?.untrackSubscription(subKey);
591
+ hooks.sendToNapplet(windowId, { type: "relay.closed", subId, message: "" });
592
+ }
593
+ function handleRelayPublish(context, windowId, msg, m) {
594
+ const { eventBuffer, hooks, replayDetector } = context;
595
+ const event = m.event;
596
+ const id = m.id ?? "";
597
+ if (!event || typeof event !== "object") {
598
+ hooks.sendToNapplet(windowId, { type: "relay.publish.error", id, error: "invalid event" });
599
+ return;
600
+ }
601
+ const replayResult = replayDetector.check(event);
602
+ if (replayResult !== null) {
603
+ hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: replayResult });
604
+ return;
605
+ }
606
+ const relayService = relayServiceFrom(context);
607
+ if (relayService) {
608
+ relayService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
609
+ } else if (hooks.relayPool?.isAvailable()) {
610
+ hooks.relayPool.publish(event);
611
+ hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: true });
612
+ } else {
613
+ hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: "no relay pool available" });
614
+ }
615
+ eventBuffer.bufferAndDeliver(event, windowId);
616
+ }
617
+ function handleRelayPublishEncrypted(context, windowId, msg) {
618
+ const { hooks } = context;
619
+ const id = msg.id ?? "";
620
+ const eventTemplate = msg.event;
621
+ const peMsg = msg;
622
+ const recipient = peMsg.recipient ?? "";
623
+ const encryption = peMsg.encryption ?? "nip44";
624
+ const replyPe = (ok, extra = {}) => {
625
+ hooks.sendToNapplet(windowId, { type: "relay.publishEncrypted.result", id, ok, ...extra });
626
+ };
627
+ if (!recipient) {
628
+ replyPe(false, { error: "missing recipient" });
629
+ return;
630
+ }
631
+ if (encryption !== "nip44" && encryption !== "nip04") {
632
+ replyPe(false, { error: `unsupported encryption scheme: ${encryption}` });
633
+ return;
634
+ }
635
+ const peSigner = hooks.auth.getSigner();
636
+ if (!peSigner) {
637
+ replyPe(false, { error: "no signer configured" });
638
+ return;
639
+ }
640
+ if (!eventTemplate || typeof eventTemplate !== "object") {
641
+ replyPe(false, { error: "invalid event template" });
642
+ return;
643
+ }
644
+ publishEncrypted(context, windowId, id, recipient, encryption, eventTemplate, replyPe);
645
+ }
646
+ function publishEncrypted(context, windowId, id, recipient, encryption, eventTemplate, replyPe) {
647
+ const { eventBuffer, hooks } = context;
648
+ const peSigner = hooks.auth.getSigner();
649
+ if (!peSigner) return;
650
+ (async () => {
651
+ try {
652
+ const plaintext = String(eventTemplate.content ?? "");
653
+ const ciphertext = encryption === "nip44" ? await peSigner.nip44?.encrypt(recipient, plaintext) ?? "" : await peSigner.nip04?.encrypt(recipient, plaintext) ?? "";
654
+ const eventWithCiphertext = { ...eventTemplate, content: ciphertext };
655
+ const signed = await peSigner.signEvent?.(eventWithCiphertext);
656
+ if (!signed) {
657
+ replyPe(false, { error: "signEvent returned null" });
658
+ return;
659
+ }
660
+ publishSignedEncrypted(context, windowId, id, signed, replyPe);
661
+ try {
662
+ eventBuffer.bufferAndDeliver(signed, windowId);
663
+ } catch {
664
+ return;
665
+ }
666
+ } catch (err) {
667
+ replyPe(false, { error: err?.message ?? "encryption failed" });
668
+ }
669
+ })();
670
+ }
671
+ function publishSignedEncrypted(context, windowId, id, signed, replyPe) {
672
+ const { hooks } = context;
673
+ const relayService = relayServiceFrom(context);
674
+ if (!relayService) {
675
+ if (hooks.relayPool?.isAvailable()) {
676
+ hooks.relayPool.publish(signed);
677
+ replyPe(true, { event: signed, eventId: signed.id });
678
+ } else {
679
+ replyPe(false, { error: "no relay pool available" });
680
+ }
681
+ return;
682
+ }
683
+ const publishMsg = { type: "relay.publish", id, event: signed };
684
+ let replied = false;
685
+ relayService.handleMessage(windowId, publishMsg, (resp) => {
686
+ if (replied) return;
687
+ const r = resp;
688
+ if (typeof r.type !== "string" || !r.type.startsWith("relay.publish")) return;
689
+ const okVal = r.ok ?? r.accepted ?? false;
690
+ replied = true;
691
+ const publishResult = { event: signed, eventId: signed.id };
692
+ if (!okVal) publishResult.error = r.error ?? r.message ?? "publish failed";
693
+ replyPe(okVal, publishResult);
694
+ });
695
+ if (!replied) {
696
+ replied = true;
697
+ replyPe(true, { event: signed, eventId: signed.id });
698
+ }
699
+ }
700
+ function handleRelayQuery(context, windowId, m) {
701
+ const id = m.id ?? "";
702
+ const filters = m.filters ?? [];
703
+ let count = 0;
704
+ for (const event of context.eventBuffer.getBufferedEvents()) {
705
+ if (matchesAnyFilter(event, filters)) count++;
706
+ }
707
+ context.hooks.sendToNapplet(windowId, { type: "relay.query.result", id, count });
708
+ }
709
+
710
+ // src/identity-handler.ts
711
+ function createIdentityHandler(context) {
712
+ return function handleIdentityMessage(windowId, msg) {
713
+ const { hooks, serviceRegistry } = context;
714
+ const identityService = serviceRegistry["identity"];
715
+ if (identityService) {
716
+ identityService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
717
+ return;
718
+ }
719
+ const id = msg.id ?? "";
720
+ const action = msg.type.slice("identity.".length);
721
+ const signer = hooks.auth.getSigner();
722
+ const sendError = (error) => {
723
+ hooks.sendToNapplet(windowId, { type: `${msg.type}.error`, id, error });
724
+ };
725
+ const sendResult = (payload) => {
726
+ hooks.sendToNapplet(windowId, { type: `${msg.type}.result`, id, ...payload });
727
+ };
728
+ switch (action) {
729
+ case "getPublicKey":
730
+ if (!signer) {
731
+ sendResult({ pubkey: "" });
732
+ return;
733
+ }
734
+ Promise.resolve(signer.getPublicKey?.()).then((pubkey) => sendResult({ pubkey: pubkey ?? "" })).catch((err) => sendError(err?.message ?? "getPublicKey failed"));
735
+ return;
736
+ case "getRelays":
737
+ if (!signer) {
738
+ sendError("no signer configured");
739
+ return;
740
+ }
741
+ Promise.resolve(signer.getRelays?.() ?? {}).then((relays) => sendResult({ relays })).catch((err) => sendError(err?.message ?? "getRelays failed"));
742
+ return;
743
+ case "getProfile":
744
+ sendResult({ profile: null });
745
+ return;
746
+ case "getFollows":
747
+ sendResult({ pubkeys: [] });
748
+ return;
749
+ case "getList":
750
+ sendResult({ entries: [] });
751
+ return;
752
+ case "getZaps":
753
+ sendResult({ zaps: [] });
754
+ return;
755
+ case "getMutes":
756
+ sendResult({ pubkeys: [] });
757
+ return;
758
+ case "getBlocked":
759
+ sendResult({ pubkeys: [] });
760
+ return;
761
+ case "getBadges":
762
+ sendResult({ badges: [] });
763
+ return;
764
+ default:
765
+ sendError(`Unknown identity action: ${action}`);
766
+ }
767
+ };
768
+ }
769
+
770
+ // src/ifc-handler.ts
771
+ function createIfcRuntime(hooks, sessionRegistry) {
772
+ const state = {
773
+ subscriptions: /* @__PURE__ */ new Map(),
774
+ channels: /* @__PURE__ */ new Map(),
775
+ channelsByWindow: /* @__PURE__ */ new Map()
776
+ };
777
+ return {
778
+ handleMessage(windowId, msg) {
779
+ handleIfcMessage(state, hooks, sessionRegistry, windowId, msg);
780
+ },
781
+ destroyWindow(windowId) {
782
+ removeWindowChannels(state, hooks, windowId);
783
+ removeWindowSubscriptions(state, windowId);
784
+ },
785
+ clear() {
786
+ state.subscriptions.clear();
787
+ state.channels.clear();
788
+ state.channelsByWindow.clear();
789
+ }
790
+ };
791
+ }
792
+ function addChannel(state, channelId, peerA, peerB) {
793
+ state.channels.set(channelId, { channelId, peerA, peerB });
794
+ for (const windowId of [peerA, peerB]) {
795
+ let set = state.channelsByWindow.get(windowId);
796
+ if (!set) {
797
+ set = /* @__PURE__ */ new Set();
798
+ state.channelsByWindow.set(windowId, set);
799
+ }
800
+ set.add(channelId);
801
+ }
802
+ }
803
+ function removeChannel(state, channelId) {
804
+ const channel = state.channels.get(channelId);
805
+ if (!channel) return;
806
+ state.channels.delete(channelId);
807
+ for (const windowId of [channel.peerA, channel.peerB]) {
808
+ const set = state.channelsByWindow.get(windowId);
809
+ if (!set) continue;
810
+ set.delete(channelId);
811
+ if (set.size === 0) state.channelsByWindow.delete(windowId);
812
+ }
813
+ }
814
+ function peerOf(state, channelId, self) {
815
+ const channel = state.channels.get(channelId);
816
+ if (!channel) return null;
817
+ if (channel.peerA === self) return channel.peerB;
818
+ if (channel.peerB === self) return channel.peerA;
819
+ return null;
820
+ }
821
+ function resolveTarget(sessionRegistry, target) {
822
+ if (sessionRegistry.getEntryByWindowId(target)) return target;
823
+ const entries = sessionRegistry.getAllEntries();
824
+ const byPubkey = entries.find((entry) => entry.pubkey === target);
825
+ return byPubkey?.windowId ?? null;
826
+ }
827
+ function handleIfcMessage(state, hooks, sessionRegistry, windowId, msg) {
828
+ const m = msg;
829
+ const dotIdx = msg.type.indexOf(".");
830
+ const action = msg.type.slice(dotIdx + 1);
831
+ switch (action) {
832
+ case "emit":
833
+ handleEmit(state, hooks, windowId, m);
834
+ return;
835
+ case "subscribe":
836
+ handleSubscribe(state, hooks, windowId, m);
837
+ return;
838
+ case "unsubscribe":
839
+ handleUnsubscribe(state, windowId, m);
840
+ return;
841
+ case "channel.open":
842
+ handleChannelOpen(state, hooks, sessionRegistry, windowId, m);
843
+ return;
844
+ case "channel.emit":
845
+ handleChannelEmit(state, hooks, windowId, m);
846
+ return;
847
+ case "channel.broadcast":
848
+ handleChannelBroadcast(state, hooks, windowId, m);
849
+ return;
850
+ case "channel.list":
851
+ handleChannelList(state, hooks, windowId, m);
852
+ return;
853
+ case "channel.close":
854
+ handleChannelClose(state, hooks, windowId, m);
855
+ return;
856
+ default:
857
+ return;
858
+ }
859
+ }
860
+ function handleEmit(state, hooks, windowId, m) {
861
+ const topic = m.topic ?? "";
862
+ if (!topic) return;
863
+ const subscribers = state.subscriptions.get(topic);
864
+ if (!subscribers) return;
865
+ for (const subscriberWindowId of subscribers) {
866
+ if (subscriberWindowId !== windowId) {
867
+ hooks.sendToNapplet(subscriberWindowId, { type: "ifc.event", topic, payload: m.payload, sender: windowId });
868
+ }
869
+ }
870
+ }
871
+ function handleSubscribe(state, hooks, windowId, m) {
872
+ const id = m.id ?? "";
873
+ const topic = m.topic ?? "";
874
+ if (!topic) {
875
+ hooks.sendToNapplet(windowId, { type: "ifc.subscribe.result", id, error: "missing topic" });
876
+ return;
877
+ }
878
+ let subscriptions = state.subscriptions.get(topic);
879
+ if (!subscriptions) {
880
+ subscriptions = /* @__PURE__ */ new Set();
881
+ state.subscriptions.set(topic, subscriptions);
882
+ }
883
+ subscriptions.add(windowId);
884
+ hooks.sendToNapplet(windowId, { type: "ifc.subscribe.result", id });
885
+ }
886
+ function handleUnsubscribe(state, windowId, m) {
887
+ const topic = m.topic ?? "";
888
+ if (!topic) return;
889
+ const subscriptions = state.subscriptions.get(topic);
890
+ if (!subscriptions) return;
891
+ subscriptions.delete(windowId);
892
+ if (subscriptions.size === 0) state.subscriptions.delete(topic);
893
+ }
894
+ function handleChannelOpen(state, hooks, sessionRegistry, windowId, m) {
895
+ const id = m.id ?? "";
896
+ const peerWindow = resolveTarget(sessionRegistry, m.target ?? "");
897
+ if (!peerWindow) {
898
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.open.result", id, error: "target not found" });
899
+ return;
900
+ }
901
+ const channelId = hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 32);
902
+ addChannel(state, channelId, windowId, peerWindow);
903
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.open.result", id, channelId, peer: peerWindow });
904
+ }
905
+ function handleChannelEmit(state, hooks, windowId, m) {
906
+ const peer = peerOf(state, m.channelId ?? "", windowId);
907
+ if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.event", channelId: m.channelId ?? "", sender: windowId, payload: m.payload });
908
+ }
909
+ function handleChannelBroadcast(state, hooks, windowId, m) {
910
+ const channels = state.channelsByWindow.get(windowId);
911
+ if (!channels) return;
912
+ for (const channelId of channels) {
913
+ const peer = peerOf(state, channelId, windowId);
914
+ if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.event", channelId, sender: windowId, payload: m.payload });
915
+ }
916
+ }
917
+ function handleChannelList(state, hooks, windowId, m) {
918
+ const channels = [];
919
+ const set = state.channelsByWindow.get(windowId);
920
+ if (set) {
921
+ for (const channelId of set) {
922
+ const peer = peerOf(state, channelId, windowId);
923
+ if (peer) channels.push({ id: channelId, peer });
924
+ }
925
+ }
926
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.list.result", id: m.id ?? "", channels });
927
+ }
928
+ function handleChannelClose(state, hooks, windowId, m) {
929
+ const channelId = m.channelId ?? "";
930
+ const peer = peerOf(state, channelId, windowId);
931
+ if (!peer) return;
932
+ hooks.sendToNapplet(windowId, { type: "ifc.channel.closed", channelId });
933
+ hooks.sendToNapplet(peer, { type: "ifc.channel.closed", channelId });
934
+ removeChannel(state, channelId);
935
+ }
936
+ function removeWindowSubscriptions(state, windowId) {
937
+ for (const [topic, subscriptions] of state.subscriptions) {
938
+ subscriptions.delete(windowId);
939
+ if (subscriptions.size === 0) state.subscriptions.delete(topic);
940
+ }
941
+ }
942
+ function removeWindowChannels(state, hooks, windowId) {
943
+ const channelIds = state.channelsByWindow.get(windowId);
944
+ if (!channelIds) return;
945
+ for (const channelId of Array.from(channelIds)) {
946
+ const peer = peerOf(state, channelId, windowId);
947
+ if (peer) hooks.sendToNapplet(peer, { type: "ifc.channel.closed", channelId });
948
+ removeChannel(state, channelId);
949
+ }
950
+ }
951
+
424
952
  // src/state-handler.ts
425
953
  function scopedKey(dTag, aggregateHash, userKey) {
426
954
  return `napplet-state:${dTag}:${aggregateHash}:${userKey}`;
@@ -450,7 +978,7 @@ function handleStorageNub(windowId, msg, sendToNapplet, sessionRegistry, aclStat
450
978
  sendToNapplet(windowId, { type: `${msg.type}.result`, id, ...payload });
451
979
  }
452
980
  function sendErrorNub(error) {
453
- sendToNapplet(windowId, { type: `${msg.type}.error`, id, error });
981
+ sendToNapplet(windowId, { type: `${msg.type}.result`, id, error });
454
982
  }
455
983
  const entry = sessionRegistry.getEntryByWindowId(windowId);
456
984
  if (!entry) {
@@ -509,7 +1037,7 @@ function handleStorageNub(windowId, msg, sendToNapplet, sessionRegistry, aclStat
509
1037
  break;
510
1038
  }
511
1039
  case "clear": {
512
- sendErrorNub("storage.clear is not in @napplet/nub-storage; action not supported");
1040
+ sendErrorNub("storage.clear is not in @napplet/nub/storage; action not supported");
513
1041
  break;
514
1042
  }
515
1043
  case "keys": {
@@ -533,14 +1061,132 @@ function cleanupNappState(pubkey, dTag, aggregateHash, statePersistence) {
533
1061
  statePersistence.clear(legacyPrefix);
534
1062
  }
535
1063
 
1064
+ // src/domain-handlers.ts
1065
+ var THEME_FALLBACK_DEFAULT = {
1066
+ colors: { background: "#0a0a0a", text: "#e0e0e0", primary: "#7aa2f7" }
1067
+ };
1068
+ function createRuntimeDomainHandlers(context) {
1069
+ return {
1070
+ storage: (windowId, msg) => handleStorageMessage(context, windowId, msg),
1071
+ media: (windowId, msg) => handleMediaMessage(context, windowId, msg),
1072
+ keys: (windowId, msg) => handleKeysMessage(context, windowId, msg),
1073
+ notify: (windowId, msg) => handleNotifyMessage(context, windowId, msg),
1074
+ theme: (windowId, msg) => handleThemeMessage(context, windowId, msg),
1075
+ config: (windowId, msg) => handleServiceOnlyMessage(context, "config", windowId, msg),
1076
+ resource: (windowId, msg) => handleServiceOnlyMessage(context, "resource", windowId, msg),
1077
+ cvm: (windowId, msg) => handleServiceOnlyMessage(context, "cvm", windowId, msg)
1078
+ };
1079
+ }
1080
+ function handleStorageMessage(context, windowId, msg) {
1081
+ const { aclState, hooks, sessionRegistry } = context;
1082
+ handleStorageNub(windowId, msg, hooks.sendToNapplet, sessionRegistry, aclState, hooks.statePersistence);
1083
+ }
1084
+ function handleMediaMessage(context, windowId, msg) {
1085
+ const { hooks, serviceRegistry } = context;
1086
+ const mediaService = serviceRegistry["media"];
1087
+ if (mediaService) {
1088
+ mediaService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1089
+ return;
1090
+ }
1091
+ if (msg.type === "media.session.create") {
1092
+ const m = msg;
1093
+ if (m.owner !== "napplet" && m.owner !== "shell") {
1094
+ hooks.sendToNapplet(windowId, {
1095
+ type: "media.session.create.result",
1096
+ id: m.id ?? "",
1097
+ error: "missing owner"
1098
+ });
1099
+ return;
1100
+ }
1101
+ if (m.owner === "shell") {
1102
+ hooks.sendToNapplet(windowId, {
1103
+ type: "media.session.create.result",
1104
+ id: m.id ?? "",
1105
+ owner: "shell",
1106
+ error: "unsupported owner mode"
1107
+ });
1108
+ return;
1109
+ }
1110
+ hooks.sendToNapplet(windowId, {
1111
+ type: "media.session.create.result",
1112
+ id: m.id ?? "",
1113
+ sessionId: m.sessionId ?? "",
1114
+ owner: m.owner
1115
+ });
1116
+ }
1117
+ }
1118
+ function handleKeysMessage(context, windowId, msg) {
1119
+ const { hooks, serviceRegistry } = context;
1120
+ const keysService = serviceRegistry["keys"];
1121
+ if (keysService) {
1122
+ keysService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1123
+ return;
1124
+ }
1125
+ if (msg.type === "keys.forward") {
1126
+ forwardHotkey(hooks, msg);
1127
+ return;
1128
+ }
1129
+ if (msg.type === "keys.registerAction") sendRegisterActionResult(hooks, windowId, msg);
1130
+ }
1131
+ function forwardHotkey(hooks, msg) {
1132
+ const m = msg;
1133
+ hooks.hotkeys.executeHotkeyFromForward({
1134
+ key: m.key ?? "",
1135
+ code: m.code ?? "",
1136
+ ctrlKey: !!m.ctrl,
1137
+ altKey: !!m.alt,
1138
+ shiftKey: !!m.shift,
1139
+ metaKey: !!m.meta
1140
+ });
1141
+ }
1142
+ function sendRegisterActionResult(hooks, windowId, msg) {
1143
+ const m = msg;
1144
+ hooks.sendToNapplet(windowId, {
1145
+ type: "keys.registerAction.result",
1146
+ id: m.id ?? "",
1147
+ actionId: m.action?.id ?? "",
1148
+ ...m.action?.defaultKey ? { binding: m.action.defaultKey } : {}
1149
+ });
1150
+ }
1151
+ function handleNotifyMessage(context, windowId, msg) {
1152
+ const { hooks, serviceRegistry } = context;
1153
+ const notifyService = serviceRegistry["notify"];
1154
+ if (notifyService) {
1155
+ notifyService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1156
+ return;
1157
+ }
1158
+ if (msg.type === "notify.send") {
1159
+ const m = msg;
1160
+ hooks.sendToNapplet(windowId, { type: "notify.send.result", id: m.id ?? "", notificationId: `shell-${Date.now()}` });
1161
+ } else if (msg.type === "notify.permission.request") {
1162
+ const m = msg;
1163
+ hooks.sendToNapplet(windowId, { type: "notify.permission.result", id: m.id ?? "", granted: true });
1164
+ }
1165
+ }
1166
+ function handleThemeMessage(context, windowId, msg) {
1167
+ const { hooks, serviceRegistry } = context;
1168
+ const themeService = serviceRegistry["theme"];
1169
+ if (themeService) {
1170
+ themeService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1171
+ return;
1172
+ }
1173
+ if (msg.type === "theme.get") {
1174
+ const m = msg;
1175
+ hooks.sendToNapplet(windowId, {
1176
+ type: "theme.get.result",
1177
+ id: m.id ?? "",
1178
+ theme: THEME_FALLBACK_DEFAULT
1179
+ });
1180
+ }
1181
+ }
1182
+ function handleServiceOnlyMessage(context, name, windowId, msg) {
1183
+ const service = context.serviceRegistry[name];
1184
+ if (!service) return;
1185
+ service.handleMessage(windowId, msg, (resp) => context.hooks.sendToNapplet(windowId, resp));
1186
+ }
1187
+
536
1188
  // 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 ?? {} };
1189
+ function createRegisteredServices(serviceRegistry) {
544
1190
  const registeredServices = /* @__PURE__ */ new Map();
545
1191
  for (const [name, handler] of Object.entries(serviceRegistry)) {
546
1192
  registeredServices.set(name, {
@@ -549,824 +1195,36 @@ function createRuntime(hooks) {
549
1195
  description: handler.descriptor.description
550
1196
  });
551
1197
  }
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
- }
1198
+ return registeredServices;
1199
+ }
1200
+ function createNubEnvelopeDispatcher(handlers) {
1327
1201
  let currentWindowId = null;
1328
1202
  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);
1203
+ const adapt = (handler) => (msg) => {
1204
+ if (currentWindowId !== null) handler(currentWindowId, msg);
1356
1205
  };
1357
- const themeAdapter = (msg) => {
1358
- if (currentWindowId === null) return;
1359
- handleThemeMessage(currentWindowId, msg);
1206
+ nubDispatch.registerNub("relay", adapt(handlers.relay));
1207
+ nubDispatch.registerNub("identity", adapt(handlers.identity));
1208
+ nubDispatch.registerNub("keys", adapt(handlers.keys));
1209
+ nubDispatch.registerNub("media", adapt(handlers.media));
1210
+ nubDispatch.registerNub("notify", adapt(handlers.notify));
1211
+ nubDispatch.registerNub("storage", adapt(handlers.storage));
1212
+ nubDispatch.registerNub("ifc", adapt(handlers.ifc));
1213
+ nubDispatch.registerNub("theme", adapt(handlers.theme));
1214
+ nubDispatch.registerNub("config", adapt(handlers.config));
1215
+ nubDispatch.registerNub("resource", adapt(handlers.resource));
1216
+ nubDispatch.registerNub("cvm", adapt(handlers.cvm));
1217
+ return (windowId, envelope) => {
1218
+ currentWindowId = windowId;
1219
+ try {
1220
+ nubDispatch.dispatch(envelope);
1221
+ } finally {
1222
+ currentWindowId = null;
1223
+ }
1360
1224
  };
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) {
1225
+ }
1226
+ function createMessageHandler(hooks, enforceNub, dispatchNubEnvelope) {
1227
+ return (windowId, msg) => {
1370
1228
  if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
1371
1229
  const envelope = msg;
1372
1230
  const dotIdx = envelope.type.indexOf(".");
@@ -1376,47 +1234,62 @@ function createRuntime(hooks) {
1376
1234
  const result = enforceNub(windowId, caps.senderCap, envelope);
1377
1235
  if (!result.allowed) {
1378
1236
  const id = envelope.id ?? "";
1379
- hooks.sendToNapplet(windowId, { type: `${envelope.type}.error`, id, error: formatDenialReason(result.capability) });
1237
+ const isIdentityDecrypt = envelope.type === "identity.decrypt";
1238
+ const isStorageEnvelope = envelope.type.startsWith("storage.");
1239
+ const error = isIdentityDecrypt ? result.reason === "class-forbidden" ? "class-forbidden" : "policy-denied" : formatDenialReason(result.capability);
1240
+ const type = isStorageEnvelope ? `${envelope.type}.result` : `${envelope.type}.error`;
1241
+ hooks.sendToNapplet(windowId, { type, id, error });
1380
1242
  return;
1381
1243
  }
1382
1244
  }
1383
- currentWindowId = windowId;
1384
- try {
1385
- nubDispatch.dispatch(envelope);
1386
- } finally {
1387
- currentWindowId = null;
1388
- }
1389
- }
1390
- const runtimeInstance = {
1391
- handleMessage,
1245
+ dispatchNubEnvelope(windowId, envelope);
1246
+ };
1247
+ }
1248
+ function createInjectedEvent(hooks, topic, payload) {
1249
+ const uuid = hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 64).padEnd(64, "0");
1250
+ return {
1251
+ id: uuid,
1252
+ pubkey: "0".repeat(64),
1253
+ created_at: Math.floor(Date.now() / 1e3),
1254
+ kind: 29e3,
1255
+ tags: [["t", topic]],
1256
+ content: JSON.stringify(payload),
1257
+ sig: "0".repeat(128)
1258
+ };
1259
+ }
1260
+ function createRuntimeInstance(context) {
1261
+ const {
1262
+ aclState,
1263
+ eventBuffer,
1264
+ hooks,
1265
+ ifcRuntime,
1266
+ manifestCache,
1267
+ registeredServices,
1268
+ replayDetector,
1269
+ serviceRegistry,
1270
+ sessionRegistry,
1271
+ subscriptions
1272
+ } = context;
1273
+ const undeclaredServiceConsents = /* @__PURE__ */ new Set();
1274
+ let consentHandler = null;
1275
+ return {
1276
+ handleMessage: context.handleMessage,
1392
1277
  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);
1278
+ eventBuffer.bufferAndDeliver(createInjectedEvent(hooks, topic, payload), null);
1405
1279
  },
1406
1280
  destroy() {
1407
1281
  manifestCache.persist();
1408
1282
  aclState.persist();
1409
1283
  replayDetector.clear();
1410
1284
  subscriptions.clear();
1411
- ifcSubscriptions.clear();
1412
- ifcChannels.clear();
1413
- ifcChannelsByWindow.clear();
1285
+ ifcRuntime.clear();
1414
1286
  eventBuffer.clear();
1415
1287
  registeredServices.clear();
1416
1288
  undeclaredServiceConsents.clear();
1417
1289
  },
1418
1290
  registerConsentHandler(handler) {
1419
- _consentHandler = handler;
1291
+ consentHandler = handler;
1292
+ void consentHandler;
1420
1293
  },
1421
1294
  registerService(name, handler) {
1422
1295
  serviceRegistry[name] = handler;
@@ -1437,20 +1310,7 @@ function createRuntime(hooks) {
1437
1310
  hooks.relayPool?.untrackSubscription(key);
1438
1311
  }
1439
1312
  }
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
- }
1313
+ ifcRuntime.destroyWindow(windowId);
1454
1314
  notifyServiceWindowDestroyed(windowId, serviceRegistry);
1455
1315
  },
1456
1316
  get sessionRegistry() {
@@ -1463,7 +1323,64 @@ function createRuntime(hooks) {
1463
1323
  return manifestCache;
1464
1324
  }
1465
1325
  };
1466
- return runtimeInstance;
1326
+ }
1327
+ function createRuntime(hooks) {
1328
+ const subscriptions = /* @__PURE__ */ new Map();
1329
+ const serviceRegistry = { ...hooks.services };
1330
+ const registeredServices = createRegisteredServices(serviceRegistry);
1331
+ const sessionRegistry = createSessionRegistry(hooks.onPendingUpdate);
1332
+ const aclState = createAclState(hooks.aclPersistence);
1333
+ const manifestCache = createManifestCache(hooks.manifestPersistence);
1334
+ const replayDetector = createReplayDetector(
1335
+ hooks.getConfigOverrides ? () => hooks.getConfigOverrides().replayWindowSeconds : void 0
1336
+ );
1337
+ const enforce = createEnforceGate({
1338
+ checkAcl: (pubkey, dTag, aggregateHash, capability) => aclState.check(pubkey, dTag, aggregateHash, capability),
1339
+ resolveIdentity: (pubkey) => {
1340
+ const entry = sessionRegistry.getEntry(pubkey);
1341
+ return entry ? { dTag: entry.dTag, aggregateHash: entry.aggregateHash } : void 0;
1342
+ },
1343
+ onAclCheck: hooks.onAclCheck
1344
+ });
1345
+ const enforceNub = createNubEnforceGate({
1346
+ checkAcl: (pubkey, dTag, aggregateHash, capability) => aclState.check(pubkey, dTag, aggregateHash, capability),
1347
+ resolveIdentityByWindowId: (windowId) => {
1348
+ const entry = sessionRegistry.getEntryByWindowId(windowId);
1349
+ return entry ? { dTag: entry.dTag, aggregateHash: entry.aggregateHash, class: entry.class } : void 0;
1350
+ },
1351
+ onAclCheck: hooks.onAclCheck
1352
+ });
1353
+ const eventBuffer = createEventBuffer(
1354
+ hooks.sendToNapplet,
1355
+ sessionRegistry,
1356
+ enforce,
1357
+ subscriptions,
1358
+ hooks.getConfigOverrides ? () => hooks.getConfigOverrides().ringBufferSize ?? RING_BUFFER_SIZE : void 0
1359
+ );
1360
+ aclState.load();
1361
+ manifestCache.load();
1362
+ const ifcRuntime = createIfcRuntime(hooks, sessionRegistry);
1363
+ const domainHandlers = createRuntimeDomainHandlers({ hooks, serviceRegistry, sessionRegistry, aclState });
1364
+ const dispatchNubEnvelope = createNubEnvelopeDispatcher({
1365
+ relay: createRelayHandler({ hooks, serviceRegistry, subscriptions, eventBuffer, replayDetector }),
1366
+ identity: createIdentityHandler({ hooks, serviceRegistry }),
1367
+ ifc: ifcRuntime.handleMessage,
1368
+ ...domainHandlers
1369
+ });
1370
+ const handleMessage = createMessageHandler(hooks, enforceNub, dispatchNubEnvelope);
1371
+ return createRuntimeInstance({
1372
+ hooks,
1373
+ serviceRegistry,
1374
+ registeredServices,
1375
+ replayDetector,
1376
+ subscriptions,
1377
+ eventBuffer,
1378
+ ifcRuntime,
1379
+ sessionRegistry,
1380
+ aclState,
1381
+ manifestCache,
1382
+ handleMessage
1383
+ });
1467
1384
  }
1468
1385
 
1469
1386
  // src/index.ts