@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/README.md CHANGED
@@ -12,6 +12,13 @@ Browser-agnostic protocol engine for the napplet protocol.
12
12
  pnpm add @kehto/runtime
13
13
  ```
14
14
 
15
+ ## Published Napplet Compatibility
16
+
17
+ `@kehto/runtime` publishes against `@napplet/core` and `@napplet/nap`
18
+ `>=0.29.0 <0.30.0`. The exact installed convention contracts are core/nap
19
+ 0.29.0 from source `dd7b3a728eb9c838b7218fcec7bb7bb00e7cc88b` and release
20
+ `60889f1c2476e063500c7ab6624af6abe0dbcbe5`.
21
+
15
22
  ## Overview
16
23
 
17
24
  `@kehto/runtime` is Kehto's NIP-5D protocol engine. It owns every incoming napplet message, gates it through the ACL enforcement layer, routes it to the correct NAP handler, and emits the corresponding reply envelope.
@@ -26,12 +33,114 @@ The runtime is built around the current draft dispatch contract from `@napplet/c
26
33
  - **relay** — `relay.publish`, `relay.publishEncrypted`, `relay.subscribe`
27
34
  - **dm** — `dm.status`, `dm.conversations`, `dm.messages`, `dm.send`, runtime service dispatch
28
35
  - **storage** — `storage.get/set/remove/keys` with quota enforcement
29
- - **theme** — `theme.get`, `theme.changed` fan-out
36
+ - **theme** — `theme.get` and automatic recipient-authorized `theme.changed`
30
37
 
31
38
  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.
32
39
 
