@kehto/paja 0.19.0 → 0.20.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.
@@ -698,6 +698,7 @@ function renderPajaHtml(config) {
698
698
  <section class="paja-notification-center" id="paja-notification-center" aria-label="Napplet notifications" aria-live="polite"></section>
699
699
  ${renderConfirmationDialog()}
700
700
  ${renderConfigDialog()}
701
+ ${renderIntentLinkDialog()}
701
702
  ${renderDuplicateDialog()}
702
703
  <footer class="bar bottom">
703
704
  <span>mode: <code>${escapeHtml(getModeLabel(config))}</code></span>
@@ -755,6 +756,27 @@ function renderConfigDialog() {
755
756
  </div>
756
757
  </dialog>`;
757
758
  }
759
+ function renderIntentLinkDialog() {
760
+ return `<dialog class="config-dialog" id="paja-intent-link-dialog" aria-labelledby="paja-intent-link-title" aria-describedby="paja-intent-link-status">
761
+ <form class="dialog" method="dialog">
762
+ <div class="dialog-title" id="paja-intent-link-title">Review intent link</div>
763
+ <label>Intent URI <input id="paja-intent-link-uri" type="text" autocomplete="off" spellcheck="false"></label>
764
+ <label>Payload <textarea id="paja-intent-link-payload" rows="5" spellcheck="false"></textarea></label>
765
+ <label>Compatible handler <select id="paja-intent-link-handler" disabled></select></label>
766
+ <label><input id="paja-intent-link-save-default" type="checkbox"> Set as my default for this role</label>
767
+ <button type="button" id="paja-intent-link-clear-default">Clear saved default</button>
768
+ <div class="dialog-copy" id="paja-intent-link-target"></div>
769
+ <div class="config-error" id="paja-intent-link-status" role="status" aria-live="polite"></div>
770
+ <div class="dialog-actions">
771
+ <button type="button" id="paja-intent-link-choose">Choose another</button>
772
+ <button type="button" id="paja-intent-link-use-handler" disabled>Use handler</button>
773
+ <button type="button" id="paja-intent-link-retry">Retry</button>
774
+ <button type="button" id="paja-intent-link-cancel">Cancel</button>
775
+ <button type="button" id="paja-intent-link-launch">Launch</button>
776
+ </div>
777
+ </form>
778
+ </dialog>`;
779
+ }
758
780
  function renderStage(config, targetLabel) {
759
781
  if (config.target.mode === "runtime-pointer") {
760
782
  return `<section class="stage" id="napplet-stage" aria-label="Loaded napplet runtimes">
@@ -1291,7 +1313,7 @@ async function sha256Hex(bytes) {
1291
1313
  // src/installed-napplet-catalog.ts
1292
1314
  import { manifestToIntentCatalogEntry } from "@kehto/services";
1293
1315
  function matchesInstalledNappletRecord(record, target) {
1294
- return target.dTag === record.dTag && target.aggregateHash === record.aggregateHash;
1316
+ return target.id === record.id && target.aggregateHash === record.aggregateHash;
1295
1317
  }
1296
1318
  var InstalledNappletCatalog = class {
1297
1319
  records = /* @__PURE__ */ new Map();
@@ -1299,6 +1321,7 @@ var InstalledNappletCatalog = class {
1299
1321
  /** Insert or replace a record from an already resolver-verified pointer. */
1300
1322
  install(resolved) {
1301
1323
  const record = freezeRecord({
1324
+ id: resolved.manifest.catalogId,
1302
1325
  dTag: resolved.dTag,
1303
1326
  aggregateHash: resolved.aggregateHash,
1304
1327
  pointer: copyPointer(resolved.pointer),
@@ -1307,31 +1330,31 @@ var InstalledNappletCatalog = class {
1307
1330
  optional: [...resolved.manifest.optional ?? []],
1308
1331
  archetypes: resolved.manifest.archetypes.map((archetype) => ({
1309
1332
  slug: archetype.slug,
1310
- convention: archetype.convention
1333
+ convention: archetype.convention,
1334
+ params: [...archetype.params]
1311
1335
  }))
1312
1336
  });
1313
- if (!record.dTag) return record;
1314
- this.records.set(record.dTag, record);
1315
- this.notify(record.dTag);
1337
+ this.records.set(record.id, record);
1338
+ this.notify(record.id);
1316
1339
  return record;
1317
1340
  }
1318
1341
  /** Remove an artifact explicitly; frame teardown never calls this method. */
1319
- remove(dTag) {
1320
- const removed = this.records.delete(dTag);
1321
- if (removed) this.notify(dTag);
1342
+ remove(id) {
1343
+ const removed = this.records.delete(id);
1344
+ if (removed) this.notify(id);
1322
1345
  return removed;
1323
1346
  }
1324
- /** Return whether a verified installation exists for a d-tag. */
1325
- has(dTag) {
1326
- return this.records.has(dTag);
1347
+ /** Return whether a verified installation exists for a catalog ID. */
1348
+ has(id) {
1349
+ return this.records.has(id);
1327
1350
  }
1328
1351
  /** Return immutable serializable installed-artifact facts. */
1329
1352
  installed() {
1330
1353
  return [...this.records.values()];
1331
1354
  }
1332
- /** Return a verified record by d-tag. */
1333
- get(dTag) {
1334
- return this.records.get(dTag);
1355
+ /** Return a verified record by catalog ID. */
1356
+ get(id) {
1357
+ return this.records.get(id);
1335
1358
  }
1336
1359
  /**
1337
1360
  * Atomically confirm that a resolved target still belongs to the exact record
@@ -1339,7 +1362,7 @@ var InstalledNappletCatalog = class {
1339
1362
  * token, so even a same-identity replacement cannot pass this check.
1340
1363
  */
1341
1364
  validateCurrent(selected, target) {
1342
- if (this.records.get(selected.dTag) !== selected) return null;
1365
+ if (this.records.get(selected.id) !== selected) return null;
1343
1366
  return matchesInstalledNappletRecord(selected, target) ? selected : null;
1344
1367
  }
1345
1368
  /**
@@ -1350,11 +1373,12 @@ var InstalledNappletCatalog = class {
1350
1373
  */
1351
1374
  intentCatalog(canReceiveIntent = () => true) {
1352
1375
  return this.installed().filter(canReceiveIntent).map((record) => manifestToIntentCatalogEntry({
1353
- dTag: record.dTag,
1376
+ catalogId: record.id,
1354
1377
  ...record.title === void 0 ? {} : { title: record.title },
1355
1378
  archetypes: record.archetypes.map((archetype) => ({
1356
1379
  slug: archetype.slug,
1357
- convention: archetype.convention
1380
+ convention: archetype.convention,
1381
+ params: [...archetype.params]
1358
1382
  }))
1359
1383
  }));
1360
1384
  }
@@ -1363,8 +1387,8 @@ var InstalledNappletCatalog = class {
1363
1387
  this.listeners.add(listener);
1364
1388
  return () => this.listeners.delete(listener);
1365
1389
  }
1366
- notify(dTag) {
1367
- for (const listener of this.listeners) listener(dTag);
1390
+ notify(id) {
1391
+ for (const listener of this.listeners) listener(id);
1368
1392
  }
1369
1393
  };
1370
1394
  function copyPointer(pointer) {
@@ -1392,7 +1416,7 @@ function freezeRecord(record) {
1392
1416
  ...record,
1393
1417
  requires: Object.freeze([...record.requires]),
1394
1418
  optional: Object.freeze([...record.optional ?? []]),
1395
- archetypes: Object.freeze(record.archetypes.map((archetype) => Object.freeze({ ...archetype })))
1419
+ archetypes: Object.freeze(record.archetypes.map((archetype) => Object.freeze({ ...archetype, params: Object.freeze([...archetype.params]) })))
1396
1420
  });
1397
1421
  }
1398
1422
 
@@ -1405,6 +1429,13 @@ var BrowserIntentController = class {
1405
1429
  }
1406
1430
  options;
1407
1431
  maxAttempts;
1432
+ /** Retain delivery work immediately; completion remains host-observable only. */
1433
+ accept(params) {
1434
+ const acceptance = Object.freeze({ completion: this.dispatch(params) });
1435
+ this.options.onAccepted?.(params, acceptance);
1436
+ return acceptance;
1437
+ }
1438
+ /** Complete an already accepted delivery. Kept public for host diagnostics and tests. */
1408
1439
  async dispatch(params) {
1409
1440
  const dispatch = freezeDispatch(params);
1410
1441
  let reason = "no-current-target";
@@ -1432,9 +1463,8 @@ var BrowserIntentController = class {
1432
1463
  }
1433
1464
  try {
1434
1465
  await this.options.send(generation, dispatch);
1435
- const windowId = this.options.getWindowId(generation);
1436
- if (!windowId) throw new Error("intent target window is unavailable");
1437
- return { windowId };
1466
+ this.options.onDelivered?.(dispatch);
1467
+ return;
1438
1468
  } catch {
1439
1469
  this.options.onTerminal?.(dispatch, "send-failed");
1440
1470
  throw new Error("intent target send failed");
@@ -1465,7 +1495,8 @@ function freezeDispatch(params) {
1465
1495
  action: params.action,
1466
1496
  convention: params.convention,
1467
1497
  ...params.payload === void 0 ? {} : { payload },
1468
- ...behavior === void 0 ? {} : { behavior }
1498
+ ...behavior === void 0 ? {} : { behavior },
1499
+ ...params.sourceWindowId === void 0 ? {} : { sourceWindowId: params.sourceWindowId }
1469
1500
  });
1470
1501
  }
1471
1502
  function freezeValue(value, seen = /* @__PURE__ */ new WeakMap()) {
@@ -1484,6 +1515,202 @@ function freezeValue(value, seen = /* @__PURE__ */ new WeakMap()) {
1484
1515
  return Object.freeze(copy);
1485
1516
  }
1486
1517
 
1518
+ // src/intent-link.ts
1519
+ import { normalizeIntentUri } from "@kehto/shell";
1520
+ var PAJA_INTENT_LINK_MAX_BYTES = 16 * 1024;
1521
+ function fail(message) {
1522
+ throw new TypeError(message);
1523
+ }
1524
+ function hasOwn(value, key) {
1525
+ return Object.prototype.hasOwnProperty.call(value, key);
1526
+ }
1527
+ function strictDecode(value, label) {
1528
+ try {
1529
+ return decodeURIComponent(value);
1530
+ } catch {
1531
+ return fail(`Paja intent link has malformed percent-encoding in ${label}`);
1532
+ }
1533
+ }
1534
+ function parseOuterQuery(search) {
1535
+ const fields = /* @__PURE__ */ Object.create(null);
1536
+ if (!search) return fields;
1537
+ for (const part of search.slice(1).split("&")) {
1538
+ const equal = part.indexOf("=");
1539
+ if (equal <= 0) fail("Paja intent link fields must be name=value");
1540
+ const name = strictDecode(part.slice(0, equal), "a field name");
1541
+ const value = strictDecode(part.slice(equal + 1), `the ${name || "unnamed"} field`);
1542
+ if (!name || hasOwn(fields, name)) fail("Paja intent link field names must be unique after decoding");
1543
+ Object.defineProperty(fields, name, { value, enumerable: true, configurable: true });
1544
+ }
1545
+ return fields;
1546
+ }
1547
+ function rawOuterValue(search, expectedName) {
1548
+ if (!search) return void 0;
1549
+ for (const part of search.slice(1).split("&")) {
1550
+ const equal = part.indexOf("=");
1551
+ if (equal <= 0) continue;
1552
+ try {
1553
+ if (decodeURIComponent(part.slice(0, equal)) === expectedName) return part.slice(equal + 1);
1554
+ } catch {
1555
+ }
1556
+ }
1557
+ return void 0;
1558
+ }
1559
+ function assertJsonValue(value) {
1560
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
1561
+ if (typeof value === "number") {
1562
+ if (Number.isFinite(value)) return;
1563
+ fail("Paja intent payload must be JSON");
1564
+ }
1565
+ if (Array.isArray(value)) {
1566
+ value.forEach(assertJsonValue);
1567
+ return;
1568
+ }
1569
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) {
1570
+ fail("Paja intent payload must be JSON");
1571
+ }
1572
+ for (const [, nested] of Object.entries(value)) {
1573
+ assertJsonValue(nested);
1574
+ }
1575
+ }
1576
+ function parseJsonPayload(value) {
1577
+ let parsed;
1578
+ try {
1579
+ parsed = JSON.parse(value);
1580
+ } catch {
1581
+ return fail("Paja intent payload must be valid JSON");
1582
+ }
1583
+ assertJsonValue(parsed);
1584
+ return parsed;
1585
+ }
1586
+ function encodeJsonPayload(value) {
1587
+ assertJsonValue(value);
1588
+ const encoded = JSON.stringify(value);
1589
+ if (encoded === void 0) fail("Paja intent payload must be JSON");
1590
+ return encoded;
1591
+ }
1592
+ function pointerParameter(pointer) {
1593
+ if (pointer.startsWith("naddr")) return "naddr";
1594
+ if (pointer.startsWith("nevent")) return "nevent";
1595
+ return "pointer";
1596
+ }
1597
+ function assertLinkSize(url) {
1598
+ if (new TextEncoder().encode(url.href).length > PAJA_INTENT_LINK_MAX_BYTES) {
1599
+ fail(`Paja intent links must not exceed ${PAJA_INTENT_LINK_MAX_BYTES} UTF-8 bytes`);
1600
+ }
1601
+ }
1602
+ function parsePajaIntentLink(href) {
1603
+ let url;
1604
+ try {
1605
+ url = new URL(href);
1606
+ } catch {
1607
+ return fail("Paja intent link must be an absolute URL");
1608
+ }
1609
+ const rawIntent = rawOuterValue(url.search, "intent");
1610
+ if (rawIntent === void 0) {
1611
+ if (rawOuterValue(url.search, "payload") !== void 0) fail("Paja intent payload requires an intent URI");
1612
+ return null;
1613
+ }
1614
+ if (url.hash) fail("Paja intent links do not support an outer fragment");
1615
+ assertLinkSize(url);
1616
+ const fields = parseOuterQuery(url.search);
1617
+ if (Object.keys(fields).some((field) => !["intent", "payload", "pointer", "naddr", "nevent"].includes(field))) {
1618
+ fail("Paja intent link contains unsupported fields");
1619
+ }
1620
+ const pointers = ["pointer", "naddr", "nevent"].filter((name) => hasOwn(fields, name));
1621
+ if (pointers.length > 1) fail("Paja intent link must not contain multiple pointer aliases");
1622
+ if (!hasOwn(fields, "intent") || !rawIntent || /[:/?#&=]/u.test(rawIntent) || !/%[0-9a-f]{2}/iu.test(rawIntent)) {
1623
+ fail("Paja intent URI must be completely percent-encoded");
1624
+ }
1625
+ const uri = fields.intent;
1626
+ const payload = hasOwn(fields, "payload") ? parseJsonPayload(fields.payload) : void 0;
1627
+ const request = normalizeIntentUri(uri, payload === void 0 ? void 0 : { payload });
1628
+ const pointer = pointers.length === 1 ? fields[pointers[0]] : void 0;
1629
+ if (pointer !== void 0 && pointer.length === 0) fail("Paja intent pointer must be non-empty text");
1630
+ if (pointer !== void 0 && request.handlerHint !== void 0) {
1631
+ fail("Paja intent link cannot combine a target pointer with a URI recommendation");
1632
+ }
1633
+ return {
1634
+ uri,
1635
+ ...pointer === void 0 ? {} : { pointer },
1636
+ ...payload === void 0 ? {} : { payload },
1637
+ request
1638
+ };
1639
+ }
1640
+ function createPajaIntentLink(descriptor, href) {
1641
+ if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) fail("Paja intent link descriptor must be an object");
1642
+ if (typeof descriptor.uri !== "string") fail("Paja intent URI must be text");
1643
+ if (descriptor.pointer !== void 0 && (typeof descriptor.pointer !== "string" || descriptor.pointer.length === 0)) {
1644
+ fail("Paja intent pointer must be non-empty text");
1645
+ }
1646
+ const request = normalizeIntentUri(
1647
+ descriptor.uri,
1648
+ hasOwn(descriptor, "payload") ? { payload: descriptor.payload } : void 0
1649
+ );
1650
+ if (descriptor.pointer !== void 0 && request.handlerHint !== void 0) {
1651
+ fail("Paja intent link cannot combine a target pointer with a URI recommendation");
1652
+ }
1653
+ let url;
1654
+ try {
1655
+ url = new URL(href);
1656
+ } catch {
1657
+ return fail("Paja intent link must use an absolute base URL");
1658
+ }
1659
+ url.search = "";
1660
+ url.hash = "";
1661
+ const pairs = [`intent=${encodeURIComponent(descriptor.uri)}`];
1662
+ if (descriptor.pointer !== void 0) pairs.push(`${pointerParameter(descriptor.pointer)}=${encodeURIComponent(descriptor.pointer)}`);
1663
+ if (hasOwn(descriptor, "payload")) pairs.push(`payload=${encodeURIComponent(encodeJsonPayload(descriptor.payload))}`);
1664
+ url.search = `?${pairs.join("&")}`;
1665
+ assertLinkSize(url);
1666
+ return url.href;
1667
+ }
1668
+
1669
+ // src/intent-launcher.ts
1670
+ import { resolveNapplet as resolveNapplet2 } from "@kehto/nip/5d";
1671
+ import { verifyEvent } from "nostr-tools/pure";
1672
+ import { neventEncode } from "nostr-tools/nip19";
1673
+ var LAUNCHER_HTML = `<!doctype html><meta charset="utf-8"><title>Paja intent launcher</title><script>let launched=false;addEventListener('message',(event)=>{if(launched||event.source!==parent)return;const data=event.data;if(!data||data.type!=='paja.intent.launch'||typeof data.uri!=='string'||typeof data.correlation!=='string')return;launched=true;window.napplet?.intent?.invoke(data.uri,data.options).then((result)=>parent.postMessage({type:'paja.intent.launcher.result',correlation:data.correlation,result},'*')).catch(()=>parent.postMessage({type:'paja.intent.launcher.result',correlation:data.correlation,result:{ok:false,error:'invoke rejected'}},'*'));});parent.postMessage({type:'paja.intent.launcher.ready'},'*');</script>`;
1674
+ var LAUNCHER_EVENT = {
1675
+ kind: 35129,
1676
+ created_at: 1760054400,
1677
+ content: "Paja verified intent launcher",
1678
+ tags: [
1679
+ ["d", "paja-intent-launcher"],
1680
+ ["x", "77cf3d2df847786177f4c16eb0b6ae1be6f5cd4e8d9be02a80bccd7c88e51bb8"],
1681
+ ["R", "intent"],
1682
+ ["title", "Paja intent launcher"]
1683
+ ],
1684
+ pubkey: "24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c",
1685
+ id: "37a0b8b71857b161ea7a8749f4642915c9101258509be6d1ce978e07c683bfb6",
1686
+ sig: "eb8e92509894dd3d86a28111a62412cff86ec36f591e4f3f692b5058174add6ef64c7996d66aae38bbd887355b3338ed6a64b300c8241875619b2e12e87d7903"
1687
+ };
1688
+ async function resolvePajaIntentLauncher() {
1689
+ const event = { ...LAUNCHER_EVENT, tags: LAUNCHER_EVENT.tags.map((tag) => [...tag]) };
1690
+ if (!verifyEvent(event)) throw new Error("Paja intent launcher manifest signature is invalid");
1691
+ const resolved = await resolveNapplet2({
1692
+ event,
1693
+ fetchBlob: async (hash) => {
1694
+ if (hash !== event.tags[1]?.[1]) throw new Error("Paja intent launcher requested an unknown artifact");
1695
+ return new TextEncoder().encode(LAUNCHER_HTML);
1696
+ }
1697
+ });
1698
+ return Object.freeze({
1699
+ pointer: Object.freeze({ type: "nevent", value: neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }), id: event.id, author: event.pubkey, kind: event.kind, relays: Object.freeze([]) }),
1700
+ event: Object.freeze({ ...event, tags: Object.freeze(event.tags.map((tag) => Object.freeze([...tag]))) }),
1701
+ relays: Object.freeze([]),
1702
+ blossomServers: Object.freeze([]),
1703
+ dTag: resolved.dTag,
1704
+ aggregateHash: resolved.aggregateHash,
1705
+ indexHtml: resolved.indexHtml,
1706
+ manifest: resolved.manifest
1707
+ });
1708
+ }
1709
+ function postPajaIntentLauncherMessage(frame, message) {
1710
+ if (!frame.contentWindow) throw new Error("Paja intent launcher frame is unavailable");
1711
+ frame.contentWindow.postMessage(message, "*");
1712
+ }
1713
+
1487
1714
  // src/server.ts
1488
1715
  import { createServer } from "http";
1489
1716
  import { readFileSync as readFileSync2 } from "fs";
@@ -1722,9 +1949,13 @@ export {
1722
1949
  findRelativeAssetReferences,
1723
1950
  InstalledNappletCatalog,
1724
1951
  BrowserIntentController,
1952
+ parsePajaIntentLink,
1953
+ createPajaIntentLink,
1954
+ resolvePajaIntentLauncher,
1955
+ postPajaIntentLauncherMessage,
1725
1956
  PAJA_TARGET_CORS_HINT,
1726
1957
  classifyTargetCors,
1727
1958
  probeTargetCors,
1728
1959
  startPajaServer
1729
1960
  };
1730
- //# sourceMappingURL=chunk-GY5ET3PC.js.map
1961
+ //# sourceMappingURL=chunk-6WEO5D3Z.js.map