@elisym/sdk 0.25.0 → 0.25.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/dist/index.cjs CHANGED
@@ -100,7 +100,9 @@ var DEFAULTS = {
100
100
  IROH_FETCH_TIMEOUT_MS: 3e5,
101
101
  // Ceiling for a single Blossom blob upload (PUT /upload). Large blobs (up to
102
102
  // LIMITS.MAX_FILE_SIZE) need far more than the 30s used for small media images.
103
- BLOSSOM_UPLOAD_TIMEOUT_MS: 3e5
103
+ BLOSSOM_UPLOAD_TIMEOUT_MS: 3e5,
104
+ // Ceiling for a single encrypted Blossom blob download (GET). Same budget as upload.
105
+ BLOSSOM_FETCH_TIMEOUT_MS: 3e5
104
106
  };
105
107
  var LIMITS = {
106
108
  MAX_INPUT_LENGTH: 1e5,
@@ -125,6 +127,12 @@ var LIMITS = {
125
127
  // providers may lower it per deployment.
126
128
  MAX_FILE_SIZE: 1073741824,
127
129
  // 1 GiB
130
+ // Cap for the ENCRYPTED Blossom path (web/SDK). The encrypt-then-upload flow is
131
+ // whole-buffer in WebCrypto + BlossomService (~3x file-size peak RAM), so this is
132
+ // deliberately far below MAX_FILE_SIZE to stay safe in a browser tab; larger files
133
+ // use iroh. The relay enforces a ~128 MiB server-side backstop.
134
+ MAX_BLOSSOM_ENCRYPTED_BYTES: 104857600,
135
+ // 100 MiB
128
136
  MAX_TIMEOUT_SECS: 600,
129
137
  // Upper bound for execution budgets (`max_execution_secs` / `execution_timeout_secs`).
130
138
  // Distinct from MAX_TIMEOUT_SECS (the result-wait cap): execution budgets may be
@@ -1262,6 +1270,62 @@ var BlossomService = class {
1262
1270
  clearTimeout(timer);
1263
1271
  }
1264
1272
  }
1273
+ /**
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.
1276
+ */
1277
+ async download(url, opts = {}) {
1278
+ const maxBytes = opts.maxBytes ?? LIMITS.MAX_FILE_SIZE;
1279
+ const controller = new AbortController();
1280
+ const timer = setTimeout(
1281
+ () => controller.abort(),
1282
+ opts.timeoutMs ?? DEFAULTS.BLOSSOM_FETCH_TIMEOUT_MS
1283
+ );
1284
+ try {
1285
+ const res = await fetch(url, { signal: controller.signal });
1286
+ if (!res.ok) {
1287
+ throw new Error(`Download failed: ${res.status} ${res.statusText}`);
1288
+ }
1289
+ const declared = Number(res.headers.get("content-length"));
1290
+ if (Number.isFinite(declared) && declared > maxBytes) {
1291
+ throw new Error(`Blob too large: ${declared} bytes exceeds limit of ${maxBytes}.`);
1292
+ }
1293
+ if (!res.body) {
1294
+ throw new Error("Download response has no body.");
1295
+ }
1296
+ const reader = res.body.getReader();
1297
+ const chunks = [];
1298
+ let total = 0;
1299
+ let chunk = await reader.read();
1300
+ while (!chunk.done) {
1301
+ total += chunk.value.byteLength;
1302
+ if (total > maxBytes) {
1303
+ await reader.cancel();
1304
+ throw new Error(`Blob exceeds limit of ${maxBytes} bytes.`);
1305
+ }
1306
+ chunks.push(chunk.value);
1307
+ chunk = await reader.read();
1308
+ }
1309
+ const bytes = new Uint8Array(total);
1310
+ let offset = 0;
1311
+ for (const c of chunks) {
1312
+ bytes.set(c, offset);
1313
+ offset += c.byteLength;
1314
+ }
1315
+ if (opts.expectedSha256 !== void 0) {
1316
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
1317
+ const hashHex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
1318
+ if (hashHex !== opts.expectedSha256) {
1319
+ throw new Error(
1320
+ `Download integrity check failed: got ${hashHex}, expected ${opts.expectedSha256}.`
1321
+ );
1322
+ }
1323
+ }
1324
+ return bytes;
1325
+ } finally {
1326
+ clearTimeout(timer);
1327
+ }
1328
+ }
1265
1329
  async uploadToBlossom(identity, bytes, hashHex, mime) {
1266
1330
  const contentType = mime || "application/octet-stream";
1267
1331
  const authHeader = this.authHeader(identity, "upload", hashHex);
@@ -1977,6 +2041,26 @@ var FileTransportSchema = zod.z.discriminatedUnion("kind", [
1977
2041
  kind: zod.z.literal("iroh"),
1978
2042
  /** Opaque iroh `BlobTicket` string. Parsed into a real ticket only at fetch time. */
1979
2043
  ticket: zod.z.string().min(1).max(MAX_TICKET_LENGTH)
2044
+ }),
2045
+ zod.z.object({
2046
+ kind: zod.z.literal("blossom"),
2047
+ /** Public HTTP(S) URL of the CIPHERTEXT blob on a Blossom relay. */
2048
+ url: zod.z.string().url().max(2048),
2049
+ /** sha256 (lowercase hex) of the ciphertext - what the relay stores and addresses. */
2050
+ sha256: zod.z.string().regex(/^[0-9a-f]{64}$/),
2051
+ /**
2052
+ * Hybrid-encryption parameters. The file bytes are AES-256-GCM encrypted with a random
2053
+ * content key; that key is NIP-44-wrapped to the recipient. `name`/`mime`/`size` on the
2054
+ * attachment describe the PLAINTEXT and live only inside the (encrypted) envelope - never
2055
+ * sent to the relay (the relay only ever sees opaque ciphertext).
2056
+ */
2057
+ enc: zod.z.object({
2058
+ alg: zod.z.literal("AES-256-GCM"),
2059
+ /** base64 12-byte GCM IV (non-secret). */
2060
+ iv: zod.z.string().min(1).max(64),
2061
+ /** NIP-44-wrapped content key. */
2062
+ key: zod.z.string().min(1).max(2048)
2063
+ })
1980
2064
  })
1981
2065
  ]);
