@aithos/sdk 0.1.0-alpha.39 → 0.1.0-alpha.40

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.
Files changed (45) hide show
  1. package/dist/src/assets.d.ts +207 -0
  2. package/dist/src/assets.js +533 -0
  3. package/dist/src/index.d.ts +1 -0
  4. package/dist/src/index.js +6 -0
  5. package/dist/src/react/AithosAsset.d.ts +66 -0
  6. package/dist/src/react/AithosAsset.js +67 -0
  7. package/dist/src/react/context.d.ts +29 -0
  8. package/dist/src/react/context.js +31 -0
  9. package/dist/src/react/index.d.ts +28 -0
  10. package/dist/src/react/index.js +30 -0
  11. package/dist/src/react/use-aithos-asset.d.ts +39 -0
  12. package/dist/src/react/use-aithos-asset.js +118 -0
  13. package/package.json +16 -2
  14. package/dist/test/auth-j3.test.d.ts +0 -2
  15. package/dist/test/auth-j3.test.js +0 -391
  16. package/dist/test/auth.test.d.ts +0 -2
  17. package/dist/test/auth.test.js +0 -175
  18. package/dist/test/compute-delegate-path.test.d.ts +0 -2
  19. package/dist/test/compute-delegate-path.test.js +0 -183
  20. package/dist/test/compute.test.d.ts +0 -2
  21. package/dist/test/compute.test.js +0 -194
  22. package/dist/test/endpoints.test.d.ts +0 -2
  23. package/dist/test/endpoints.test.js +0 -62
  24. package/dist/test/envelope.test.d.ts +0 -2
  25. package/dist/test/envelope.test.js +0 -318
  26. package/dist/test/ethos-first-edition.test.d.ts +0 -2
  27. package/dist/test/ethos-first-edition.test.js +0 -248
  28. package/dist/test/ethos.test.d.ts +0 -2
  29. package/dist/test/ethos.test.js +0 -219
  30. package/dist/test/key-store.test.d.ts +0 -2
  31. package/dist/test/key-store.test.js +0 -161
  32. package/dist/test/mandates-compute.test.d.ts +0 -2
  33. package/dist/test/mandates-compute.test.js +0 -256
  34. package/dist/test/mandates.test.d.ts +0 -2
  35. package/dist/test/mandates.test.js +0 -93
  36. package/dist/test/sdk.test.d.ts +0 -2
  37. package/dist/test/sdk.test.js +0 -126
  38. package/dist/test/signer.test.d.ts +0 -2
  39. package/dist/test/signer.test.js +0 -117
  40. package/dist/test/signup-bootstrap.test.d.ts +0 -2
  41. package/dist/test/signup-bootstrap.test.js +0 -311
  42. package/dist/test/wallet.test.d.ts +0 -2
  43. package/dist/test/wallet.test.js +0 -121
  44. package/dist/test/web.test.d.ts +0 -2
  45. package/dist/test/web.test.js +0 -270