40
+ ### NAP-RELAY publish boundary
41
+
42
+ The publish path follows draft [NAP-RELAY PR #2 at
43
+ `0be8abce18beb46ca37bd4ddd042f58d30b4eedc`](https://github.com/napplet/naps/pull/2).
44
+ A napplet submits an `EventTemplate`; the runtime obtains the active
45
+ shell-owned signer, signs once, and gives only the resulting `NostrEvent` to
46
+ the relay backend. Success returns exactly one correlated
47
+ `relay.publish.result` with `ok: true`, the full signed `event`, and `eventId`.
48
+ Signing, replay, relay, and service failures return `ok: false` plus `error`
49
+ and are never buffered as successful local publications.
50
+
51
+ The released `@napplet/nap@0.29.0` SDK accepts `EventTemplate`, while its
52
+ `RelayPublishMessage.event` declaration still says `NostrEvent`; Kehto records
53
+ that mismatch as upstream package drift and follows the NAP wire direction.
54
+
55
+ ## Identity and theme result policy
56
+
57
+ This runtime follows NAP-IDENTITY and NAP-THEME at `napplet/naps` master
58
+ `5ac0490461ca6fec2f0d2e45b4835cf9bc08de24`.
59
+
60
+ - `identity.getPublicKey` always yields exactly one correlated
61
+ `identity.getPublicKey.result`; no signer or a signer failure is
62
+ `pubkey: ""`. Other supported readonly identity operations yield their
63
+ matching result shapes with safe defaults, while unsupported identity actions
64
+ are silently ignored.
65
+ - `theme.get` uses a complete three-color result. For denied or unavailable
66
+ reads, Kehto's explicit policy is one fixed non-sensitive normal result with
67
+ no `error` field. This reconciles the draft's error-only example without
68
+ emitting `theme.*.error` or inventing a mixed theme/error payload.
69
+ - The runtime neither accepts nor creates identity/theme subscription messages.
70
+ Change delivery is host-owned and recipient-gated by the shell bridge; it is
71
+ not routed through NAP-INC or NAP-INTENT.
72
+
73
+ Phase 105 adopts the published `@napplet/*` convention package line; archived
74
+ phase records remain history rather than active compatibility guidance.
75
+
76
+ ## NAP-INTENT runtime boundary
77
+
78
+ Phase 104 implements the draft [NAP-INTENT PR #91 at
79
+ `a718915ddefa2f03a0126579601f59d8bd86f7c4`](https://github.com/napplet/naps/pull/91).
80
+ The runtime accepts only source-side `intent.invoke`, `intent.available`, and
81
+ `intent.handlers` requests. It validates the exact queryless
82
+ `napplet:<archetype>/<action>` identity, derives the sender dTag from the live
83
+ authenticated session, and shapes policy denials as sanctioned result
84
+ envelopes without exposing ACL or firewall details.
85
+
86
+ Registered services receive a frozen `ServiceRuntimeContext`: current dTag
87
+ resolution, a frozen live-window snapshot, and recipient-policy-aware sends.
88
+ An `intent.invoke.result` with `ok: true` means only that a host controller has
89
+ already retained delivery responsibility. The service sends that result before
90
+ starting the retained task; target startup, readiness, reuse, replacement,
91
+ retry, and terminal failure remain private host policy. A ready target receives
92
+ one no-ID `intent.deliver` with the runtime-attested sender, never a visible INC
93
+ carrier or window/lifecycle fields.
94
+
95
+ The canonical public `Intent*` contracts are the released `@napplet/core` /
96
+ `@napplet/nap` declarations; Kehto retains no local type mirror. An accepted
97
+ result transfers delivery responsibility before a retained task starts, and a
98
+ post-acceptance outcome never creates a second source result.
99
+
100
+ Phase 105 completed released package adoption and persistent installed-manifest
101
+ controllers for the live Paja and playground hosts.
102
+
103
+ ## NAP-INC Contract
104
+
105
+ The active INC boundary follows merged
106
+ [`naps/NAP-INC.md`](https://github.com/napplet/naps/blob/6461e4b37c29dc09a20dff35d9515889c4433874/naps/NAP-INC.md)
107
+ on `napplet/naps` master
108
+ `6461e4b37c29dc09a20dff35d9515889c4433874`. The document remains marked
109
+ draft, but the merged path is the protocol authority.
110
+
111
+ The released projection binding converts a convention query to a text payload
112
+ map before sending `inc.emit`. Runtime `inc-handler` routing then uses an exact
113
+ queryless topic identity, with no query-bearing normalized wire/discovery
114
+ identity, prefix/wildcard/query-aware matching, generic or service-over-INC
115
+ prefix dispatch, or runtime payload-kind inference. The runtime derives the
116
+ **runtime-attested dTag** from the registered source; callers never supply a
117
+ sender, topic delivery excludes its source, and IDs/payloads are opaque.
118
+
119
+ Channels apply **open-time authorization** only: ACL and target liveness are
120
+ checked at `inc.channel.open`, never per later message. The target receives
121
+ `inc.channel.opened` before the opener result and both sides get symmetric
122
+ handles with `onOpened`, `on`, and `onClosed`; early events and terminal closure
123
+ are retained in order, bounded overflow closes the channel, and close or endpoint
124
+ destruction tears it down deterministically. `channel.list` is informational
125
+ only. Track draft follow-up through
126
+ [`kehto/web#203`](https://github.com/kehto/web/issues/203) and [its upstream
127
+ resolution reply](https://github.com/kehto/web/issues/203#issuecomment-5060904495),
128
+ not the obsolete opener-only model.
129
+
130
+ This is INC documentation only. **Phase 104** owns every public #91 NAP-INTENT
131
+ binding/resolution/delivery change; **Phase 105** records the completed released
132
+ package adoption. Historical changelogs and archived planning are preserved
133
+ records, not targets for active-surface edits.
134
+
33
135
  Everything plugs into a single factory, `createRuntime()`, via a `RuntimeAdapter` hook bag — persistence, relay pool, auth, services, and so on. No DOM, no postMessage, no localStorage: those live in `@kehto/shell`.
34
136
 
137
+ `RelayPoolAdapter.publish()` may return a promise. The runtime waits for that
138
+ promise before reporting publish success, buffers only successful publishes,
139
+ and releases failed replay reservations so a deterministic signed event can be
140
+ retried without allowing concurrent duplicate publication.
141
+ `publishToScopedRelay()` may similarly return `Promise<boolean>` when a host
142
+ needs transport settlement before reporting the scoped publication outcome.
143
+
35
144
  ## Quick Start
36
145
 
37
146
  ```ts
package/dist/index.d.ts CHANGED
@@ -205,8 +205,13 @@ interface RelayPoolAdapter {
205
205
  * Returns a handle that can cancel the subscription.
206
206
  */
207
207
  subscribe(filters: NostrFilter[], callback: (item: NostrEvent | 'EOSE') => void, relayUrls?: string[]): RelaySubscriptionHandle;
208
- /** Publish an event to relays. */
209
- publish(event: NostrEvent): void;
208
+ /**
209
+ * Publish an event to relays.
210
+ *
211
+ * Async adapters return a promise that settles only after the transport has
212
+ * accepted or rejected the publish.
213
+ */
214
+ publish(event: NostrEvent): void | Promise<void>;
210
215
  /** Select relay URLs appropriate for the given filters. */
211
216
  selectRelayTier(filters: NostrFilter[]): string[];
212
217
  /** Track a subscription key for lifecycle management. */
@@ -217,8 +222,13 @@ interface RelayPoolAdapter {
217
222
  openScopedRelay(windowId: string, relayUrl: string, subId: string, filters: NostrFilter[], sendToNapplet: SendToNapplet): void;
218
223
  /** Close a scoped relay connection. */
219
224
  closeScopedRelay(windowId: string): void;
220
- /** Publish to a scoped relay. Returns false if no active scoped relay. */
221
- publishToScopedRelay(windowId: string, event: NostrEvent): boolean;
225
+ /**
226
+ * Publish to a scoped relay.
227
+ *
228
+ * Async adapters settle the returned promise after transport acceptance.
229
+ * Returns false if no active scoped relay or publication fails.
230
+ */
231
+ publishToScopedRelay(windowId: string, event: NostrEvent): boolean | Promise<boolean>;
222
232
  /** Whether a relay pool is available. */
223
233
  isAvailable(): boolean;
224
234
  }
@@ -517,6 +527,40 @@ interface AclEntryExternal {
517
527
  blocked: boolean;
518
528
  stateQuota?: number;
519
529
  }
530
+ /**
531
+ * Narrow runtime-owned access granted to a registered service.
532
+ *
533
+ * The context exposes current immutable identity values and policy-aware target
534
+ * delivery without exposing the mutable session registry, ACL state, adapter
535
+ * hooks, or raw transport.
536
+ */
537
+ interface ServiceRuntimeContext {
538
+ /**
539
+ * Resolve a currently registered window to its authenticated napplet dTag.
540
+ *
541
+ * @param windowId - Internal live window identifier.
542
+ * @returns The authenticated dTag, or `undefined` when no live session exists.
543
+ */
544
+ resolveDTag(windowId: string): string | undefined;
545
+ /**
546
+ * List current registered window identifiers.
547
+ *
548
+ * @returns A fresh frozen snapshot of live window identifiers.
549
+ */
550
+ listWindowIds(): readonly string[];
551
+ /**
552
+ * Send a runtime-originated message after current recipient policy checks.
553
+ *
554
+ * The message must have a recipient capability mapping. The target must be a
555
+ * live session whose immutable domain environment and current ACL grant both
556
+ * permit the message.
557
+ *
558
+ * @param windowId - Intended live recipient window.
559
+ * @param message - Runtime-originated NIP-5D envelope.
560
+ * @returns `true` only when the message was delivered to the adapter.
561
+ */
562
+ sendToEligibleNapplet(windowId: string, message: NappletMessage): boolean;
563
+ }
520
564
  /**
521
565
  * Handler for service-specific messages from napplets.
522
566
  * Services receive NIP-5D NappletMessage envelopes and respond via the `send` callback.
@@ -538,6 +582,14 @@ interface AclEntryExternal {
538
582
  interface ServiceHandler {
539
583
  /** Metadata describing this service. */
540
584
  descriptor: ServiceDescriptor;
585
+ /**
586
+ * Attach this handler to one runtime instance.
587
+ *
588
+ * @param context - Narrow runtime-owned identity and recipient-send access.
589
+ */
590
+ onRegistered?(context: ServiceRuntimeContext): void;
591
+ /** Release runtime attachment state when replaced, unregistered, or destroyed. */
592
+ onUnregistered?(): void;
541
593
  /**
542
594
  * Handle a NIP-5D envelope from a napplet.
543
595
  *
@@ -623,6 +675,12 @@ interface RuntimeConfigOverrides {
623
675
  interface RuntimeAdapter {
624
676
  /** Send a NIP-01 message to a napplet by windowId. */
625
677
  sendToNapplet: SendToNapplet;
678
+ /**
679
+ * Determines whether a session-established napplet may invoke a NAP domain.
680
+ * When omitted, every domain remains eligible for dispatch. Browser shells
681
+ * use this to enforce the immutable environment delivered in `shell.init`.
682
+ */
683
+ isDomainAllowed?: (windowId: string, domain: string) => boolean;
626
684
  /**
627
685
  * Relay pool operations.
628
686
  * Optional when a 'relay' or 'relay-pool' service is registered via
@@ -877,6 +935,17 @@ interface SessionRegistry {
877
935
  getEntry(pubkey: string): SessionEntry | undefined;
878
936
  /** Get the windowId for a napplet pubkey. */
879
937
  getWindowId(pubkey: string): string | undefined;
938
+ /**
939
+ * Resolve an unambiguous live napplet dTag to its internal window ID.
940
+ *
941
+ * A duplicate dTag fails closed rather than selecting an arbitrary session.
942
+ * Window IDs and pubkeys remain internal transport identities and never
943
+ * resolve through this lookup.
944
+ *
945
+ * @param dTag - Runtime-attested napplet dTag
946
+ * @returns The sole owning window ID, or undefined when missing or ambiguous
947
+ */
948
+ getWindowIdByDTag(dTag: string): string | undefined;
880
949
  /** Check if a windowId has a registered napplet. */
881
950
  isRegistered(windowId: string): boolean;
882
951
  /** Get all registered napplet entries. */
@@ -924,6 +993,19 @@ declare const createNappKeyRegistry: typeof createSessionRegistry;
924
993
  * AclPersistence. No localStorage or DOM references.
925
994
  */
926
995
 
996
+ /** An ACL authority change that can invalidate runtime-owned routes. */
997
+ type AclMutation = {
998
+ readonly type: 'revoke';
999
+ readonly dTag: string;
1000
+ readonly aggregateHash: string;
1001
+ readonly capability: Capability;
1002
+ } | {
1003
+ readonly type: 'block';
1004
+ readonly dTag: string;
1005
+ readonly aggregateHash: string;
1006
+ };
1007
+ /** Receives synchronous ACL authority changes after persistence succeeds. */
1008
+ type AclMutationObserver = (mutation: AclMutation) => void;
927
1009
  /**
928
1010
  * ACL state container — wraps @kehto/acl's pure functions with
929
1011
  * persistence and a convenient imperative API.
@@ -955,6 +1037,7 @@ interface AclStateContainer {
955
1037
  *
956
1038
  * @param persistence - Storage backend for ACL state
957
1039
  * @param defaultPolicy - Default ACL policy for unknown identities
1040
+ * @param onMutation - Internal runtime observer for revoke and block facts
958
1041
  * @returns An AclStateContainer instance
959
1042
  *
960
1043
  * @example
@@ -963,7 +1046,7 @@ interface AclStateContainer {
963
1046
  * aclState.load();
964
1047
  * ```
965
1048
  */
966
- declare function createAclState(persistence: AclPersistence, defaultPolicy?: 'permissive' | 'restrictive'): AclStateContainer;
1049
+ declare function createAclState(persistence: AclPersistence, defaultPolicy?: 'permissive' | 'restrictive', onMutation?: AclMutationObserver): AclStateContainer;
967
1050
 
968
1051
  /**
969
1052
  * firewall-state.ts — Firewall state container with persistence hooks.
@@ -1142,10 +1225,31 @@ declare function createManifestCache(persistence: ManifestPersistence): Manifest
1142
1225
  */
1143
1226
  interface ReplayDetector {
1144
1227
  /**
1145
- * Check if an event should be rejected as a replay.
1146
- * Returns null if event is valid, or a string reason if it should be rejected.
1228
+ * Check if an event should be rejected as a replay and record it as processed.
1229
+ * Returns null if the event is valid, or a string reason if it should be rejected.
1147
1230
  */
1148
1231
  check(event: NostrEvent): string | null;
1232
+ /**
1233
+ * Check an event and atomically reserve its ID for pending processing.
1234
+ * Call {@link commit} after processing succeeds or {@link release} after it fails.
1235
+ *
1236
+ * @param event - Signed event whose ID should be reserved
1237
+ * @returns null if reserved, or a string reason if it should be rejected
1238
+ */
1239
+ reserve(event: NostrEvent): string | null;
1240
+ /**
1241
+ * Commit a pending reservation as successfully processed.
1242
+ *
1243
+ * @param eventId - Reserved signed event ID
1244
+ */
1245
+ commit(eventId: string): void;
1246
+ /**
1247
+ * Release a pending reservation so a failed operation can be retried.
1248
+ * Committed event IDs are never removed by this method.
1249
+ *
1250
+ * @param eventId - Reserved signed event ID
1251
+ */
1252
+ release(eventId: string): void;
1149
1253
  /** Clear all tracked event IDs. */
1150
1254
  clear(): void;
1151
1255
  }
@@ -1447,11 +1551,9 @@ declare function cleanupNappState(pubkey: string, dTag: string, aggregateHash: s
1447
1551
  /**
1448
1552
  * Route a NappletMessage envelope to the matching service handler.
1449
1553
  *
1450
- * NAP-domain services are routed by the domain prefix of message.type
1451
- * (e.g., 'signer.signEvent' -> 'signer' service).
1452
- *
1453
- * INC-routed services receive inc.emit messages and are routed by the
1454
- * topic prefix before ':' (e.g., topic 'audio:play' -> 'audio' service).
1554
+ * Direct NAP-domain services are routed by the domain in their wire
1555
+ * message.type (e.g., 'signer.signEvent' -> 'signer' service). INC messages
1556
+ * are owned by the INC runtime; their topic values are opaque here.
1455
1557
  *
1456
1558
  * Returns true if a service handled the message, false otherwise.
1457
1559
  *
@@ -1484,4 +1586,4 @@ declare function routeServiceMessage(windowId: string, message: NappletMessage,
1484
1586
  */
1485
1587
  declare function notifyServiceWindowDestroyed(windowId: string, services: ServiceRegistry): void;
1486
1588
 
1487
- export { type AclCheckEvent, type AclChecker, type AclEntryExternal, type AclPersistence, type AclStateContainer, type AuthAdapter, type CacheAdapter, type CompatibilityReport, type ConfigAdapter, type ConsentHandler, type ConsentRequest, type CryptoAdapter, type DmAdapter, type EnforceConfig, type EnforceResult, type EventBuffer, type FirewallEvent, type FirewallPersistence, type FirewallStateContainer, type GuidPersistence, type HashVerifierAdapter, type HotkeyAdapter, type IdentityResolver, type ManifestCache, type ManifestCacheEntry, type ManifestPersistence, type NapEnforceConfig, type NappKeyEntry, type NappKeyRegistry, type PendingUpdate, type PendingUpdateNotifier, RING_BUFFER_SIZE, type RelayConfigAdapter, type RelayEventCarrier, type RelayEventResult, type RelayEventSidecar, type RelayPoolAdapter, type RelaySubscriptionHandle, type ReplayDetector, type Runtime, type RuntimeAdapter, type RuntimeConfigOverrides, type SendToNapplet, type ServiceDescriptor, type ServiceHandler, type ServiceInfo, type ServiceRegistry, type SessionEntry, type SessionRegistry, type ShellSecretPersistence, type Signer, type StatePersistence, type SubscriptionEntry, type VerificationCacheEntry, type WindowManagerAdapter, cleanupNappState, createAclState, createEnforceGate, createEventBuffer, createFirewallState, createManifestCache, createNapEnforceGate, createNappKeyRegistry, createRelayEventResult, createRelayEventResultWithHints, createReplayDetector, createRuntime, createSessionRegistry, formatDenialReason, handleStorageNap, matchesAnyFilter, matchesFilter, notifyServiceWindowDestroyed, relayEventResultFromCarrier, routeServiceMessage };
1589
+ export { type AclCheckEvent, type AclChecker, type AclEntryExternal, type AclMutation, type AclMutationObserver, type AclPersistence, type AclStateContainer, type AuthAdapter, type CacheAdapter, type CompatibilityReport, type ConfigAdapter, type ConsentHandler, type ConsentRequest, type CryptoAdapter, type DmAdapter, type EnforceConfig, type EnforceResult, type EventBuffer, type FirewallEvent, type FirewallPersistence, type FirewallStateContainer, type GuidPersistence, type HashVerifierAdapter, type HotkeyAdapter, type IdentityResolver, type ManifestCache, type ManifestCacheEntry, type ManifestPersistence, type NapEnforceConfig, type NappKeyEntry, type NappKeyRegistry, type PendingUpdate, type PendingUpdateNotifier, RING_BUFFER_SIZE, type RelayConfigAdapter, type RelayEventCarrier, type RelayEventResult, type RelayEventSidecar, type RelayPoolAdapter, type RelaySubscriptionHandle, type ReplayDetector, type Runtime, type RuntimeAdapter, type RuntimeConfigOverrides, type SendToNapplet, type ServiceDescriptor, type ServiceHandler, type ServiceInfo, type ServiceRegistry, type ServiceRuntimeContext, type SessionEntry, type SessionRegistry, type ShellSecretPersistence, type Signer, type StatePersistence, type SubscriptionEntry, type VerificationCacheEntry, type WindowManagerAdapter, cleanupNappState, createAclState, createEnforceGate, createEventBuffer, createFirewallState, createManifestCache, createNapEnforceGate, createNappKeyRegistry, createRelayEventResult, createRelayEventResultWithHints, createReplayDetector, createRuntime, createSessionRegistry, formatDenialReason, handleStorageNap, matchesAnyFilter, matchesFilter, notifyServiceWindowDestroyed, relayEventResultFromCarrier, routeServiceMessage };