@kehto/paja 0.15.2 → 0.16.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
@@ -156,7 +156,7 @@ outside this behavior.
156
156
  [NAP-IDENTITY at `6461e4b37c29dc09a20dff35d9515889c4433874`](https://github.com/napplet/naps/blob/6461e4b37c29dc09a20dff35d9515889c4433874/naps/NAP-IDENTITY.md)
157
157
  is byte-identical to the recorded `napplet/naps` master document for this phase.
158
158
  Pinned [NAP-OUTBOX at `4589a8f9a16d8aa29b3740e2b3b0cdca11e0976e`](https://github.com/napplet/naps/blob/4589a8f9a16d8aa29b3740e2b3b0cdca11e0976e/naps/NAP-OUTBOX.md)
159
- together with installed `@napplet/nap@0.31.2` types is the PoC contract because
159
+ together with installed `@napplet/nap@0.32.0` types is the PoC contract because
160
160
  current master has no NAP-OUTBOX path. Paja therefore makes no current-master
161
161
  OUTBOX conformance claim. Blossom upload behavior targets pinned
162
162
  [NAP-UPLOAD at `a7cc17463cbf5d9cb87884b31071bc4fc826034c`](https://github.com/napplet/naps/blob/a7cc17463cbf5d9cb87884b31071bc4fc826034c/naps/NAP-UPLOAD.md).
@@ -210,7 +210,7 @@ identity. A superseded target/source, failed open/readiness, or terminal send is
210
210
  handled by the controller's replacement/retry/terminal policy and produces a
211
211
  canonical failed `IntentResult`.
212
212
 
213
- `@napplet/shim@0.29.2` supplies no generic shell API. Kehto deliberately keeps
213
+ `@napplet/shim@0.30.0` supplies no generic shell API. Kehto deliberately keeps
214
214
  its host-owned mandatory `window.napplet.shell` prelude: it installs the live
215
215
  receiver before the one bare `shell.ready`, caches the first `shell.init`, and
216
216
  provides local `ready()`, `supports()`, read-only `services`, and one-shot
@@ -311,13 +311,19 @@ canonical `network-error`, while any CORS-readable response is returned as NAP
311
311
  bytes.
312
312
 
313
313
  `data:` remains locally decoded. `blossom:` is a separate, content-addressed
314
- boundary and is advertised only when at least one usable host-owned
315
- runtime-pointer or upload server is configured. The only accepted identifier is
316
- `blossom:sha256:<hex>`; Paja refuses redirects, verifies the requested SHA-256,
317
- and permits plain-HTTP Blossom transport only for configured loopback
318
- development servers. Resource-origin permissiveness does not alter Paja's
319
- independent signer confirmations. This behavior targets draft
320
- [NAP-RESOURCE at `fa6bcc6`](https://github.com/napplet/naps/blob/fa6bcc6935aa19e7b70ab2a2c721dafca77c78e1/naps/NAP-RESOURCE.md).
314
+ boundary and is advertised because each request may provide server locations
315
+ without a host default. Paja accepts only public-looking HTTPS origin hints,
316
+ discards invalid/private literals, deduplicates them, and tries them before
317
+ host-configured runtime-pointer or upload defaults. The combined list is capped
318
+ at eight candidates. The only accepted identifier is `blossom:sha256:<hex>`;
319
+ Paja refuses redirects, verifies the requested SHA-256, and permits plain-HTTP
320
+ transport only for configured loopback development defaults. Browser-only Paja
321
+ cannot pin DNS results, so production runtimes must add the draft's DNS-time
322
+ private-address checks. This behavior targets draft
323
+ [NAP-RESOURCE at `9511232`](https://github.com/napplet/naps/blob/9511232f69313aa7953d110e35d32cc28d506f66/naps/NAP-RESOURCE.md), with the merged package implementation
324
+ [`napplet/web#206@19e0029b`](https://github.com/napplet/web/pull/206) released as
325
+ `@napplet/core`/`@napplet/nap` 0.32.0, `@napplet/shim` 0.30.0, and
326
+ `@napplet/sdk` 0.28.0.
321
327
 
322
328
  Full package docs: [`docs/packages/paja.md`](../../docs/packages/paja.md).
323
329
  Getting started: [`docs/how-tos/paja-getting-started.md`](../../docs/how-tos/paja-getting-started.md).
@@ -638,7 +638,7 @@ function deserialize2(json) {
638
638
  return defaultConfig();
639
639
  }
640
640
 
641
- // ../../node_modules/.pnpm/@napplet+core@0.31.1/node_modules/@napplet/core/dist/index.js
641
+ // ../../node_modules/.pnpm/@napplet+core@0.32.0/node_modules/@napplet/core/dist/index.js
642
642
  function createDispatch() {
643
643
  const handlers = /* @__PURE__ */ new Map();
644
644
  function registerNap2(domain, handler) {
@@ -4802,11 +4802,21 @@ function nappletNamespacePrelude(domains) {
4802
4802
  }
4803
4803
  function makeResource() {
4804
4804
  const objectUrls = /* @__PURE__ */ new Set();
4805
- const bytes = (url) => request({ type: "resource.bytes", url }, "resource.bytes.result", (msg) => msg.blob);
4805
+ const bytes = (url, options) => request({
4806
+ type: "resource.bytes",
4807
+ url,
4808
+ ...Array.isArray(options?.servers) ? { servers: Array.from(options.servers) } : {}
4809
+ }, "resource.bytes.result", (msg) => msg.blob);
4806
4810
  return {
4807
4811
  info: () => request({ type: "resource.info" }, "resource.info.result", (msg) => msg.info),
4808
4812
  bytes,
4809
- bytesMany: (urls) => request({ type: "resource.bytesMany", urls }, "resource.bytesMany.result", (msg) => Array.isArray(msg.items) ? msg.items : []),
4813
+ bytesMany: (requests) => request({
4814
+ type: "resource.bytesMany",
4815
+ requests: Array.from(requests || [], (entry) => ({
4816
+ url: entry.url,
4817
+ ...Array.isArray(entry.servers) ? { servers: Array.from(entry.servers) } : {}
4818
+ }))
4819
+ }, "resource.bytesMany.result", (msg) => Array.isArray(msg.items) ? msg.items : []),
4810
4820
  bytesAsObjectURL(url) {
4811
4821
  let objectUrl = "";
4812
4822
  const handle = {
@@ -11155,7 +11165,8 @@ function normalizeResourceInfo(info) {
11155
11165
  return {
11156
11166
  schemes: Array.isArray(info.schemes) ? info.schemes.filter((scheme) => typeof scheme?.scheme === "string").map((scheme) => ({ scheme: scheme.scheme, enabled: scheme.enabled !== false })) : [],
11157
11167
  ...typeof info.maxBytes === "number" ? { maxBytes: info.maxBytes } : {},
11158
- ...typeof info.maxUrls === "number" ? { maxUrls: info.maxUrls } : {}
11168
+ ...typeof info.maxUrls === "number" ? { maxUrls: info.maxUrls } : {},
11169
+ ...typeof info.maxServers === "number" ? { maxServers: info.maxServers } : {}
11159
11170
  };
11160
11171
  }
11161
11172
  async function resolveResourceInfo(options, windowId) {
@@ -11194,10 +11205,11 @@ function classifyResourceFailure(error) {
11194
11205
  message: error instanceof Error ? error.message : String(error)
11195
11206
  };
11196
11207
  }
11197
- async function fetchResourceItem(options, windowId, info, url, signal) {
11208
+ async function fetchResourceItem(options, windowId, info, request, signal) {
11209
+ const { url } = request;
11198
11210
  const parsedUrl = parseResourceUrl(url);
11199
11211
  if (!parsedUrl) return resourceInvalidRequest(url, `invalid URL: ${url}`);
11200
- if (options.isOriginGranted && options.getConnectGrants) {
11212
+ if (parsedUrl.origin !== "null" && options.isOriginGranted && options.getConnectGrants) {
11201
11213
  const identity = options.resolveIdentity?.(windowId) ?? null;
11202
11214
  if (!identity) {
11203
11215
  return {
@@ -11221,7 +11233,8 @@ async function fetchResourceItem(options, windowId, info, url, signal) {
11221
11233
  try {
11222
11234
  const response = await options.fetch(url, {
11223
11235
  method: "GET",
11224
- signal
11236
+ signal,
11237
+ ...parsedUrl.protocol === "blossom:" && request.servers !== void 0 ? { servers: [...request.servers] } : {}
11225
11238
  });
11226
11239
  const buffer = await response.arrayBuffer();
11227
11240
  if (info.maxBytes !== void 0 && buffer.byteLength > info.maxBytes) {
@@ -11245,12 +11258,12 @@ async function fetchResourceItem(options, windowId, info, url, signal) {
11245
11258
  }
11246
11259
  }
11247
11260
  async function handleBytes(options, state, windowId, msg, send) {
11248
- const { requestId, url } = msg;
11261
+ const { requestId, ...request } = msg;
11249
11262
  const controller = new AbortController();
11250
11263
  trackRequest(state, requestId, windowId, controller);
11251
11264
  try {
11252
11265
  const info = await resolveResourceInfo(options, windowId);
11253
- const item = await fetchResourceItem(options, windowId, info, url, controller.signal);
11266
+ const item = await fetchResourceItem(options, windowId, info, request, controller.signal);
11254
11267
  if (controller.signal.aborted) return;
11255
11268
  if (!item.ok) {
11256
11269
  sendResourceError(send, requestId, item.message, item.error);
@@ -11272,9 +11285,9 @@ async function handleBytes(options, state, windowId, msg, send) {
11272
11285
  }
11273
11286
  }
11274
11287
  async function handleBytesMany(options, state, windowId, msg, send) {
11275
- const { requestId, urls } = msg;
11276
- if (!Array.isArray(urls) || urls.length === 0 || urls.some((url) => typeof url !== "string")) {
11277
- sendBytesManyError(send, requestId, "resource.bytesMany requires a non-empty urls array", "invalid-request");
11288
+ const { requestId, requests } = msg;
11289
+ if (!isResourceRequestList(requests)) {
11290
+ sendBytesManyError(send, requestId, "resource.bytesMany requires a non-empty requests array", "invalid-request");
11278
11291
  return;
11279
11292
  }
11280
11293
  let info;
@@ -11285,7 +11298,7 @@ async function handleBytesMany(options, state, windowId, msg, send) {
11285
11298
  sendBytesManyError(send, requestId, failure3.message, failure3.error);
11286
11299
  return;
11287
11300
  }
11288
- if (info.maxUrls !== void 0 && urls.length > info.maxUrls) {
11301
+ if (info.maxUrls !== void 0 && requests.length > info.maxUrls) {
11289
11302
  sendBytesManyError(send, requestId, `resource.bytesMany exceeds ${info.maxUrls} URLs`, "too-large");
11290
11303
  return;
11291
11304
  }
@@ -11293,8 +11306,8 @@ async function handleBytesMany(options, state, windowId, msg, send) {
11293
11306
  trackRequest(state, requestId, windowId, controller);
11294
11307
  try {
11295
11308
  const items = [];
11296
- for (const url of urls) {
11297
- const item = await fetchResourceItem(options, windowId, info, url, controller.signal);
11309
+ for (const request of requests) {
11310
+ const item = await fetchResourceItem(options, windowId, info, request, controller.signal);
11298
11311
  if (controller.signal.aborted) return;
11299
11312
  if (item.ok) {
11300
11313
  items.push({
@@ -11326,6 +11339,12 @@ async function handleBytesMany(options, state, windowId, msg, send) {
11326
11339
  untrackRequest(state, windowId, requestId);
11327
11340
  }
11328
11341
  }
11342
+ function hasValidServerHints(request) {
11343
+ return request.servers === void 0 || Array.isArray(request.servers) && request.servers.every((server) => typeof server === "string");
11344
+ }
11345
+ function isResourceRequestList(value) {
11346
+ return Array.isArray(value) && value.length > 0 && value.every((request) => typeof request === "object" && request !== null && typeof request.url === "string" && hasValidServerHints(request));
11347
+ }
11329
11348
  function handleCancel(state, windowId, requestId) {
11330
11349
  const entry = state.inFlight.get(requestKey(windowId, requestId));
11331
11350
  if (entry) {
@@ -11363,11 +11382,15 @@ function createResourceService(options) {
11363
11382
  const m = message;
11364
11383
  const requestId = requestIdFromMessage(m);
11365
11384
  if (!requestId) return;
11366
- if (typeof m.url !== "string") {
11367
- sendResourceError(send, requestId, "resource.bytes requires a URL", "invalid-request");
11385
+ if (typeof m.url !== "string" || !hasValidServerHints(m)) {
11386
+ sendResourceError(send, requestId, "resource.bytes requires a URL and optional string servers", "invalid-request");
11368
11387
  return;
11369
11388
  }
11370
- handleBytes(options, state, windowId, { requestId, url: m.url }, send).catch(() => {
11389
+ handleBytes(options, state, windowId, {
11390
+ requestId,
11391
+ url: m.url,
11392
+ ...m.servers !== void 0 ? { servers: [...m.servers] } : {}
11393
+ }, send).catch(() => {
11371
11394
  });
11372
11395
  return;
11373
11396
  }
@@ -11383,7 +11406,7 @@ function createResourceService(options) {
11383
11406
  const m = message;
11384
11407
  const requestId = requestIdFromMessage(m);
11385
11408
  if (!requestId) return;
11386
- handleBytesMany(options, state, windowId, { requestId, urls: m.urls ?? [] }, send).catch(() => {
11409
+ handleBytesMany(options, state, windowId, { requestId, requests: m.requests ?? [] }, send).catch(() => {
11387
11410
  });
11388
11411
  return;
11389
11412
  }
@@ -18184,6 +18207,7 @@ async function resolveNapplet(options) {
18184
18207
  // src/browser-resource.ts
18185
18208
  var PAJA_RESOURCE_MAX_BYTES = 10 * 1024 * 1024;
18186
18209
  var PAJA_RESOURCE_MAX_URLS = 100;
18210
+ var PAJA_RESOURCE_MAX_SERVERS = 8;
18187
18211
  var UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
18188
18212
  var BLOSSOM_RESOURCE_PATTERN = /^blossom:sha256:([0-9a-f]{64})$/i;
18189
18213
  function createPajaResourceFetch(options = {}) {
@@ -18194,7 +18218,7 @@ function createPajaResourceFetch(options = {}) {
18194
18218
  throw new ResourceServiceError("invalid-request", "NAP-RESOURCE is read-only");
18195
18219
  }
18196
18220
  if (url.protocol === "blossom:") {
18197
- return fetchBlossomResource(value, init.signal, options);
18221
+ return fetchBlossomResource(value, init.signal, init.servers ?? [], options);
18198
18222
  }
18199
18223
  if (url.protocol === "http:" || url.protocol === "https:") {
18200
18224
  return fetchNetworkResource(value, init.signal, options.fetch ?? globalThis.fetch);
@@ -18217,19 +18241,18 @@ function createPajaResourceFetch(options = {}) {
18217
18241
  return new Response(arrayBufferFor(bytes), { headers: { "content-type": mime } });
18218
18242
  };
18219
18243
  }
18220
- function pajaResourceInfo(blossomServers = []) {
18244
+ function pajaResourceInfo() {
18221
18245
  const schemes = [
18222
18246
  { scheme: "data", enabled: true },
18223
18247
  { scheme: "https", enabled: true },
18224
- { scheme: "http", enabled: true }
18248
+ { scheme: "http", enabled: true },
18249
+ { scheme: "blossom", enabled: true }
18225
18250
  ];
18226
- if (usableBlossomServers(blossomServers).length > 0) {
18227
- schemes.push({ scheme: "blossom", enabled: true });
18228
- }
18229
18251
  return {
18230
18252
  schemes,
18231
18253
  maxBytes: PAJA_RESOURCE_MAX_BYTES,
18232
- maxUrls: PAJA_RESOURCE_MAX_URLS
18254
+ maxUrls: PAJA_RESOURCE_MAX_URLS,
18255
+ maxServers: PAJA_RESOURCE_MAX_SERVERS
18233
18256
  };
18234
18257
  }
18235
18258
  async function fetchNetworkResource(value, signal, fetcher) {
@@ -18261,20 +18284,24 @@ async function fetchNetworkResource(value, signal, fetcher) {
18261
18284
  }
18262
18285
  return new Response(arrayBufferFor(bytes), { headers: { "content-type": mime } });
18263
18286
  }
18264
- async function fetchBlossomResource(value, signal, options) {
18287
+ async function fetchBlossomResource(value, signal, requestServers, options) {
18265
18288
  const match = BLOSSOM_RESOURCE_PATTERN.exec(value);
18266
18289
  if (!match?.[1]) {
18267
18290
  throw new ResourceServiceError("invalid-request", "expected blossom:sha256:<64 hex characters>");
18268
18291
  }
18269
18292
  const expectedHash = match[1].toLowerCase();
18270
- const servers = usableBlossomServers(options.getBlossomServers?.() ?? []);
18293
+ const servers = resolveBlossomServers(
18294
+ requestServers,
18295
+ options.getBlossomServers?.() ?? []
18296
+ );
18271
18297
  if (servers.length === 0) {
18272
- throw new ResourceServiceError("blocked-by-policy", "Paja has no configured Blossom server");
18298
+ throw new ResourceServiceError("blocked-by-policy", "Paja has no accepted Blossom server");
18273
18299
  }
18274
18300
  const fetcher = options.fetch ?? globalThis.fetch;
18275
18301
  let foundHashMismatch = false;
18276
18302
  let foundNotFound = false;
18277
- let lastNetworkMessage = "all configured Blossom servers failed";
18303
+ let foundInconclusive = false;
18304
+ let lastNetworkMessage = "all accepted Blossom servers failed";
18278
18305
  for (const server of servers) {
18279
18306
  if (signal.aborted) throw new DOMException("Resource request cancelled", "AbortError");
18280
18307
  const resourceUrl = `${server}/${expectedHash}`;
@@ -18284,10 +18311,13 @@ async function fetchBlossomResource(value, signal, options) {
18284
18311
  method: "GET",
18285
18312
  signal,
18286
18313
  redirect: "error",
18287
- cache: "no-store"
18314
+ cache: "no-store",
18315
+ credentials: "omit",
18316
+ referrerPolicy: "no-referrer"
18288
18317
  });
18289
18318
  } catch (error) {
18290
18319
  if (signal.aborted || isAbortError(error)) throw error;
18320
+ foundInconclusive = true;
18291
18321
  lastNetworkMessage = error instanceof Error ? error.message : String(error);
18292
18322
  continue;
18293
18323
  }
@@ -18296,6 +18326,7 @@ async function fetchBlossomResource(value, signal, options) {
18296
18326
  continue;
18297
18327
  }
18298
18328
  if (!response.ok) {
18329
+ foundInconclusive = true;
18299
18330
  lastNetworkMessage = `Blossom server returned HTTP ${response.status}`;
18300
18331
  continue;
18301
18332
  }
@@ -18313,11 +18344,48 @@ async function fetchBlossomResource(value, signal, options) {
18313
18344
  if (foundHashMismatch) {
18314
18345
  throw new ResourceServiceError("decode-failed", "Blossom response did not match the requested SHA-256");
18315
18346
  }
18347
+ if (foundInconclusive) {
18348
+ throw new ResourceServiceError("network-error", lastNetworkMessage);
18349
+ }
18316
18350
  if (foundNotFound) {
18317
18351
  throw new ResourceServiceError("not-found", "Blossom resource was not found");
18318
18352
  }
18319
18353
  throw new ResourceServiceError("network-error", lastNetworkMessage);
18320
18354
  }
18355
+ function resolveBlossomServers(requestServers, configuredServers) {
18356
+ const servers = [];
18357
+ for (const value of requestServers) {
18358
+ const server = normalizePublicBlossomHint(value);
18359
+ if (server && !servers.includes(server)) servers.push(server);
18360
+ if (servers.length === PAJA_RESOURCE_MAX_SERVERS) return servers;
18361
+ }
18362
+ for (const server of usableBlossomServers(configuredServers)) {
18363
+ if (!servers.includes(server)) servers.push(server);
18364
+ if (servers.length === PAJA_RESOURCE_MAX_SERVERS) break;
18365
+ }
18366
+ return servers;
18367
+ }
18368
+ function normalizePublicBlossomHint(value) {
18369
+ if (typeof value !== "string" || value.trim().length === 0) return null;
18370
+ try {
18371
+ const url = new URL(value.trim());
18372
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/" || !isPublicHostname(url.hostname)) return null;
18373
+ return url.origin;
18374
+ } catch {
18375
+ return null;
18376
+ }
18377
+ }
18378
+ function isPublicHostname(value) {
18379
+ const hostname = value.replace(/^\[|\]$/gu, "").toLowerCase();
18380
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname.endsWith(".internal") || !hostname.includes(".")) return false;
18381
+ if (hostname.includes(":")) {
18382
+ return hostname !== "::" && hostname !== "::1" && !hostname.startsWith("::ffff:") && !/^(?:fc|fd|fe[89ab]|ff)/iu.test(hostname) && !hostname.startsWith("2001:db8:");
18383
+ }
18384
+ const octets = hostname.split(".").map(Number);
18385
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return true;
18386
+ const [first, second] = octets;
18387
+ return first !== 0 && first !== 10 && first !== 127 && first < 224 && !(first === 100 && second >= 64 && second <= 127) && !(first === 169 && second === 254) && !(first === 172 && second >= 16 && second <= 31) && !(first === 192 && second === 168) && !(first === 198 && (second === 18 || second === 19));
18388
+ }
18321
18389
  function usableBlossomServers(values) {
18322
18390
  const servers = [];
18323
18391
  for (const value of values) {
@@ -19649,7 +19717,7 @@ function createDevServices(backend, getSimulation, relayConfig, onThemeService,
19649
19717
  const services = {
19650
19718
  resource: createResourceService({
19651
19719
  fetch: createPajaResourceFetch({ getBlossomServers }),
19652
- resourceInfo: () => pajaResourceInfo(getBlossomServers())
19720
+ resourceInfo: () => pajaResourceInfo()
19653
19721
  })
19654
19722
  };
19655
19723
  if (getSimulation().capabilities.domains.keys && typeof document !== "undefined") {