@aithos/sdk 0.1.0-alpha.53 → 0.1.0-alpha.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/src/assets.d.ts +19 -3
- package/dist/src/auth.js +50 -72
- package/dist/src/compute.d.ts +98 -0
- package/dist/src/compute.js +47 -0
- package/dist/src/data.d.ts +28 -7
- package/dist/src/data.js +85 -12
- package/dist/src/index.d.ts +5 -3
- package/dist/src/index.js +15 -2
- package/dist/src/internal/cmk-wrap.d.ts +41 -0
- package/dist/src/internal/cmk-wrap.js +132 -0
- package/dist/src/key-store.d.ts +7 -3
- package/dist/src/migrate.d.ts +105 -0
- package/dist/src/migrate.js +367 -0
- package/dist/src/rotate.d.ts +94 -0
- package/dist/src/rotate.js +298 -0
- package/dist/src/sdk.d.ts +26 -2
- package/dist/src/sdk.js +45 -1
- package/dist/test/converse.test.d.ts +2 -0
- package/dist/test/converse.test.js +162 -0
- package/dist/test/migrate.test.d.ts +2 -0
- package/dist/test/migrate.test.js +340 -0
- package/dist/test/rotate-ethos.test.d.ts +2 -0
- package/dist/test/rotate-ethos.test.js +151 -0
- package/dist/test/rotate.test.d.ts +2 -0
- package/dist/test/rotate.test.js +63 -0
- package/dist/test/schema-autoresolve.test.d.ts +2 -0
- package/dist/test/schema-autoresolve.test.js +136 -0
- package/package.json +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Raw 32-byte seeds for a rotation. `root` is unchanged; the rest are new. */
|
|
2
|
+
export interface RotationSeeds {
|
|
3
|
+
readonly root: Uint8Array;
|
|
4
|
+
readonly public: Uint8Array;
|
|
5
|
+
readonly circle: Uint8Array;
|
|
6
|
+
readonly self: Uint8Array;
|
|
7
|
+
/** Optional dedicated #data sphere (added if present). */
|
|
8
|
+
readonly data?: Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
interface VerificationMethodLike {
|
|
11
|
+
id: string;
|
|
12
|
+
type: "Ed25519VerificationKey2020" | "X25519KeyAgreementKey2020";
|
|
13
|
+
controller: string;
|
|
14
|
+
publicKeyMultibase: string;
|
|
15
|
+
}
|
|
16
|
+
/** Minimal structural view of a did.json (browser-safe; no node import). */
|
|
17
|
+
export interface DidDocumentLike {
|
|
18
|
+
"@context": readonly string[];
|
|
19
|
+
id: string;
|
|
20
|
+
verificationMethod: readonly VerificationMethodLike[];
|
|
21
|
+
keyAgreement?: readonly VerificationMethodLike[];
|
|
22
|
+
aithos: {
|
|
23
|
+
version: "0.1.0";
|
|
24
|
+
display_name?: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
rotated: readonly unknown[];
|
|
27
|
+
};
|
|
28
|
+
proof?: unknown;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build + sign a rotated did.json. The result rotates every Ethos sphere whose
|
|
32
|
+
* seed differs from the previously-published key, appends one `rotated[]` entry
|
|
33
|
+
* per changed sphere (preserving prior history), carries / adds `#data`, and is
|
|
34
|
+
* signed by `#root`. Ready to POST via `aithos.rotate_sphere_key`.
|
|
35
|
+
*
|
|
36
|
+
* @param seeds the post-rotation seeds (same root, new sphere seeds).
|
|
37
|
+
* @param previousDoc the currently-published did.json (old pubkeys + history).
|
|
38
|
+
* @param displayName display name to carry on the doc.
|
|
39
|
+
* @param reason audit reason recorded on each new rotated entry.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildSignedRotatedDidDocument(seeds: RotationSeeds, previousDoc: DidDocumentLike, displayName: string, reason?: string): DidDocumentLike;
|
|
42
|
+
import { type RekeyReport } from "./migrate.js";
|
|
43
|
+
import { type ParsedRecoveryFile } from "./internal/recovery-file.js";
|
|
44
|
+
export interface RotateEthosOptions {
|
|
45
|
+
/** api.aithos.be base URL. Default {@link DEFAULT_API_BASE_URL}. NOTE: the
|
|
46
|
+
* Ethos-zone recopy uses @aithos/protocol-client's ambient endpoint
|
|
47
|
+
* (api.aithos.be); a non-default apiBaseUrl only redirects the did.json
|
|
48
|
+
* rotation, not the zone publish — keep them aligned (prod) for now. */
|
|
49
|
+
readonly apiBaseUrl?: string;
|
|
50
|
+
/** PDS base URL for the collection re-key. Default `https://pds.aithos.be`. */
|
|
51
|
+
readonly pdsUrl?: string;
|
|
52
|
+
readonly fetch?: typeof fetch;
|
|
53
|
+
/** Audit reason recorded in rotated[]. */
|
|
54
|
+
readonly reason?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Skip the Ethos circle/self zone recopy (the live, protocol-client-RPC part).
|
|
57
|
+
* Used by hermetic tests and by data-only accounts. WARNING: with real
|
|
58
|
+
* circle/self content, skipping while rotating the sphere keys would orphan
|
|
59
|
+
* that content — rotateEthos refuses to rotate the Ethos spheres when an
|
|
60
|
+
* edition exists and zones are skipped (unless `force`).
|
|
61
|
+
*/
|
|
62
|
+
readonly skipZones?: boolean;
|
|
63
|
+
/** Override the safety refusal above. */
|
|
64
|
+
readonly force?: boolean;
|
|
65
|
+
readonly onProgress?: (msg: string) => void;
|
|
66
|
+
}
|
|
67
|
+
export interface RotateEthosResult {
|
|
68
|
+
readonly did: string;
|
|
69
|
+
/** Ethos spheres whose key changed (always public/circle/self here). */
|
|
70
|
+
readonly rotatedSpheres: readonly string[];
|
|
71
|
+
/** Whether a fresh edition was republished (zones re-sealed under new keys). */
|
|
72
|
+
readonly editionRepublished: boolean;
|
|
73
|
+
/** Whether the account had an Ethos edition at all. */
|
|
74
|
+
readonly hadEdition: boolean;
|
|
75
|
+
/** Collection re-key report (dual #data wraps toward the NEW #data). */
|
|
76
|
+
readonly collections: RekeyReport;
|
|
77
|
+
/** New recovery file (ALL new sphere seeds + new #data). PERSIST THIS. */
|
|
78
|
+
readonly updatedRecoveryFile: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Fully rotate an Ethos: new public/circle/self/#data keys (root unchanged),
|
|
82
|
+
* re-encrypt the Ethos zones under the new keys (fresh edition), and dual-wrap
|
|
83
|
+
* every data collection toward the new #data. Entirely client-side; uses only
|
|
84
|
+
* existing API primitives (rotate_sphere_key, publish_ethos_edition via the
|
|
85
|
+
* protocol-client editor, rotate_cmk). Returns a new recovery file — persist it.
|
|
86
|
+
*
|
|
87
|
+
* Option (a) semantics: the new head edition (carrying all current content) is
|
|
88
|
+
* fully verifiable under the new keys; pre-rotation edition snapshots are not
|
|
89
|
+
* re-verifiable against the rotated did.json (rotated[] retains the old keys so
|
|
90
|
+
* a rotated[]-aware verifier can be added later without re-rotating).
|
|
91
|
+
*/
|
|
92
|
+
export declare function rotateEthos(recovery: ParsedRecoveryFile | string, opts?: RotateEthosOptions): Promise<RotateEthosResult>;
|
|
93
|
+
export {};
|
|
94
|
+
//# sourceMappingURL=rotate.d.ts.map
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* Ethos rotation — building blocks.
|
|
5
|
+
*
|
|
6
|
+
* This module currently ships the foundational, fully-tested piece of the
|
|
7
|
+
* "rotate an Ethos completely" feature: {@link buildSignedRotatedDidDocument},
|
|
8
|
+
* which produces a root-signed did.json that rotates one or more sphere keys
|
|
9
|
+
* (and carries / adds the optional `#data` sphere), extending `aithos.rotated[]`
|
|
10
|
+
* so it is accepted by the existing `aithos.rotate_sphere_key` primitive.
|
|
11
|
+
*
|
|
12
|
+
* Root is NEVER rotated — it IS the DID (`did:aithos:<root-mb>`). Rotating it
|
|
13
|
+
* would change the DID (= a new identity).
|
|
14
|
+
*
|
|
15
|
+
* Browser-safe: only depends on `@aithos/protocol-core/{did,canonical}`
|
|
16
|
+
* (node-free) + the SDK's own X25519 derivation + @noble. So it runs both in
|
|
17
|
+
* the migration script (node) and in a future in-browser app.aithos.be/rotate
|
|
18
|
+
* page.
|
|
19
|
+
*
|
|
20
|
+
* NOTE (scope): the *full* `rotateEthos` orchestration also re-encrypts the
|
|
21
|
+
* Ethos circle/self ZONES under the new sphere keys (publish a fresh edition)
|
|
22
|
+
* before the rotated sphere keys take effect — otherwise existing encrypted
|
|
23
|
+
* zone content sealed to the old sphere kex becomes unreadable. That zone
|
|
24
|
+
* recopy goes through `@aithos/protocol-client`'s editor (loadEditSnapshot /
|
|
25
|
+
* publishZoneEdit) and is built/tested separately. This module deliberately
|
|
26
|
+
* exposes only the did.json builder so callers can't accidentally rotate the
|
|
27
|
+
* Ethos sphere keys without the matching zone recopy.
|
|
28
|
+
*/
|
|
29
|
+
import * as ed from "@noble/ed25519";
|
|
30
|
+
import { sha512 } from "@noble/hashes/sha2.js";
|
|
31
|
+
import { canonicalize } from "@aithos/protocol-core/canonical";
|
|
32
|
+
import { ed25519PublicKeyToMultibase, x25519PublicKeyToMultibase, } from "@aithos/protocol-core/did";
|
|
33
|
+
import { ed25519SeedToX25519PublicKey } from "./internal/cmk-wrap.js";
|
|
34
|
+
// noble/ed25519 v2 needs a sync sha512 for sync sign.
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
ed.etc.sha512Sync = (...m) =>
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
sha512(ed.etc.concatBytes(...m));
|
|
39
|
+
const SPHERES = ["public", "circle", "self"];
|
|
40
|
+
/**
|
|
41
|
+
* Build + sign a rotated did.json. The result rotates every Ethos sphere whose
|
|
42
|
+
* seed differs from the previously-published key, appends one `rotated[]` entry
|
|
43
|
+
* per changed sphere (preserving prior history), carries / adds `#data`, and is
|
|
44
|
+
* signed by `#root`. Ready to POST via `aithos.rotate_sphere_key`.
|
|
45
|
+
*
|
|
46
|
+
* @param seeds the post-rotation seeds (same root, new sphere seeds).
|
|
47
|
+
* @param previousDoc the currently-published did.json (old pubkeys + history).
|
|
48
|
+
* @param displayName display name to carry on the doc.
|
|
49
|
+
* @param reason audit reason recorded on each new rotated entry.
|
|
50
|
+
*/
|
|
51
|
+
export function buildSignedRotatedDidDocument(seeds, previousDoc, displayName, reason = "rotate") {
|
|
52
|
+
const rootPub = ed.getPublicKey(seeds.root);
|
|
53
|
+
const did = "did:aithos:" + ed25519PublicKeyToMultibase(rootPub);
|
|
54
|
+
if (previousDoc.id !== did) {
|
|
55
|
+
throw new Error("buildSignedRotatedDidDocument: root seed does not match previousDoc.id — root (and the DID) is never rotated");
|
|
56
|
+
}
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const verificationMethod = SPHERES.map((sphere) => ({
|
|
59
|
+
id: `${did}#${sphere}`,
|
|
60
|
+
type: "Ed25519VerificationKey2020",
|
|
61
|
+
controller: did,
|
|
62
|
+
publicKeyMultibase: ed25519PublicKeyToMultibase(ed.getPublicKey(seeds[sphere])),
|
|
63
|
+
}));
|
|
64
|
+
const keyAgreement = SPHERES.map((sphere) => ({
|
|
65
|
+
id: `${did}#${sphere}-kex`,
|
|
66
|
+
type: "X25519KeyAgreementKey2020",
|
|
67
|
+
controller: did,
|
|
68
|
+
publicKeyMultibase: x25519PublicKeyToMultibase(ed25519SeedToX25519PublicKey(seeds[sphere])),
|
|
69
|
+
}));
|
|
70
|
+
if (seeds.data) {
|
|
71
|
+
verificationMethod.push({
|
|
72
|
+
id: `${did}#data`,
|
|
73
|
+
type: "Ed25519VerificationKey2020",
|
|
74
|
+
controller: did,
|
|
75
|
+
publicKeyMultibase: ed25519PublicKeyToMultibase(ed.getPublicKey(seeds.data)),
|
|
76
|
+
});
|
|
77
|
+
keyAgreement.push({
|
|
78
|
+
id: `${did}#data-kex`,
|
|
79
|
+
type: "X25519KeyAgreementKey2020",
|
|
80
|
+
controller: did,
|
|
81
|
+
publicKeyMultibase: x25519PublicKeyToMultibase(ed25519SeedToX25519PublicKey(seeds.data)),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Carry prior history + append one entry per Ethos sphere whose key changed.
|
|
85
|
+
const prevKeyOf = (sphere) => previousDoc.verificationMethod.find((vm) => vm.id === `${did}#${sphere}`)?.publicKeyMultibase;
|
|
86
|
+
const rotated = [...(previousDoc.aithos.rotated ?? [])];
|
|
87
|
+
for (const sphere of SPHERES) {
|
|
88
|
+
const oldKey = prevKeyOf(sphere);
|
|
89
|
+
const newKey = ed25519PublicKeyToMultibase(ed.getPublicKey(seeds[sphere]));
|
|
90
|
+
if (oldKey && oldKey !== newKey) {
|
|
91
|
+
rotated.push({ sphere, previous_key: oldKey, rotated_at: now, reason });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const unsigned = {
|
|
95
|
+
"@context": ["https://www.w3.org/ns/did/v1", "https://aithos.dev/spec/v0.1"],
|
|
96
|
+
id: did,
|
|
97
|
+
verificationMethod,
|
|
98
|
+
keyAgreement,
|
|
99
|
+
aithos: { version: "0.1.0", display_name: displayName, created_at: now, rotated },
|
|
100
|
+
proof: {
|
|
101
|
+
type: "Ed25519Signature2020",
|
|
102
|
+
created: now,
|
|
103
|
+
verificationMethod: `${did}#root`,
|
|
104
|
+
proofPurpose: "assertionMethod",
|
|
105
|
+
proofValue: "",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const bytes = new TextEncoder().encode(canonicalize(unsigned));
|
|
109
|
+
const sig = ed.sign(bytes, seeds.root);
|
|
110
|
+
return { ...unsigned, proof: { ...unsigned.proof, proofValue: base64url(sig) } };
|
|
111
|
+
}
|
|
112
|
+
function base64url(bytes) {
|
|
113
|
+
let bin = "";
|
|
114
|
+
for (let i = 0; i < bytes.length; i++)
|
|
115
|
+
bin += String.fromCharCode(bytes[i]);
|
|
116
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
117
|
+
}
|
|
118
|
+
/* ========================================================================== */
|
|
119
|
+
/* rotateEthos — full rotation orchestration */
|
|
120
|
+
/* ========================================================================== */
|
|
121
|
+
import { loadEditSnapshot, publishZoneEdit, } from "@aithos/protocol-client";
|
|
122
|
+
import { addDataSphereWrap } from "./migrate.js";
|
|
123
|
+
import { DEFAULT_API_BASE_URL } from "./auth.js";
|
|
124
|
+
import { signOwnerEnvelope } from "./internal/envelope.js";
|
|
125
|
+
import { parseRecoveryFile, } from "./internal/recovery-file.js";
|
|
126
|
+
/**
|
|
127
|
+
* Fully rotate an Ethos: new public/circle/self/#data keys (root unchanged),
|
|
128
|
+
* re-encrypt the Ethos zones under the new keys (fresh edition), and dual-wrap
|
|
129
|
+
* every data collection toward the new #data. Entirely client-side; uses only
|
|
130
|
+
* existing API primitives (rotate_sphere_key, publish_ethos_edition via the
|
|
131
|
+
* protocol-client editor, rotate_cmk). Returns a new recovery file — persist it.
|
|
132
|
+
*
|
|
133
|
+
* Option (a) semantics: the new head edition (carrying all current content) is
|
|
134
|
+
* fully verifiable under the new keys; pre-rotation edition snapshots are not
|
|
135
|
+
* re-verifiable against the rotated did.json (rotated[] retains the old keys so
|
|
136
|
+
* a rotated[]-aware verifier can be added later without re-rotating).
|
|
137
|
+
*/
|
|
138
|
+
export async function rotateEthos(recovery, opts = {}) {
|
|
139
|
+
const rec = typeof recovery === "string" ? parseRecoveryFile(recovery) : recovery;
|
|
140
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
141
|
+
const apiBaseUrl = (opts.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
142
|
+
const reason = opts.reason ?? "full-rotation";
|
|
143
|
+
const did = rec.did;
|
|
144
|
+
const rootSeed = hexToBytes(rec.seedsHex.root);
|
|
145
|
+
const log = opts.onProgress ?? (() => { });
|
|
146
|
+
// 1. Fetch the currently-published did.json.
|
|
147
|
+
log("fetching current did.json");
|
|
148
|
+
const idRes = await apiRpc(fetchImpl, apiBaseUrl, "aithos.get_identity", { did }, did, rootSeed);
|
|
149
|
+
const previousDoc = (idRes.object ?? idRes);
|
|
150
|
+
// 2. Generate fresh seeds (root kept).
|
|
151
|
+
const newSeeds = {
|
|
152
|
+
root: rootSeed,
|
|
153
|
+
public: randomSeed32(),
|
|
154
|
+
circle: randomSeed32(),
|
|
155
|
+
self: randomSeed32(),
|
|
156
|
+
data: randomSeed32(),
|
|
157
|
+
};
|
|
158
|
+
const newDataHex = bytesToHex(newSeeds.data);
|
|
159
|
+
// 3. Probe whether an Ethos edition exists (mockable apiRpc — NOT the
|
|
160
|
+
// protocol-client editor, so this stays testable). Absent → -32020.
|
|
161
|
+
let hadEdition = true;
|
|
162
|
+
try {
|
|
163
|
+
log("probing for an existing edition");
|
|
164
|
+
await apiRpc(fetchImpl, apiBaseUrl, "aithos.get_ethos_manifest", { did }, did, rootSeed);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
if (e.code === -32020 || /not found/i.test(String(e.message))) {
|
|
168
|
+
hadEdition = false;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (hadEdition && opts.skipZones && !opts.force) {
|
|
175
|
+
throw new Error("rotateEthos: this Ethos has an edition with (possibly encrypted) zone content, but skipZones was set. " +
|
|
176
|
+
"Rotating the sphere keys without recopying the zones would orphan that content. " +
|
|
177
|
+
"Pass force: true only if you are certain the zones hold nothing to preserve.");
|
|
178
|
+
}
|
|
179
|
+
// 4. Publish the rotated did.json (root-signed) via rotate_sphere_key.
|
|
180
|
+
log("publishing rotated did.json");
|
|
181
|
+
const rotatedDoc = buildSignedRotatedDidDocument(newSeeds, previousDoc, rec.displayName, reason);
|
|
182
|
+
await apiRpc(fetchImpl, apiBaseUrl, "aithos.rotate_sphere_key", { new_did_document: rotatedDoc }, did, rootSeed);
|
|
183
|
+
// 5. Republish a fresh edition under the NEW identity → zones re-sealed under
|
|
184
|
+
// the new sphere keys (LIVE: protocol-client editor, ambient api endpoint).
|
|
185
|
+
// loadEditSnapshot decrypts circle/self with the OLD keys; publishZoneEdit
|
|
186
|
+
// re-seals them under the NEW identity.
|
|
187
|
+
let editionRepublished = false;
|
|
188
|
+
if (hadEdition && !opts.skipZones) {
|
|
189
|
+
log("loading current edition (old keys) + republishing (new keys)");
|
|
190
|
+
const snap = await loadEditSnapshot(did, toStoredIdentity(rec));
|
|
191
|
+
const newStored = toStoredIdentity({
|
|
192
|
+
handle: rec.handle,
|
|
193
|
+
displayName: rec.displayName,
|
|
194
|
+
did,
|
|
195
|
+
seedsHex: {
|
|
196
|
+
root: rec.seedsHex.root,
|
|
197
|
+
public: bytesToHex(newSeeds.public),
|
|
198
|
+
circle: bytesToHex(newSeeds.circle),
|
|
199
|
+
self: bytesToHex(newSeeds.self),
|
|
200
|
+
data: newDataHex,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
await publishZoneEdit({
|
|
204
|
+
identity: newStored,
|
|
205
|
+
snapshot: snap,
|
|
206
|
+
newPublicSections: snap.publicSections ?? [],
|
|
207
|
+
...(snap.circleSections ? { newCircleSections: snap.circleSections } : {}),
|
|
208
|
+
...(snap.selfSections ? { newSelfSections: snap.selfSections } : {}),
|
|
209
|
+
});
|
|
210
|
+
editionRepublished = true;
|
|
211
|
+
}
|
|
212
|
+
// 6. Dual-wrap every collection toward the NEW #data (unwrap with old seeds).
|
|
213
|
+
log("re-keying collections toward the new #data");
|
|
214
|
+
const collections = await addDataSphereWrap(rec, {
|
|
215
|
+
...(opts.pdsUrl ? { pdsUrl: opts.pdsUrl } : {}),
|
|
216
|
+
fetch: fetchImpl,
|
|
217
|
+
targetDataSeedHex: newDataHex,
|
|
218
|
+
});
|
|
219
|
+
// 7. New recovery (all new seeds; persist!).
|
|
220
|
+
const updatedRecoveryFile = JSON.stringify({
|
|
221
|
+
aithos_recovery_version: "0.1.0-plaintext",
|
|
222
|
+
handle: rec.handle,
|
|
223
|
+
display_name: rec.displayName,
|
|
224
|
+
did,
|
|
225
|
+
seeds_hex: {
|
|
226
|
+
root: rec.seedsHex.root,
|
|
227
|
+
public: bytesToHex(newSeeds.public),
|
|
228
|
+
circle: bytesToHex(newSeeds.circle),
|
|
229
|
+
self: bytesToHex(newSeeds.self),
|
|
230
|
+
data: newDataHex,
|
|
231
|
+
},
|
|
232
|
+
saved_at: new Date().toISOString(),
|
|
233
|
+
}, null, 2);
|
|
234
|
+
return {
|
|
235
|
+
did,
|
|
236
|
+
rotatedSpheres: ["public", "circle", "self"],
|
|
237
|
+
editionRepublished,
|
|
238
|
+
hadEdition,
|
|
239
|
+
collections,
|
|
240
|
+
updatedRecoveryFile,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/* -------------------------------------------------------------------------- */
|
|
244
|
+
/* helpers */
|
|
245
|
+
/* -------------------------------------------------------------------------- */
|
|
246
|
+
function toStoredIdentity(rec) {
|
|
247
|
+
return {
|
|
248
|
+
handle: rec.handle,
|
|
249
|
+
displayName: rec.displayName,
|
|
250
|
+
did: rec.did,
|
|
251
|
+
seeds: {
|
|
252
|
+
root: rec.seedsHex.root,
|
|
253
|
+
public: rec.seedsHex.public,
|
|
254
|
+
circle: rec.seedsHex.circle,
|
|
255
|
+
self: rec.seedsHex.self,
|
|
256
|
+
...(rec.seedsHex.data ? { data: rec.seedsHex.data } : {}),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async function apiRpc(fetchImpl, apiBaseUrl, method, params, did, rootSeed) {
|
|
261
|
+
const url = `${apiBaseUrl}/mcp/primitives/${method.includes("get_") ? "read" : "write"}`;
|
|
262
|
+
const envelope = await signOwnerEnvelope({
|
|
263
|
+
iss: did,
|
|
264
|
+
aud: url,
|
|
265
|
+
method,
|
|
266
|
+
params,
|
|
267
|
+
verificationMethod: `${did}#root`,
|
|
268
|
+
signer: { sign: async (m) => ed.sign(m, rootSeed) },
|
|
269
|
+
});
|
|
270
|
+
const r = await fetchImpl(url, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: `rotate_${Date.now()}`, method, params: { ...params, _envelope: envelope } }),
|
|
274
|
+
});
|
|
275
|
+
const j = (await r.json());
|
|
276
|
+
if (j.error) {
|
|
277
|
+
throw Object.assign(new Error(`${method} failed: ${j.error.message}`), { code: j.error.code });
|
|
278
|
+
}
|
|
279
|
+
return j.result ?? {};
|
|
280
|
+
}
|
|
281
|
+
function randomSeed32() {
|
|
282
|
+
const buf = new Uint8Array(32);
|
|
283
|
+
globalThis.crypto?.getRandomValues(buf);
|
|
284
|
+
return buf;
|
|
285
|
+
}
|
|
286
|
+
function hexToBytes(hex) {
|
|
287
|
+
const out = new Uint8Array(hex.length / 2);
|
|
288
|
+
for (let i = 0; i < out.length; i++)
|
|
289
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
function bytesToHex(b) {
|
|
293
|
+
let out = "";
|
|
294
|
+
for (let i = 0; i < b.length; i++)
|
|
295
|
+
out += b[i].toString(16).padStart(2, "0");
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=rotate.js.map
|
package/dist/src/sdk.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AithosAuth } from "./auth.js";
|
|
2
2
|
import { AppsNamespace } from "./apps.js";
|
|
3
|
-
import { ComputeNamespace } from "./compute.js";
|
|
3
|
+
import { ComputeNamespace, type ComputeWorkingSet } from "./compute.js";
|
|
4
4
|
import { type AithosSdkEndpoints } from "./endpoints.js";
|
|
5
|
-
import { EthosNamespace } from "./ethos.js";
|
|
5
|
+
import { EthosNamespace, type ZoneName } from "./ethos.js";
|
|
6
6
|
import { MandatesNamespace } from "./mandates.js";
|
|
7
7
|
import { WalletNamespace } from "./wallet.js";
|
|
8
8
|
import { WebNamespace } from "./web.js";
|
|
@@ -57,5 +57,29 @@ export declare class AithosSDK {
|
|
|
57
57
|
constructor(config: AithosSDKConfig);
|
|
58
58
|
/** DID of the currently signed-in owner, or null if no owner is loaded. */
|
|
59
59
|
get userDid(): string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Build the client-decrypted working-set for an agentic conversation.
|
|
62
|
+
*
|
|
63
|
+
* Reads the requested ethos zones for `did` through {@link EthosNamespace}
|
|
64
|
+
* (which decrypts client-side using the active owner/delegate keys) and
|
|
65
|
+
* packages them into the {@link ComputeWorkingSet} shape that
|
|
66
|
+
* `sdk.compute.runConversation({ workingSet })` consumes.
|
|
67
|
+
*
|
|
68
|
+
* Only zones the session can actually read are included: a zone the
|
|
69
|
+
* mandate does not grant (or whose delegate wrap is missing) throws
|
|
70
|
+
* `ethos_zone_unreadable` / `ethos_anonymous_private_zone` on read and is
|
|
71
|
+
* silently skipped — so the working-set is naturally bounded by what the
|
|
72
|
+
* caller is authorized to see. The proxy never holds a decryption key;
|
|
73
|
+
* this is where the plaintext is produced (see PLATFORM-COMPUTE-AGENTIC-MCP.md §4).
|
|
74
|
+
*
|
|
75
|
+
* Structured (gamma) data collections are not loaded here in v1 — attach
|
|
76
|
+
* them to the returned object's `data` field yourself if needed.
|
|
77
|
+
*
|
|
78
|
+
* @param did Subject DID whose ethos to read (usually the signed-in owner).
|
|
79
|
+
* @param opts.zones Zones to attempt. Defaults to all three.
|
|
80
|
+
*/
|
|
81
|
+
buildWorkingSet(did: string, opts?: {
|
|
82
|
+
readonly zones?: readonly ZoneName[];
|
|
83
|
+
}): Promise<ComputeWorkingSet>;
|
|
60
84
|
}
|
|
61
85
|
//# sourceMappingURL=sdk.d.ts.map
|
package/dist/src/sdk.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Mathieu Colla
|
|
3
3
|
import { AppsNamespace } from "./apps.js";
|
|
4
|
-
import { ComputeNamespace } from "./compute.js";
|
|
4
|
+
import { ComputeNamespace, } from "./compute.js";
|
|
5
5
|
import { resolveEndpoints } from "./endpoints.js";
|
|
6
6
|
import { EthosNamespace } from "./ethos.js";
|
|
7
7
|
import { MandatesNamespace } from "./mandates.js";
|
|
8
|
+
import { AithosSDKError } from "./types.js";
|
|
8
9
|
import { WalletNamespace } from "./wallet.js";
|
|
9
10
|
import { WebNamespace } from "./web.js";
|
|
10
11
|
export class AithosSDK {
|
|
@@ -80,5 +81,48 @@ export class AithosSDK {
|
|
|
80
81
|
get userDid() {
|
|
81
82
|
return this.auth.getOwnerInfo()?.did ?? null;
|
|
82
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Build the client-decrypted working-set for an agentic conversation.
|
|
86
|
+
*
|
|
87
|
+
* Reads the requested ethos zones for `did` through {@link EthosNamespace}
|
|
88
|
+
* (which decrypts client-side using the active owner/delegate keys) and
|
|
89
|
+
* packages them into the {@link ComputeWorkingSet} shape that
|
|
90
|
+
* `sdk.compute.runConversation({ workingSet })` consumes.
|
|
91
|
+
*
|
|
92
|
+
* Only zones the session can actually read are included: a zone the
|
|
93
|
+
* mandate does not grant (or whose delegate wrap is missing) throws
|
|
94
|
+
* `ethos_zone_unreadable` / `ethos_anonymous_private_zone` on read and is
|
|
95
|
+
* silently skipped — so the working-set is naturally bounded by what the
|
|
96
|
+
* caller is authorized to see. The proxy never holds a decryption key;
|
|
97
|
+
* this is where the plaintext is produced (see PLATFORM-COMPUTE-AGENTIC-MCP.md §4).
|
|
98
|
+
*
|
|
99
|
+
* Structured (gamma) data collections are not loaded here in v1 — attach
|
|
100
|
+
* them to the returned object's `data` field yourself if needed.
|
|
101
|
+
*
|
|
102
|
+
* @param did Subject DID whose ethos to read (usually the signed-in owner).
|
|
103
|
+
* @param opts.zones Zones to attempt. Defaults to all three.
|
|
104
|
+
*/
|
|
105
|
+
async buildWorkingSet(did, opts) {
|
|
106
|
+
const zones = opts?.zones ?? ["public", "circle", "self"];
|
|
107
|
+
const client = await this.ethos.of(did);
|
|
108
|
+
const ethos = {};
|
|
109
|
+
for (const zone of zones) {
|
|
110
|
+
try {
|
|
111
|
+
const sections = await client.zone(zone).sections();
|
|
112
|
+
ethos[zone] = sections.map((s) => ({
|
|
113
|
+
id: s.id,
|
|
114
|
+
title: s.title,
|
|
115
|
+
body: s.body,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// Zone not granted / not decryptable for this session → skip it.
|
|
120
|
+
if (e instanceof AithosSDKError)
|
|
121
|
+
continue;
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { ethos };
|
|
126
|
+
}
|
|
83
127
|
}
|
|
84
128
|
//# sourceMappingURL=sdk.js.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Unit tests for sdk.compute.runConversation with a mock fetch.
|
|
4
|
+
//
|
|
5
|
+
// Mirrors compute.test.ts: a real BrowserIdentity drives the actual
|
|
6
|
+
// envelope-signing path, and we assert on the JSON-RPC body posted to
|
|
7
|
+
// the compute proxy (method name + camelCase→snake_case param mapping).
|
|
8
|
+
import { strict as assert } from "node:assert";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
11
|
+
import { AithosAuth, AithosSDK, AithosSDKError, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
12
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
13
|
+
const APP_DID = "did:aithos:app:test";
|
|
14
|
+
async function makeSdk(fetchImpl) {
|
|
15
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
16
|
+
const auth = new AithosAuth({
|
|
17
|
+
authBaseUrl: "https://auth.test",
|
|
18
|
+
fetch: (() => {
|
|
19
|
+
throw new Error("auth not used in converse tests");
|
|
20
|
+
}),
|
|
21
|
+
sessionStore: noopStore(),
|
|
22
|
+
keyStore: memoryKeyStore(),
|
|
23
|
+
});
|
|
24
|
+
const { text } = serializeRecoveryFile(id);
|
|
25
|
+
await auth.signInWithRecovery({ file: text });
|
|
26
|
+
return new AithosSDK({
|
|
27
|
+
auth,
|
|
28
|
+
appDid: APP_DID,
|
|
29
|
+
endpoints: { compute: "https://compute.example.test" },
|
|
30
|
+
fetch: fetchImpl,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const HAPPY_RESULT = {
|
|
34
|
+
content: "Voici le résumé.",
|
|
35
|
+
stopReason: "end_turn",
|
|
36
|
+
iterations: 2,
|
|
37
|
+
usage: { inputTokens: 420, outputTokens: 95 },
|
|
38
|
+
toolCalls: [{ name: "ethos_read_section", ok: true, turn: 1 }],
|
|
39
|
+
creditsCharged: 130,
|
|
40
|
+
walletBalance: 99_870,
|
|
41
|
+
auditId: "audit-cv-1",
|
|
42
|
+
fundedBy: "purchase",
|
|
43
|
+
};
|
|
44
|
+
describe("compute.runConversation — happy path + param mapping", () => {
|
|
45
|
+
it("posts aithos.compute_converse with mapped params and parses the result", async () => {
|
|
46
|
+
let capturedUrl;
|
|
47
|
+
let capturedInit;
|
|
48
|
+
const fakeFetch = async (input, init) => {
|
|
49
|
+
capturedUrl = typeof input === "string" ? input : input.toString();
|
|
50
|
+
capturedInit = init;
|
|
51
|
+
return new Response(JSON.stringify({ result: HAPPY_RESULT }), {
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: { "content-type": "application/json" },
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const sdk = await makeSdk(fakeFetch);
|
|
57
|
+
const out = await sdk.compute.runConversation({
|
|
58
|
+
mandateId: "mandate:abc",
|
|
59
|
+
model: "claude-sonnet-4-6",
|
|
60
|
+
system: "Tu agis dans la voix de l'utilisateur.",
|
|
61
|
+
messages: [{ role: "user", content: "Résume mon ethos." }],
|
|
62
|
+
mcp: { server: "aithos", tools: ["ethos_list_sections", "ethos_read_section"] },
|
|
63
|
+
workingSet: {
|
|
64
|
+
ethos: { public: [{ id: "p1", title: "Bio", body: "..." }] },
|
|
65
|
+
},
|
|
66
|
+
maxIterations: 5,
|
|
67
|
+
maxTokens: 800,
|
|
68
|
+
temperature: 0.4,
|
|
69
|
+
});
|
|
70
|
+
assert.deepEqual(out, HAPPY_RESULT);
|
|
71
|
+
assert.equal(capturedUrl, "https://compute.example.test/v1/invoke");
|
|
72
|
+
assert.equal(capturedInit?.method, "POST");
|
|
73
|
+
const body = JSON.parse(capturedInit?.body);
|
|
74
|
+
assert.equal(body.jsonrpc, "2.0");
|
|
75
|
+
assert.equal(body.method, "aithos.compute_converse");
|
|
76
|
+
assert.equal(body.params.app_did, APP_DID);
|
|
77
|
+
assert.equal(body.params.mandate_id, "mandate:abc");
|
|
78
|
+
assert.equal(body.params.model, "claude-sonnet-4-6");
|
|
79
|
+
assert.equal(body.params.system, "Tu agis dans la voix de l'utilisateur.");
|
|
80
|
+
// camelCase → snake_case mapping on the wire.
|
|
81
|
+
assert.equal(body.params.max_iterations, 5);
|
|
82
|
+
assert.equal(body.params.max_tokens, 800);
|
|
83
|
+
assert.equal(body.params.temperature, 0.4);
|
|
84
|
+
assert.ok(body.params.working_set, "working_set must be on the wire");
|
|
85
|
+
assert.deepEqual(body.params.mcp, {
|
|
86
|
+
server: "aithos",
|
|
87
|
+
tools: ["ethos_list_sections", "ethos_read_section"],
|
|
88
|
+
});
|
|
89
|
+
assert.match(body.params.idempotency_key, /^[0-9a-f]{32}$/);
|
|
90
|
+
assert.ok(body.params._envelope, "request must carry a signed envelope");
|
|
91
|
+
// SDK-only camelCase keys must NOT leak onto the wire.
|
|
92
|
+
assert.equal(body.params.maxIterations, undefined);
|
|
93
|
+
assert.equal(body.params.workingSet, undefined);
|
|
94
|
+
});
|
|
95
|
+
it("omits optional fields when not provided", async () => {
|
|
96
|
+
let capturedInit;
|
|
97
|
+
const fakeFetch = async (_input, init) => {
|
|
98
|
+
capturedInit = init;
|
|
99
|
+
return new Response(JSON.stringify({ result: HAPPY_RESULT }), {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: { "content-type": "application/json" },
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const sdk = await makeSdk(fakeFetch);
|
|
105
|
+
await sdk.compute.runConversation({
|
|
106
|
+
mandateId: "mandate:abc",
|
|
107
|
+
model: "claude-haiku-4-5",
|
|
108
|
+
messages: [{ role: "user", content: "Salut" }],
|
|
109
|
+
});
|
|
110
|
+
const body = JSON.parse(capturedInit?.body);
|
|
111
|
+
assert.equal(body.params.system, undefined);
|
|
112
|
+
assert.equal(body.params.mcp, undefined);
|
|
113
|
+
assert.equal(body.params.working_set, undefined);
|
|
114
|
+
assert.equal(body.params.max_iterations, undefined);
|
|
115
|
+
assert.equal(body.params.max_tokens, undefined);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe("compute.runConversation — errors", () => {
|
|
119
|
+
it("maps a JSON-RPC error to AithosSDKError with the proxy code", async () => {
|
|
120
|
+
const fakeFetch = async () => new Response(JSON.stringify({
|
|
121
|
+
error: { code: -32071, message: "insufficient credits" },
|
|
122
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
123
|
+
const sdk = await makeSdk(fakeFetch);
|
|
124
|
+
await assert.rejects(() => sdk.compute.runConversation({
|
|
125
|
+
mandateId: "mandate:abc",
|
|
126
|
+
model: "claude-sonnet-4-6",
|
|
127
|
+
messages: [{ role: "user", content: "Hi" }],
|
|
128
|
+
}), (err) => {
|
|
129
|
+
assert.ok(err instanceof AithosSDKError);
|
|
130
|
+
assert.equal(err.code, "-32071");
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
it("throws sdk_no_signer when no owner and no mandate", async () => {
|
|
135
|
+
// Build an SDK with no signed-in owner.
|
|
136
|
+
const auth = new AithosAuth({
|
|
137
|
+
authBaseUrl: "https://auth.test",
|
|
138
|
+
fetch: (() => {
|
|
139
|
+
throw new Error("unused");
|
|
140
|
+
}),
|
|
141
|
+
sessionStore: noopStore(),
|
|
142
|
+
keyStore: memoryKeyStore(),
|
|
143
|
+
});
|
|
144
|
+
const sdk = new AithosSDK({
|
|
145
|
+
auth,
|
|
146
|
+
appDid: APP_DID,
|
|
147
|
+
endpoints: { compute: "https://compute.example.test" },
|
|
148
|
+
fetch: (() => {
|
|
149
|
+
throw new Error("fetch must not be reached");
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
await assert.rejects(() => sdk.compute.runConversation({
|
|
153
|
+
model: "claude-sonnet-4-6",
|
|
154
|
+
messages: [{ role: "user", content: "Hi" }],
|
|
155
|
+
}), (err) => {
|
|
156
|
+
assert.ok(err instanceof AithosSDKError);
|
|
157
|
+
assert.equal(err.code, "sdk_no_signer");
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
//# sourceMappingURL=converse.test.js.map
|