@elisym/sdk 0.25.1 → 0.25.4

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/dist/index.cjs CHANGED
@@ -98,6 +98,11 @@ var DEFAULTS = {
98
98
  // default, not a protocol constant - the transfer is resumable and its own
99
99
  // budget, decoupled from the result-wait window.
100
100
  IROH_FETCH_TIMEOUT_MS: 3e5,
101
+ // Ceiling for a single iroh SEED (addFromPath/addBytes/share). Seeding is local
102
+ // (hash + store-copy + ticket mint), so this is a generous backstop: it bounds
103
+ // the JS await so a wedged native call surfaces as a thrown error (and triggers a
104
+ // node reset) instead of an indefinite hang that stalls file delivery.
105
+ IROH_SEED_TIMEOUT_MS: 12e4,
101
106
  // Ceiling for a single Blossom blob upload (PUT /upload). Large blobs (up to
102
107
  // LIMITS.MAX_FILE_SIZE) need far more than the 30s used for small media images.
103
108
  BLOSSOM_UPLOAD_TIMEOUT_MS: 3e5,
@@ -1219,6 +1224,17 @@ var BlossomService = class {
1219
1224
  this.serverUrl = serverUrl;
1220
1225
  this.fallback = fallback;
1221
1226
  }
1227
+ /**
1228
+ * The content-addressed GET URL for a blob, derivable from its sha256 BEFORE
1229
+ * upload (BUD-01: `<serverUrl>/<sha256>`, no extension for our octet-stream
1230
+ * ciphertext uploads - same form `delete` addresses by). Lets a caller build a
1231
+ * complete attachment descriptor and defer the actual byte upload (the descriptor
1232
+ * is submitted first, the bytes PUT later). `upload()` re-verifies the server
1233
+ * returns this exact url.
1234
+ */
1235
+ contentUrl(sha256) {
1236
+ return `${this.serverUrl}/${sha256}`;
1237
+ }
1222
1238
  /**
1223
1239
  * Upload a file to the Blossom server, returning its descriptor. On any failure, falls
1224
1240
  * back to the configured uploader (if any) and returns a normalized descriptor with
@@ -1271,18 +1287,38 @@ var BlossomService = class {
1271
1287
  }
1272
1288
  }
1273
1289
  /**
1274
- * Download a public blob (BUD-01 GET, no auth). Bounds memory on the ACTUAL streamed bytes (never
1275
- * the declared Content-Length) and verifies the sha256 when `expectedSha256` is given. Browser-safe.
1290
+ * Download a content-addressed blob from THIS Blossom server (BUD-01 GET, no auth). Bounds memory
1291
+ * on the ACTUAL streamed bytes (never the declared Content-Length) and verifies the sha256 when
1292
+ * `expectedSha256` is given. Browser-safe.
1293
+ *
1294
+ * SSRF guard: `url` typically arrives inside a remote counterparty's encrypted job envelope, so it
1295
+ * is untrusted. elisym blobs are content-addressed on the single configured server (`seedBytes`
1296
+ * refuses non-content-addressed fallbacks), so a legitimate URL is always `<serverUrl>/<sha256>`.
1297
+ * The origin is pinned to `serverUrl` and redirects are refused, so a crafted url (or a 30x from
1298
+ * the host) can't coerce a fetch to loopback, cloud-metadata, or internal addresses. Federation
1299
+ * across Blossom servers would replace this single-origin pin with an explicit allowlist.
1276
1300
  */
1277
1301
  async download(url, opts = {}) {
1302
+ if (new URL(url).origin !== new URL(this.serverUrl).origin) {
1303
+ throw new Error(`Refusing to download from a non-Blossom origin: ${url}`);
1304
+ }
1278
1305
  const maxBytes = opts.maxBytes ?? LIMITS.MAX_FILE_SIZE;
1279
1306
  const controller = new AbortController();
1280
1307
  const timer = setTimeout(
1281
1308
  () => controller.abort(),
1282
1309
  opts.timeoutMs ?? DEFAULTS.BLOSSOM_FETCH_TIMEOUT_MS
1283
1310
  );
1311
+ const externalSignal = opts.signal;
1312
+ const onExternalAbort = () => controller.abort();
1313
+ if (externalSignal !== void 0) {
1314
+ if (externalSignal.aborted) {
1315
+ controller.abort();
1316
+ } else {
1317
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
1318
+ }
1319
+ }
1284
1320
  try {
1285
- const res = await fetch(url, { signal: controller.signal });
1321
+ const res = await fetch(url, { signal: controller.signal, redirect: "error" });
1286
1322
  if (!res.ok) {
1287
1323
  throw new Error(`Download failed: ${res.status} ${res.statusText}`);
1288
1324
  }
@@ -1324,6 +1360,9 @@ var BlossomService = class {
1324
1360
  return bytes;
1325
1361
  } finally {
1326
1362
  clearTimeout(timer);
1363
+ if (externalSignal !== void 0) {
1364
+ externalSignal.removeEventListener("abort", onExternalAbort);
1365
+ }
1327
1366
  }
1328
1367
  }
1329
1368
  async uploadToBlossom(identity, bytes, hashHex, mime) {
@@ -1457,6 +1496,9 @@ function parseCapabilityEvent(event, network) {
1457
1496
  if (card.inputMime !== void 0 && (typeof card.inputMime !== "string" || card.inputMime.length > 255) || card.outputMime !== void 0 && (typeof card.outputMime !== "string" || card.outputMime.length > 255)) {
1458
1497
  return null;
1459
1498
  }
1499
+ if (card.inputText !== void 0 && !["required", "optional", "none"].includes(card.inputText)) {
1500
+ card.inputText = void 0;
1501
+ }
1460
1502
  if (card.payment?.job_price !== null && card.payment?.job_price !== void 0 && (!Number.isInteger(card.payment.job_price) || card.payment.job_price < 0)) {
1461
1503
  return null;
1462
1504
  }
@@ -2087,7 +2129,11 @@ var FileAttachmentSchema = zod.z.object({
2087
2129
  var JobPayloadEnvelopeSchema = zod.z.object({
2088
2130
  v: zod.z.literal(ENVELOPE_VERSION),
2089
2131
  text: zod.z.string().optional(),
2090
- attachment: FileAttachmentSchema.optional()
2132
+ // Legacy single attachment - kept (and mirrored from `attachments[0]`) so an old
2133
+ // decoder that doesn't know `attachments` still gets the first file.
2134
+ attachment: FileAttachmentSchema.optional(),
2135
+ // Multiple result/input files. Additive; old decoders strip this unknown key.
2136
+ attachments: zod.z.array(FileAttachmentSchema).optional()
2091
2137
  });
2092
2138
  var ACCEPT_TRANSPORTS_TAG = "accept";
2093
2139
  var KNOWN_TRANSPORT_KINDS = ["iroh", "blossom"];
@@ -2120,12 +2166,21 @@ function readAcceptedTransports(tags) {
2120
2166
  }
2121
2167
  return out.length > 0 ? out : void 0;
2122
2168
  }
2169
+ function attachmentsOf(decoded) {
2170
+ if (decoded.attachments !== void 0 && decoded.attachments.length > 0) {
2171
+ return decoded.attachments;
2172
+ }
2173
+ return decoded.attachment !== void 0 ? [decoded.attachment] : [];
2174
+ }
2123
2175
  function encodeJobPayload(payload) {
2124
2176
  const envelope = { v: ENVELOPE_VERSION };
2125
2177
  if (payload.text !== void 0) {
2126
2178
  envelope.text = payload.text;
2127
2179
  }
2128
- if (payload.attachment !== void 0) {
2180
+ if (payload.attachments !== void 0 && payload.attachments.length > 0) {
2181
+ envelope.attachments = payload.attachments;
2182
+ envelope.attachment = payload.attachments[0];
2183
+ } else if (payload.attachment !== void 0) {
2129
2184
  envelope.attachment = payload.attachment;
2130
2185
  }
2131
2186
  return JSON.stringify(envelope);
@@ -2153,7 +2208,11 @@ function decodeJobPayload(content) {
2153
2208
  `Invalid elisym job payload (v=${JSON.stringify(version)}): ${result.error.message}`
2154
2209
  );
2155
2210
  }
2156
- return { text: result.data.text, attachment: result.data.attachment };
2211
+ return {
2212
+ text: result.data.text,
2213
+ attachment: result.data.attachment,
2214
+ attachments: result.data.attachments
2215
+ };
2157
2216
  }
2158
2217
 
2159
2218
  // src/services/marketplace.ts
@@ -2318,7 +2377,7 @@ var MarketplaceService = class {
2318
2377
  }
2319
2378
  resultDelivered = true;
2320
2379
  try {
2321
- cb.onResult?.(decoded.text ?? "", ev.id, decoded.attachment);
2380
+ cb.onResult?.(decoded.text ?? "", ev.id, decoded.attachment, attachmentsOf(decoded));
2322
2381
  } catch {
2323
2382
  } finally {
2324
2383
  done();
@@ -2482,8 +2541,8 @@ var MarketplaceService = class {
2482
2541
  );
2483
2542
  }
2484
2543
  /** Submit a job result with NIP-44 encrypted content. Result kind is derived from the request kind. */
2485
- async submitJobResult(identity, requestEvent, content, amount, attachment) {
2486
- const hasAttachment = attachment !== void 0;
2544
+ async submitJobResult(identity, requestEvent, content, amount, attachments) {
2545
+ const hasAttachment = attachments !== void 0 && attachments.length > 0;
2487
2546
  if (!content && !hasAttachment) {
2488
2547
  throw new Error("Job result content must not be empty.");
2489
2548
  }
@@ -2497,7 +2556,7 @@ var MarketplaceService = class {
2497
2556
  );
2498
2557
  }
2499
2558
  const shouldEncrypt = isEncrypted(requestEvent);
2500
- const payload = hasAttachment ? encodeJobPayload({ text: content || void 0, attachment }) : content;
2559
+ const payload = hasAttachment ? encodeJobPayload({ text: content || void 0, attachments }) : content;
2501
2560
  if (shouldEncrypt) {
2502
2561
  const payloadBytes = utf8ByteLength(payload);
2503
2562
  if (payloadBytes > LIMITS.NIP44_MAX_PLAINTEXT_BYTES) {
@@ -2538,11 +2597,11 @@ var MarketplaceService = class {
2538
2597
  * With maxAttempts=3: try, ~1s, try, ~2s, try, throw.
2539
2598
  * Jitter: 0.5x-1.0x of calculated delay.
2540
2599
  */
2541
- async submitJobResultWithRetry(identity, requestEvent, content, amount, maxAttempts = DEFAULTS.RESULT_RETRY_COUNT, baseDelayMs = DEFAULTS.RESULT_RETRY_BASE_MS, attachment) {
2600
+ async submitJobResultWithRetry(identity, requestEvent, content, amount, maxAttempts = DEFAULTS.RESULT_RETRY_COUNT, baseDelayMs = DEFAULTS.RESULT_RETRY_BASE_MS, attachments) {
2542
2601
  const attempts = Math.max(1, maxAttempts);
2543
2602
  for (let attempt = 0; attempt < attempts; attempt++) {
2544
2603
  try {
2545
- return await this.submitJobResult(identity, requestEvent, content, amount, attachment);
2604
+ return await this.submitJobResult(identity, requestEvent, content, amount, attachments);
2546
2605
  } catch (e) {
2547
2606
  if (attempt >= attempts - 1) {
2548
2607
  throw e;
@@ -3701,11 +3760,12 @@ function createBlossomTransport(opts) {
3701
3760
  enc: { alg: "AES-256-GCM", iv, key: wrappedKey }
3702
3761
  };
3703
3762
  },
3704
- async fetchToBytes({ transport, senderPubkey, maxBytes }) {
3763
+ async fetchToBytes({ transport, senderPubkey, maxBytes, signal }) {
3705
3764
  const plaintextCap = maxBytes ?? LIMITS.MAX_BLOSSOM_ENCRYPTED_BYTES;
3706
3765
  const ciphertext = await blossom.download(transport.url, {
3707
3766
  maxBytes: plaintextCap + AES_GCM_TAG_BYTES,
3708
- expectedSha256: transport.sha256
3767
+ expectedSha256: transport.sha256,
3768
+ signal
3709
3769
  });
3710
3770
  return decryptBytesFromSender(
3711
3771
  ciphertext,
@@ -3719,6 +3779,10 @@ function createBlossomTransport(opts) {
3719
3779
  }
3720
3780
 
3721
3781
  // src/transport/file-jobs.ts
3782
+ async function sha256Hex(bytes) {
3783
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
3784
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
3785
+ }
3722
3786
  var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3723
3787
  ".exe",
3724
3788
  ".dll",
@@ -3744,21 +3808,56 @@ function looksExecutable(name, type) {
3744
3808
  const ext = dot >= 0 ? name.slice(dot).toLowerCase() : "";
3745
3809
  return EXECUTABLE_EXTENSIONS.has(ext) || EXECUTABLE_MIMES.has(type);
3746
3810
  }
3747
- async function buildEncryptedFileInput(args) {
3811
+ async function prepareEncryptedFileInput(args) {
3748
3812
  const { file, providerPubkey, identity, blossom } = args;
3749
3813
  const name = file.name ?? "upload";
3750
3814
  if (looksExecutable(name, file.type)) {
3751
3815
  throw new Error("Refusing to upload an executable file.");
3752
3816
  }
3753
- const transport = createBlossomTransport({ blossom, identity });
3754
3817
  const bytes = new Uint8Array(await file.arrayBuffer());
3755
- const member = await transport.seedBytes({ bytes, recipientPubkey: providerPubkey });
3756
- return {
3818
+ if (bytes.byteLength > LIMITS.MAX_BLOSSOM_ENCRYPTED_BYTES) {
3819
+ throw new Error(
3820
+ `File too large for the encrypted-Blossom transport: ${bytes.byteLength} bytes (max ${LIMITS.MAX_BLOSSOM_ENCRYPTED_BYTES}).`
3821
+ );
3822
+ }
3823
+ const { ciphertext, wrappedKey, iv } = await encryptBytesForRecipient(
3824
+ bytes,
3825
+ identity.secretKey,
3826
+ providerPubkey
3827
+ );
3828
+ const sha256 = await sha256Hex(ciphertext);
3829
+ const member = {
3830
+ kind: "blossom",
3831
+ url: blossom.contentUrl(sha256),
3832
+ sha256,
3833
+ enc: { alg: "AES-256-GCM", iv, key: wrappedKey }
3834
+ };
3835
+ const attachment = {
3757
3836
  name,
3758
3837
  size: bytes.byteLength,
3759
3838
  mime: file.type || "application/octet-stream",
3760
3839
  transports: [member]
3761
3840
  };
3841
+ const upload = async () => {
3842
+ const descriptor = await blossom.upload(
3843
+ identity,
3844
+ new Blob([ciphertext], { type: "application/octet-stream" })
3845
+ );
3846
+ if (descriptor.provider !== "blossom") {
3847
+ throw new Error("Blossom upload fell back to a non-content-addressed provider.");
3848
+ }
3849
+ if (descriptor.sha256 !== sha256 || descriptor.url !== member.url) {
3850
+ throw new Error(
3851
+ `Blossom upload descriptor mismatch (expected ${member.url} / ${sha256}, got ${descriptor.url} / ${descriptor.sha256}).`
3852
+ );
3853
+ }
3854
+ };
3855
+ return { attachment, upload };
3856
+ }
3857
+ async function buildEncryptedFileInput(args) {
3858
+ const prepared = await prepareEncryptedFileInput(args);
3859
+ await prepared.upload();
3860
+ return prepared.attachment;
3762
3861
  }
3763
3862
  async function fetchEncryptedFileOutput(args) {
3764
3863
  const { attachment, providerPubkey, identity, blossom, maxBytes } = args;
@@ -4395,6 +4494,7 @@ exports.assertExpiry = assertExpiry;
4395
4494
  exports.assertLamports = assertLamports;
4396
4495
  exports.assetByKey = assetByKey;
4397
4496
  exports.assetKey = assetKey;
4497
+ exports.attachmentsOf = attachmentsOf;
4398
4498
  exports.buildAcceptTransportsTag = buildAcceptTransportsTag;
4399
4499
  exports.buildEncryptedFileInput = buildEncryptedFileInput;
4400
4500
  exports.buildPaymentInstructions = buildPaymentInstructions;
@@ -4431,6 +4531,7 @@ exports.nip44Encrypt = nip44Encrypt;
4431
4531
  exports.parseAssetAmount = parseAssetAmount;
4432
4532
  exports.parsePaymentRequest = parsePaymentRequest;
4433
4533
  exports.pickPercentileFee = pickPercentileFee;
4534
+ exports.prepareEncryptedFileInput = prepareEncryptedFileInput;
4434
4535
  exports.readAcceptedTransports = readAcceptedTransports;
4435
4536
  exports.resolveAssetFromPaymentRequest = resolveAssetFromPaymentRequest;
4436
4537
  exports.resolveKnownAsset = resolveKnownAsset;