1982
2066
  var FileAttachmentSchema = zod.z.object({
@@ -1985,8 +2069,18 @@ var FileAttachmentSchema = zod.z.object({
1985
2069
  /** Declared size in bytes (display/hint only; enforcement is on actual streamed bytes). */
1986
2070
  size: zod.z.number().int().nonnegative(),
1987
2071
  mime: zod.z.string().min(1).max(255),
1988
- /** Ordered by sender preference; at least one. */
1989
- transports: zod.z.array(FileTransportSchema).min(1),
2072
+ /**
2073
+ * Ordered by sender preference; at least one KNOWN transport. Parsed leniently: unknown
2074
+ * transport `kind`s are dropped (not rejected) so adding a new transport never makes an older
2075
+ * decoder throw away the whole envelope - it just ignores the kinds it doesn't know and uses
2076
+ * the ones it does. At least one known transport must survive, else the attachment is invalid.
2077
+ */
2078
+ transports: zod.z.array(zod.z.unknown()).transform(
2079
+ (arr) => arr.flatMap((t) => {
2080
+ const parsed = FileTransportSchema.safeParse(t);
2081
+ return parsed.success ? [parsed.data] : [];
2082
+ })
2083
+ ).refine((arr) => arr.length >= 1, { message: "attachment has no known transport" }),
1990
2084
  /** Optional provider hint (unix seconds) for when seeding may stop. */
1991
2085
  seedingExpiresAt: zod.z.number().int().nonnegative().optional()
1992
2086
  });
@@ -1995,6 +2089,37 @@ var JobPayloadEnvelopeSchema = zod.z.object({
1995
2089
  text: zod.z.string().optional(),
1996
2090
  attachment: FileAttachmentSchema.optional()
1997
2091
  });
2092
+ var ACCEPT_TRANSPORTS_TAG = "accept";
2093
+ var KNOWN_TRANSPORT_KINDS = ["iroh", "blossom"];
2094
+ function isKnownTransportKind(value) {
2095
+ return KNOWN_TRANSPORT_KINDS.includes(value);
2096
+ }
2097
+ function buildAcceptTransportsTag(kinds) {
2098
+ const seen = /* @__PURE__ */ new Set();
2099
+ const out = [ACCEPT_TRANSPORTS_TAG];
2100
+ for (const kind of kinds) {
2101
+ if (isKnownTransportKind(kind) && !seen.has(kind)) {
2102
+ seen.add(kind);
2103
+ out.push(kind);
2104
+ }
2105
+ }
2106
+ return out;
2107
+ }
2108
+ function readAcceptedTransports(tags) {
2109
+ const tag = tags.find((t) => t[0] === ACCEPT_TRANSPORTS_TAG);
2110
+ if (tag === void 0) {
2111
+ return void 0;
2112
+ }
2113
+ const seen = /* @__PURE__ */ new Set();
2114
+ const out = [];
2115
+ for (const value of tag.slice(1)) {
2116
+ if (isKnownTransportKind(value) && !seen.has(value)) {
2117
+ seen.add(value);
2118
+ out.push(value);
2119
+ }
2120
+ }
2121
+ return out.length > 0 ? out : void 0;
2122
+ }
1998
2123
  function encodeJobPayload(payload) {
1999
2124
  const envelope = { v: ENVELOPE_VERSION };
2000
2125
  if (payload.text !== void 0) {
@@ -2097,6 +2222,12 @@ var MarketplaceService = class {
2097
2222
  tags.push(["p", options.providerPubkey]);
2098
2223
  tags.push(["encrypted", "nip44"]);
2099
2224
  }
2225
+ if (options.acceptTransports && options.acceptTransports.length > 0) {
2226
+ const acceptTag = buildAcceptTransportsTag(options.acceptTransports);
2227
+ if (acceptTag.length > 1) {
2228
+ tags.push(acceptTag);
2229
+ }
2230
+ }
2100
2231
  const kind = jobRequestKind(options.kindOffset ?? DEFAULT_KIND_OFFSET);
2101
2232
  const event = nostrTools.finalizeEvent(
2102
2233
  {
@@ -3505,6 +3636,147 @@ var ElisymClient = class {
3505
3636
  }
3506
3637
  };
3507
3638
 
3639
+ // src/primitives/file-crypto.ts
3640
+ var KEY_BYTES = 32;
3641
+ var IV_BYTES = 12;
3642
+ function bytesToBase64(bytes) {
3643
+ let bin = "";
3644
+ for (const b of bytes) {
3645
+ bin += String.fromCharCode(b);
3646
+ }
3647
+ return btoa(bin);
3648
+ }
3649
+ function base64ToBytes(b64) {
3650
+ const bin = atob(b64);
3651
+ const out = new Uint8Array(bin.length);
3652
+ for (let i = 0; i < bin.length; i += 1) {
3653
+ out[i] = bin.charCodeAt(i);
3654
+ }
3655
+ return out;
3656
+ }
3657
+ async function encryptBytesForRecipient(bytes, senderSk, recipientPubkey) {
3658
+ const rawKey = crypto.getRandomValues(new Uint8Array(KEY_BYTES));
3659
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
3660
+ const key = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt"]);
3661
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, tagLength: 128 }, key, bytes);
3662
+ const wrappedKey = nip44Encrypt(bytesToBase64(rawKey), senderSk, recipientPubkey);
3663
+ return { ciphertext: new Uint8Array(ct), wrappedKey, iv: bytesToBase64(iv) };
3664
+ }
3665
+ async function decryptBytesFromSender(ciphertext, wrappedKey, iv, receiverSk, senderPubkey) {
3666
+ const rawKey = base64ToBytes(nip44Decrypt(wrappedKey, receiverSk, senderPubkey));
3667
+ const key = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["decrypt"]);
3668
+ const pt = await crypto.subtle.decrypt(
3669
+ { name: "AES-GCM", iv: base64ToBytes(iv), tagLength: 128 },
3670
+ key,
3671
+ ciphertext
3672
+ );
3673
+ return new Uint8Array(pt);
3674
+ }
3675
+
3676
+ // src/transport/blossom-transport.ts
3677
+ var AES_GCM_TAG_BYTES = 16;
3678
+ function createBlossomTransport(opts) {
3679
+ const { blossom, identity } = opts;
3680
+ return {
3681
+ async seedBytes({ bytes, recipientPubkey }) {
3682
+ if (bytes.byteLength > LIMITS.MAX_BLOSSOM_ENCRYPTED_BYTES) {
3683
+ throw new Error(
3684
+ `File too large for encrypted Blossom: ${bytes.byteLength} bytes exceeds ${LIMITS.MAX_BLOSSOM_ENCRYPTED_BYTES}.`
3685
+ );
3686
+ }
3687
+ const { ciphertext, wrappedKey, iv } = await encryptBytesForRecipient(
3688
+ bytes,
3689
+ identity.secretKey,
3690
+ recipientPubkey
3691
+ );
3692
+ const blob = new Blob([ciphertext], { type: "application/octet-stream" });
3693
+ const descriptor = await blossom.upload(identity, blob);
3694
+ if (descriptor.provider !== "blossom") {
3695
+ throw new Error("Blossom upload fell back to a non-content-addressed provider.");
3696
+ }
3697
+ return {
3698
+ kind: "blossom",
3699
+ url: descriptor.url,
3700
+ sha256: descriptor.sha256,
3701
+ enc: { alg: "AES-256-GCM", iv, key: wrappedKey }
3702
+ };
3703
+ },
3704
+ async fetchToBytes({ transport, senderPubkey, maxBytes }) {
3705
+ const plaintextCap = maxBytes ?? LIMITS.MAX_BLOSSOM_ENCRYPTED_BYTES;
3706
+ const ciphertext = await blossom.download(transport.url, {
3707
+ maxBytes: plaintextCap + AES_GCM_TAG_BYTES,
3708
+ expectedSha256: transport.sha256
3709
+ });
3710
+ return decryptBytesFromSender(
3711
+ ciphertext,
3712
+ transport.enc.key,
3713
+ transport.enc.iv,
3714
+ identity.secretKey,
3715
+ senderPubkey
3716
+ );
3717
+ }
3718
+ };
3719
+ }
3720
+
3721
+ // src/transport/file-jobs.ts
3722
+ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3723
+ ".exe",
3724
+ ".dll",
3725
+ ".bat",
3726
+ ".cmd",
3727
+ ".com",
3728
+ ".msi",
3729
+ ".sh",
3730
+ ".app",
3731
+ ".scr",
3732
+ ".ps1"
3733
+ ]);
3734
+ var EXECUTABLE_MIMES = /* @__PURE__ */ new Set([
3735
+ "application/x-msdownload",
3736
+ "application/x-msdos-program",
3737
+ "application/x-sh",
3738
+ "application/x-executable",
3739
+ "application/vnd.microsoft.portable-executable",
3740
+ "application/x-mach-binary"
3741
+ ]);
3742
+ function looksExecutable(name, type) {
3743
+ const dot = name.lastIndexOf(".");
3744
+ const ext = dot >= 0 ? name.slice(dot).toLowerCase() : "";
3745
+ return EXECUTABLE_EXTENSIONS.has(ext) || EXECUTABLE_MIMES.has(type);
3746
+ }
3747
+ async function buildEncryptedFileInput(args) {
3748
+ const { file, providerPubkey, identity, blossom } = args;
3749
+ const name = file.name ?? "upload";
3750
+ if (looksExecutable(name, file.type)) {
3751
+ throw new Error("Refusing to upload an executable file.");
3752
+ }
3753
+ const transport = createBlossomTransport({ blossom, identity });
3754
+ const bytes = new Uint8Array(await file.arrayBuffer());
3755
+ const member = await transport.seedBytes({ bytes, recipientPubkey: providerPubkey });
3756
+ return {
3757
+ name,
3758
+ size: bytes.byteLength,
3759
+ mime: file.type || "application/octet-stream",
3760
+ transports: [member]
3761
+ };
3762
+ }
3763
+ async function fetchEncryptedFileOutput(args) {
3764
+ const { attachment, providerPubkey, identity, blossom, maxBytes } = args;
3765
+ const member = attachment.transports.find(
3766
+ (t) => t.kind === "blossom"
3767
+ );
3768
+ if (member === void 0) {
3769
+ throw new Error("Attachment has no blossom transport.");
3770
+ }
3771
+ const transport = createBlossomTransport({ blossom, identity });
3772
+ const bytes = await transport.fetchToBytes({
3773
+ transport: member,
3774
+ senderPubkey: providerPubkey,
3775
+ maxBytes
3776
+ });
3777
+ return { bytes, name: attachment.name, mime: attachment.mime };
3778
+ }
3779
+
3508
3780
  // src/services/jobErrors.ts
