@kehto/runtime 0.25.1 → 0.26.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
@@ -40,6 +40,68 @@ 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
+ ### Storage quota administration
44
+
45
+ Hosts can call `runtime.aclState.setStateQuota(pubkey, dTag, aggregateHash, bytes)`
46
+ to update the exact principal's live storage limit. `bytes` must be an integer
47
+ from zero through `Number.MAX_SAFE_INTEGER`; malformed input throws `RangeError`
48
+ before changing policy. Call `aclState.persist()` separately to save the quota.
49
+ The default remains 524,288 bytes. Grants and block status are preserved.
50
+
51
+ The existing storage handler consumes this same quota across shared and instance
52
+ storage for that principal, counting UTF-8 bytes including scoped storage keys.
53
+ Lowering a quota preserves stored values; writes that exceed the new limit fail
54
+ until data is removed or the host increases the quota. Zero prevents nonempty
55
+ storage writes while reads/removals remain subject to their existing ACL checks.
56
+
57
+ This host administration API conforms to [NAP-STORAGE draft PR #3 at
58
+ `4f4cf0ca7ecbd24c284a5d63bcd85e3de6af37ca`](https://github.com/napplet/naps/blob/4f4cf0ca7ecbd24c284a5d63bcd85e3de6af37ca/naps/NAP-STORAGE.md).
59
+ It adds no napplet-callable quota setter or wire field.
60
+
61
+ ### ACL principal enumeration
62
+
63
+ `aclState.getAllEntries()` and `getEntry()` return `dTag` and `aggregateHash`
64
+ alongside the policy fields. Use these values with keyed ACL methods; two
65
+ principals with identical grants still have distinct identities after reload.
66
+ Enumeration keeps `pubkey: ''` because ACL identity is keyed by dTag and artifact
67
+ hash. Returned objects and capability arrays are detached from the owned state.
68
+
69
+ ```ts
70
+ for (const entry of runtime.aclState.getAllEntries()) {
71
+ runtime.aclState.revoke(entry.pubkey, entry.dTag, entry.aggregateHash, 'relay:read');
72
+ }
73
+ ```
74
+
75
+ This host API adds no protocol messages and does not change serialized ACL keys.
76
+ Custom `AclEntryExternal` producers must populate the two required identity fields.
77
+
78
+ ### ACL persistence outcomes
79
+
80
+ ACL mutations update live policy immediately. Revoke/block observers run
81
+ synchronously after that change, before any explicit save. `aclState.persist()`
82
+ returns `true` when the persistence backend completes and `false` if it throws.
83
+ A failed save never rolls back live policy or reactivates retired subscriptions;
84
+ a newly loaded container can still see the last successfully saved policy.
85
+
86
+ `aclState.clear()` resets live policy to its configured default and attempts to
87
+ clear storage, returning the same success boolean. A failed clear leaves that
88
+ live reset in place and old durable data unchanged. Retry persistence or clearing
89
+ after handling the backend failure. These results report the synchronous backend
90
+ contract, not physical disk durability. Hosts must not swallow backend errors if
91
+ they want the container to detect failure.
92
+
93
+ ```ts
94
+ runtime.aclState.revoke('', dTag, aggregateHash, 'relay:read');
95
+ if (!runtime.aclState.persist()) {
96
+ showSaveError('Access is revoked for this session, but the policy was not saved.');
97
+ }
98
+ ```
99
+
100
+ Runtime destruction remains best-effort; hosts requiring an actionable save
101
+ result should call `persist()` before teardown. This host API policy is outside
102
+ the NAP wire contract. No old-use warning can detect callers ignoring a return
103
+ value; see [the compatibility register](https://github.com/kehto/web/blob/main/docs/compatibility.md).
104
+
43
105
  ### NAP-RELAY subscription revocation
44
106
 
45
107
  Revoking `relay:read` or blocking a principal immediately retires ordinary relay
@@ -205,6 +267,11 @@ runtime.handleMessage('window-1', {
205
267
  - `createSessionRegistry` — bidirectional windowId ↔ `SessionEntry` store
206
268
  - `createNappKeyRegistry` — deprecated alias retained for v1.1 migration consumers
207
269
 
270
+ `unregister(windowId)` retires the window's registration, lookup state, and pending
271
+ update, including NIP-5D sessions whose pubkey is empty. Repeated calls are safe.
272
+ If a newer window owns the same pubkey lookup, that newer entry is preserved;
273
+ use `getEntryByWindowId` to distinguish live windows sharing an empty pubkey.
274
+
208
275
  `runtime.sessionRegistry.register(windowId, entry)` establishes a fresh trusted
209
276
  source lifecycle and retires that window's previous initialization burst budget,
210
277
  including when the host reuses the window ID, timestamp, or entry object. Ordinary
package/dist/index.d.ts CHANGED
@@ -530,6 +530,10 @@ type PendingUpdateNotifier = (windowId: string) => void;
530
530
  /** External ACL entry — used in shell commands (shell:acl-get etc.). */
531
531
  interface AclEntryExternal {
532
532
  pubkey: string;
533
+ /** Verified napplet identifier. */
534
+ dTag: string;
535
+ /** Verified artifact hash. */
536
+ aggregateHash: string;
533
537
  capabilities: Capability[];
534
538
  blocked: boolean;
535
539
  stateQuota?: number;
@@ -959,7 +963,7 @@ declare function formatDenialReason(capability: Capability): string;
959
963
  interface SessionRegistry {
960
964
  /** Establish a fresh host-attested lifecycle, mapping windowId to pubkey and vice versa. */
961
965
  register(windowId: string, entry: SessionEntry): void;
962
- /** Unregister a napplet by windowId, removing both mappings. */
966
+ /** Remove a window and its owned mappings, including empty-pubkey sessions. */
963
967
  unregister(windowId: string): void;
964
968
  /** Get the pubkey associated with a windowId. */
965
969
  getPubkey(windowId: string): string | undefined;
@@ -1037,7 +1041,7 @@ type AclMutation = {
1037
1041
  readonly dTag: string;
1038
1042
  readonly aggregateHash: string;
1039
1043
  };
1040
- /** Receives synchronous ACL authority changes after persistence succeeds. */
1044
+ /** Receives synchronous in-memory revoke/block changes, before any explicit persistence. */
1041
1045
  type AclMutationObserver = (mutation: AclMutation) => void;
1042
1046
  /**
1043
1047
  * ACL state container — wraps @kehto/acl's pure functions with
@@ -1060,9 +1064,40 @@ interface AclStateContainer {
1060
1064
  getEntry(pubkey: string, dTag: string, aggregateHash: string): AclEntryExternal | undefined;
1061
1065
  getAllEntries(): AclEntryExternal[];
1062
1066
  getStateQuota(pubkey: string, dTag: string, aggregateHash: string): number;
1063
- persist(): void;
1067
+ /**
1068
+ * Set a principal's live storage quota without changing grants or stored data.
1069
+ * Call persist() separately to save the policy.
1070
+ * @param pubkey - Retained identity argument; policy is keyed by dTag and hash.
1071
+ * @param dTag - Verified napplet identifier.
1072
+ * @param aggregateHash - Verified artifact hash.
1073
+ * @param bytes - Integer byte count from zero through Number.MAX_SAFE_INTEGER.
1074
+ * @throws RangeError if bytes is not a non-negative safe integer.
1075
+ * @example
1076
+ * ```ts
1077
+ * acl.setStateQuota('', dTag, aggregateHash, 1024 * 1024);
1078
+ * acl.persist();
1079
+ * ```
1080
+ */
1081
+ setStateQuota(pubkey: string, dTag: string, aggregateHash: string, bytes: number): void;
1082
+ /**
1083
+ * Save live state without rolling back live policy on failure.
1084
+ * @returns True if the backend completed; false if it threw.
1085
+ * @example
1086
+ * ```ts
1087
+ * if (!acl.persist()) showSaveError();
1088
+ * ```
1089
+ */
1090
+ persist(): boolean;
1064
1091
  load(): void;
1065
- clear(): void;
1092
+ /**
1093
+ * Reset live state to the configured default and attempt to clear storage.
1094
+ * @returns True if storage cleared; false if only the live reset succeeded.
1095
+ * @example
1096
+ * ```ts
1097
+ * if (!acl.clear()) showSaveError();
1098
+ * ```
1099
+ */
1100
+ clear(): boolean;
1066
1101
  }
1067
1102
  /**
1068
1103
  * Create an ACL state container backed by @kehto/acl and persisted
package/dist/index.js CHANGED
@@ -51,10 +51,10 @@ function createSessionRegistry(notifier, onRegister) {
51
51
  },
52
52
  unregister(windowId) {
53
53
  const pubkey = byWindowId.get(windowId);
54
- if (pubkey) {
54
+ if (pubkey !== void 0 && byPubkey.get(pubkey)?.windowId === windowId) {
55
55
  byPubkey.delete(pubkey);
56
- byWindowId.delete(windowId);
57
56
  }
57
+ byWindowId.delete(windowId);
58
58
  byWindowIdEntry.delete(windowId);
59
59
  pendingUpdates.delete(windowId);
60
60
  },
@@ -122,6 +122,7 @@ import {
122
122
  serialize,
123
123
  deserialize,
124
124
  getQuota,
125
+ setQuota,
125
126
  CAP_RELAY_READ,
126
127
  CAP_RELAY_WRITE,
127
128
  CAP_CACHE_READ,
@@ -237,15 +238,20 @@ function createAclState(persistence, defaultPolicy = "permissive", onMutation) {
237
238
  if (!entry) return void 0;
238
239
  return {
239
240
  pubkey: pubkey || "",
241
+ dTag,
242
+ aggregateHash,
240
243
  capabilities: bitsToCapabilities(entry.caps),
241
244
  blocked: entry.blocked,
242
245
  stateQuota: entry.quota
243
246
  };
244
247
  },
245
248
  getAllEntries() {
246
- return Object.entries(state.entries).map(([, entry]) => {
249
+ return Object.entries(state.entries).map(([key, entry]) => {
250
+ const separator = key.lastIndexOf(":");
247
251
  return {
248
252
  pubkey: "",
253
+ dTag: key.slice(0, separator),
254
+ aggregateHash: key.slice(separator + 1),
249
255
  capabilities: bitsToCapabilities(entry.caps),
250
256
  blocked: entry.blocked,
251
257
  stateQuota: entry.quota
@@ -256,10 +262,20 @@ function createAclState(persistence, defaultPolicy = "permissive", onMutation) {
256
262
  const id = toIdentity(pubkey, dTag, aggregateHash);
257
263
  return getQuota(state, id);
258
264
  },
265
+ setStateQuota(pubkey, dTag, aggregateHash, bytes) {
266
+ if (!Number.isSafeInteger(bytes) || bytes < 0) {
267
+ throw new RangeError("State quota must be a non-negative safe integer byte count");
268
+ }
269
+ const id = toIdentity(pubkey, dTag, aggregateHash);
270
+ ensureRuntimeDefaultEntry(id);
271
+ state = setQuota(state, id, bytes);
272
+ },
259
273
  persist() {
260
274
  try {
261
275
  persistence.persist(serialize(state));
276
+ return true;
262
277
  } catch {
278
+ return false;
263
279
  }
264
280
  },
265
281
  load() {
@@ -275,7 +291,9 @@ function createAclState(persistence, defaultPolicy = "permissive", onMutation) {
275
291
  state = createState(defaultPolicy);
276
292
  try {
277
293
  persistence.persist("");
294
+ return true;
278
295
  } catch {
296
+ return false;
279
297
  }
280
298
  }
281
299
  };