@kehto/runtime 0.25.2 → 0.26.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,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
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;
@@ -633,6 +637,8 @@ interface ServiceHandler {
633
637
  * @param windowId - The destroyed napplet's window identifier
634
638
  */
635
639
  onWindowDestroyed?(windowId: string): void;
640
+ /** Revoke live output when this endpoint loses a capability or is blocked. */
641
+ onCapabilityRevoked?(windowId: string, capability: Capability | null): void;
636
642
  }
637
643
  /**
638
644
  * Registry of services available to napplets.
@@ -1037,7 +1043,7 @@ type AclMutation = {
1037
1043
  readonly dTag: string;
1038
1044
  readonly aggregateHash: string;
1039
1045
  };
1040
- /** Receives synchronous ACL authority changes after persistence succeeds. */
1046
+ /** Receives synchronous in-memory revoke/block changes, before any explicit persistence. */
1041
1047
  type AclMutationObserver = (mutation: AclMutation) => void;
1042
1048
  /**
1043
1049
  * ACL state container — wraps @kehto/acl's pure functions with
@@ -1060,9 +1066,40 @@ interface AclStateContainer {
1060
1066
  getEntry(pubkey: string, dTag: string, aggregateHash: string): AclEntryExternal | undefined;
1061
1067
  getAllEntries(): AclEntryExternal[];
1062
1068
  getStateQuota(pubkey: string, dTag: string, aggregateHash: string): number;
1063
- persist(): void;
1069
+ /**
1070
+ * Set a principal's live storage quota without changing grants or stored data.
1071
+ * Call persist() separately to save the policy.
1072
+ * @param pubkey - Retained identity argument; policy is keyed by dTag and hash.
1073
+ * @param dTag - Verified napplet identifier.
1074
+ * @param aggregateHash - Verified artifact hash.
1075
+ * @param bytes - Integer byte count from zero through Number.MAX_SAFE_INTEGER.
1076
+ * @throws RangeError if bytes is not a non-negative safe integer.
1077
+ * @example
1078
+ * ```ts
1079
+ * acl.setStateQuota('', dTag, aggregateHash, 1024 * 1024);
1080
+ * acl.persist();
1081
+ * ```
1082
+ */
1083
+ setStateQuota(pubkey: string, dTag: string, aggregateHash: string, bytes: number): void;
1084
+ /**
1085
+ * Save live state without rolling back live policy on failure.
1086
+ * @returns True if the backend completed; false if it threw.
1087
+ * @example
1088
+ * ```ts
1089
+ * if (!acl.persist()) showSaveError();
1090
+ * ```
1091
+ */
1092
+ persist(): boolean;
1064
1093
  load(): void;
1065
- clear(): void;
1094
+ /**
1095
+ * Reset live state to the configured default and attempt to clear storage.
1096
+ * @returns True if storage cleared; false if only the live reset succeeded.
1097
+ * @example
1098
+ * ```ts
1099
+ * if (!acl.clear()) showSaveError();
1100
+ * ```
1101
+ */
1102
+ clear(): boolean;
1066
1103
  }
1067
1104
  /**
1068
1105
  * Create an ACL state container backed by @kehto/acl and persisted
package/dist/index.js CHANGED
@@ -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
  };
@@ -1737,8 +1755,8 @@ function handleMediaMessage(context, windowId, msg) {
1737
1755
  hooks.sendToNapplet(windowId, {
1738
1756
  type: "media.session.create.result",
1739
1757
  id: m.id ?? "",
1740
- sessionId: m.sessionId ?? "",
1741
- owner: m.owner
1758
+ owner: m.owner,
1759
+ error: "media service unavailable"
1742
1760
  });
1743
1761
  }
1744
1762
  }
@@ -2175,11 +2193,15 @@ function createRuntime(hooks) {
2175
2193
  });
2176
2194
  let incRuntime = null;
2177
2195
  const aclState = createAclState(hooks.aclPersistence, "permissive", (mutation) => {
2178
- if (mutation.type === "revoke" && mutation.capability !== "relay:read") return;
2179
2196
  for (const entry of sessionRegistry.getAllEntries()) {
2180
2197
  if (entry.dTag === mutation.dTag && entry.aggregateHash === mutation.aggregateHash) {
2181
- revokeRelaySubscriptions({ hooks, serviceRegistry, subscriptions }, entry.windowId);
2182
- incRuntime?.revokeWindow(entry.windowId);
2198
+ for (const service of Object.values(serviceRegistry)) {
2199
+ service.onCapabilityRevoked?.(entry.windowId, mutation.type === "block" ? null : mutation.capability);
2200
+ }
2201
+ if (mutation.type === "block" || mutation.capability === "relay:read") {
2202
+ revokeRelaySubscriptions({ hooks, serviceRegistry, subscriptions }, entry.windowId);
2203
+ incRuntime?.revokeWindow(entry.windowId);
2204
+ }
2183
2205
  }
2184
2206
  }
2185
2207
  });