3509
3781
  var AGENT_UNAVAILABLE_MARKERS = [
3510
3782
  "agent temporarily unavailable",
@@ -4076,6 +4348,7 @@ function makeCensor() {
4076
4348
  };
4077
4349
  }
4078
4350
 
4351
+ exports.ACCEPT_TRANSPORTS_TAG = ACCEPT_TRANSPORTS_TAG;
4079
4352
  exports.BlossomService = BlossomService;
4080
4353
  exports.BoundedSet = BoundedSet;
4081
4354
  exports.DEFAULTS = DEFAULTS;
@@ -4122,6 +4395,8 @@ exports.assertExpiry = assertExpiry;
4122
4395
  exports.assertLamports = assertLamports;
4123
4396
  exports.assetByKey = assetByKey;
4124
4397
  exports.assetKey = assetKey;
4398
+ exports.buildAcceptTransportsTag = buildAcceptTransportsTag;
4399
+ exports.buildEncryptedFileInput = buildEncryptedFileInput;
4125
4400
  exports.buildPaymentInstructions = buildPaymentInstructions;
4126
4401
  exports.calculateProtocolFee = calculateProtocolFee;
4127
4402
  exports.classifyJobError = classifyJobError;
@@ -4130,13 +4405,17 @@ exports.clearProtocolConfigCache = clearProtocolConfigCache;
4130
4405
  exports.clearQuickVerifyCache = clearQuickVerifyCache;
