@kehto/runtime 0.24.1 → 0.25.1

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/README.md CHANGED
@@ -40,6 +40,20 @@ The runtime is built around the current draft dispatch contract from `@napplet/c
40
40
 
41
41
  Signing is shell-mediated inside `relay.publish` / `relay.publishEncrypted` (NIP-44 default, NIP-04 opt-in). The legacy signer domain is dissolved — napplets never see a host-injected nostr object and cannot call signer-sign RPCs directly.
42
42
 
43
+ ### NAP-RELAY subscription revocation
44
+
45
+ Revoking `relay:read` or blocking a principal immediately retires ordinary relay
46
+ subscriptions in every window with that exact `dTag` and aggregate hash. The
47
+ runtime closes registered relay/cache backends and direct adapter subscriptions,
48
+ sends `relay.closed` with reason `denied: relay:read`, and suppresses queued events
49
+ and EOSE callbacks. Regranting access requires a new subscription; old callbacks
50
+ remain invalid even if the same `subId` is reused. Other principals, other artifact
51
+ versions, and revocation of unrelated capabilities leave subscriptions active.
52
+
53
+ This host policy conforms to [NAP-RELAY draft PR #2 at
54
+ `9f30b3505335a52c574ff570576687d6467021ff`](https://github.com/napplet/naps/blob/9f30b3505335a52c574ff570576687d6467021ff/naps/NAP-RELAY.md),
55
+ which permits ACL enforcement and policy-driven subscription termination.
56
+
43
57
  ### NAP-RELAY publish boundary
44
58
 
45
59
  The publish path follows draft [NAP-RELAY PR #2 at
@@ -223,10 +237,24 @@ implementations of `FirewallStateContainer` must provide this method.
223
237
  - `cleanupNappState` — remove persisted state when a napplet window closes
224
238
 
225
239
  ### Service dispatch
226
- - `routeServiceMessage` — domain-prefix router into the service registry
240
+ - `routeServiceMessage` — policy-free domain selection primitive; never a fallback for untrusted ingress
227
241
  - `notifyServiceWindowDestroyed` — lifecycle fan-out to every service handler
228
242
  - Service-only NAP domains, including `dm` and `fs`, route through handlers registered with `runtime.registerService('<domain>', handler)`.
229
243
 
244
+ For domains outside the built-in dispatcher, handlers opt in with an exact
245
+ `messageCapabilities` map (existing ACL capability or explicit `null` per inbound
246
+ type). `Runtime.handleMessage` requires a live unblocked source, an explicit
247
+ `isDomainAllowed` grant, the declared ACL grant, and firewall admission. Unknown
248
+ types and extension policy denials stay silent on the wire. Registration alone
249
+ attaches a service; it does not expose or grant a domain. Fixed NAP routes retain
250
+ their own authorization. Pending replies are invalidated by service/source
251
+ replacement or teardown and recheck current domain/ACL policy.
252
+
253
+ Browser hosts must provide the owning extension contract and inject its granted
254
+ namespace binding before scripts run. Stock Paja/playground bindings do not inject
255
+ arbitrary extensions. See [Register a service](../../docs/how-tos/register-service.md#domains-outside-the-built-in-dispatcher)
256
+ for the complete host contract and spec bounds.
257
+
230
258
  ### Types
231
259
  40+ interfaces — including `Runtime`, `RuntimeAdapter`, `SendToNapplet`, `RelayPoolAdapter`, `ServiceHandler`, `ServiceRegistry`, `NappletMessage`, `SessionEntry`, `AclEntryExternal`, `AclCheckEvent`, and the per-adapter hook types — are exported from `./types.js` for host-app integration.
232
260
 
package/dist/index.d.ts CHANGED
@@ -583,12 +583,12 @@ interface ServiceRuntimeContext {
583
583
  *
584
584
  * @example
585
585
  * ```ts
