@kehto/runtime 0.18.3 → 0.19.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
@@ -66,11 +66,20 @@ function createSessionRegistry(notifier) {
66
66
  getWindowId(pubkey) {
67
67
  return byPubkey.get(pubkey)?.windowId;
68
68
  },
69
+ getWindowIdByDTag(dTag) {
70
+ let resolvedWindowId;
71
+ for (const [windowId, entry] of byWindowIdEntry) {
72
+ if (entry.dTag !== dTag) continue;
73
+ if (resolvedWindowId !== void 0) return void 0;
74
+ resolvedWindowId = windowId;
75
+ }
76
+ return resolvedWindowId;
77
+ },
69
78
  isRegistered(windowId) {
70
79
  return byWindowId.has(windowId);
71
80
  },
72
81
  getAllEntries() {
73
- return Array.from(byPubkey.values());
82
+ return Array.from(byWindowIdEntry.values());
74
83
  },
75
84
  getInstanceId(windowId) {
76
85
  const pubkey = byWindowId.get(windowId);
@@ -178,7 +187,7 @@ function bitsToCapabilities(bits) {
178
187
  function toIdentity(pubkey, dTag, hash) {
179
188
  return { pubkey, dTag, hash };
180
189
  }
181
- function createAclState(persistence, defaultPolicy = "permissive") {
190
+ function createAclState(persistence, defaultPolicy = "permissive", onMutation) {
182
191
  let state = createState(defaultPolicy);
183
192
  function ensureRuntimeDefaultEntry(id) {
184
193
  if (state.defaultPolicy !== "permissive") return;
@@ -199,11 +208,13 @@ function createAclState(persistence, defaultPolicy = "permissive") {
199
208
  const id = toIdentity(pubkey, dTag, aggregateHash);
200
209
  ensureRuntimeDefaultEntry(id);
201
210
  state = revoke(state, id, capToBit(capability));
211
+ onMutation?.({ type: "revoke", dTag, aggregateHash, capability });
202
212
  },
203
213
  block(pubkey, dTag, aggregateHash) {
204
214
  const id = toIdentity(pubkey, dTag, aggregateHash);
205
215
  ensureRuntimeDefaultEntry(id);
206
216
  state = block(state, id);
217
+ onMutation?.({ type: "block", dTag, aggregateHash });
207
218
  },
208
219
  unblock(pubkey, dTag, aggregateHash) {
209
220
  const id = toIdentity(pubkey, dTag, aggregateHash);
@@ -399,18 +410,39 @@ function createManifestCache(persistence) {
399
410
  var REPLAY_WINDOW_SECONDS = 30;
400
411
  function createReplayDetector(getReplayWindow) {
401
412
  const seenEventIds = /* @__PURE__ */ new Map();
413
+ function checkAndTrack(event, state) {
414
+ const replayWindow = getReplayWindow?.() ?? REPLAY_WINDOW_SECONDS;
415
+ const now = Math.floor(Date.now() / 1e3);
416
+ for (const [id, entry] of seenEventIds) {
417
+ if (entry.state === "committed" && now - entry.timestamp > replayWindow) {
418
+ seenEventIds.delete(id);
419
+ }
420
+ }
421
+ if (now - event.created_at > replayWindow) return "invalid: event created_at too old";
422
+ if (event.created_at - now > 10) return "invalid: event created_at in the future";
423
+ if (seenEventIds.has(event.id)) return "duplicate: already processed";
424
+ seenEventIds.set(event.id, { state, timestamp: now });
425
+ return null;
426
+ }
402
427
  return {
403
428
  check(event) {
404
- const replayWindow = getReplayWindow?.() ?? REPLAY_WINDOW_SECONDS;
405
- const now = Math.floor(Date.now() / 1e3);
406
- if (now - event.created_at > replayWindow) return "invalid: event created_at too old";
407
- if (event.created_at - now > 10) return "invalid: event created_at in the future";
408
- if (seenEventIds.has(event.id)) return "duplicate: already processed";
409
- seenEventIds.set(event.id, now);
410
- for (const [id, timestamp] of seenEventIds) {
411
- if (now - timestamp > replayWindow) seenEventIds.delete(id);
429
+ return checkAndTrack(event, "committed");
430
+ },
431
+ reserve(event) {
432
+ return checkAndTrack(event, "pending");
433
+ },
434
+ commit(eventId) {
435
+ const entry = seenEventIds.get(eventId);
436
+ if (entry?.state !== "pending") return;
437
+ seenEventIds.set(eventId, {
438
+ state: "committed",
439
+ timestamp: Math.floor(Date.now() / 1e3)
440
+ });
441
+ },
442
+ release(eventId) {
443
+ if (seenEventIds.get(eventId)?.state === "pending") {
444
+ seenEventIds.delete(eventId);
412
445
  }
413
- return null;
414
446
  },
415
447
  clear() {
416
448
  seenEventIds.clear();
@@ -524,21 +556,13 @@ import { createDispatch } from "@napplet/core";
524
556
 
525
557
  // src/service-dispatch.ts
526
558
  function routeServiceMessage(windowId, message, services, sendToNapplet) {
559
+ if (message.type === "inc.emit") return false;
527
560
  const domain = message.type.split(".")[0];
528
561
  const handler = services[domain];
529
562
  if (handler) {
530
563
  handler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
531
564
  return true;
532
565
  }
533
- const incMessage = message;
534
- if (message.type === "inc.emit" && typeof incMessage.topic === "string") {
535
- const prefix = incMessage.topic.split(":")[0];
536
- const incHandler = services[prefix];
537
- if (incHandler) {
538
- incHandler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
539
- return true;
540
- }
541
- }
542
566
  return false;
543
567
  }
544
568
  function notifyServiceWindowDestroyed(windowId, services) {
@@ -564,7 +588,7 @@ function createRelayHandler(context) {
564
588
  handleRelayClose(context, windowId, msg, m);
565
589
  return;
566
590
  case "publish":
567
- handleRelayPublish(context, windowId, msg, m);
591
+ handleRelayPublish(context, windowId, m);
568
592
  return;
569
593
  case "publishEncrypted":
570
594
  handleRelayPublishEncrypted(context, windowId, msg);
@@ -688,29 +712,106 @@ function handleRelayClose(context, windowId, msg, m) {
688
712
  hooks.relayPool?.untrackSubscription(subKey);
689
713
  hooks.sendToNapplet(windowId, { type: "relay.closed", subId, message: "" });
690
714
  }
691
- function handleRelayPublish(context, windowId, msg, m) {
692
- const { eventBuffer, hooks, replayDetector } = context;
693
- const event = m.event;
715
+ function handleRelayPublish(context, windowId, m) {
716
+ const { hooks, replayDetector } = context;
717
+ const eventTemplate = m.event;
694
718
  const id = m.id ?? "";
695
- if (!event || typeof event !== "object") {
696
- hooks.sendToNapplet(windowId, { type: "relay.publish.error", id, error: "invalid event" });
719
+ if (!eventTemplate || typeof eventTemplate !== "object") {
720
+ sendRelayPublishResult(hooks, windowId, id, false, void 0, "invalid event template");
697
721
  return;
698
722
  }
699
- const replayResult = replayDetector.check(event);
700
- if (replayResult !== null) {
701
- hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: replayResult });
723
+ const signer = hooks.auth.getSigner();
724
+ const signEvent = signer?.signEvent?.bind(signer);
725
+ if (!signEvent) {
726
+ sendRelayPublishResult(hooks, windowId, id, false, void 0, "no signer configured");
702
727
  return;
703
728
  }
729
+ void (async () => {
730
+ try {
731
+ const signed = await signEvent(eventTemplate);
732
+ if (!signed) {
733
+ sendRelayPublishResult(hooks, windowId, id, false, void 0, "signEvent returned no event");
734
+ return;
735
+ }
736
+ const replayResult = replayDetector.reserve(signed);
737
+ if (replayResult !== null) {
738
+ sendRelayPublishResult(hooks, windowId, id, false, void 0, replayResult);
739
+ return;
740
+ }
741
+ publishSignedRelayEvent(context, windowId, id, signed, (ok, error) => {
742
+ if (ok) replayDetector.commit(signed.id);
743
+ else replayDetector.release(signed.id);
744
+ sendRelayPublishResult(hooks, windowId, id, ok, ok ? signed : void 0, error);
745
+ });
746
+ } catch (error) {
747
+ sendRelayPublishResult(
748
+ hooks,
749
+ windowId,
750
+ id,
751
+ false,
752
+ void 0,
753
+ error instanceof Error && error.message ? error.message : "event signing failed"
754
+ );
755
+ }
756
+ })();
757
+ }
758
+ function sendRelayPublishResult(hooks, windowId, id, ok, event, error) {
759
+ const result = ok && event ? { type: "relay.publish.result", id, ok: true, event, eventId: event.id } : { type: "relay.publish.result", id, ok: false, error: error ?? "publish failed" };
760
+ hooks.sendToNapplet(windowId, result);
761
+ }
762
+ function publishSignedRelayEvent(context, windowId, id, signed, reply) {
763
+ const { eventBuffer, hooks } = context;
764
+ let settled = false;
765
+ const settle = (ok, error) => {
766
+ if (settled) return;
767
+ settled = true;
768
+ if (ok) {
769
+ try {
770
+ eventBuffer.bufferAndDeliver(signed, windowId);
771
+ } catch {
772
+ }
773
+ }
774
+ reply(ok, error);
775
+ };
704
776
  const relayService = relayServiceFrom(context);
705
777
  if (relayService) {
706
- relayService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
707
- } else if (hooks.relayPool?.isAvailable()) {
708
- hooks.relayPool.publish(event);
709
- hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: true });
710
- } else {
711
- hooks.sendToNapplet(windowId, { type: "relay.publish.result", id, accepted: false, message: "no relay pool available" });
778
+ const publishMessage = { type: "relay.publish", id, event: signed };
779
+ try {
780
+ relayService.handleMessage(windowId, publishMessage, (response) => {
781
+ if (response.type !== "relay.publish.result" && response.type !== "relay.publish.error") {
782
+ return;
783
+ }
784
+ const result = response;
785
+ const ok = response.type === "relay.publish.result" && (result.ok ?? result.accepted ?? false);
786
+ settle(ok, ok ? void 0 : result.error ?? result.message ?? "publish failed");
787
+ });
788
+ } catch (error) {
789
+ settle(
790
+ false,
791
+ error instanceof Error && error.message ? error.message : "relay service failed"
792
+ );
793
+ }
794
+ return;
795
+ }
796
+ if (!hooks.relayPool?.isAvailable()) {
797
+ settle(false, "no relay pool available");
798
+ return;
799
+ }
800
+ try {
801
+ const publishResult = hooks.relayPool.publish(signed);
802
+ void Promise.resolve(publishResult).then(
803
+ () => settle(true),
804
+ (error) => settle(
805
+ false,
806
+ error instanceof Error && error.message ? error.message : "publish failed"
807
+ )
808
+ );
809
+ } catch (error) {
810
+ settle(
811
+ false,
812
+ error instanceof Error && error.message ? error.message : "publish failed"
813
+ );
712
814
  }
713
- eventBuffer.bufferAndDeliver(event, windowId);
714
815
  }
715
816
  function handleRelayPublishEncrypted(context, windowId, msg) {
716
817
  const { hooks } = context;
@@ -742,7 +843,7 @@ function handleRelayPublishEncrypted(context, windowId, msg) {
742
843
  publishEncrypted(context, windowId, id, recipient, encryption, eventTemplate, replyPe);
743
844
  }
744
845
  function publishEncrypted(context, windowId, id, recipient, encryption, eventTemplate, replyPe) {
745
- const { eventBuffer, hooks } = context;
846
+ const { hooks } = context;
746
847
  const peSigner = hooks.auth.getSigner();
747
848
  if (!peSigner) return;
748
849
  (async () => {
@@ -755,46 +856,14 @@ function publishEncrypted(context, windowId, id, recipient, encryption, eventTem
755
856
  replyPe(false, { error: "signEvent returned null" });
756
857
  return;
757
858
  }
758
- publishSignedEncrypted(context, windowId, id, signed, replyPe);
759
- try {
760
- eventBuffer.bufferAndDeliver(signed, windowId);
761
- } catch {
762
- return;
763
- }
859
+ publishSignedRelayEvent(context, windowId, id, signed, (ok, error) => {
860
+ replyPe(ok, ok ? { event: signed, eventId: signed.id } : { error: error ?? "publish failed" });
861
+ });
764
862
  } catch (err) {
765
863
  replyPe(false, { error: err?.message ?? "encryption failed" });
766
864
  }
767
865
  })();
768
866
  }
769
- function publishSignedEncrypted(context, windowId, id, signed, replyPe) {
770
- const { hooks } = context;
771
- const relayService = relayServiceFrom(context);
772
- if (!relayService) {
773
- if (hooks.relayPool?.isAvailable()) {
774
- hooks.relayPool.publish(signed);
775
- replyPe(true, { event: signed, eventId: signed.id });
776
- } else {
777
- replyPe(false, { error: "no relay pool available" });
778
- }
779
- return;
780
- }
781
- const publishMsg = { type: "relay.publish", id, event: signed };
782
- let replied = false;
783
- relayService.handleMessage(windowId, publishMsg, (resp) => {
784
- if (replied) return;
785
- const r = resp;
786
- if (typeof r.type !== "string" || !r.type.startsWith("relay.publish")) return;
787
- const okVal = r.ok ?? r.accepted ?? false;
788
- replied = true;
789
- const publishResult = { event: signed, eventId: signed.id };
790
- if (!okVal) publishResult.error = r.error ?? r.message ?? "publish failed";
791
- replyPe(okVal, publishResult);
792
- });
793
- if (!replied) {
794
- replied = true;
795
- replyPe(true, { event: signed, eventId: signed.id });
796
- }
797
- }
798
867
  function handleRelayQuery(context, windowId, m) {
799
868
  const { hooks, serviceRegistry, eventBuffer } = context;
800
869
  const id = m.id ?? "";
@@ -921,6 +990,63 @@ function handleRelayQuery(context, windowId, m) {
921
990
  }, relayUrls);
922
991
  }
923
992
 
993
+ // src/domain-results.ts
994
+ var RUNTIME_THEME_FALLBACK = {
995
+ colors: { background: "#0a0a0a", text: "#e0e0e0", primary: "#7aa2f7" }
996
+ };
997
+ var IDENTITY_SAFE_DEFAULTS = {
998
+ "identity.getPublicKey": { pubkey: "" },
999
+ "identity.getRelays": { relays: {} },
1000
+ "identity.getProfile": { profile: null },
1001
+ "identity.getFollows": { pubkeys: [] },
1002
+ "identity.getList": { entries: [] },
1003
+ "identity.getZaps": { zaps: [] },
1004
+ "identity.getMutes": { pubkeys: [] },
1005
+ "identity.getBlocked": { pubkeys: [] },
1006
+ "identity.getBadges": { badges: [] }
1007
+ };
1008
+ function isRuntimeOwnedIdentityOrThemeRequest(type) {
1009
+ return type === "theme.get" || type in IDENTITY_SAFE_DEFAULTS;
1010
+ }
1011
+ function createRuntimeOwnedResultEnvelope(type, id) {
1012
+ return Object.assign({ type }, { id });
1013
+ }
1014
+ function isIdentityOrThemeMessage(message) {
1015
+ return message.type.startsWith("identity.") || message.type.startsWith("theme.");
1016
+ }
1017
+ function createIntentPolicyDenial(message) {
1018
+ const id = message.id ?? "";
1019
+ if (message.type === "intent.invoke") {
1020
+ return {
1021
+ type: "intent.invoke.result",
1022
+ id,
1023
+ result: { ok: false, error: "invoke rejected" }
1024
+ };
1025
+ }
1026
+ if (message.type === "intent.available" || message.type === "intent.handlers") {
1027
+ return {
1028
+ type: `${message.type}.result`,
1029
+ id,
1030
+ error: "intent request denied"
1031
+ };
1032
+ }
1033
+ return void 0;
1034
+ }
1035
+ function createCanonicalDomainResult(message) {
1036
+ if (!isRuntimeOwnedIdentityOrThemeRequest(message.type)) return void 0;
1037
+ const id = message.id ?? "";
1038
+ if (message.type === "theme.get") {
1039
+ return Object.assign(
1040
+ createRuntimeOwnedResultEnvelope("theme.get.result", id),
1041
+ { theme: RUNTIME_THEME_FALLBACK }
1042
+ );
1043
+ }
1044
+ return Object.assign(
1045
+ createRuntimeOwnedResultEnvelope(`${message.type}.result`, id),
1046
+ IDENTITY_SAFE_DEFAULTS[message.type]
1047
+ );
1048
+ }
1049
+
924
1050
  // src/identity-handler.ts
925
1051
  function createIdentityHandler(context) {
926
1052
  return function handleIdentityMessage(windowId, msg) {
@@ -930,14 +1056,12 @@ function createIdentityHandler(context) {
930
1056
  identityService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
931
1057
  return;
932
1058
  }
933
- const id = msg.id ?? "";
934
1059
  const action = msg.type.slice("identity.".length);
935
1060
  const signer = hooks.auth.getSigner();
936
- const sendError = (error) => {
937
- hooks.sendToNapplet(windowId, { type: `${msg.type}.error`, id, error });
938
- };
939
- const sendResult = (payload) => {
940
- hooks.sendToNapplet(windowId, { type: `${msg.type}.result`, id, ...payload });
1061
+ const fallback = createCanonicalDomainResult(msg);
1062
+ if (!fallback) return;
1063
+ const sendResult = (payload = {}) => {
1064
+ hooks.sendToNapplet(windowId, { ...fallback, ...payload });
941
1065
  };
942
1066
  switch (action) {
943
1067
  case "getPublicKey":
@@ -945,14 +1069,14 @@ function createIdentityHandler(context) {
945
1069
  sendResult({ pubkey: "" });
946
1070
  return;
947
1071
  }
948
- Promise.resolve(signer.getPublicKey?.()).then((pubkey) => sendResult({ pubkey: pubkey ?? "" })).catch((err) => sendError(err?.message ?? "getPublicKey failed"));
1072
+ Promise.resolve(signer.getPublicKey?.()).then((pubkey) => sendResult({ pubkey: pubkey ?? "" })).catch(() => sendResult({ pubkey: "" }));
949
1073
  return;
950
1074
  case "getRelays":
951
1075
  if (!signer) {
952
- sendError("no signer configured");
1076
+ sendResult();
953
1077
  return;
954
1078
  }
955
- Promise.resolve(signer.getRelays?.() ?? {}).then((relays) => sendResult({ relays })).catch((err) => sendError(err?.message ?? "getRelays failed"));
1079
+ Promise.resolve(signer.getRelays?.() ?? {}).then((relays) => sendResult({ relays })).catch(() => sendResult());
956
1080
  return;
957
1081
  case "getProfile":
958
1082
  sendResult({ profile: null });
@@ -976,7 +1100,7 @@ function createIdentityHandler(context) {
976
1100
  sendResult({ badges: [] });
977
1101
  return;
978
1102
  default:
979
- sendError(`Unknown identity action: ${action}`);
1103
+ return;
980
1104
  }
981
1105
  };
982
1106
  }
@@ -1043,7 +1167,8 @@ function isPlainObject(value) {
1043
1167
  }
1044
1168
 
1045
1169
  // src/inc-handler.ts
1046
- function createIncRuntime(hooks, sessionRegistry) {
1170
+ var MAX_CHANNELS_PER_WINDOW = 32;
1171
+ function createIncRuntime(hooks, sessionRegistry, authorizeChannelOpen) {
1047
1172
  const state = {
1048
1173
  subscriptions: /* @__PURE__ */ new Map(),
1049
1174
  channels: /* @__PURE__ */ new Map(),
@@ -1051,12 +1176,15 @@ function createIncRuntime(hooks, sessionRegistry) {
1051
1176
  };
1052
1177
  return {
1053
1178
  handleMessage(windowId, msg) {
1054
- handleIncMessage(state, hooks, sessionRegistry, windowId, msg);
1179
+ handleIncMessage(state, hooks, sessionRegistry, authorizeChannelOpen, windowId, msg);
1055
1180
  },
1056
1181
  destroyWindow(windowId) {
1057
1182
  removeWindowChannels(state, hooks, windowId);
1058
1183
  removeWindowSubscriptions(state, windowId);
1059
1184
  },
1185
+ revokeWindow(windowId) {
1186
+ revokeWindowChannels(state, hooks, windowId);
1187
+ },
1060
1188
  clear() {
1061
1189
  state.subscriptions.clear();
1062
1190
  state.channels.clear();
@@ -1094,18 +1222,18 @@ function peerOf(state, channelId, self) {
1094
1222
  return null;
1095
1223
  }
1096
1224
  function resolveTarget(sessionRegistry, target) {
1097
- if (sessionRegistry.getEntryByWindowId(target)) return target;
1098
- const entries = sessionRegistry.getAllEntries();
1099
- const byPubkey = entries.find((entry) => entry.pubkey === target);
1100
- return byPubkey?.windowId ?? null;
1225
+ return sessionRegistry.getWindowIdByDTag(target) ?? null;
1101
1226
  }
1102
- function handleIncMessage(state, hooks, sessionRegistry, windowId, msg) {
1227
+ function hasReachedChannelLimit(state, windowId) {
1228
+ return (state.channelsByWindow.get(windowId)?.size ?? 0) >= MAX_CHANNELS_PER_WINDOW;
1229
+ }
1230
+ function handleIncMessage(state, hooks, sessionRegistry, authorizeChannelOpen, windowId, msg) {
1103
1231
  const m = msg;
1104
1232
  const dotIdx = msg.type.indexOf(".");
1105
1233
  const action = msg.type.slice(dotIdx + 1);
1106
1234
  switch (action) {
1107
1235
  case "emit":
1108
- handleEmit(state, hooks, windowId, m);
1236
+ handleEmit(state, hooks, sessionRegistry, windowId, m);
1109
1237
  return;
1110
1238
  case "subscribe":
1111
1239
  handleSubscribe(state, hooks, windowId, m);
@@ -1114,16 +1242,16 @@ function handleIncMessage(state, hooks, sessionRegistry, windowId, msg) {
1114
1242
  handleUnsubscribe(state, windowId, m);
1115
1243
  return;
1116
1244
  case "channel.open":
1117
- handleChannelOpen(state, hooks, sessionRegistry, windowId, m);
1245
+ handleChannelOpen(state, hooks, sessionRegistry, authorizeChannelOpen, windowId, m);
1118
1246
  return;
1119
1247
  case "channel.emit":
1120
- handleChannelEmit(state, hooks, windowId, m);
1248
+ handleChannelEmit(state, hooks, sessionRegistry, windowId, m);
1121
1249
  return;
1122
1250
  case "channel.broadcast":
1123
- handleChannelBroadcast(state, hooks, windowId, m);
1251
+ handleChannelBroadcast(state, hooks, sessionRegistry, windowId, m);
1124
1252
  return;
1125
1253
  case "channel.list":
1126
- handleChannelList(state, hooks, windowId, m);
1254
+ handleChannelList(state, hooks, sessionRegistry, windowId, m);
1127
1255
  return;
1128
1256
  case "channel.close":
1129
1257
  handleChannelClose(state, hooks, windowId, m);
@@ -1132,14 +1260,16 @@ function handleIncMessage(state, hooks, sessionRegistry, windowId, msg) {
1132
1260
  return;
1133
1261
  }
1134
1262
  }
1135
- function handleEmit(state, hooks, windowId, m) {
1263
+ function handleEmit(state, hooks, sessionRegistry, windowId, m) {
1136
1264
  const topic = m.topic ?? "";
1265
+ const sender = sessionRegistry.getEntryByWindowId(windowId)?.dTag;
1266
+ if (!sender) return;
1137
1267
  if (!topic) return;
1138
1268
  const subscribers = state.subscriptions.get(topic);
1139
1269
  if (!subscribers) return;
1140
1270
  for (const subscriberWindowId of subscribers) {
1141
1271
  if (subscriberWindowId !== windowId) {
1142
- hooks.sendToNapplet(subscriberWindowId, { type: "inc.event", topic, payload: m.payload, sender: windowId });
1272
+ hooks.sendToNapplet(subscriberWindowId, { type: "inc.event", topic, payload: m.payload, sender });
1143
1273
  }
1144
1274
  }
1145
1275
  }
@@ -1166,51 +1296,96 @@ function handleUnsubscribe(state, windowId, m) {
1166
1296
  subscriptions.delete(windowId);
1167
1297
  if (subscriptions.size === 0) state.subscriptions.delete(topic);
1168
1298
  }
1169
- function handleChannelOpen(state, hooks, sessionRegistry, windowId, m) {
1299
+ function handleChannelOpen(state, hooks, sessionRegistry, authorizeChannelOpen, windowId, m) {
1170
1300
  const id = m.id ?? "";
1171
1301
  const peerWindow = resolveTarget(sessionRegistry, m.target ?? "");
1172
- if (!peerWindow) {
1302
+ const openerDTag = sessionRegistry.getEntryByWindowId(windowId)?.dTag;
1303
+ const peerDTag = peerWindow ? sessionRegistry.getEntryByWindowId(peerWindow)?.dTag : void 0;
1304
+ if (!peerWindow || !openerDTag || !peerDTag) {
1173
1305
  hooks.sendToNapplet(windowId, { type: "inc.channel.open.result", id, error: "target not found" });
1174
1306
  return;
1175
1307
  }
1308
+ if (!authorizeChannelOpen(peerWindow, m)) {
1309
+ hooks.sendToNapplet(windowId, { type: "inc.channel.open.result", id, error: "target denied" });
1310
+ return;
1311
+ }
1312
+ if (hasReachedChannelLimit(state, windowId) || hasReachedChannelLimit(state, peerWindow)) {
1313
+ hooks.sendToNapplet(windowId, { type: "inc.channel.open.result", id, error: "channel limit reached" });
1314
+ return;
1315
+ }
1176
1316
  const channelId = hooks.crypto.randomUUID().replace(/-/g, "").slice(0, 32);
1177
1317
  addChannel(state, channelId, windowId, peerWindow);
1178
- hooks.sendToNapplet(windowId, { type: "inc.channel.open.result", id, channelId, peer: peerWindow });
1318
+ try {
1319
+ hooks.sendToNapplet(peerWindow, { type: "inc.channel.opened", channelId, peer: openerDTag });
1320
+ } catch {
1321
+ removeChannel(state, channelId);
1322
+ hooks.sendToNapplet(windowId, { type: "inc.channel.open.result", id, error: "target unavailable" });
1323
+ return;
1324
+ }
1325
+ try {
1326
+ hooks.sendToNapplet(windowId, { type: "inc.channel.open.result", id, channelId, peer: peerDTag });
1327
+ } catch {
1328
+ teardownChannel(state, hooks, channelId);
1329
+ }
1179
1330
  }
1180
- function handleChannelEmit(state, hooks, windowId, m) {
1331
+ function handleChannelEmit(state, hooks, sessionRegistry, windowId, m) {
1181
1332
  const peer = peerOf(state, m.channelId ?? "", windowId);
1182
- if (peer) {
1183
- hooks.sendToNapplet(peer, { type: "inc.channel.event", channelId: m.channelId ?? "", sender: windowId, payload: m.payload });
1333
+ const sender = sessionRegistry.getEntryByWindowId(windowId)?.dTag;
1334
+ if (peer && sender) {
1335
+ hooks.sendToNapplet(peer, { type: "inc.channel.event", channelId: m.channelId ?? "", sender, payload: m.payload });
1184
1336
  }
1185
1337
  }
1186
- function handleChannelBroadcast(state, hooks, windowId, m) {
1338
+ function handleChannelBroadcast(state, hooks, sessionRegistry, windowId, m) {
1187
1339
  const channels = state.channelsByWindow.get(windowId);
1188
- if (!channels) return;
1340
+ const sender = sessionRegistry.getEntryByWindowId(windowId)?.dTag;
1341
+ if (!channels || !sender) return;
1189
1342
  for (const channelId of channels) {
1190
1343
  const peer = peerOf(state, channelId, windowId);
1191
1344
  if (peer) {
1192
- hooks.sendToNapplet(peer, { type: "inc.channel.event", channelId, sender: windowId, payload: m.payload });
1345
+ hooks.sendToNapplet(peer, { type: "inc.channel.event", channelId, sender, payload: m.payload });
1193
1346
  }
1194
1347
  }
1195
1348
  }
1196
- function handleChannelList(state, hooks, windowId, m) {
1349
+ function handleChannelList(state, hooks, sessionRegistry, windowId, m) {
1197
1350
  const channels = [];
1198
1351
  const set = state.channelsByWindow.get(windowId);
1199
1352
  if (set) {
1200
1353
  for (const channelId of set) {
1201
1354
  const peer = peerOf(state, channelId, windowId);
1202
- if (peer) channels.push({ id: channelId, peer });
1355
+ const peerDTag = peer ? sessionRegistry.getEntryByWindowId(peer)?.dTag : void 0;
1356
+ if (peerDTag) channels.push({ id: channelId, peer: peerDTag });
1203
1357
  }
1204
1358
  }
1205
1359
  hooks.sendToNapplet(windowId, { type: "inc.channel.list.result", id: m.id ?? "", channels });
1206
1360
  }
1207
1361
  function handleChannelClose(state, hooks, windowId, m) {
1208
1362
  const channelId = m.channelId ?? "";
1209
- const peer = peerOf(state, channelId, windowId);
1210
- if (!peer) return;
1211
- hooks.sendToNapplet(windowId, { type: "inc.channel.closed", channelId });
1212
- hooks.sendToNapplet(peer, { type: "inc.channel.closed", channelId });
1213
- removeChannel(state, channelId);
1363
+ if (!peerOf(state, channelId, windowId)) return;
1364
+ teardownChannel(state, hooks, channelId);
1365
+ }
1366
+ function teardownChannel(state, hooks, channelId, destroyedWindowId) {
1367
+ const channel = state.channels.get(channelId);
1368
+ if (!channel) return;
1369
+ try {
1370
+ if (destroyedWindowId) {
1371
+ const survivor = peerOf(state, channelId, destroyedWindowId);
1372
+ if (survivor) {
1373
+ try {
1374
+ hooks.sendToNapplet(survivor, { type: "inc.channel.closed", channelId, reason: "peer destroyed" });
1375
+ } catch {
1376
+ }
1377
+ }
1378
+ return;
1379
+ }
1380
+ for (const peer of [channel.peerA, channel.peerB]) {
1381
+ try {
1382
+ hooks.sendToNapplet(peer, { type: "inc.channel.closed", channelId });
1383
+ } catch {
1384
+ }
1385
+ }
1386
+ } finally {
1387
+ removeChannel(state, channelId);
1388
+ }
1214
1389
  }
1215
1390
  function removeWindowSubscriptions(state, windowId) {
1216
1391
  for (const [topic, subscriptions] of state.subscriptions) {
@@ -1222,11 +1397,14 @@ function removeWindowChannels(state, hooks, windowId) {
1222
1397
  const channelIds = state.channelsByWindow.get(windowId);
1223
1398
  if (!channelIds) return;
1224
1399
  for (const channelId of Array.from(channelIds)) {
1225
- const peer = peerOf(state, channelId, windowId);
1226
- if (peer) {
1227
- hooks.sendToNapplet(peer, { type: "inc.channel.closed", channelId });
1228
- }
1229
- removeChannel(state, channelId);
1400
+ teardownChannel(state, hooks, channelId, windowId);
1401
+ }
1402
+ }
1403
+ function revokeWindowChannels(state, hooks, windowId) {
1404
+ const channelIds = state.channelsByWindow.get(windowId);
1405
+ if (!channelIds) return;
1406
+ for (const channelId of Array.from(channelIds)) {
1407
+ teardownChannel(state, hooks, channelId);
1230
1408
  }
1231
1409
  }
1232
1410
 
@@ -1377,9 +1555,6 @@ function cleanupNappState(pubkey, dTag, aggregateHash, statePersistence) {
1377
1555
  }
1378
1556
 
1379
1557
  // src/domain-handlers.ts
1380
- var THEME_FALLBACK_DEFAULT = {
1381
- colors: { background: "#0a0a0a", text: "#e0e0e0", primary: "#7aa2f7" }
1382
- };
1383
1558
  function createRuntimeDomainHandlers(context) {
1384
1559
  return {
1385
1560
  storage: (windowId, msg) => handleStorageMessage(context, windowId, msg),
@@ -1441,8 +1616,9 @@ function handleMediaMessage(context, windowId, msg) {
1441
1616
  }
1442
1617
  }
1443
1618
  function handleKeysMessage(context, windowId, msg) {
1444
- const { hooks, serviceRegistry } = context;
1619
+ const { hooks, serviceRegistry, sessionRegistry } = context;
1445
1620
  if (msg.type === "keys.forward") {
1621
+ if (!sessionRegistry.getEntryByWindowId(windowId)) return;
1446
1622
  forwardHotkey(hooks, msg);
1447
1623
  return;
1448
1624
  }
@@ -1469,8 +1645,7 @@ function sendRegisterActionResult(hooks, windowId, msg) {
1469
1645
  hooks.sendToNapplet(windowId, {
1470
1646
  type: "keys.registerAction.result",
1471
1647
  id: m.id ?? "",
1472
- actionId: m.action?.id ?? "",
1473
- ...m.action?.defaultKey ? { binding: m.action.defaultKey } : {}
1648
+ actionId: m.action?.id ?? ""
1474
1649
  });
1475
1650
  }
1476
1651
  function handleNotifyMessage(context, windowId, msg) {
@@ -1495,16 +1670,13 @@ function handleThemeMessage(context, windowId, msg) {
1495
1670
  themeService.handleMessage(windowId, msg, (resp) => hooks.sendToNapplet(windowId, resp));
1496
1671
  return;
1497
1672
  }
1498
- if (msg.type === "theme.get") {
1499
- const m = msg;
1500
- hooks.sendToNapplet(windowId, {
1501
- type: "theme.get.result",
1502
- id: m.id ?? "",
1503
- theme: THEME_FALLBACK_DEFAULT
1504
- });
1505
- }
1673
+ const fallback = createCanonicalDomainResult(msg);
1674
+ if (fallback) hooks.sendToNapplet(windowId, fallback);
1506
1675
  }
1507
1676
  function handleServiceOnlyMessage(context, name, windowId, msg) {
1677
+ if (name === "intent" && msg.type !== "intent.invoke" && msg.type !== "intent.available" && msg.type !== "intent.handlers") {
1678
+ return;
1679
+ }
1508
1680
  const service = context.serviceRegistry[name];
1509
1681
  if (!service) return;
1510
1682
  service.handleMessage(windowId, msg, (resp) => context.hooks.sendToNapplet(windowId, resp));
@@ -1592,7 +1764,20 @@ function buildObservation(envelope, windowId, senderCap, sessionRegistry, getFoc
1592
1764
  const focus = getFocusContext?.(windowId) ?? { focused: true };
1593
1765
  const opClass = senderCap ?? envelope.type;
1594
1766
  const { kind, size } = extractKindSize(envelope);
1595
- return { napplet, opClass, kind, size, initElapsedMs, focused: focus.focused, msSinceFocusGain: focus.msSinceFocusGain, now };
1767
+ return {
1768
+ napplet,
1769
+ opClass,
1770
+ kind,
1771
+ size,
1772
+ initElapsedMs,
1773
+ // `windowId` comes from the host's source-bound session lookup, never the
1774
+ // napplet message envelope. It scopes startup burst accounting to this
1775
+ // registered realm while rate limits remain dTag-wide.
1776
+ initKey: windowId,
1777
+ focused: focus.focused,
1778
+ msSinceFocusGain: focus.msSinceFocusGain,
1779
+ now
1780
+ };
1596
1781
  }
1597
1782
  function createFirewallGate(config) {
1598
1783
  const { firewallState, sessionRegistry, hooks, fireConsent } = config;
@@ -1603,10 +1788,25 @@ function createFirewallGate(config) {
1603
1788
  const napplet = obs.napplet;
1604
1789
  const opClass = obs.opClass;
1605
1790
  if (decision === "reject" || decision === "prompt") {
1791
+ const intentDenial = createIntentPolicyDenial(envelope);
1792
+ if (intentDenial) {
1793
+ hooks.sendToNapplet(windowId, intentDenial);
1794
+ hooks.onFirewallEvent?.({ windowId, napplet, opClass, decision, action, ruleId, reason, message: envelope });
1795
+ if (decision === "prompt") fireConsent(windowId, napplet);
1796
+ return "drop";
1797
+ }
1798
+ const canonicalResult = createCanonicalDomainResult(envelope);
1799
+ if (canonicalResult) {
1800
+ hooks.sendToNapplet(windowId, canonicalResult);
1801
+ hooks.onFirewallEvent?.({ windowId, napplet, opClass, decision, action, ruleId, reason, message: envelope });
1802
+ if (decision === "prompt") fireConsent(windowId, napplet);
1803
+ return "drop";
1804
+ }
1606
1805
  const id = envelope.id ?? "";
1607
- const isStorageEnvelope = envelope.type.startsWith("storage.");
1608
- const type = isStorageEnvelope ? `${envelope.type}.result` : `${envelope.type}.error`;
1609
- hooks.sendToNapplet(windowId, { type, id, error: `firewall: ${reason}` });
1806
+ const type = denialResponseType(envelope);
1807
+ if (type) {
1808
+ hooks.sendToNapplet(windowId, { type, id, error: `firewall: ${reason}` });
1809
+ }
1610
1810
  hooks.onFirewallEvent?.({ windowId, napplet, opClass, decision, action, ruleId, reason, message: envelope });
1611
1811
  if (decision === "prompt") {
1612
1812
  fireConsent(windowId, napplet);
@@ -1619,21 +1819,45 @@ function createFirewallGate(config) {
1619
1819
  return "dispatch";
1620
1820
  };
1621
1821
  }
1622
- function createMessageHandler(hooks, enforceNap, dispatchNapEnvelope, firewallGate) {
1822
+ function denialResponseType(envelope) {
1823
+ if (envelope.type.startsWith("intent.")) return null;
1824
+ if (envelope.type.startsWith("storage.")) return `${envelope.type}.result`;
1825
+ if (envelope.type === "inc.subscribe" || envelope.type === "inc.channel.open") {
1826
+ return `${envelope.type}.result`;
1827
+ }
1828
+ if (envelope.type.startsWith("inc.")) return null;
1829
+ return `${envelope.type}.error`;
1830
+ }
1831
+ function createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnvelope, firewallGate) {
1623
1832
  return (windowId, msg) => {
1624
1833
  if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
1625
1834
  const envelope = msg;
1626
1835
  const dotIdx = envelope.type.indexOf(".");
1627
1836
  if (dotIdx === -1) return;
1837
+ if (!sessionRegistry.getEntryByWindowId(windowId)) return;
1838
+ const domain = envelope.type.slice(0, dotIdx);
1839
+ if (hooks.isDomainAllowed && !hooks.isDomainAllowed(windowId, domain)) return;
1840
+ if (isIdentityOrThemeMessage(envelope) && !createCanonicalDomainResult(envelope)) return;
1628
1841
  const caps = resolveCapabilitiesNap(envelope);
1629
1842
  if (caps.senderCap) {
1630
1843
  const result = enforceNap(windowId, caps.senderCap, envelope);
1631
1844
  if (!result.allowed) {
1845
+ const intentDenial = createIntentPolicyDenial(envelope);
1846
+ if (intentDenial) {
1847
+ hooks.sendToNapplet(windowId, intentDenial);
1848
+ return;
1849
+ }
1850
+ const canonicalResult = createCanonicalDomainResult(envelope);
1851
+ if (canonicalResult) {
1852
+ hooks.sendToNapplet(windowId, canonicalResult);
1853
+ return;
1854
+ }
1632
1855
  const id = envelope.id ?? "";
1633
- const isStorageEnvelope = envelope.type.startsWith("storage.");
1634
1856
  const error = formatDenialReason(result.capability);
1635
- const type = isStorageEnvelope ? `${envelope.type}.result` : `${envelope.type}.error`;
1636
- hooks.sendToNapplet(windowId, { type, id, error });
1857
+ const type = denialResponseType(envelope);
1858
+ if (type) {
1859
+ hooks.sendToNapplet(windowId, { type, id, error });
1860
+ }
1637
1861
  return;
1638
1862
  }
1639
1863
  }
@@ -1670,12 +1894,64 @@ function createRuntimeInstance(context) {
1670
1894
  consentHandlerRef
1671
1895
  } = context;
1672
1896
  const undeclaredServiceConsents = /* @__PURE__ */ new Set();
1897
+ const attachedServices = /* @__PURE__ */ new Map();
1898
+ const isDomainAllowed = hooks.isDomainAllowed;
1899
+ const sendToNapplet = hooks.sendToNapplet;
1900
+ const serviceContext = Object.freeze({
1901
+ resolveDTag(windowId) {
1902
+ return sessionRegistry.getEntryByWindowId(windowId)?.dTag;
1903
+ },
1904
+ listWindowIds() {
1905
+ return Object.freeze(
1906
+ sessionRegistry.getAllEntries().map((entry) => entry.windowId)
1907
+ );
1908
+ },
1909
+ sendToEligibleNapplet(windowId, message) {
1910
+ const entry = sessionRegistry.getEntryByWindowId(windowId);
1911
+ if (!entry || typeof message.type !== "string") return false;
1912
+ const dotIndex = message.type.indexOf(".");
1913
+ if (dotIndex <= 0) return false;
1914
+ const domain = message.type.slice(0, dotIndex);
1915
+ try {
1916
+ if (isDomainAllowed && !isDomainAllowed(windowId, domain)) return false;
1917
+ const { recipientCap } = resolveCapabilitiesNap(message);
1918
+ if (!recipientCap) return false;
1919
+ if (!aclState.check("", entry.dTag, entry.aggregateHash, recipientCap)) {
1920
+ return false;
1921
+ }
1922
+ sendToNapplet(windowId, message);
1923
+ return true;
1924
+ } catch {
1925
+ return false;
1926
+ }
1927
+ }
1928
+ });
1929
+ function attachService(name, handler) {
1930
+ attachedServices.set(name, handler);
1931
+ try {
1932
+ handler.onRegistered?.(serviceContext);
1933
+ } catch {
1934
+ }
1935
+ }
1936
+ function detachService(name) {
1937
+ const handler = attachedServices.get(name);
1938
+ if (!handler) return;
1939
+ attachedServices.delete(name);
1940
+ try {
1941
+ handler.onUnregistered?.();
1942
+ } catch {
1943
+ }
1944
+ }
1945
+ for (const [name, handler] of Object.entries(serviceRegistry)) {
1946
+ attachService(name, handler);
1947
+ }
1673
1948
  return {
1674
1949
  handleMessage: context.handleMessage,
1675
1950
  injectEvent(topic, payload) {
1676
1951
  eventBuffer.bufferAndDeliver(createInjectedEvent(hooks, topic, payload), null);
1677
1952
  },
1678
1953
  destroy() {
1954
+ for (const name of attachedServices.keys()) detachService(name);
1679
1955
  manifestCache.persist();
1680
1956
  aclState.persist();
1681
1957
  firewallState.persist();
@@ -1690,14 +1966,17 @@ function createRuntimeInstance(context) {
1690
1966
  consentHandlerRef.current = handler;
1691
1967
  },
1692
1968
  registerService(name, handler) {
1969
+ detachService(name);
1693
1970
  serviceRegistry[name] = handler;
1694
1971
  registeredServices.set(name, {
1695
1972
  name: handler.descriptor.name,
1696
1973
  version: handler.descriptor.version,
1697
1974
  description: handler.descriptor.description
1698
1975
  });
1976
+ attachService(name, handler);
1699
1977
  },
1700
1978
  unregisterService(name) {
1979
+ detachService(name);
1701
1980
  delete serviceRegistry[name];
1702
1981
  registeredServices.delete(name);
1703
1982
  },
@@ -1730,7 +2009,15 @@ function createRuntime(hooks) {
1730
2009
  const serviceRegistry = { ...hooks.services };
1731
2010
  const registeredServices = createRegisteredServices(serviceRegistry);
1732
2011
  const sessionRegistry = createSessionRegistry(hooks.onPendingUpdate);
1733
- const aclState = createAclState(hooks.aclPersistence);
2012
+ let incRuntime = null;
2013
+ const aclState = createAclState(hooks.aclPersistence, "permissive", (mutation) => {
2014
+ if (mutation.type === "revoke" && mutation.capability !== "relay:read") return;
2015
+ for (const entry of sessionRegistry.getAllEntries()) {
2016
+ if (entry.dTag === mutation.dTag && entry.aggregateHash === mutation.aggregateHash) {
2017
+ incRuntime?.revokeWindow(entry.windowId);
2018
+ }
2019
+ }
2020
+ });
1734
2021
  const firewallState = createFirewallState(hooks.firewallPersistence);
1735
2022
  const manifestCache = createManifestCache(hooks.manifestPersistence);
1736
2023
  const replayDetector = createReplayDetector(
@@ -1767,6 +2054,11 @@ function createRuntime(hooks) {
1767
2054
  },
1768
2055
  onAclCheck: hooks.onAclCheck
1769
2056
  });
2057
+ incRuntime = createIncRuntime(
2058
+ hooks,
2059
+ sessionRegistry,
2060
+ (targetWindowId, message) => enforceNap(targetWindowId, "relay:read", message).allowed
2061
+ );
1770
2062
  const firewallGate = createFirewallGate({ firewallState, sessionRegistry, hooks, fireConsent });
1771
2063
  const eventBuffer = createEventBuffer(
1772
2064
  hooks.sendToNapplet,
@@ -1778,7 +2070,6 @@ function createRuntime(hooks) {
1778
2070
  aclState.load();
1779
2071
  firewallState.load();
1780
2072
  manifestCache.load();
1781
- const incRuntime = createIncRuntime(hooks, sessionRegistry);
1782
2073
  const domainHandlers = createRuntimeDomainHandlers({ hooks, serviceRegistry, sessionRegistry, aclState });
1783
2074
  const dispatchNapEnvelope = createNapEnvelopeDispatcher({
1784
2075
  relay: createRelayHandler({ hooks, serviceRegistry, subscriptions, eventBuffer, replayDetector }),
@@ -1787,7 +2078,7 @@ function createRuntime(hooks) {
1787
2078
  inc: incRuntime.handleMessage,
1788
2079
  ...domainHandlers
1789
2080
  });
1790
- const handleMessage = createMessageHandler(hooks, enforceNap, dispatchNapEnvelope, firewallGate);
2081
+ const handleMessage = createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnvelope, firewallGate);
1791
2082
  return createRuntimeInstance({
1792
2083
  hooks,
1793
2084
  serviceRegistry,