@aithos/sdk 0.1.0-alpha.54 → 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.
@@ -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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=migrate.test.d.ts.map
@@ -0,0 +1,340 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 Mathieu Colla
3
+ // Hermetic end-to-end tests for the legacy #data sphere migration
4
+ // (src/migrate.ts). No network: an in-memory PDS + registry mock implements
5
+ // the subset of primitives the flow touches. This also serves as the
6
+ // conformance guard between src/internal/cmk-wrap.ts and src/data.ts — the
7
+ // migration re-wraps with cmk-wrap, and a real DataClient (data.ts) must be
8
+ // able to read the result, and vice-versa.
9
+ import { test } from "node:test";
10
+ import { strict as assert } from "node:assert";
11
+ import { createBrowserIdentity } from "@aithos/protocol-client";
12
+ import { createDataClient } from "../src/data.js";
13
+ import { ensureDataSphere, rekeyLegacyCollections, addDataSphereWrap, migrateLegacyEthosToDataSphere, } from "../src/migrate.js";
14
+ import { parseRecoveryFile } from "../src/internal/recovery-file.js";
15
+ function makePds() {
16
+ const collections = new Map();
17
+ const records = new Map();
18
+ const augmentCalls = [];
19
+ const fetchImpl = (async (_url, init) => {
20
+ const body = JSON.parse(init.body);
21
+ const p = body.method;
22
+ const params = body.params ?? {};
23
+ const ok = (result) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), {
24
+ status: 200,
25
+ headers: { "content-type": "application/json" },
26
+ });
27
+ const rpcErr = (code, message) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, error: { code, message } }), {
28
+ status: 200,
29
+ headers: { "content-type": "application/json" },
30
+ });
31
+ const urnFor = (did, name) => `urn:aithos:collection:${did}:${name}`;
32
+ switch (p) {
33
+ case "aithos.augment_identity": {
34
+ augmentCalls.push(params.did_document);
35
+ return ok({ did: params.did_document.id, idempotent: false, data_sphere_added: true });
36
+ }
37
+ case "aithos.data.create_collection": {
38
+ const urn = urnFor(params.subject_did, params.collection_name);
39
+ collections.set(urn, {
40
+ urn,
41
+ name: params.collection_name,
42
+ schema: params.schema,
43
+ subject_did: params.subject_did,
44
+ cmk_envelope: params.cmk_envelope,
45
+ record_count: 0,
46
+ });
47
+ records.set(urn, new Map());
48
+ return ok({ urn, name: params.collection_name, schema: params.schema, cmk_envelope: params.cmk_envelope });
49
+ }
50
+ case "aithos.data.get_collection": {
51
+ const urn = urnFor(params.subject_did, params.collection_name);
52
+ const c = collections.get(urn);
53
+ if (!c)
54
+ return rpcErr(-32020, "not found");
55
+ return ok({ urn: c.urn, name: c.name, schema: c.schema, cmk_envelope: c.cmk_envelope, record_count: c.record_count });
56
+ }
57
+ case "aithos.data.list_collections": {
58
+ const items = [...collections.values()]
59
+ .filter((c) => c.subject_did === params.subject_did)
60
+ .map((c) => ({ name: c.name, schema: c.schema, record_count: c.record_count }));
61
+ return ok({ items });
62
+ }
63
+ case "aithos.data.insert_record": {
64
+ const recs = records.get(params.collection_urn);
65
+ if (!recs)
66
+ return rpcErr(-32020, "no collection");
67
+ recs.set(params.record_id, {
68
+ record_id: params.record_id,
69
+ metadata: params.metadata,
70
+ payload: params.payload,
71
+ });
72
+ const c = [...collections.values()].find((x) => x.urn === params.collection_urn);
73
+ if (c)
74
+ c.record_count++;
75
+ return ok({ record_id: params.record_id });
76
+ }
77
+ case "aithos.data.get_record": {
78
+ const recs = records.get(params.collection_urn);
79
+ const r = recs?.get(params.record_id);
80
+ if (!r)
81
+ return rpcErr(-32020, "not found");
82
+ return ok(r);
83
+ }
84
+ case "aithos.data.list_records": {
85
+ const recs = records.get(params.collection_urn) ?? new Map();
86
+ return ok({ items: [...recs.values()] });
87
+ }
88
+ case "aithos.data.rotate_cmk": {
89
+ const c = [...collections.values()].find((x) => x.urn === params.collection_urn);
90
+ if (!c)
91
+ return rpcErr(-32020, "not found");
92
+ c.cmk_envelope = params.new_cmk_envelope;
93
+ return ok({ rotated_at: new Date().toISOString(), records_rewrapped: (params.re_wrapped_deks ?? []).length });
94
+ }
95
+ default:
96
+ return rpcErr(-32601, `method not found: ${p}`);
97
+ }
98
+ });
99
+ return { fetchImpl, collections, records, augmentCalls };
100
+ }
101
+ /* -------------------------------------------------------------------------- */
102
+ /* Helpers */
103
+ /* -------------------------------------------------------------------------- */
104
+ function hex(b) {
105
+ let s = "";
106
+ for (const x of b)
107
+ s += x.toString(16).padStart(2, "0");
108
+ return s;
109
+ }
110
+ function hexToBytes(h) {
111
+ const out = new Uint8Array(h.length / 2);
112
+ for (let i = 0; i < out.length; i++)
113
+ out[i] = parseInt(h.substr(i * 2, 2), 16);
114
+ return out;
115
+ }
116
+ /** A legacy recovery file (root/public/circle/self only — NO #data). */
117
+ function legacyRecoveryJson(id) {
118
+ return JSON.stringify({
119
+ aithos_recovery_version: "0.1.0-plaintext",
120
+ handle: id.handle,
121
+ display_name: id.displayName,
122
+ did: id.did,
123
+ seeds_hex: {
124
+ root: hex(id.root.seed),
125
+ public: hex(id.public.seed),
126
+ circle: hex(id.circle.seed),
127
+ self: hex(id.self.seed),
128
+ },
129
+ saved_at: new Date().toISOString(),
130
+ });
131
+ }
132
+ const API = "https://api.test";
133
+ const PDS = "https://pds.test";
134
+ /* -------------------------------------------------------------------------- */
135
+ /* Tests */
136
+ /* -------------------------------------------------------------------------- */
137
+ test("migrate: legacy collection (self-sealed) → #data, record stays readable", async () => {
138
+ const id = createBrowserIdentity("legacy_user", "Legacy User");
139
+ const did = id.did;
140
+ const pds = makePds();
141
+ const recovery = legacyRecoveryJson(id);
142
+ // 1. Old app created the collection signing under #self (sphereSeed = self).
143
+ const legacyClient = createDataClient({
144
+ pdsUrl: PDS,
145
+ did,
146
+ sphereSeed: id.self.seed,
147
+ verificationMethod: `${did}#self`,
148
+ fetch: pds.fetchImpl,
149
+ });
150
+ await legacyClient.createCollection({ name: "contacts", schema: "aithos.contacts.v1" });
151
+ const recId = await legacyClient.collection("contacts").insert({
152
+ name: "Jean Dupont",
153
+ phone: "+33612345678",
154
+ notes: "secret pre-migration note",
155
+ });
156
+ const before = await legacyClient.collection("contacts").get(recId);
157
+ assert.equal(before.phone, "+33612345678", "legacy read works pre-migration");
158
+ // Sanity: the stored owner wrap is labelled #data-kex but sealed to the self key.
159
+ const urn = `urn:aithos:collection:${did}:contacts`;
160
+ assert.equal(pds.collections.get(urn).cmk_envelope.wraps[0].recipient, `${did}#data-kex`, "owner wrap is labelled #data-kex regardless of the sealing seed");
161
+ // 2. ensureDataSphere — adds #data, returns a recovery file carrying it.
162
+ const ensure = await ensureDataSphere(recovery, { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
163
+ assert.equal(ensure.added, true);
164
+ assert.equal(ensure.dataSeedHex.length, 64, "a 32-byte #data seed was generated");
165
+ assert.equal(pds.augmentCalls.length, 1, "augment_identity was called once");
166
+ // 3. rekey — re-wrap CMK from self to #data.
167
+ const rekey = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
168
+ pdsUrl: PDS,
169
+ fetch: pds.fetchImpl,
170
+ });
171
+ assert.equal(rekey.collections.length, 1);
172
+ assert.equal(rekey.collections[0].status, "rekeyed");
173
+ assert.equal(rekey.collections[0].unwrappedWith, "self", "auto-discovered the self seed");
174
+ // owner wrap recipient label unchanged; still exactly one owner wrap.
175
+ const envAfter = pds.collections.get(urn).cmk_envelope;
176
+ const ownerWraps = envAfter.wraps.filter((w) => w.recipient === `${did}#data-kex`);
177
+ assert.equal(ownerWraps.length, 1, "exactly one owner wrap after rekey");
178
+ // 4. A fresh #data client reads the SAME record and decrypts it.
179
+ const dataClient = createDataClient({
180
+ pdsUrl: PDS,
181
+ did,
182
+ sphereSeed: hexToBytes(ensure.dataSeedHex),
183
+ verificationMethod: `${did}#data`,
184
+ fetch: pds.fetchImpl,
185
+ });
186
+ const after = await dataClient.collection("contacts").get(recId);
187
+ assert.ok(after, "record fetched post-migration");
188
+ assert.equal(after.phone, "+33612345678", "phone decrypts under #data");
189
+ assert.equal(after.notes, "secret pre-migration note", "notes decrypt under #data");
190
+ // 5. Idempotence — re-running rekey is a no-op (already-data).
191
+ const rekey2 = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
192
+ pdsUrl: PDS,
193
+ fetch: pds.fetchImpl,
194
+ });
195
+ assert.equal(rekey2.collections[0].status, "already-data");
196
+ });
197
+ test("migrate: dry-run reports a planned rekey without mutating the envelope", async () => {
198
+ const id = createBrowserIdentity("dry_user", "Dry User");
199
+ const did = id.did;
200
+ const pds = makePds();
201
+ const legacyClient = createDataClient({
202
+ pdsUrl: PDS,
203
+ did,
204
+ sphereSeed: id.public.seed, // sealed under #public this time
205
+ verificationMethod: `${did}#public`,
206
+ fetch: pds.fetchImpl,
207
+ });
208
+ await legacyClient.createCollection({ name: "leads", schema: "aithos.contacts.v1" });
209
+ await legacyClient.collection("leads").insert({ name: "Ana", phone: "+34000" });
210
+ const urn = `urn:aithos:collection:${did}:leads`;
211
+ const envBefore = JSON.stringify(pds.collections.get(urn).cmk_envelope);
212
+ const ensure = await ensureDataSphere(legacyRecoveryJson(id), { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
213
+ const rekey = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
214
+ pdsUrl: PDS,
215
+ fetch: pds.fetchImpl,
216
+ dryRun: true,
217
+ });
218
+ assert.equal(rekey.dryRun, true);
219
+ assert.equal(rekey.collections[0].status, "planned");
220
+ assert.equal(rekey.collections[0].unwrappedWith, "public");
221
+ assert.equal(JSON.stringify(pds.collections.get(urn).cmk_envelope), envBefore, "dry-run did NOT touch the stored envelope");
222
+ });
223
+ test("migrate: delegate wraps are preserved across rekey", async () => {
224
+ const id = createBrowserIdentity("deleg_user", "Deleg User");
225
+ const did = id.did;
226
+ const pds = makePds();
227
+ const legacyClient = createDataClient({
228
+ pdsUrl: PDS,
229
+ did,
230
+ sphereSeed: id.self.seed,
231
+ verificationMethod: `${did}#self`,
232
+ fetch: pds.fetchImpl,
233
+ });
234
+ await legacyClient.createCollection({ name: "shared", schema: "aithos.contacts.v1" });
235
+ // Inject a fake delegate wrap (a did:key recipient) directly into the stored
236
+ // envelope to simulate a prior authorize_app.
237
+ const urn = `urn:aithos:collection:${did}:shared`;
238
+ const env = pds.collections.get(urn).cmk_envelope;
239
+ env.wraps.push({
240
+ recipient: "did:key:zDelegateKey#data-kex",
241
+ // The other fields aren't read by the migration (it filters by recipient).
242
+ });
243
+ const ensure = await ensureDataSphere(legacyRecoveryJson(id), { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
244
+ const rekey = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
245
+ pdsUrl: PDS,
246
+ fetch: pds.fetchImpl,
247
+ });
248
+ assert.equal(rekey.collections[0].status, "rekeyed");
249
+ assert.equal(rekey.collections[0].delegateWrapsPreserved, 1);
250
+ const after = pds.collections.get(urn).cmk_envelope;
251
+ assert.ok(after.wraps.some((w) => w.recipient === "did:key:zDelegateKey#data-kex"), "delegate wrap survived the rekey");
252
+ assert.equal(after.wraps.filter((w) => w.recipient === `${did}#data-kex`).length, 1, "still exactly one owner wrap");
253
+ });
254
+ test("migrate: add-data-wrap keeps the legacy reader AND enables #data (dual-read)", async () => {
255
+ // Mirrors linkedone: a collection created+read under #root (post-store passes
256
+ // sphereSeed: rootSeed). We must NOT break the #root reader, but ALSO let a
257
+ // #data client read the same data.
258
+ const id = createBrowserIdentity("dual_user", "Dual User");
259
+ const did = id.did;
260
+ const pds = makePds();
261
+ // Legacy app wraps the CMK under #root (recipient label is #data-kex anyway).
262
+ const rootClient = createDataClient({
263
+ pdsUrl: PDS,
264
+ did,
265
+ sphereSeed: id.root.seed,
266
+ verificationMethod: `${did}#root`,
267
+ fetch: pds.fetchImpl,
268
+ });
269
+ await rootClient.createCollection({ name: "posts", schema: "aithos.contacts.v1" });
270
+ const recId = await rootClient.collection("posts").insert({ name: "Léa", phone: "+33secret-root" });
271
+ // Onboard onto #data in ADD mode (non-destructive).
272
+ const ensure = await ensureDataSphere(legacyRecoveryJson(id), { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
273
+ const report = await addDataSphereWrap(parseRecoveryFile(ensure.updatedRecoveryFile), {
274
+ pdsUrl: PDS,
275
+ fetch: pds.fetchImpl,
276
+ });
277
+ assert.equal(report.collections[0].status, "data-wrap-added");
278
+ assert.equal(report.collections[0].unwrappedWith, "root");
279
+ // Two owner wraps now share the #data-kex label.
280
+ const urn = `urn:aithos:collection:${did}:posts`;
281
+ const ownerWraps = pds.collections.get(urn).cmk_envelope.wraps.filter((w) => w.recipient === `${did}#data-kex`);
282
+ assert.equal(ownerWraps.length, 2, "legacy + #data owner wraps coexist");
283
+ // 1) The legacy #root reader STILL works (unchanged app).
284
+ const rootClient2 = createDataClient({
285
+ pdsUrl: PDS,
286
+ did,
287
+ sphereSeed: id.root.seed,
288
+ verificationMethod: `${did}#root`,
289
+ fetch: pds.fetchImpl,
290
+ });
291
+ const viaRoot = await rootClient2.collection("posts").get(recId);
292
+ assert.equal(viaRoot.phone, "+33secret-root", "#root reader still decrypts (multi-wrap reader tries all)");
293
+ // 2) A #data client reads the SAME record.
294
+ const dataClient = createDataClient({
295
+ pdsUrl: PDS,
296
+ did,
297
+ sphereSeed: hexToBytes(ensure.dataSeedHex),
298
+ verificationMethod: `${did}#data`,
299
+ fetch: pds.fetchImpl,
300
+ });
301
+ const viaData = await dataClient.collection("posts").get(recId);
302
+ assert.equal(viaData.phone, "+33secret-root", "#data reader decrypts the same data");
303
+ // Idempotent: re-running add is a no-op.
304
+ const again = await addDataSphereWrap(parseRecoveryFile(ensure.updatedRecoveryFile), { pdsUrl: PDS, fetch: pds.fetchImpl });
305
+ assert.equal(again.collections[0].status, "already-data");
306
+ });
307
+ test("migrate: combined helper runs ensure + rekey and returns updated recovery", async () => {
308
+ const id = createBrowserIdentity("combo_user", "Combo User");
309
+ const did = id.did;
310
+ const pds = makePds();
311
+ const legacyClient = createDataClient({
312
+ pdsUrl: PDS,
313
+ did,
314
+ sphereSeed: id.self.seed,
315
+ verificationMethod: `${did}#self`,
316
+ fetch: pds.fetchImpl,
317
+ });
318
+ await legacyClient.createCollection({ name: "contacts", schema: "aithos.contacts.v1" });
319
+ const recId = await legacyClient.collection("contacts").insert({ name: "Zoe", phone: "+1999" });
320
+ const out = await migrateLegacyEthosToDataSphere(legacyRecoveryJson(id), {
321
+ apiBaseUrl: API,
322
+ pdsUrl: PDS,
323
+ fetch: pds.fetchImpl,
324
+ });
325
+ assert.equal(out.ensure.added, true);
326
+ assert.equal(out.rekey.collections[0].status, "rekeyed");
327
+ const parsed = parseRecoveryFile(out.updatedRecoveryFile);
328
+ assert.equal(typeof parsed.seedsHex.data, "string");
329
+ // Read back under #data via the returned seed.
330
+ const dataClient = createDataClient({
331
+ pdsUrl: PDS,
332
+ did,
333
+ sphereSeed: hexToBytes(out.ensure.dataSeedHex),
334
+ verificationMethod: `${did}#data`,
335
+ fetch: pds.fetchImpl,
336
+ });
337
+ const after = await dataClient.collection("contacts").get(recId);
338
+ assert.equal(after.phone, "+1999");
339
+ });
340
+ //# sourceMappingURL=migrate.test.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=rotate-ethos.test.d.ts.map