@kehto/shell 0.18.0 → 0.19.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
@@ -15,9 +15,9 @@ pnpm add @kehto/shell
15
15
  ## Published Napplet Compatibility
16
16
 
17
17
  `@kehto/shell` publishes against `@napplet/core` and `@napplet/nap`
18
- `>=0.29.0 <0.30.0`. The installed core/nap 0.29.0 contracts are from source
19
- `dd7b3a728eb9c838b7218fcec7bb7bb00e7cc88b` and release
20
- `60889f1c2476e063500c7ab6624af6abe0dbcbe5`.
18
+ `>=0.31.0 <0.32.0`. The installed core/nap 0.31.0 contracts are from source
19
+ `7b675622e13870628ce174833d7b2a33cf32a0ab` and release
20
+ `03ad65b66413e5798536ef48695ffc4c2508f2c3`.
21
21
 
22
22
  ## Overview
23
23
 
@@ -28,7 +28,7 @@ The primary entry point is `createShellBridge()` — it owns the postMessage lis
28
28
  Current draft behaviors this package enforces:
29
29
 
30
30
  - The shell does not inject a host-provided nostr object into napplets — NIP-5D explicitly forbids napplet-visible signing. Napplets call `relay.publish` / `relay.publishEncrypted` and the shell mediates the signing flow internally (NIP-44 default, NIP-04 opt-in for encrypted envelopes).
31
- - `injectNappletNamespacePrelude()` implements the NIP-5D injected-domain bootstrap and mandatory NAP-SHELL shim: hosts prepend it to `srcdoc` outside verified artifact bytes, install the parent-bound `shell.init` receiver, emit one `shell.ready`, and expose callable NAP interfaces before authored scripts run. Optional namespaces are filtered to the bare-domain allowlist; `shell` is always retained. Published core 0.29.0 and shim 0.27.0 omit generic mandatory shell, so Kehto retains this host-owned prelude under NAP-SHELL `5ac0490461ca6fec2f0d2e45b4835cf9bc08de24` until an upstream correction is reviewed.
31
+ - `injectNappletNamespacePrelude()` implements the NIP-5D injected-domain bootstrap and mandatory NAP-SHELL shim: hosts prepend it to `srcdoc` outside verified artifact bytes, install the parent-bound `shell.init` receiver, emit one `shell.ready`, and expose callable NAP interfaces before authored scripts run. Optional namespaces are filtered to the bare-domain allowlist; `shell` is always retained. Published core 0.31.0 and shim 0.29.0 omit generic mandatory shell, so Kehto retains this host-owned prelude under NAP-SHELL `5ac0490461ca6fec2f0d2e45b4835cf9bc08de24` until an upstream correction is reviewed.
32
32
  - `window.napplet.shell.supports(domain)` answers synchronously and locally from the cached first `shell.init` environment. It returns `false` before `shell.init`, for unknown values, and for domains that are not live and granted to that napplet; it never sends a support-query message.
33
33
  - Five optional per-domain proxies — `createIdentityProxy`, `createThemeProxy`, `createKeysProxy`, `createMediaProxy`, `createNotifyProxy` — can be composed between napplet and runtime to intercept request traffic per NAP. They are NOT wired by default. Identity/theme proxy `emit()` compatibility members fail closed; hosts must deliver automatic changes through `ShellBridge.publishIdentityChanged()` / `publishTheme()`, which enforce live session, granted domain, and current ACL.
34
34
  - `keys.forward` is napplet-to-shell only. Active napplets suppress locally-bound keys from `keys.bindings` before forwarding; shell-initiated action triggers use `keys.action`.
@@ -37,25 +37,25 @@ Current draft behaviors this package enforces:
37
37
  ### NAP-INC binding and channel contract
38
38
 
39
39
  The injected INC binding follows merged