@@ -0,0 +1,533 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 Mathieu Colla
3
+ /**
4
+ * `sdk.assets` — high-level API for the Aithos assets sub-protocol PDS.
5
+ *
6
+ * Stores binary content (images, PDFs, audio, video) owned by a
7
+ * subject, encrypted client-side under per-asset AMKs (Asset Master
8
+ * Keys), accessible to authorized apps via signed mandates (v0.2).
9
+ *
10
+ * const assets = sdk.assets;
11
+ * const avatar = await assets.upload({
12
+ * bytes: pngBuffer,
13
+ * mediaType: "image/png",
14
+ * attachTo: { ethos: { zone: "public", sectionId: "sec_identity" } },
15
+ * });
16
+ * // avatar.url is a stable CloudFront URL (public asset)
17
+ *
18
+ * const cv = await assets.upload({
19
+ * bytes: pdfBuffer,
20
+ * mediaType: "application/pdf",
21
+ * attachTo: { ethos: { zone: "circle", sectionId: "sec_career_docs" } },
22
+ * });
23
+ * // cv is private: stored encrypted, fetched via short-lived presigned URL.
24
+ *
25
+ * const bytes = await assets.fetch(cv.urn);
26
+ * // decrypted plaintext returned to the caller
27
+ *
28
+ * The module wires:
29
+ * - AMK generation + wrap (via @aithos/assets-crypto)
30
+ * - Bytes encryption with the canonical nonce-prefix on-disk layout
31
+ * - RecipientResolver (v0.2-ethos: maps {zone} → recipient set)
32
+ * - Direct S3 PUT against the presigned URL returned by init_upload
33
+ * - In-memory AMK cache (per asset URN) for sub-second re-fetches
34
+ * - Signed envelope JSON-RPC dispatch to /mcp/primitives/{read,write}
35
+ *
36
+ * Spec ref: spec/assets/ in the aithos-protocol repo.
37
+ */
38
+ import { generateAMK, wrapAMKForRecipient, unwrapAMK, encryptAssetBytes, decryptAssetBytes, plaintextSha256Hex, } from "@aithos/assets-crypto";
39
+ import { x25519 } from "@noble/curves/ed25519.js";
40
+ import { sha512 } from "@noble/hashes/sha2.js";
41
+ import { signOwnerEnvelope } from "./internal/envelope.js";
42
+ /* -------------------------------------------------------------------------- */
43
+ /* Public factory */
44
+ /* -------------------------------------------------------------------------- */
45
+ export function createAssetsClient(args) {
46
+ return new AssetsClient(args);
47
+ }
48
+ /* -------------------------------------------------------------------------- */
49
+ /* AssetsClient implementation */
50
+ /* -------------------------------------------------------------------------- */
51
+ export class AssetsClient {
52
+ #pdsUrl;
53
+ #did;
54
+ #seed;
55
+ #verificationMethod;
56
+ #fetch;
57
+ #resolver;
58
+ /** Cache: URN → AMK (recovered on first fetch, reused thereafter). */
59
+ #amkCache = new Map();
60
+ constructor(args) {
61
+ this.#pdsUrl = args.pdsUrl.replace(/\/$/, "");
62
+ this.#did = args.did;
63
+ this.#seed = args.sphereSeed;
64
+ this.#verificationMethod = args.verificationMethod;
65
+ this.#fetch = args.fetch ?? globalThis.fetch.bind(globalThis);
66
+ this.#resolver = args.recipientResolver ?? defaultSelfResolver(args);
67
+ }
68
+ /* ------------------------------------------------------------------ */
69
+ /* upload */
70
+ /* ------------------------------------------------------------------ */
71
+ async upload(input) {
72
+ const regime = resolveRegime(input);
73
+ const sha = plaintextSha256Hex(input.bytes);
74
+ // For private regime, generate AMK + wraps eagerly so the
75
+ // init_upload call already carries the envelope (the server
76
+ // records it as part of the pending-upload metadata).
77
+ let amk;
78
+ let amkEnvelope;
79
+ let recipientSet;
80
+ if (regime === "private") {
81
+ recipientSet = await this.#resolver.resolve({
82
+ subjectDid: this.#did,
83
+ context: input.attachTo,
84
+ });
85
+ if (recipientSet.recipients.length === 0) {
86
+ throw new Error("sdk.assets.upload: private regime requires at least one recipient — check RecipientResolver");
87
+ }
88
+ amk = generateAMK();
89
+ // The bytes encryption nonce is computed inside encryptAssetBytes;
90
+ // the wrap list will be filled below once we know the final URN
91
+ // (it's bound into the AAD via assetUrn).
92
+ // We pre-compose a placeholder envelope to satisfy server
93
+ // validation, then re-emit with the real URN at init+complete.
94
+ amkEnvelope = {
95
+ alg: "xchacha20poly1305-ietf",
96
+ nonce: "",
97
+ wraps: [],
98
+ };
99
+ }
100
+ // init_upload
101
+ const initResp = (await this.#callWrite("aithos.assets.init_upload", {
102
+ subject_did: this.#did,
103
+ media_type: input.mediaType,
104
+ size_bytes: input.bytes.length,
105
+ sha256_of_plaintext: sha,
106
+ ...(input.attachTo ? { attached_context: serializeContext(input.attachTo) } : {}),
107
+ regime,
108
+ ...(input.forwardSecrecy ? { forward_secrecy: input.forwardSecrecy } : {}),
109
+ ...(amkEnvelope ? { amk_envelope: amkEnvelope } : {}),
110
+ }));
111
+ if (initResp.result === "dedup_hit") {
112
+ // Asset already exists — return existing URN; no PUT needed.
113
+ const asset = initResp.asset;
114
+ if (!asset) {
115
+ throw new Error("sdk.assets.upload: dedup_hit response is missing the asset metadata");
116
+ }
117
+ return {
118
+ urn: initResp.urn,
119
+ assetId: initResp.asset_id,
120
+ mediaType: asset.media_type,
121
+ sizeBytes: asset.size_bytes,
122
+ sha256OfPlaintext: asset.sha256_of_plaintext,
123
+ encrypted: asset.encrypted,
124
+ url: !asset.encrypted ? composePublicUrl(asset) : undefined,
125
+ dedupHit: true,
126
+ };
127
+ }
128
+ const finalUrn = initResp.urn;
129
+ // Build the real wraps using the final URN.
130
+ let blob;
131
+ let nonceB64;
132
+ let finalWraps = [];
133
+ if (regime === "private") {
134
+ finalWraps = recipientSet.recipients.map((r) => wrapAMKForRecipient({
135
+ amk: amk,
136
+ recipientPublicKey: r.x25519PublicKey,
137
+ recipientDidUrl: r.didUrl,
138
+ assetUrn: finalUrn,
139
+ }));
140
+ const enc = encryptAssetBytes({
141
+ amk: amk,
142
+ assetUrn: finalUrn,
143
+ plaintext: input.bytes,
144
+ });
145
+ blob = enc.blob;
146
+ nonceB64 = enc.nonce_b64;
147
+ }
148
+ else {
149
+ blob = input.bytes;
150
+ }
151
+ // PUT bytes to S3. TS lib.dom typings expect BodyInit; a plain
152
+ // Uint8Array is accepted at runtime by all modern fetch
153
+ // implementations (browser, Node 18+, undici), but the lib.dom
154
+ // signature requires a cast to a BodyInit-compatible view.
155
+ const putResp = await this.#fetch(initResp.upload_url, {
156
+ method: "PUT",
157
+ headers: {
158
+ "content-type": regime === "private" ? "application/octet-stream" : input.mediaType,
159
+ },
160
+ body: blob,
161
+ });
162
+ if (!putResp.ok) {
163
+ throw new Error(`sdk.assets.upload: S3 PUT failed with status ${putResp.status}`);
164
+ }
165
+ // complete_upload (with real AMK envelope for private uploads)
166
+ const completePayload = {
167
+ upload_session: initResp.upload_session,
168
+ observed_sha256_of_plaintext: sha,
169
+ };
170
+ if (regime === "private") {
171
+ // The server stores the amk_envelope from init_upload — for v0.1
172
+ // we don't have a separate "update_envelope_on_complete" hook,
173
+ // so we recompose it here client-side and call authorize_grantee-
174
+ // style updates if we need to push real wraps. The simpler v0.1
175
+ // workaround: include the envelope in init_upload from the start
176
+ // by precomputing the URN client-side — but the URN is allocated
177
+ // by the server. For now we ship the envelope on complete via a
178
+ // backwards-compatible extension field, and the backend stores
179
+ // it on the asset doc.
180
+ completePayload.amk_envelope = {
181
+ alg: "xchacha20poly1305-ietf",
182
+ nonce: nonceB64,
183
+ wraps: finalWraps,
184
+ };
185
+ }
186
+ const completeResp = (await this.#callWrite("aithos.assets.complete_upload", completePayload));
187
+ // Cache the AMK locally for the lifetime of this session.
188
+ if (amk) {
189
+ this.#amkCache.set(finalUrn, amk);
190
+ }
191
+ return {
192
+ urn: completeResp.urn,
193
+ assetId: initResp.asset_id,
194
+ mediaType: input.mediaType,
195
+ sizeBytes: input.bytes.length,
196
+ sha256OfPlaintext: sha,
197
+ encrypted: regime === "private",
198
+ url: regime === "public"
199
+ ? composePublicUrl(completeResp.asset)
200
+ : undefined,
201
+ dedupHit: false,
202
+ };
203
+ }
204
+ /* ------------------------------------------------------------------ */
205
+ /* uploadWithThumbnails */
206
+ /* ------------------------------------------------------------------ */
207
+ /**
208
+ * Upload a primary asset plus one or more thumbnails (downscaled
209
+ * client-side). Convenience method for the Deep avatar use case and
210
+ * any UI that displays the same asset at multiple resolutions.
211
+ *
212
+ * The thumbnails are attached to the same context as the primary
213
+ * and uploaded in parallel. They carry the {@link AssetReference}
214
+ * role `"thumbnail"` when referenced from a section (see
215
+ * spec/assets/03-asset-descriptors.md §3.2.3).
216
+ */
217
+ async uploadWithThumbnails(input) {
218
+ const primary = await this.upload(input);
219
+ const thumbnails = await Promise.all(input.sizes.map(async (size) => {
220
+ const scaledBytes = await input.downscale(input.bytes, size);
221
+ const thumb = await this.upload({
222
+ ...input,
223
+ bytes: scaledBytes,
224
+ // Thumbnails inherit the primary's attachTo so they live in
225
+ // the same recipient set.
226
+ });
227
+ return { size, result: thumb };
228
+ }));
229
+ return { primary, thumbnails };
230
+ }
231
+ /* ------------------------------------------------------------------ */
232
+ /* fetch */
233
+ /* ------------------------------------------------------------------ */
234
+ async fetch(urn) {
235
+ const resp = (await this.#callRead("aithos.assets.get_asset", {
236
+ urn,
237
+ }));
238
+ const asset = resp.asset;
239
+ const r = await this.#fetch(resp.fetch_url);
240
+ if (!r.ok) {
241
+ throw new Error(`sdk.assets.fetch: byte fetch failed with status ${r.status}`);
242
+ }
243
+ const blob = new Uint8Array(await r.arrayBuffer());
244
+ let bytes;
245
+ if (asset.encrypted) {
246
+ const amk = this.#amkCache.get(urn) ??
247
+ this.#unwrapAmkForSelf(urn, asset.amk_envelope);
248
+ this.#amkCache.set(urn, amk);
249
+ bytes = decryptAssetBytes({
250
+ amk,
251
+ assetUrn: urn,
252
+ blob,
253
+ expectedSha256Hex: asset.sha256_of_plaintext,
254
+ });
255
+ }
256
+ else {
257
+ bytes = blob;
258
+ // SHA verification still applies for public assets.
259
+ if (plaintextSha256Hex(bytes) !== asset.sha256_of_plaintext) {
260
+ throw new Error("sdk.assets.fetch: plaintext SHA-256 mismatch on public asset");
261
+ }
262
+ }
263
+ return {
264
+ urn,
265
+ mediaType: asset.media_type,
266
+ sizeBytes: asset.size_bytes,
267
+ bytes,
268
+ sha256OfPlaintext: asset.sha256_of_plaintext,
269
+ };
270
+ }
271
+ /* ------------------------------------------------------------------ */
272
+ /* head */
273
+ /* ------------------------------------------------------------------ */
274
+ async head(urn) {
275
+ const r = (await this.#callRead("aithos.assets.head_asset", {
276
+ urn,
277
+ }));
278
+ return r.asset;
279
+ }
280
+ /* ------------------------------------------------------------------ */
281
+ /* list */
282
+ /* ------------------------------------------------------------------ */
283
+ async list(opts = {}) {
284
+ const params = {
285
+ subject_did: this.#did,
286
+ };
287
+ if (opts.limit !== undefined)
288
+ params.limit = opts.limit;
289
+ if (opts.cursor)
290
+ params.cursor = opts.cursor;
291
+ if (opts.order)
292
+ params.order = opts.order;
293
+ if (opts.includeOrphaned)
294
+ params.include_orphaned = true;
295
+ if (opts.includeTombstoned)
296
+ params.include_tombstoned = true;
297
+ if (opts.filter) {
298
+ const f = {};
299
+ if (opts.filter.mediaTypePrefix)
300
+ f.media_type_prefix = opts.filter.mediaTypePrefix;
301
+ if (opts.filter.sizeBytes)
302
+ f.size_bytes = opts.filter.sizeBytes;
303
+ if (opts.filter.createdAfter)
304
+ f.created_after = opts.filter.createdAfter;
305
+ if (opts.filter.createdBefore)
306
+ f.created_before = opts.filter.createdBefore;
307
+ params.filter = f;
308
+ }
309
+ const r = (await this.#callRead("aithos.assets.list_assets", params));
310
+ return {
311
+ items: r.items.map((it) => ({
312
+ urn: it.urn,
313
+ assetId: it.asset_id,
314
+ mediaType: it.media_type,
315
+ sizeBytes: it.size_bytes,
316
+ sha256OfPlaintext: it.sha256_of_plaintext,
317
+ encrypted: it.encrypted,
318
+ state: it.state,
319
+ referenceCount: it.reference_count,
320
+ createdAt: it.created_at,
321
+ modifiedAt: it.modified_at,
322
+ })),
323
+ ...(r.next_cursor ? { nextCursor: r.next_cursor } : {}),
324
+ };
325
+ }
326
+ /* ------------------------------------------------------------------ */
327
+ /* ref / unref / listReferences */
328
+ /* ------------------------------------------------------------------ */
329
+ async ref(urn, reference) {
330
+ const r = (await this.#callWrite("aithos.assets.ref_asset", {
331
+ urn,
332
+ reference,
333
+ }));
334
+ return { referenceCount: r.reference_count, gammaRef: r.gamma_ref };
335
+ }
336
+ async unref(urn, reference) {
337
+ const r = (await this.#callWrite("aithos.assets.unref_asset", {
338
+ urn,
339
+ reference,
340
+ }));
341
+ return { referenceCount: r.reference_count, gammaRef: r.gamma_ref };
342
+ }
343
+ async listReferences(urn) {
344
+ const r = (await this.#callRead("aithos.assets.list_references", {
345
+ urn,
346
+ }));
347
+ return r.items;
348
+ }
349
+ /* ------------------------------------------------------------------ */
350
+ /* delete */
351
+ /* ------------------------------------------------------------------ */
352
+ async delete(urn) {
353
+ const r = (await this.#callWrite("aithos.assets.delete_asset", {
354
+ urn,
355
+ }));
356
+ this.#amkCache.delete(urn);
357
+ return { tombstonedAt: r.tombstoned_at, gammaRef: r.gamma_ref };
358
+ }
359
+ /* ------------------------------------------------------------------ */
360
+ /* reset cache */
361
+ /* ------------------------------------------------------------------ */
362
+ /** Zero in-memory AMK cache. Useful at user logout. */
363
+ reset() {
364
+ for (const k of this.#amkCache.values())
365
+ k.fill(0);
366
+ this.#amkCache.clear();
367
+ }
368
+ /* ------------------------------------------------------------------ */
369
+ /* Internals — envelope dispatch */
370
+ /* ------------------------------------------------------------------ */
371
+ async #callRead(method, params) {
372
+ return this.#dispatch("/mcp/primitives/read", method, params);
373
+ }
374
+ async #callWrite(method, params) {
375
+ return this.#dispatch("/mcp/primitives/write", method, params);
376
+ }
377
+ async #dispatch(path, method, params) {
378
+ const aud = `${this.#pdsUrl}${path}`;
379
+ const envelope = await this.#signEnvelope({ aud, method, params });
380
+ const body = {
381
+ jsonrpc: "2.0",
382
+ id: makeUlidLite(),
383
+ method,
384
+ params: { ...params, _envelope: envelope },
385
+ };
386
+ const r = await this.#fetch(aud, {
387
+ method: "POST",
388
+ headers: { "content-type": "application/json" },
389
+ body: JSON.stringify(body),
390
+ });
391
+ const json = (await r.json());
392
+ if (json.error) {
393
+ const err = new Error(json.error.message);
394
+ err.code = json.error.code;
395
+ err.data = json.error.data;
396
+ throw err;
397
+ }
398
+ return json.result ?? {};
399
+ }
400
+ async #signEnvelope(args) {
401
+ return signOwnerEnvelope({
402
+ iss: this.#did,
403
+ aud: args.aud,
404
+ method: args.method,
405
+ params: args.params,
406
+ verificationMethod: this.#verificationMethod,
407
+ signer: {
408
+ sign: async (msg) => {
409
+ // We delegate to @noble/ed25519 directly to avoid pulling the
410
+ // SDK's Signer abstraction surface into this module. The
411
+ // dependency is already present transitively via assets-crypto.
412
+ const { signAsync } = await import("@noble/ed25519");
413
+ return signAsync(msg, this.#seed);
414
+ },
415
+ },
416
+ });
417
+ }
418
+ /* ------------------------------------------------------------------ */
419
+ /* Internals — AMK unwrap for self */
420
+ /* ------------------------------------------------------------------ */
421
+ #unwrapAmkForSelf(urn, envelope) {
422
+ const ourPub = ed25519SeedToX25519PublicKey(this.#seed);
423
+ void ourPub;
424
+ const ourPriv = ed25519SeedToX25519PrivateKey(this.#seed);
425
+ // Try every wrap that names a recipient under our DID. The DID
426
+ // URL fragment is unconstrained — `#kex`, `#circle-kex`, `#self-kex`
427
+ // are all candidates. We attempt each wrap whose `recipient`
428
+ // starts with our DID; AAD binding makes wrong attempts fail
429
+ // cleanly.
430
+ const ours = envelope.wraps.filter((w) => w.recipient.startsWith(this.#did));
431
+ for (const wrap of ours) {
432
+ try {
433
+ return unwrapAMK({
434
+ wrap,
435
+ recipientPrivateKey: ourPriv,
436
+ assetUrn: urn,
437
+ });
438
+ }
439
+ catch {
440
+ // Wrong fragment / wrong key — try the next wrap.
441
+ }
442
+ }
443
+ throw new Error(`sdk.assets.fetch: no AMK wrap for ${this.#did} found in asset ${urn}`);
444
+ }
445
+ }
446
+ /* -------------------------------------------------------------------------- */
447
+ /* Default RecipientResolver — self-only */
448
+ /* -------------------------------------------------------------------------- */
449
+ function defaultSelfResolver(args) {
450
+ return {
451
+ async resolve(input) {
452
+ // For the default self-only resolver we always wrap to the
453
+ // subject's own X25519 sphere key. Other recipients (grantees)
454
+ // can be added explicitly later via authorize_grantee in v0.2.
455
+ const pub = ed25519SeedToX25519PublicKey(args.sphereSeed);
456
+ const didUrl = recipientDidUrlForContext(input.subjectDid, input.context);
457
+ return {
458
+ recipients: [{ didUrl, x25519PublicKey: pub }],
459
+ };
460
+ },
461
+ };
462
+ }
463
+ /** Pick a DID URL fragment matching the attaching zone. */
464
+ function recipientDidUrlForContext(subjectDid, context) {
465
+ if (context?.ethos?.zone === "circle")
466
+ return `${subjectDid}#circle-kex`;
467
+ if (context?.ethos?.zone === "self")
468
+ return `${subjectDid}#self-kex`;
469
+ if (context?.data)
470
+ return `${subjectDid}#data-kex`;
471
+ return `${subjectDid}#kex`;
472
+ }
473
+ function resolveRegime(input) {
474
+ if (input.regime === "public")
475
+ return "public";
476
+ if (input.regime === "private")
477
+ return "private";
478
+ if (input.attachTo?.ethos?.zone === "public")
479
+ return "public";
480
+ return "private";
481
+ }
482
+ function serializeContext(ctx) {
483
+ if (ctx.ethos) {
484
+ return {
485
+ kind: "ethos",
486
+ zone: ctx.ethos.zone,
487
+ ...(ctx.ethos.sectionId ? { section_id: ctx.ethos.sectionId } : {}),
488
+ };
489
+ }
490
+ if (ctx.data) {
491
+ return {
492
+ kind: "data",
493
+ collection_urn: ctx.data.collectionUrn,
494
+ ...(ctx.data.recordId ? { record_id: ctx.data.recordId } : {}),
495
+ };
496
+ }
497
+ return {};
498
+ }
499
+ function composePublicUrl(asset) {
500
+ return asset.public_url;
501
+ }
502
+ /**
503
+ * Derive a 32-byte X25519 private key from an Ed25519 seed via
504
+ * SHA-512 truncation + clamping. Same construction libsodium uses for
505
+ * `crypto_sign_ed25519_sk_to_curve25519`.
506
+ */
507
+ function ed25519SeedToX25519PrivateKey(seed) {
508
+ if (seed.length !== 32)
509
+ throw new Error("Ed25519 seed must be 32 bytes");
510
+ const h = sha512(seed);
511
+ const sk = new Uint8Array(h.slice(0, 32));
512
+ // Clamp per X25519 spec
513
+ sk[0] = sk[0] & 248;
514
+ sk[31] = sk[31] & 127;
515
+ sk[31] = sk[31] | 64;
516
+ return sk;
517
+ }
518
+ function ed25519SeedToX25519PublicKey(seed) {
519
+ const sk = ed25519SeedToX25519PrivateKey(seed);
520
+ try {
521
+ return x25519.getPublicKey(sk);
522
+ }
523
+ finally {
524
+ sk.fill(0);
525
+ }
526
+ }
527
+ function makeUlidLite() {
528
+ // Lightweight monotonic-ish id for JSON-RPC `id` field — not
529
+ // crypto-grade. Production code uses ulid().
530
+ return (Date.now().toString(36) +
531
+ Math.random().toString(36).substring(2, 10));
532
+ }
533
+ //# sourceMappingURL=assets.js.map
@@ -24,4 +24,5 @@ export * as onboarding from "./onboarding.js";
24
24
  export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
25
25
  export type { Section } from "@aithos/protocol-client";
26
26
  export { createDataClient, type CreateDataClientArgs, type DataClient, type DataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
27
+ export { createAssetsClient, AssetsClient, type CreateAssetsClientArgs, type AttachedContext, type AssetUploadInput, type AssetUploadResult, type AssetFetchResult, type AssetBrief, type ListAssetsOpts, type ThumbnailUploadInput, type ThumbnailUploadResult, type RecipientResolver, type RecipientSet, } from "./assets.js";
27
28
  //# sourceMappingURL=index.d.ts.map
package/dist/src/index.js CHANGED
@@ -62,4 +62,10 @@ export { createBrowserIdentity, browserIdentityFromStored, } from "@aithos/proto
62
62
  // the lifecycle of subject-owned, encrypted, schema-validated records.
63
63
  // See spec/data/ in the aithos-protocol repo.
64
64
  export { createDataClient, } from "./data.js";
65
+ // `sdk.assets` — Aithos assets sub-protocol PDS client. Upload,
66
+ // fetch, list, ref/unref binary content (images, PDFs, audio, video)
67
+ // owned by a subject. AEAD-encrypted per-asset under AMKs wrapped for
68
+ // the subject's X25519 sphere keys (and, in v0.2, mandate grantees).
69
+ // See spec/assets/ in the aithos-protocol repo.
70
+ export { createAssetsClient, AssetsClient, } from "./assets.js";
65
71
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `<AithosAsset>` — drop-in component for displaying Aithos-hosted
3
+ * binary content with the simplest possible API:
4
+ *
5
+ * <AithosAsset urn={cv.urn} alt="CV" className="w-full" />
6
+ * <AithosAsset urn={video.urn} as="video" controls />
7
+ * <AithosAsset urn={song.urn} as="audio" controls />
8
+ * <AithosAsset urn={cv.urn} as="download" filename="cv.pdf">
9
+ * Download my CV (PDF)
10
+ * </AithosAsset>
11
+ *
12
+ * The component handles the full lifecycle:
13
+ * - Fetch + decrypt via the assets client (from context or `client` prop).
14
+ * - Show `fallback` while loading.
15
+ * - Show `errorFallback` (or alt text in img-mode) on failure.
16
+ * - Revoke the `blob:` URL on unmount or URN change.
17
+ *
18
+ * For public assets attached to the Ethos public zone, you can also
19
+ * just use the stable CloudFront URL directly (`<img src={asset.url} />`).
20
+ * This component is meant for private regime assets that require
21
+ * client-side decryption.
22
+ */
23
+ import type { ReactNode, ImgHTMLAttributes, VideoHTMLAttributes, AudioHTMLAttributes, AnchorHTMLAttributes } from "react";
24
+ import type { AssetsClient } from "../assets.js";
25
+ interface AithosAssetBaseProps {
26
+ /** Asset URN, e.g. `urn:aithos:asset:did:aithos:z6Mkr…:asset_01J…`. */
27
+ readonly urn: string;
28
+ /** Override the assets client from context. */
29
+ readonly client?: AssetsClient | null;
30
+ /** Rendered while the fetch+decrypt is in flight. */
31
+ readonly fallback?: ReactNode;
32
+ /** Rendered on fetch/decrypt failure. `as="img"` defaults to showing alt instead. */
33
+ readonly errorFallback?: ReactNode;
34
+ /** Called once the asset is decrypted and ready to display. */
35
+ readonly onLoad?: () => void;
36
+ /** Called when the fetch/decrypt fails. */
37
+ readonly onError?: (err: Error) => void;
38
+ /**
39
+ * If `true`, keep the previous asset visible while a new URN is
40
+ * being fetched, instead of flashing the fallback. Default `false`.
41
+ */
42
+ readonly keepPreviousOnUrnChange?: boolean;
43
+ }
44
+ export interface AithosImageProps extends AithosAssetBaseProps, Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "onLoad" | "onError"> {
45
+ readonly as?: "img";
46
+ }
47
+ export interface AithosVideoProps extends AithosAssetBaseProps, Omit<VideoHTMLAttributes<HTMLVideoElement>, "src" | "onLoad" | "onError"> {
48
+ readonly as: "video";
49
+ }
50
+ export interface AithosAudioProps extends AithosAssetBaseProps, Omit<AudioHTMLAttributes<HTMLAudioElement>, "src" | "onLoad" | "onError"> {
51
+ readonly as: "audio";
52
+ }
53
+ export interface AithosDownloadProps extends AithosAssetBaseProps, Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onLoad" | "onError"> {
54
+ readonly as: "download";
55
+ /** Suggested filename in the browser's download dialog. */
56
+ readonly filename?: string;
57
+ readonly children?: ReactNode;
58
+ }
59
+ export type AithosAssetProps = AithosImageProps | AithosVideoProps | AithosAudioProps | AithosDownloadProps;
60
+ /**
61
+ * One component, four media kinds. The `as` prop selects between
62
+ * `<img>` (default), `<video>`, `<audio>`, and a styled `<a download>`.
63
+ */
64
+ export declare function AithosAsset(props: AithosAssetProps): ReactNode;
65
+ export {};
66
+ //# sourceMappingURL=AithosAsset.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from "react";
3
+ import { useAithosAsset } from "./use-aithos-asset.js";
4
+ /* -------------------------------------------------------------------------- */
5
+ /* Component */
6
+ /* -------------------------------------------------------------------------- */
7
+ /**
8
+ * One component, four media kinds. The `as` prop selects between
9
+ * `<img>` (default), `<video>`, `<audio>`, and a styled `<a download>`.
10
+ */
11
+ export function AithosAsset(props) {
12
+ const { urn, client, fallback, errorFallback, onLoad, onError, keepPreviousOnUrnChange, ...rest } = props;
13
+ const state = useAithosAsset(urn, {
14
+ client: client ?? undefined,
15
+ keepPreviousOnUrnChange,
16
+ });
17
+ // onLoad / onError side-effects
18
+ useEffect(() => {
19
+ if (state.url && onLoad)
20
+ onLoad();
21
+ }, [state.url, onLoad]);
22
+ useEffect(() => {
23
+ if (state.error && onError)
24
+ onError(state.error);
25
+ }, [state.error, onError]);
26
+ // Loading
27
+ if (state.loading) {
28
+ return (fallback ?? null);
29
+ }
30
+ // Error
31
+ if (state.error) {
32
+ if (errorFallback !== undefined) {
33
+ return errorFallback;
34
+ }
35
+ // For img-mode, the alt attribute is a sensible default error
36
+ // surface (the browser renders the alt text when src is broken).
37
+ if (rest.as === undefined || rest.as === "img") {
38
+ const imgProps = rest;
39
+ // eslint-disable-next-line jsx-a11y/alt-text
40
+ return _jsx("img", { ...imgProps, src: undefined });
41
+ }
42
+ return null;
43
+ }
44
+ // No URL yet (no URN supplied → idle state)
45
+ if (!state.url) {
46
+ return null;
47
+ }
48
+ // Dispatch on `as`
49
+ const as = rest.as ?? "img";
50
+ const { as: _consumed, ...domProps } = rest;
51
+ void _consumed;
52
+ if (as === "img") {
53
+ return (_jsx("img", { ...domProps, src: state.url }));
54
+ }
55
+ if (as === "video") {
56
+ return (_jsx("video", { ...domProps, src: state.url }));
57
+ }
58
+ if (as === "audio") {
59
+ return (_jsx("audio", { ...domProps, src: state.url }));
60
+ }
61
+ if (as === "download") {
62
+ const { filename, children, ...anchorRest } = domProps;
63
+ return (_jsx("a", { ...anchorRest, href: state.url, download: filename ?? true, children: children }));
64
+ }
65
+ return null;
66
+ }
67
+ //# sourceMappingURL=AithosAsset.js.map