4131
4406
  exports.compareAgentsByRank = compareAgentsByRank;
4132
4407
  exports.computeRankKey = computeRankKey;
4408
+ exports.createBlossomTransport = createBlossomTransport;
4133
4409
  exports.createPaymentRequestWithOnchainConfig = createPaymentRequestWithOnchainConfig;
4134
4410
  exports.createSlidingWindowLimiter = createSlidingWindowLimiter;
4135
4411
  exports.decodeJobPayload = decodeJobPayload;
4412
+ exports.decryptBytesFromSender = decryptBytesFromSender;
4136
4413
  exports.encodeJobPayload = encodeJobPayload;
4414
+ exports.encryptBytesForRecipient = encryptBytesForRecipient;
4137
4415
  exports.estimateNetworkBaseline = estimateNetworkBaseline;
4138
4416
  exports.estimatePriorityFeeMicroLamports = estimatePriorityFeeMicroLamports;
4139
4417
  exports.estimateSolFeeLamports = estimateSolFeeLamports;
4418
+ exports.fetchEncryptedFileOutput = fetchEncryptedFileOutput;
4140
4419
  exports.formatAssetAmount = formatAssetAmount;
4141
4420
  exports.formatFeeBreakdown = formatFeeBreakdown;
4142
4421
  exports.formatNetworkBaseline = formatNetworkBaseline;
@@ -4152,6 +4431,7 @@ exports.nip44Encrypt = nip44Encrypt;
4152
4431
  exports.parseAssetAmount = parseAssetAmount;
4153
4432
  exports.parsePaymentRequest = parsePaymentRequest;
4154
4433
  exports.pickPercentileFee = pickPercentileFee;
4434
+ exports.readAcceptedTransports = readAcceptedTransports;
4155
4435
  exports.resolveAssetFromPaymentRequest = resolveAssetFromPaymentRequest;
4156
4436
  exports.resolveKnownAsset = resolveKnownAsset;
4157
4437
  exports.timeAgo = timeAgo;