40
- [`naps/NAP-INC.md`](https://github.com/napplet/naps/blob/6461e4b37c29dc09a20dff35d9515889c4433874/naps/NAP-INC.md)
40
+ [`naps/NAP-INC.md`](https://github.com/napplet/naps/blob/5ac0490461ca6fec2f0d2e45b4835cf9bc08de24/naps/NAP-INC.md)
41
41
  on `napplet/naps` master
42
- `6461e4b37c29dc09a20dff35d9515889c4433874`. The specification remains marked
42
+ `5ac0490461ca6fec2f0d2e45b4835cf9bc08de24`. The specification remains marked
43
43
  draft, but its merged path is the protocol authority.
44
44
 
45
45
  The released package projection and `window.napplet.inc` binding own
46
46
  query-to-text-payload
47
47
  transposition: it converts a convention URI query before emitting a stable,
48
- exact queryless topic identity. Subscriptions reject query-bearing identities.
49
- The binding never creates a normalized query-bearing wire/discovery identity,
48
+ exact stable convention topic identity. Subscriptions reject query- or fragment-bearing
49
+ **convention** identities; arbitrary opaque topics remain byte-for-byte exact,
50
+ including `?` and `#`. The binding never creates a normalized query-bearing
51
+ wire/discovery identity,
50
52
  does not do prefix/wildcard/query-aware matching or service-over-INC prefix
51
53
  dispatch, and does not infer payload kinds. Runtime delivery supplies the
52
54
  **runtime-attested dTag**; no caller sender is accepted, topic source exclusion
53
55
  is runtime-owned, and IDs and payloads are opaque.
54
56
 
55
- Merged NAP-INC delivers one `IncEvent` to `on(topic, callback)`. Released
56
- `@napplet/nap@0.29.0` instead declares and implements a
57
- `(payload, NostrEvent)` callback. The protected binding follows the NAP;
58
- package-based consumers must bridge that upstream callback drift.
57
+ Merged NAP-INC and released `@napplet/nap@0.31.0` both deliver one `IncEvent`
58
+ to `on(topic, callback)`.
59
59
 
60
60
  For channels, runtime ACL checks are open-only rather than per-message. The
61
61
  target `inc.channel.opened` before the opener result creates an equivalent target
@@ -69,18 +69,18 @@ the prior opener-only interpretation is obsolete.
69
69
 
70
70
  ### NAP-INTENT binding and delivery
71
71
 
72
- Phase 104 implements the draft [NAP-INTENT PR #91 at
73
- `a718915ddefa2f03a0126579601f59d8bd86f7c4`](https://github.com/napplet/naps/pull/91).
74
- The protected `window.napplet.intent` binding normalizes URI invocation into
75
- exact `archetype`, `action`, queryless `convention`, and optional payload
76
- fields. It rejects fragments, malformed/repeated queries, query plus explicit
77
- payload, mismatched normalized fields, and caller-supplied sender data.
78
-
79
- Only parent-originated result, change, and delivery envelopes settle the
80
- binding. Incoming no-ID `intent.deliver` values are retained until an
81
- `onDelivery` handler is registered. The runtime, not the source, attests the
82
- sender dTag; an accepted result records retained responsibility and does not
83
- expose target window, handled, protocol, retry, or lifecycle state.
72
+ Kehto implements merged [NAP-INTENT at
73
+ `5ac0490461ca6fec2f0d2e45b4835cf9bc08de24`](https://github.com/napplet/naps/blob/5ac0490461ca6fec2f0d2e45b4835cf9bc08de24/naps/NAP-INTENT.md).
74
+ The protected `window.napplet.intent` binding accepts structured
75
+ `invoke(request)` calls and `open(archetype, payload?, opts?)`. It defaults
76
+ `action` to `open`, preserves an optional queryless convention, rejects
77
+ unsupported fields and caller-supplied sender data, and resolves only
78
+ parent-originated correlated results.
79
+
80
+ Successful results include `handled`, `handler`, `windowId`, and `convention`
81
+ after target dispatch completes. There is no `intent.deliver` or `onDelivery`;
82
+ selected targets consume their convention through the ordinary
83
+ runtime-attested INC binding.
84
84
 
85
85
  Phase 105 completed released `@napplet/*` package adoption and persistent live
86
86
  Paja/playground catalogs and target controllers. The Phase 104 Paja simulator
package/dist/index.js CHANGED
@@ -1334,9 +1334,14 @@ function nappletNamespacePrelude(domains) {
1334
1334
  function fire(message) {
1335
1335
  post(message);
1336
1336
  }
1337
+ function isConventionTopic(topic) {
1338
+ const pathEnd = topic.search(/[?#]/);
1339
+ const path = pathEnd === -1 ? topic : topic.slice(0, pathEnd);
1340
+ return /^napplet:[^/?#]+\/[^/?#]+$/.test(path);
1341
+ }
1337
1342
  function normalizeConventionUri(topic, hasExplicitPayload) {
1338
1343
  if (typeof topic !== "string") throw new TypeError("INC topic must be text");
1339
- if (!topic.startsWith("napplet:")) return { topic };
1344
+ if (!isConventionTopic(topic)) return { topic };
1340
1345
  if (topic.includes("#")) throw new TypeError("Convention topics cannot contain fragments");
1341
1346
  const queryStart = topic.indexOf("?");
1342
1347
  if (queryStart === -1) {
@@ -1345,7 +1350,7 @@ function nappletNamespacePrelude(domains) {
1345
1350
  if (hasExplicitPayload) {
1346
1351
  throw new TypeError("Convention queries cannot be combined with an explicit payload");
1347
1352
  }
1348
- const values = {};
1353
+ const entries = [];
1349
1354
  const names = /* @__PURE__ */ new Set();
1350
1355
  const query = topic.slice(queryStart + 1);
1351
1356
  if (query) {
@@ -1364,10 +1369,10 @@ function nappletNamespacePrelude(domains) {
1364
1369
  }
1365
1370
  if (names.has(name)) throw new TypeError("Convention query names must be unique");
1366
1371
  names.add(name);
1367
- values[name] = value;
1372
+ entries.push([name, value]);
1368
1373
  }
1369
1374
  }
1370
- return { topic: topic.slice(0, queryStart), payload: values };
1375
+ return { topic: topic.slice(0, queryStart), payload: Object.fromEntries(entries) };
1371
1376
  }
1372
1377
  function resultOrError(msg) {
1373
1378
  return msg;
@@ -1526,7 +1531,7 @@ function nappletNamespacePrelude(domains) {
1526
1531
  const unopenedClosed = /* @__PURE__ */ new Map();
1527
1532
  function stableSubscriptionTopic(topic) {
1528
1533
  if (typeof topic !== "string") throw new TypeError("INC topic must be text");
1529
- if (topic.includes("?") || topic.includes("#")) {
1534
+ if (isConventionTopic(topic) && (topic.includes("?") || topic.includes("#"))) {
1530
1535
  throw new TypeError("INC subscriptions require a queryless topic identity");
1531
1536
  }
1532
1537
  return topic;
@@ -1547,7 +1552,6 @@ function nappletNamespacePrelude(domains) {
1547
1552
  },
1548
1553
  on(handler) {
1549
1554
  if (typeof handler !== "function") throw new TypeError("INC channel event handler must be a function");
1550
- if (state.closed) return subscriptionHandle(() => void 0);
1551
1555
  state.eventHandlers.add(handler);
1552
1556
  const pending = state.pendingEvents.splice(0);
1553
1557
  for (const event of pending) handler(event);
@@ -2217,77 +2221,57 @@ function nappletNamespacePrelude(domains) {
2217
2221
  };
2218
2222
  }
2219
2223
  function makeIntent() {
2220
- const deliveryHandlers = /* @__PURE__ */ new Set();
2221
- const pendingDeliveries = [];
2222
2224
  const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
2223
- const normalizeDelivery = (value) => {
2224
- if (!value || typeof value !== "object" || typeof value.sender !== "string" || typeof value.archetype !== "string" || typeof value.action !== "string" || typeof value.convention !== "string") {
2225
- return void 0;
2225
+ const normalizeRequest = (value) => {
2226
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2227
+ throw new TypeError("Intent request must be an object");
2226
2228
  }
2227
- const delivery = value;
2228
- return {
2229
- sender: delivery.sender,
2230
- archetype: delivery.archetype,
2231
- action: delivery.action,
2232
- convention: delivery.convention,
2233
- ...hasOwn(delivery, "payload") ? { payload: delivery.payload } : {}
2234
- };
2235
- };
2236
- listen((event) => {
2237
- if (!isParentMessage(event)) return;
2238
- const msg = event.data;
2239
- if (typeof msg !== "object" || msg === null || msg.type !== "intent.deliver") return;
2240
- const delivery = normalizeDelivery(msg.delivery);
2241
- if (!delivery) return;
2242
- if (deliveryHandlers.size === 0) pendingDeliveries.push(delivery);
2243
- else for (const handler of deliveryHandlers) handler(delivery);
2244
- });
2245
- function normalizeIntentUri(uri, explicitPayload, actionMustBe) {
2246
- if (typeof uri !== "string" || !uri.startsWith("napplet:")) {
2247
- throw new TypeError("Intent convention URI must start with napplet:");
2229
+ const supplied = value;
2230
+ if (hasOwn(supplied, "sender")) {
2231
+ throw new TypeError("Intent callers cannot supply sender");
2248
2232
  }
2249
- if (uri.includes("#")) throw new TypeError("Intent convention URI cannot contain fragments");
2250
- const normalized = normalizeConventionUri(uri, explicitPayload);
2251
- const match = /^napplet:([^/?#]+)\/([^/?#]+)$/.exec(normalized.topic);
2252
- if (!match) throw new TypeError("Intent convention URI must be napplet:<archetype>/<intent>");
2253
- if (actionMustBe !== void 0 && match[2] !== actionMustBe) {
2254
- throw new TypeError(`Intent convention URI must use the ${actionMustBe} intent`);
2233
+ const allowed2 = ["archetype", "action", "convention", "payload", "handler", "behavior"];
2234
+ if (Object.keys(supplied).some((field) => !allowed2.includes(field))) {
2235
+ throw new TypeError("Intent request contains unsupported fields");
2255
2236
  }
2256
- return {
2257
- archetype: match[1],
2258
- action: match[2],
2259
- convention: normalized.topic,
2260
- ...normalized.payload === void 0 ? {} : { payload: normalized.payload }
2261
- };
2262
- }
2263
- const invoke = (uri, opts, actionMustBe) => {
2264
- const supplied = opts && typeof opts === "object" ? opts : void 0;
2265
- const explicitPayload = supplied?.payload !== void 0;
2266
- const normalized = normalizeIntentUri(uri, explicitPayload, actionMustBe);
2267
- if (supplied && hasOwn(supplied, "sender")) {
2268
- throw new TypeError("Intent callers cannot supply sender");
2237
+ if (typeof supplied.archetype !== "string" || supplied.archetype.length === 0) {
2238
+ throw new TypeError("Intent request requires an archetype");
2269
2239
  }
2270
- for (const field of ["archetype", "action", "convention"]) {
2271
- if (supplied && hasOwn(supplied, field) && supplied[field] !== normalized[field]) {
2272
- throw new TypeError(`Intent options cannot override URI-derived ${field}`);
2273
- }
2240
+ const action = supplied.action === void 0 ? "open" : supplied.action;
2241
+ if (typeof action !== "string" || action.length === 0) {
2242
+ throw new TypeError("Intent action must be a non-empty string");
2243
+ }
2244
+ if (supplied.convention !== void 0 && (typeof supplied.convention !== "string" || !/^napplet:[^/?#\s]+\/[^/?#\s]+$/.test(supplied.convention))) {
2245
+ throw new TypeError("Intent convention must be queryless");
2246
+ }
2247
+ if (supplied.handler !== void 0 && (typeof supplied.handler !== "string" || supplied.handler.length === 0)) {
2248
+ throw new TypeError("Intent handler must be a non-empty string");
2274
2249
  }
2275
2250
  const behaviorValue = supplied?.behavior;
2276
2251
  const behavior = behaviorValue && typeof behaviorValue === "object" ? behaviorValue : void 0;
2252
+ if (behaviorValue !== void 0 && (!behavior || Array.isArray(behaviorValue) || Object.keys(behavior).some((field) => field !== "focus" && field !== "newWindow" && field !== "reuse") || hasOwn(behavior, "focus") && typeof behavior.focus !== "boolean" || hasOwn(behavior, "newWindow") && typeof behavior.newWindow !== "boolean" || hasOwn(behavior, "reuse") && typeof behavior.reuse !== "boolean")) {
2253
+ throw new TypeError("Intent behavior contains unsupported fields");
2254
+ }
2277
2255
  const sanitizedBehavior = {
2278
2256
  ...typeof behavior?.focus === "boolean" ? { focus: behavior.focus } : {},
2257
+ ...typeof behavior?.newWindow === "boolean" ? { newWindow: behavior.newWindow } : {},
2279
2258
  ...typeof behavior?.reuse === "boolean" ? { reuse: behavior.reuse } : {}
2280
2259
  };
2281
- const handler = typeof supplied?.handler === "string" ? supplied.handler : void 0;
2260
+ return {
2261
+ archetype: supplied.archetype,
2262
+ action,
2263
+ ...typeof supplied.convention === "string" ? { convention: supplied.convention } : {},
2264
+ ...hasOwn(supplied, "payload") ? { payload: supplied.payload } : {},
2265
+ ...typeof supplied.handler === "string" ? { handler: supplied.handler } : {},
2266
+ ...Object.keys(sanitizedBehavior).length === 0 ? {} : { behavior: sanitizedBehavior }
2267
+ };
2268
+ };
2269
+ const invoke = (value) => {
2270
+ const normalized = normalizeRequest(value);
2282
2271
  return request(
2283
2272
  {
2284
2273
  type: "intent.invoke",
2285
- request: {
2286
- ...normalized,
2287
- ...explicitPayload ? { payload: supplied?.payload } : {},
2288
- ...handler === void 0 ? {} : { handler },
2289
- ...Object.keys(sanitizedBehavior).length === 0 ? {} : { behavior: sanitizedBehavior }
2290
- }
2274
+ request: normalized
2291
2275
  },
2292
2276
  "intent.invoke.result",
2293
2277
  (msg) => fieldOrThrow(msg, "result", "intent.invoke.result missing result")
@@ -2295,7 +2279,12 @@ function nappletNamespacePrelude(domains) {
2295
2279
  };
2296
2280
  return {
2297
2281
  invoke,
2298
- open: (uri, opts) => invoke(uri, opts, "open"),
2282
+ open: (archetype, payload, opts) => invoke({
2283
+ archetype,
2284
+ action: "open",
2285
+ ...payload === void 0 ? {} : { payload },
2286
+ ...opts
2287
+ }),
2299
2288
  available: (archetype) => request(
2300
2289
  { type: "intent.available", archetype },
2301
2290
  "intent.available.result",
@@ -2310,14 +2299,7 @@ function nappletNamespacePrelude(domains) {
2310
2299
  "intent.changed",
2311
2300
  callback,
2312
2301
  (msg) => msg.availability
2313
- ),
2314
- onDelivery: (callback) => {
2315
- if (typeof callback !== "function") throw new TypeError("Intent delivery handler must be a function");
2316
- deliveryHandlers.add(callback);
2317
- const pending = pendingDeliveries.splice(0);
2318
- for (const delivery of pending) callback(delivery);
2319
- return subscriptionHandle(() => deliveryHandlers.delete(callback));
2320
- }
2302
+ )
2321
2303
  };
2322
2304
  }
2323
2305
  function makeWebrtc() {