586
- * const audioHandler: ServiceHandler = {
587
- * descriptor: { name: 'audio', version: '1.0.0' },
588
- * handleMessage(windowId, message, send) {
589
- * if (message.type === 'inc.emit') {
590
- * // process audio inc event...
591
- * send({ type: 'inc.emit.result', id: (message as any).id });
586
+ * const exampleHandler: ServiceHandler = {
587
+ * descriptor: { name: 'example', version: '1.0.0' },
588
+ * messageCapabilities: { 'example.ping': null },
589
+ * handleMessage(_windowId, message, send) {
590
+ * if (message.type === 'example.ping') {
591
+ * send({ type: 'example.pong' });
592
592
  * }
593
593
  * },
594
594
  * };
@@ -597,6 +597,19 @@ interface ServiceRuntimeContext {
597
597
  interface ServiceHandler {
598
598
  /** Metadata describing this service. */
599
599
  descriptor: ServiceDescriptor;
600
+ /**
601
+ * Explicit inbound types for a domain outside the built-in dispatcher.
602
+ * Each own key is an exact `domain.action` type; its value is an existing
603
+ * ACL capability or explicit `null` for no additional capability requirement.
604
+ * Omission keeps non-built-in services attachment-only. Built-in NAP routes
605
+ * ignore this map and retain their canonical capability mapping.
606
+ *
607
+ * Extension dispatch also requires a live, unblocked source, an explicit
608
+ * `RuntimeAdapter.isDomainAllowed` grant, and firewall admission. This map
609
+ * neither injects a namespace binding nor grants any capability. The handler
610
+ * owns payload validation and its extension's response contract.
611
+ */
612
+ messageCapabilities?: Readonly<Record<string, Capability | null>>;
600
613
  /**
601
614
  * Attach this handler to one runtime instance.
602
615
  *
@@ -623,14 +636,14 @@ interface ServiceHandler {
623
636
  }
624
637
  /**
625
638
  * Registry of services available to napplets.
626
- * Each key is a service name (e.g., 'audio', 'notifications').
627
- * Napplets discover available services via kind 29010 service discovery events.
639
+ * Keys select exact wire domains for direct routing (e.g., 'notify').
640
+ * Metadata/attachment alone does not expose a domain. Custom domains require
641
+ * ServiceHandler.messageCapabilities and explicit host domain authorization.
628
642
  *
629
643
  * @example
630
644
  * ```ts
631
645
  * const services: ServiceRegistry = {
632
- * audio: audioHandler,
633
- * notifications: notificationHandler,
646
+ * notify: notificationHandler,
634
647
  * };
635
648
  * ```
636
649
  */
@@ -692,8 +705,9 @@ interface RuntimeAdapter {
692
705
  sendToNapplet: SendToNapplet;
693
706
  /**
694
707
  * Determines whether a session-established napplet may invoke a NAP domain.
695
- * When omitted, every domain remains eligible for dispatch. Browser shells
696
- * use this to enforce the immutable environment delivered in `shell.init`.
708
+ * Built-in routes remain eligible when omitted; extension routes require
709
+ * an explicit true result. Browser shells must derive this decision from
710
+ * the source-bound namespace exposed before napplet scripts execute.
697
711
  */
698
712
  isDomainAllowed?: (windowId: string, domain: string) => boolean;
699
713
  /**
@@ -792,14 +806,17 @@ interface RuntimeAdapter {
792
806
  /**
793
807
  * Optional service extensions. Shell/host registers service handlers here
794
808
  * for static initialization. Services can also be added dynamically via
795
- * runtime.registerService(). Each key is a service name (e.g., 'audio').
809
+ * runtime.registerService(). Keys select exact domains (e.g., 'notify').
810
+ * Outside built-in domains, handlers also need messageCapabilities and an
811
+ * explicit isDomainAllowed grant. Hosts own each extension's NAP contract,
812
+ * payload validation, and pre-execution browser namespace binding.
796
813
  *
797
814
  * @example
798
815
  * ```ts
799
816
  * const hooks: RuntimeAdapter = {
800
817
  * // ... required adapters ...
801
818
  * services: {
802
- * audio: myAudioServiceHandler,
819
+ * notify: myNotificationServiceHandler,
803
820
  * },
804
821
  * };
805
822
  * ```
@@ -1456,7 +1473,11 @@ interface Runtime {
1456
1473
  * Register a service handler dynamically after runtime creation.
1457
1474
  * If a handler is already registered for this name, it is replaced.
1458
1475
  *
1459
- * @param name - Service name (e.g., 'audio', 'notifications')
1476
+ * Built-in domains keep their NAP routing. Other domains require the
1477
+ * handler's exact messageCapabilities map and an explicit host domain grant.
1478
+ * Registration never injects namespace bindings or grants capabilities.
1479
+ *
1480
+ * @param name - Exact domain name (e.g., 'notify' or a host extension)
1460
1481
  * @param handler - The service handler implementation
1461
1482
  */
1462
1483
  registerService(name: string, handler: ServiceHandler): void;
@@ -1580,9 +1601,12 @@ declare function cleanupNappState(pubkey: string, dTag: string, aggregateHash: s
1580
1601
  * Route a NappletMessage envelope to the matching service handler.
1581
1602
  *
1582
1603
  * Direct NAP-domain services are routed by the domain in their wire
1583
- * message.type (e.g., 'signer.signEvent' -> 'signer' service). INC messages
1604
+ * message.type (e.g., 'notify.send' -> 'notify' service). INC messages
1584
1605
  * are owned by the INC runtime; their topic values are opaque here.
1585
1606
  *
1607
+ * This low-level selection primitive performs no source, domain, ACL,
1608
+ * messageCapabilities, or firewall checks. Never call it as a fallback for
1609
+ * untrusted input; host ingress must use Runtime.handleMessage.
1586
1610
  * Returns true if a service handled the message, false otherwise.
1587
1611
  *
1588
1612
  * @param windowId - The napplet window that sent the message
package/dist/index.js CHANGED
@@ -565,11 +565,52 @@ function relayEventResultFromCarrier(carrier) {
565
565
  // src/runtime.ts
566
566
  import { createDispatch } from "@napplet/core";
567
567
 
568
+ // src/service-extensions.ts
569
+ import { ALL_CAPABILITIES } from "@kehto/acl/capabilities";
570
+ function createServiceExtensionRouter(context) {
571
+ const { hooks, services, registrations, sessions, acl, enforce, firewall } = context;
572
+ const windowLifetimes = /* @__PURE__ */ new Map();
573
+ function handleMessage(windowId, message) {
574
+ const dot = message.type.indexOf(".");
575
+ const domain = message.type.slice(0, dot);
576
+ if (dot <= 0 || dot === message.type.length - 1 || !/^[a-z][a-z0-9-]*$/.test(domain) || !Object.hasOwn(services, domain)) return;
577
+ const handler = services[domain];
578
+ const policies = handler.messageCapabilities;
579
+ if (!policies || !Object.hasOwn(policies, message.type)) return;
580
+ const capability = policies[message.type];
581
+ if (capability !== null && !ALL_CAPABILITIES.includes(capability)) return;
582
+ const session = sessions.getEntryByWindowId(windowId);
583
+ const registration = registrations.get(domain);
584
+ if (!session || !registration) return;
585
+ const allowed = () => hooks.isDomainAllowed?.(windowId, domain) === true && !acl.isBlocked("", session.dTag, session.aggregateHash) && (capability === null || enforce(windowId, capability, message).allowed);
586
+ if (!allowed() || firewall(windowId, message, capability, false) === "drop") return;
587
+ let lifetime = windowLifetimes.get(windowId);
588
+ if (!lifetime) {
589
+ lifetime = {};
590
+ windowLifetimes.set(windowId, lifetime);
591
+ }
592
+ handler.handleMessage(windowId, message, (response) => {
593
+ if (registrations.get(domain) !== registration || services[domain] !== handler || windowLifetimes.get(windowId) !== lifetime || sessions.getEntryByWindowId(windowId) !== session) return;
594
+ if (!response || typeof response.type !== "string" || !response.type.startsWith(`${domain}.`) || response.type.length <= dot + 1) return;
595
+ if (allowed()) hooks.sendToNapplet(windowId, response);
596
+ });
597
+ }
598
+ return {
599
+ handleMessage,
600
+ destroyWindow(windowId) {
601
+ windowLifetimes.delete(windowId);
602
+ },
603
+ destroy() {
604
+ windowLifetimes.clear();
605
+ }
606
+ };
607
+ }
608
+
568
609
  // src/service-dispatch.ts
569
610
  function routeServiceMessage(windowId, message, services, sendToNapplet) {
570
611
  if (message.type === "inc.emit") return false;
571
612
  const domain = message.type.split(".")[0];
572
- const handler = services[domain];
613
+ const handler = Object.hasOwn(services, domain) ? services[domain] : void 0;
573
614
  if (handler) {
574
615
  handler.handleMessage(windowId, message, (msg) => sendToNapplet(windowId, msg));
575
616
  return true;
@@ -633,12 +674,17 @@ function handleRelaySubscribe(context, windowId, msg, m) {
633
674
  const filters = m.filters ?? [];
634
675
  if (!subId) return;
635
676
  const subKey = `${windowId}:${subId}`;
636
- subscriptions.set(subKey, { windowId, filters });
677
+ const entry = { windowId, filters };
678
+ subscriptions.set(subKey, entry);
679
+ const isActive = () => subscriptions.get(subKey) === entry && context.canReadRelay(windowId);
680
+ const send = (response) => {
681
+ if (isActive()) hooks.sendToNapplet(windowId, normalizeRelayServiceResponse(response));
682
+ };
637
683
  const seenIds = /* @__PURE__ */ new Set();
638
684
  function deliver(event, relayHints) {
639
685
  if (seenIds.has(event.id)) return;
640
686
  seenIds.add(event.id);
641
- if (subscriptions.has(subKey)) {
687
+ if (isActive()) {
642
688
  sendRelayEvent(hooks, windowId, subId, createRelayEventResultWithHints(event, relayHints));
643
689
  }
644
690
  }
@@ -649,22 +695,16 @@ function handleRelaySubscribe(context, windowId, msg, m) {
649
695
  const relayService = relayServiceFrom(context);
650
696
  const cacheService = !serviceRegistry["relay"] ? serviceRegistry["cache"] : void 0;
651
697
  if (!isShellKind && relayService) {
652
- relayService.handleMessage(windowId, msg, (resp) => {
653
- if (!subscriptions.has(subKey)) return;
654
- hooks.sendToNapplet(windowId, normalizeRelayServiceResponse(resp));
655
- });
656
- if (cacheService) {
657
- cacheService.handleMessage(windowId, msg, (resp) => {
658
- if (!subscriptions.has(subKey)) return;
659
- hooks.sendToNapplet(windowId, normalizeRelayServiceResponse(resp));
660
- });
661
- }
698
+ relayService.handleMessage(windowId, msg, send);
699
+ if (cacheService && isActive()) cacheService.handleMessage(windowId, msg, send);
700
+ if (!isActive()) closeRelayBackends(context, windowId, subId, subKey);
662
701
  return;
663
702
  }
664
703
  const relayHint = typeof m.relay === "string" && m.relay.length > 0 ? m.relay : void 0;
665
- deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, isShellKind, deliver, relayHint);
704
+ if (!isActive()) return;
705
+ deliverFromRuntimeBackends(context, subId, subKey, filters, isShellKind, deliver, send, isActive, relayHint);
666
706
  }
667
- function deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, isShellKind, deliver, relayHint) {
707
+ function deliverFromRuntimeBackends(context, subId, subKey, filters, isShellKind, deliver, send, isActive, relayHint) {
668
708
  const { hooks } = context;
669
709
  const cache = hooks.cache;
670
710
  if (cache?.isAvailable() && !isShellKind) {
@@ -675,7 +715,7 @@ function deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, i
675
715
  }
676
716
  const pool = hooks.relayPool;
677
717
  if (!pool?.isAvailable() && !isShellKind) {
678
- hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
718
+ send({ type: "relay.eose", subId });
679
719
  return;
680
720
  }
681
721
  if (!pool?.isAvailable() || isShellKind) return;
@@ -684,15 +724,16 @@ function deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, i
684
724
  const eoseFallbackTimer = setTimeout(() => {
685
725
  if (!eoseSent) {
686
726
  eoseSent = true;
687
- hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
727
+ send({ type: "relay.eose", subId });
688
728
  }
689
729
  }, 15e3);
690
730
  const subscription = pool.subscribe(filters, (item) => {
731
+ if (!isActive()) return;
691
732
  if (item === "EOSE") {
692
733
  clearTimeout(eoseFallbackTimer);
693
734
  if (!eoseSent) {
694
735
  eoseSent = true;
695
- hooks.sendToNapplet(windowId, { type: "relay.eose", subId });
736
+ send({ type: "relay.eose", subId });
696
737
  }
697
738
  return;
698
739
  }
@@ -710,6 +751,31 @@ function deliverFromRuntimeBackends(context, windowId, subId, subKey, filters, i
710
751
  clearTimeout(eoseFallbackTimer);
711
752
  subscription.unsubscribe();
712
753
  });
754
+ if (!isActive()) pool.untrackSubscription(subKey);
755
+ }
756
+ function closeRelayBackends(context, windowId, subId, subKey) {
757
+ const relay = context.serviceRegistry["relay"] ?? context.serviceRegistry["relay-pool"];
758
+ const cache = !context.serviceRegistry["relay"] ? context.serviceRegistry["cache"] : void 0;
759
+ for (const service of /* @__PURE__ */ new Set([relay, cache])) {
760
+ try {
761
+ service?.handleMessage(windowId, { type: "relay.close", subId }, () => {
762
+ });
763
+ } catch {
764
+ }
765
+ }
766
+ try {
767
+ context.hooks.relayPool?.untrackSubscription(subKey);
768
+ } catch {
769
+ }
770
+ }
771
+ function revokeRelaySubscriptions(context, windowId) {
772
+ const entries = [...context.subscriptions].filter(([, entry]) => entry.windowId === windowId);
773
+ for (const [key] of entries) context.subscriptions.delete(key);
774
+ for (const [key] of entries) {
775
+ const subId = key.slice(windowId.length + 1);
776
+ closeRelayBackends(context, windowId, subId, key);
777
+ context.hooks.sendToNapplet(windowId, { type: "relay.closed", subId, reason: "denied: relay:read" });
778
+ }
713
779
  }
714
780
  function handleRelayClose(context, windowId, msg, m) {
715
781
  const { hooks, subscriptions } = context;
@@ -1850,13 +1916,18 @@ function buildObservation(envelope, windowId, senderCap, sessionRegistry, getFoc
1850
1916
  }
1851
1917
  function createFirewallGate(config) {
1852
1918
  const { firewallState, sessionRegistry, hooks, fireConsent } = config;
1853
- return function firewallGate(windowId, envelope, senderCap) {
1919
+ return function firewallGate(windowId, envelope, senderCap, respond = true) {
1854
1920
  const obs = buildObservation(envelope, windowId, senderCap, sessionRegistry, hooks.getFocusContext);
1855
1921
  const result = firewallState.evaluate(obs);
1856
1922
  const { decision, action, ruleId, reason } = result;
1857
1923
  const napplet = obs.napplet;
1858
1924
  const opClass = obs.opClass;
1859
1925
  if (decision === "reject" || decision === "prompt") {
1926
+ if (!respond) {
1927
+ hooks.onFirewallEvent?.({ windowId, napplet, opClass, decision, action, ruleId, reason, message: envelope });
1928
+ if (decision === "prompt") fireConsent(windowId, napplet);
1929
+ return "drop";
1930
+ }
1860
1931
  const intentDenial = createIntentPolicyDenial(envelope);
1861
1932
  if (intentDenial) {
1862
1933
  hooks.sendToNapplet(windowId, intentDenial);
@@ -1907,7 +1978,7 @@ function createDenialResponse(envelope, error) {
1907
1978
  const type = denialResponseType(envelope);
1908
1979
  return type ? { type, id, error } : null;
1909
1980
  }
1910
- function createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnvelope, firewallGate) {
1981
+ function createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnvelope, serviceRouting, firewallGate) {
1911
1982
  return (windowId, msg) => {
1912
1983
  if (typeof msg !== "object" || msg === null || Array.isArray(msg) || !("type" in msg) || typeof msg.type !== "string") return;
1913
1984
  const envelope = msg;
@@ -1915,6 +1986,10 @@ function createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnv
1915
1986
  if (dotIdx === -1) return;
1916
1987
  if (!sessionRegistry.getEntryByWindowId(windowId)) return;
1917
1988
  const domain = envelope.type.slice(0, dotIdx);
1989
+ if (!serviceRouting.builtInDomains.has(domain)) {
1990
+ serviceRouting.extensions.handleMessage(windowId, envelope);
1991
+ return;
1992
+ }
1918
1993
  if (hooks.isDomainAllowed && !hooks.isDomainAllowed(windowId, domain)) return;
1919
1994
  if (isIdentityOrThemeMessage(envelope) && !createCanonicalDomainResult(envelope)) return;
1920
1995
  const caps = resolveCapabilitiesNap(envelope);
@@ -1967,7 +2042,8 @@ function createRuntimeInstance(context) {
1967
2042
  serviceRegistry,
1968
2043
  sessionRegistry,
1969
2044
  subscriptions,
1970
- consentHandlerRef
2045
+ consentHandlerRef,
2046
+ extensions
1971
2047
  } = context;
1972
2048
  const undeclaredServiceConsents = /* @__PURE__ */ new Set();
1973
2049
  const attachedServices = /* @__PURE__ */ new Map();
@@ -2031,6 +2107,8 @@ function createRuntimeInstance(context) {
2031
2107
  eventBuffer.bufferAndDeliver(createInjectedEvent(hooks, topic, payload), null);
2032
2108
  },
2033
2109
  destroy() {
2110
+ extensions.destroy();
2111
+ registeredServices.clear();
2034
2112
  for (const name of attachedServices.keys()) detachService(name);
2035
2113
  manifestCache.persist();
2036
2114
  aclState.persist();
@@ -2039,13 +2117,13 @@ function createRuntimeInstance(context) {
2039
2117
  subscriptions.clear();
2040
2118
  incRuntime.clear();
2041
2119
  eventBuffer.clear();
2042
- registeredServices.clear();
2043
2120
  undeclaredServiceConsents.clear();
2044
2121
  },
2045
2122
  registerConsentHandler(handler) {
2046
2123
  consentHandlerRef.current = handler;
2047
2124
  },
2048
2125
  registerService(name, handler) {
2126
+ registeredServices.delete(name);
2049
2127
  detachService(name);
2050
2128
  serviceRegistry[name] = handler;
2051
2129
  registeredServices.set(name, {
@@ -2056,11 +2134,12 @@ function createRuntimeInstance(context) {
2056
2134
  attachService(name, handler);
2057
2135
  },
2058
2136
  unregisterService(name) {
2137
+ registeredServices.delete(name);
2059
2138
  detachService(name);
2060
2139
  delete serviceRegistry[name];
2061
- registeredServices.delete(name);
2062
2140
  },
2063
2141
  destroyWindow(windowId) {
2142
+ extensions.destroyWindow(windowId);
2064
2143
  for (const [key] of subscriptions) {
2065
2144
  if (key.startsWith(`${windowId}:`)) {
2066
2145
  subscriptions.delete(key);
@@ -2089,14 +2168,17 @@ function createRuntime(hooks) {
2089
2168
  const serviceRegistry = { ...hooks.services };
2090
2169
  const registeredServices = createRegisteredServices(serviceRegistry);
2091
2170
  const firewallState = createFirewallState(hooks.firewallPersistence);
2171
+ let extensions;
2092
2172
  const sessionRegistry = createSessionRegistry(hooks.onPendingUpdate, (windowId) => {
2093
2173
  firewallState.resetInitBudget(windowId);
2174
+ extensions?.destroyWindow(windowId);
2094
2175
  });
2095
2176
  let incRuntime = null;
2096
2177
  const aclState = createAclState(hooks.aclPersistence, "permissive", (mutation) => {
2097
2178
  if (mutation.type === "revoke" && mutation.capability !== "relay:read") return;
2098
2179
  for (const entry of sessionRegistry.getAllEntries()) {
2099
2180
  if (entry.dTag === mutation.dTag && entry.aggregateHash === mutation.aggregateHash) {
2181
+ revokeRelaySubscriptions({ hooks, serviceRegistry, subscriptions }, entry.windowId);
2100
2182
  incRuntime?.revokeWindow(entry.windowId);
2101
2183
  }
2102
2184
  }
@@ -2153,14 +2235,35 @@ function createRuntime(hooks) {
2153
2235
  firewallState.load();
2154
2236
  manifestCache.load();
2155
2237
  const domainHandlers = createRuntimeDomainHandlers({ hooks, serviceRegistry, sessionRegistry, aclState });
2156
- const dispatchNapEnvelope = createNapEnvelopeDispatcher({
2157
- relay: createRelayHandler({ hooks, serviceRegistry, subscriptions, eventBuffer, replayDetector }),
2238
+ const napHandlers = {
2239
+ relay: createRelayHandler({
2240
+ hooks,
2241
+ serviceRegistry,
2242
+ subscriptions,
2243
+ eventBuffer,
2244
+ replayDetector,
2245
+ canReadRelay(windowId) {
2246
+ const entry = sessionRegistry.getEntryByWindowId(windowId);
2247
+ return !!entry && aclState.check("", entry.dTag, entry.aggregateHash, "relay:read");
2248
+ }
2249
+ }),
2158
2250
  identity: createIdentityHandler({ hooks, serviceRegistry }),
2159
2251
  count: createCountHandler({ hooks, serviceRegistry }),
2160
2252
  inc: incRuntime.handleMessage,
2161
2253
  ...domainHandlers
2254
+ };
2255
+ const dispatchNapEnvelope = createNapEnvelopeDispatcher(napHandlers);
2256
+ const builtInDomains = /* @__PURE__ */ new Set([...Object.keys(napHandlers), "shell", "signer"]);
2257
+ extensions = createServiceExtensionRouter({
2258
+ hooks,
2259
+ services: serviceRegistry,
2260
+ registrations: registeredServices,
2261
+ sessions: sessionRegistry,
2262
+ acl: aclState,
2263
+ enforce: enforceNap,
2264
+ firewall: firewallGate
2162
2265
  });
2163
- const handleMessage = createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnvelope, firewallGate);
2266
+ const handleMessage = createMessageHandler(hooks, sessionRegistry, enforceNap, dispatchNapEnvelope, { builtInDomains, extensions }, firewallGate);
2164
2267
  return createRuntimeInstance({
2165
2268
  hooks,
2166
2269
  serviceRegistry,
@@ -2174,14 +2277,15 @@ function createRuntime(hooks) {
2174
2277
  firewallState,
2175
2278
  manifestCache,
2176
2279
  consentHandlerRef,
2177
- handleMessage
2280
+ handleMessage,
2281
+ extensions
2178
2282
  });
2179
2283
  }
2180
2284
 
2181
2285
  // src/index.ts
2182
- import { ALL_CAPABILITIES } from "@kehto/acl/capabilities";
2286
+ import { ALL_CAPABILITIES as ALL_CAPABILITIES2 } from "@kehto/acl/capabilities";
2183
2287
  export {
2184
- ALL_CAPABILITIES,
2288
+ ALL_CAPABILITIES2 as ALL_CAPABILITIES,
2185
2289
  RING_BUFFER_SIZE,
2186
2290
  cleanupNappState,
2187
2291
  createAclState,