@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.
- package/dist/src/assets.d.ts +207 -0
- package/dist/src/assets.js +533 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +6 -0
- package/dist/src/react/AithosAsset.d.ts +66 -0
- package/dist/src/react/AithosAsset.js +67 -0
- package/dist/src/react/context.d.ts +29 -0
- package/dist/src/react/context.js +31 -0
- package/dist/src/react/index.d.ts +28 -0
- package/dist/src/react/index.js +30 -0
- package/dist/src/react/use-aithos-asset.d.ts +39 -0
- package/dist/src/react/use-aithos-asset.js +118 -0
- package/package.json +16 -2
- package/dist/test/auth-j3.test.d.ts +0 -2
- package/dist/test/auth-j3.test.js +0 -391
- package/dist/test/auth.test.d.ts +0 -2
- package/dist/test/auth.test.js +0 -175
- package/dist/test/compute-delegate-path.test.d.ts +0 -2
- package/dist/test/compute-delegate-path.test.js +0 -183
- package/dist/test/compute.test.d.ts +0 -2
- package/dist/test/compute.test.js +0 -194
- package/dist/test/endpoints.test.d.ts +0 -2
- package/dist/test/endpoints.test.js +0 -62
- package/dist/test/envelope.test.d.ts +0 -2
- package/dist/test/envelope.test.js +0 -318
- package/dist/test/ethos-first-edition.test.d.ts +0 -2
- package/dist/test/ethos-first-edition.test.js +0 -248
- package/dist/test/ethos.test.d.ts +0 -2
- package/dist/test/ethos.test.js +0 -219
- package/dist/test/key-store.test.d.ts +0 -2
- package/dist/test/key-store.test.js +0 -161
- package/dist/test/mandates-compute.test.d.ts +0 -2
- package/dist/test/mandates-compute.test.js +0 -256
- package/dist/test/mandates.test.d.ts +0 -2
- package/dist/test/mandates.test.js +0 -93
- package/dist/test/sdk.test.d.ts +0 -2
- package/dist/test/sdk.test.js +0 -126
- package/dist/test/signer.test.d.ts +0 -2
- package/dist/test/signer.test.js +0 -117
- package/dist/test/signup-bootstrap.test.d.ts +0 -2
- package/dist/test/signup-bootstrap.test.js +0 -311
- package/dist/test/wallet.test.d.ts +0 -2
- package/dist/test/wallet.test.js +0 -121
- package/dist/test/web.test.d.ts +0 -2
- 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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|