@aithos/sdk 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +45 -0
- package/dist/src/auth-api.d.ts +50 -0
- package/dist/src/auth-api.js +102 -0
- package/dist/src/auth.d.ts +253 -0
- package/dist/src/auth.js +940 -0
- package/dist/src/compute.d.ts +370 -9
- package/dist/src/compute.js +369 -16
- package/dist/src/ethos.d.ts +117 -1
- package/dist/src/ethos.js +646 -16
- package/dist/src/index.d.ts +11 -4
- package/dist/src/index.js +31 -5
- package/dist/src/internal/delegate-bundle.d.ts +18 -0
- package/dist/src/internal/delegate-bundle.js +94 -0
- package/dist/src/internal/delegate-state.d.ts +45 -0
- package/dist/src/internal/delegate-state.js +120 -0
- package/dist/src/internal/owner-signers.d.ts +78 -0
- package/dist/src/internal/owner-signers.js +179 -0
- package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
- package/dist/src/internal/protocol-client-bridge.js +20 -0
- package/dist/src/internal/recovery-file.d.ts +29 -0
- package/dist/src/internal/recovery-file.js +98 -0
- package/dist/src/internal/signer.d.ts +59 -0
- package/dist/src/internal/signer.js +86 -0
- package/dist/src/key-store.d.ts +128 -0
- package/dist/src/key-store.js +244 -0
- package/dist/src/mandates.d.ts +163 -1
- package/dist/src/mandates.js +286 -8
- package/dist/src/sdk.d.ts +36 -3
- package/dist/src/sdk.js +27 -23
- package/dist/src/session-store.d.ts +58 -0
- package/dist/src/session-store.js +158 -0
- package/dist/src/wallet.d.ts +4 -6
- package/dist/src/wallet.js +18 -8
- package/dist/test/auth-j3.test.d.ts +2 -0
- package/dist/test/auth-j3.test.js +391 -0
- package/dist/test/auth.test.d.ts +2 -0
- package/dist/test/auth.test.js +175 -0
- package/dist/test/compute-delegate-path.test.d.ts +2 -0
- package/dist/test/compute-delegate-path.test.js +183 -0
- package/dist/test/compute.test.js +184 -11
- package/dist/test/ethos-first-edition.test.d.ts +2 -0
- package/dist/test/ethos-first-edition.test.js +248 -0
- package/dist/test/ethos.test.d.ts +2 -0
- package/dist/test/ethos.test.js +219 -0
- package/dist/test/key-store.test.d.ts +2 -0
- package/dist/test/key-store.test.js +161 -0
- package/dist/test/mandates-compute.test.d.ts +2 -0
- package/dist/test/mandates-compute.test.js +256 -0
- package/dist/test/mandates.test.d.ts +2 -0
- package/dist/test/mandates.test.js +93 -0
- package/dist/test/sdk.test.js +70 -30
- package/dist/test/signer.test.d.ts +2 -0
- package/dist/test/signer.test.js +117 -0
- package/dist/test/signup-bootstrap.test.d.ts +2 -0
- package/dist/test/signup-bootstrap.test.js +222 -0
- package/dist/test/wallet.test.js +20 -9
- package/package.json +4 -3
package/dist/src/ethos.js
CHANGED
|
@@ -1,21 +1,651 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Mathieu Colla
|
|
3
|
-
//
|
|
3
|
+
// `sdk.ethos` namespace — high-level ethos editing.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// `@aithos/protocol-client` for the common operations: parsing zone
|
|
7
|
-
// markdown, composing zone documents, building signed editions of the
|
|
8
|
-
// user's ethos.
|
|
5
|
+
// Lazy + commit pattern:
|
|
9
6
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
|
|
7
|
+
// const me = sdk.ethos.me();
|
|
8
|
+
// me.zone("public").addSection({ title, body });
|
|
9
|
+
// me.zone("circle").deleteSection(id);
|
|
10
|
+
// await me.publish(); // 1 edition
|
|
11
|
+
//
|
|
12
|
+
// Mutations stage in memory. `sections()` / `pendingChanges()` /
|
|
13
|
+
// `publish()` are the points where the snapshot is fetched (memoized
|
|
14
|
+
// per zone) and the staged changes are applied on top.
|
|
15
|
+
//
|
|
16
|
+
// Three actor modes:
|
|
17
|
+
// - owner — sdk.ethos.me() returns this; full access to public
|
|
18
|
+
// + private zones using OwnerSigners
|
|
19
|
+
// - delegate — sdk.ethos.of(did) when a mandate matches this
|
|
20
|
+
// subject; capability bounded by mandate scopes
|
|
21
|
+
// - anonymous — sdk.ethos.of(did) when no mandate matches; public
|
|
22
|
+
// zone read-only, every write rejected
|
|
23
|
+
//
|
|
24
|
+
// The namespace stays thin: it builds an EthosClient with the right
|
|
25
|
+
// actor and forwards all real work into protocol-client's
|
|
26
|
+
// `loadEditSnapshot` / `publishZoneEdit` / `publishPublicZoneAsDelegate`
|
|
27
|
+
// / `publishPrivateZoneAsDelegate`.
|
|
28
|
+
import { addSectionToList, AithosRpcError, browserIdentityFromStored, buildSignedEnvelope, buildSignedFirstEditionFromSections, deleteSectionFromList, loadEditSnapshot, modifySectionInList, publishPrivateZoneAsDelegate, publishPublicZoneAsDelegate, publishZoneEdit, signedDidDocument, writeEndpoint, } from "@aithos/protocol-client";
|
|
29
|
+
import { delegateKeyPair } from "./internal/protocol-client-bridge.js";
|
|
30
|
+
import { AithosSDKError } from "./types.js";
|
|
31
|
+
export const ZONE_NAMES = ["public", "circle", "self"];
|
|
32
|
+
/* -------------------------------------------------------------------------- */
|
|
33
|
+
/* EthosClient — per-subject working buffer */
|
|
34
|
+
/* -------------------------------------------------------------------------- */
|
|
35
|
+
export class EthosClient {
|
|
36
|
+
subjectDid;
|
|
37
|
+
mode;
|
|
38
|
+
#actor;
|
|
39
|
+
#snapshots = new Map();
|
|
40
|
+
#mutations = [];
|
|
41
|
+
/**
|
|
42
|
+
* Set to `true` when the server reports that no edition has been
|
|
43
|
+
* published yet for this subject (JSON-RPC code -32020 with the message
|
|
44
|
+
* `not found: edition for <did>`). Toggled lazily on the first read /
|
|
45
|
+
* publish attempt and cleared again after a successful first-edition
|
|
46
|
+
* publish so subsequent operations take the regular `publishZoneEdit`
|
|
47
|
+
* path.
|
|
48
|
+
*/
|
|
49
|
+
#ethosHasNoEditionYet = false;
|
|
50
|
+
constructor(actor) {
|
|
51
|
+
this.#actor = actor;
|
|
52
|
+
this.subjectDid = actor.subjectDid;
|
|
53
|
+
this.mode = actor.kind;
|
|
54
|
+
}
|
|
55
|
+
/** Return the per-zone proxy. */
|
|
56
|
+
zone(name) {
|
|
57
|
+
if (!ZONE_NAMES.includes(name)) {
|
|
58
|
+
throw new AithosSDKError("ethos_invalid_zone", `unknown zone "${String(name)}"`);
|
|
59
|
+
}
|
|
60
|
+
return new EthosZone(this, name);
|
|
61
|
+
}
|
|
62
|
+
hasPendingChanges() {
|
|
63
|
+
return this.#mutations.length > 0;
|
|
64
|
+
}
|
|
65
|
+
pendingChanges() {
|
|
66
|
+
return this.#mutations.slice();
|
|
67
|
+
}
|
|
68
|
+
discard() {
|
|
69
|
+
this.#mutations = [];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build and publish a new edition with all staged mutations applied.
|
|
73
|
+
* Throws if there's nothing staged. After a successful publish, the
|
|
74
|
+
* mutation buffer is cleared and any cached snapshot is invalidated
|
|
75
|
+
* so the next read picks up the fresh edition.
|
|
76
|
+
*/
|
|
77
|
+
async publish() {
|
|
78
|
+
if (this.#actor.kind === "anonymous") {
|
|
79
|
+
throw new AithosSDKError("ethos_anonymous_cannot_publish", "anonymous reader has no signing capability");
|
|
80
|
+
}
|
|
81
|
+
if (this.#mutations.length === 0) {
|
|
82
|
+
throw new AithosSDKError("ethos_nothing_to_publish", "no staged mutations; call addSection / updateSection / deleteSection first");
|
|
83
|
+
}
|
|
84
|
+
// Compute effective sections per zone, but only for zones that
|
|
85
|
+
// actually have staged mutations. Zones without staged changes
|
|
86
|
+
// roll forward by reusing protocol-client's existing bytes.
|
|
87
|
+
const touched = new Set(this.#mutations.map((m) => m.zone));
|
|
88
|
+
// Owner-side path is the most common: owner can write any zone they
|
|
89
|
+
// have staged mutations for. Build everything we need then call
|
|
90
|
+
// publishZoneEdit once.
|
|
91
|
+
if (this.#actor.kind === "owner") {
|
|
92
|
+
// First-edition path: no prior edition exists. Detected when the
|
|
93
|
+
// tolerant snapshot wrapper returned null on a previous read OR
|
|
94
|
+
// when this is the first network touch and the server responds
|
|
95
|
+
// with "not found: edition". Skip the snapshot fetch entirely and
|
|
96
|
+
// build a height=1 manifest from the staged mutations.
|
|
97
|
+
const snap = await this.#tryEnsureSnapshotOwner();
|
|
98
|
+
if (snap === null) {
|
|
99
|
+
return this.#publishFirstEditionOwner();
|
|
100
|
+
}
|
|
101
|
+
const newPublic = touched.has("public")
|
|
102
|
+
? this.#applyMutations("public", snap.publicSections)
|
|
103
|
+
: undefined;
|
|
104
|
+
const newCircle = touched.has("circle")
|
|
105
|
+
? this.#applyMutations("circle", snap.circleSections ?? [])
|
|
106
|
+
: undefined;
|
|
107
|
+
const newSelf = touched.has("self")
|
|
108
|
+
? this.#applyMutations("self", snap.selfSections ?? [])
|
|
109
|
+
: undefined;
|
|
110
|
+
const identity = this.#actor.signers._unsafeStoredIdentity();
|
|
111
|
+
try {
|
|
112
|
+
const result = await publishZoneEdit({
|
|
113
|
+
identity,
|
|
114
|
+
snapshot: snap,
|
|
115
|
+
// protocol-client's publishZoneEdit always wants newPublic; if we
|
|
116
|
+
// didn't touch public we re-publish the existing public sections.
|
|
117
|
+
newPublicSections: newPublic ?? snap.publicSections,
|
|
118
|
+
...(newCircle ? { newCircleSections: newCircle } : {}),
|
|
119
|
+
...(newSelf ? { newSelfSections: newSelf } : {}),
|
|
120
|
+
});
|
|
121
|
+
this.#afterPublish();
|
|
122
|
+
return projectPublishResult(result.manifest, this.subjectDid, [
|
|
123
|
+
...touched,
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
// identity holds raw seeds via reference; not our copy to zeroize
|
|
128
|
+
// (the underlying seeds live in the OwnerSigners' private fields).
|
|
129
|
+
// Drop our reference to the temporary StoredIdentity object.
|
|
130
|
+
void identity;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Delegate path. Per-zone scope check + per-zone protocol-client
|
|
134
|
+
// function. For a multi-zone delegate publish we'd need to call
|
|
135
|
+
// multiple endpoints; we iterate zones, calling the right primitive.
|
|
136
|
+
if (this.#actor.kind === "delegate") {
|
|
137
|
+
const actor = this.#actor.actor;
|
|
138
|
+
const stored = delegateActorToStored(actor);
|
|
139
|
+
// Validate scopes upfront.
|
|
140
|
+
for (const zone of touched) {
|
|
141
|
+
const required = `ethos.write.${zone}`;
|
|
142
|
+
const scopes = actor.mandate["scopes"] ?? [];
|
|
143
|
+
if (!Array.isArray(scopes) || !scopes.includes(required)) {
|
|
144
|
+
throw new AithosSDKError("ethos_delegate_scope_missing", `delegate mandate does not grant ${required}`, { data: { mandateId: actor.mandateId, scopes } });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const snap = await this.#ensureSnapshotDelegate();
|
|
148
|
+
// Public-zone update — use publishPublicZoneAsDelegate.
|
|
149
|
+
if (touched.has("public")) {
|
|
150
|
+
const newPublic = this.#applyMutations("public", snap.publicSections);
|
|
151
|
+
await publishPublicZoneAsDelegate({
|
|
152
|
+
delegate: stored,
|
|
153
|
+
snapshot: snap,
|
|
154
|
+
newPublicSections: newPublic,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Private zones — one publishPrivateZoneAsDelegate call each.
|
|
158
|
+
for (const zone of ["circle", "self"]) {
|
|
159
|
+
if (!touched.has(zone))
|
|
160
|
+
continue;
|
|
161
|
+
// CRITICAL — refuse to publish a private zone the delegate
|
|
162
|
+
// can't decrypt. publishPrivateZoneAsDelegate treats
|
|
163
|
+
// `newSections` as the COMPLETE new content of the zone (the
|
|
164
|
+
// server can't merge encrypted bytes it can't read), so if we
|
|
165
|
+
// forwarded `[]` here the new edition would silently wipe
|
|
166
|
+
// every section the owner had. This data-loss landed in alpha.9
|
|
167
|
+
// — alpha.10 turns it into a clean error so the caller knows
|
|
168
|
+
// the mandate isn't usable until the owner has bootstrapped a
|
|
169
|
+
// wrap for this delegate (a single owner-side publish on the
|
|
170
|
+
// zone after mint is enough).
|
|
171
|
+
if (snap.zoneDecryptErrors?.[zone]) {
|
|
172
|
+
throw new AithosSDKError("ethos_delegate_cannot_overwrite_unreadable", `delegate cannot publish to "${zone}": the existing edition is not readable for this delegate (${snap.zoneDecryptErrors[zone]}). Ask the owner to publish ${zone} once so this delegate's wrap is included; only then can the delegate write — otherwise the new edition would replace content this delegate cannot see.`, {
|
|
173
|
+
data: {
|
|
174
|
+
zone,
|
|
175
|
+
mandateId: actor.mandateId,
|
|
176
|
+
decryptError: snap.zoneDecryptErrors[zone],
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const base = (zone === "circle"
|
|
181
|
+
? snap.circleSections
|
|
182
|
+
: snap.selfSections) ?? [];
|
|
183
|
+
const newSections = this.#applyMutations(zone, base);
|
|
184
|
+
await publishPrivateZoneAsDelegate({
|
|
185
|
+
delegate: stored,
|
|
186
|
+
snapshot: snap,
|
|
187
|
+
zone,
|
|
188
|
+
newSections,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
this.#afterPublish();
|
|
192
|
+
// We don't have a single "manifest" to project from since each call
|
|
193
|
+
// returned its own. Use the last touched call's snapshot as the
|
|
194
|
+
// base for height — fetch fresh on next read anyway.
|
|
195
|
+
return {
|
|
196
|
+
editionHeight: snap.manifest.edition.height + 1,
|
|
197
|
+
manifestHash: "", // not surfaced by protocol-client today on the delegate path
|
|
198
|
+
subjectDid: this.subjectDid,
|
|
199
|
+
zonesPublished: [...touched],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Unreachable — anonymous case returned earlier.
|
|
203
|
+
throw new AithosSDKError("ethos_invalid_actor", "unsupported actor for publish()");
|
|
204
|
+
}
|
|
205
|
+
/* ------------------------------------------------------------------------ */
|
|
206
|
+
/* Internal — snapshot management */
|
|
207
|
+
/* ------------------------------------------------------------------------ */
|
|
208
|
+
async _readZone(zone) {
|
|
209
|
+
if (this.#actor.kind === "anonymous") {
|
|
210
|
+
if (zone !== "public") {
|
|
211
|
+
throw new AithosSDKError("ethos_anonymous_private_zone", `anonymous reader cannot access the "${zone}" zone`);
|
|
212
|
+
}
|
|
213
|
+
const snap = await this.#tryEnsureSnapshotAnonymous();
|
|
214
|
+
// No edition yet → no base sections; only staged mutations contribute.
|
|
215
|
+
const base = snap === null ? [] : snap.publicSections;
|
|
216
|
+
return this.#applyMutations(zone, base);
|
|
217
|
+
}
|
|
218
|
+
if (this.#actor.kind === "owner") {
|
|
219
|
+
const snap = await this.#tryEnsureSnapshotOwner();
|
|
220
|
+
if (snap === null) {
|
|
221
|
+
// No edition yet for this owner — base sections are empty in every
|
|
222
|
+
// zone. Staged mutations apply on top of an empty list.
|
|
223
|
+
return this.#applyMutations(zone, []);
|
|
224
|
+
}
|
|
225
|
+
const base = baseSectionsFromSnapshot(snap, zone);
|
|
226
|
+
// Surface decryption errors clearly (if reading a private zone we
|
|
227
|
+
// can't unwrap, the snapshot has it in zoneDecryptErrors).
|
|
228
|
+
if (zone !== "public" && snap.zoneDecryptErrors?.[zone]) {
|
|
229
|
+
throw new AithosSDKError("ethos_zone_unreadable", `cannot read ${zone}: ${snap.zoneDecryptErrors[zone]}`);
|
|
230
|
+
}
|
|
231
|
+
return this.#applyMutations(zone, base);
|
|
232
|
+
}
|
|
233
|
+
// Delegate
|
|
234
|
+
const snap = await this.#tryEnsureSnapshotDelegate();
|
|
235
|
+
if (snap === null) {
|
|
236
|
+
return this.#applyMutations(zone, []);
|
|
237
|
+
}
|
|
238
|
+
const base = baseSectionsFromSnapshot(snap, zone);
|
|
239
|
+
if (zone !== "public" && snap.zoneDecryptErrors?.[zone]) {
|
|
240
|
+
throw new AithosSDKError("ethos_zone_unreadable", `cannot read ${zone}: ${snap.zoneDecryptErrors[zone]}`);
|
|
241
|
+
}
|
|
242
|
+
return this.#applyMutations(zone, base);
|
|
243
|
+
}
|
|
244
|
+
_stageAdd(zone, input) {
|
|
245
|
+
if (this.#actor.kind === "anonymous") {
|
|
246
|
+
throw new AithosSDKError("ethos_anonymous_write", "anonymous reader cannot stage mutations");
|
|
247
|
+
}
|
|
248
|
+
const section = {
|
|
249
|
+
id: "sec_" + randomHex(12),
|
|
250
|
+
title: input.title,
|
|
251
|
+
body: input.body,
|
|
252
|
+
gamma_ref: "gamma_none_" + randomHex(24),
|
|
253
|
+
...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
|
|
254
|
+
};
|
|
255
|
+
this.#mutations.push({ kind: "add", zone, section });
|
|
256
|
+
}
|
|
257
|
+
_stageUpdate(zone, sectionId, patch) {
|
|
258
|
+
if (this.#actor.kind === "anonymous") {
|
|
259
|
+
throw new AithosSDKError("ethos_anonymous_write", "anonymous reader cannot stage mutations");
|
|
260
|
+
}
|
|
261
|
+
this.#mutations.push({ kind: "update", zone, sectionId, patch });
|
|
262
|
+
}
|
|
263
|
+
_stageDelete(zone, sectionId) {
|
|
264
|
+
if (this.#actor.kind === "anonymous") {
|
|
265
|
+
throw new AithosSDKError("ethos_anonymous_write", "anonymous reader cannot stage mutations");
|
|
266
|
+
}
|
|
267
|
+
this.#mutations.push({ kind: "delete", zone, sectionId });
|
|
268
|
+
}
|
|
269
|
+
/** Apply staged mutations for a zone on top of base sections. */
|
|
270
|
+
#applyMutations(zone, base) {
|
|
271
|
+
let acc = base;
|
|
272
|
+
for (const m of this.#mutations) {
|
|
273
|
+
if (m.zone !== zone)
|
|
274
|
+
continue;
|
|
275
|
+
switch (m.kind) {
|
|
276
|
+
case "add":
|
|
277
|
+
acc = addSectionToList(acc, {
|
|
278
|
+
title: m.section.title,
|
|
279
|
+
body: m.section.body,
|
|
280
|
+
...(m.section.tags ? { tags: m.section.tags } : {}),
|
|
281
|
+
});
|
|
282
|
+
break;
|
|
283
|
+
case "update":
|
|
284
|
+
acc = modifySectionInList(acc, {
|
|
285
|
+
sectionId: m.sectionId,
|
|
286
|
+
...(m.patch.title !== undefined ? { title: m.patch.title } : {}),
|
|
287
|
+
...(m.patch.body !== undefined ? { body: m.patch.body } : {}),
|
|
288
|
+
...(m.patch.tags !== undefined ? { tags: m.patch.tags } : {}),
|
|
289
|
+
});
|
|
290
|
+
break;
|
|
291
|
+
case "delete":
|
|
292
|
+
acc = deleteSectionFromList(acc, m.sectionId);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return acc;
|
|
297
|
+
}
|
|
298
|
+
async #ensureSnapshotOwner() {
|
|
299
|
+
const cached = this.#snapshots.get("public");
|
|
300
|
+
if (cached)
|
|
301
|
+
return cached;
|
|
302
|
+
if (this.#actor.kind !== "owner") {
|
|
303
|
+
throw new AithosSDKError("ethos_invalid_actor", "expected owner actor");
|
|
304
|
+
}
|
|
305
|
+
const identity = this.#actor.signers._unsafeStoredIdentity();
|
|
306
|
+
const snap = await loadEditSnapshot(this.subjectDid, identity);
|
|
307
|
+
this.#cacheSnapshotAllZones(snap);
|
|
308
|
+
return snap;
|
|
309
|
+
}
|
|
310
|
+
async #ensureSnapshotDelegate() {
|
|
311
|
+
const cached = this.#snapshots.get("public");
|
|
312
|
+
if (cached)
|
|
313
|
+
return cached;
|
|
314
|
+
if (this.#actor.kind !== "delegate") {
|
|
315
|
+
throw new AithosSDKError("ethos_invalid_actor", "expected delegate actor");
|
|
316
|
+
}
|
|
317
|
+
const stored = delegateActorToStored(this.#actor.actor);
|
|
318
|
+
const snap = await loadEditSnapshot(this.subjectDid, undefined, stored);
|
|
319
|
+
this.#cacheSnapshotAllZones(snap);
|
|
320
|
+
return snap;
|
|
321
|
+
}
|
|
322
|
+
async #ensureSnapshotAnonymous() {
|
|
323
|
+
const cached = this.#snapshots.get("public");
|
|
324
|
+
if (cached)
|
|
325
|
+
return cached;
|
|
326
|
+
const snap = await loadEditSnapshot(this.subjectDid);
|
|
327
|
+
this.#cacheSnapshotAllZones(snap);
|
|
328
|
+
return snap;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Wrap {@link #ensureSnapshotOwner} so the "no edition published yet"
|
|
332
|
+
* server response (-32020 with message `not found: edition for <did>`)
|
|
333
|
+
* is converted into `null`. Once converted, {@link #ethosHasNoEditionYet}
|
|
334
|
+
* is set to short-circuit subsequent reads without re-hitting the network.
|
|
335
|
+
*
|
|
336
|
+
* Returns `null` to mean "subject has an identity but no editions yet —
|
|
337
|
+
* treat all zones as empty". Any other error is re-thrown.
|
|
338
|
+
*/
|
|
339
|
+
async #tryEnsureSnapshotOwner() {
|
|
340
|
+
if (this.#ethosHasNoEditionYet)
|
|
341
|
+
return null;
|
|
342
|
+
try {
|
|
343
|
+
return await this.#ensureSnapshotOwner();
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
if (isNoEditionYetError(e)) {
|
|
347
|
+
this.#ethosHasNoEditionYet = true;
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
throw e;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async #tryEnsureSnapshotDelegate() {
|
|
354
|
+
if (this.#ethosHasNoEditionYet)
|
|
355
|
+
return null;
|
|
356
|
+
try {
|
|
357
|
+
return await this.#ensureSnapshotDelegate();
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
if (isNoEditionYetError(e)) {
|
|
361
|
+
this.#ethosHasNoEditionYet = true;
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async #tryEnsureSnapshotAnonymous() {
|
|
368
|
+
if (this.#ethosHasNoEditionYet)
|
|
369
|
+
return null;
|
|
370
|
+
try {
|
|
371
|
+
return await this.#ensureSnapshotAnonymous();
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
if (isNoEditionYetError(e)) {
|
|
375
|
+
this.#ethosHasNoEditionYet = true;
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
throw e;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
#cacheSnapshotAllZones(snap) {
|
|
382
|
+
for (const z of ZONE_NAMES)
|
|
383
|
+
this.#snapshots.set(z, snap);
|
|
384
|
+
}
|
|
385
|
+
#afterPublish() {
|
|
386
|
+
this.#mutations = [];
|
|
387
|
+
this.#snapshots.clear();
|
|
388
|
+
}
|
|
389
|
+
/* ------------------------------------------------------------------------ */
|
|
390
|
+
/* First-edition publish (owner) */
|
|
391
|
+
/* ------------------------------------------------------------------------ */
|
|
392
|
+
/**
|
|
393
|
+
* Publish height=1 for an owner whose Ethos identity exists on
|
|
394
|
+
* `api.aithos.be` (provisioned by `auth.signUp()` in alpha.6+) but who
|
|
395
|
+
* has no editions yet. Builds the manifest from the staged ADD
|
|
396
|
+
* mutations on the public zone and POSTs `aithos.publish_ethos_edition`.
|
|
397
|
+
*
|
|
398
|
+
* Limitations of the alpha.7 cut:
|
|
399
|
+
* - Public zone only. Staged mutations on circle/self are rejected
|
|
400
|
+
* with `ethos_first_edition_public_only` — those zones can be
|
|
401
|
+
* populated in subsequent editions via the regular
|
|
402
|
+
* `publishZoneEdit` path once the public zone has been seeded.
|
|
403
|
+
* - First-edition publishes don't accept update/delete mutations
|
|
404
|
+
* (there's nothing to update or delete yet) — those are rejected
|
|
405
|
+
* with `ethos_first_edition_invalid_op`.
|
|
406
|
+
*/
|
|
407
|
+
async #publishFirstEditionOwner() {
|
|
408
|
+
if (this.#actor.kind !== "owner") {
|
|
409
|
+
// Defensive — caller already checked this branch.
|
|
410
|
+
throw new AithosSDKError("ethos_invalid_actor", "expected owner actor");
|
|
411
|
+
}
|
|
412
|
+
// Validate the staged operation set. First edition = ADDs on public
|
|
413
|
+
// zone only.
|
|
414
|
+
const publicAdds = [];
|
|
415
|
+
for (const m of this.#mutations) {
|
|
416
|
+
if (m.kind !== "add") {
|
|
417
|
+
throw new AithosSDKError("ethos_first_edition_invalid_op", `first edition: cannot ${m.kind} a section before any edition exists; only addSection is supported on a fresh Ethos`, { data: { mutation: m } });
|
|
418
|
+
}
|
|
419
|
+
if (m.zone !== "public") {
|
|
420
|
+
throw new AithosSDKError("ethos_first_edition_public_only", `first edition: only the "public" zone is supported on a fresh Ethos; "${m.zone}" sections can be added after the first publish`, { data: { zone: m.zone } });
|
|
421
|
+
}
|
|
422
|
+
publicAdds.push({ section: m.section });
|
|
423
|
+
}
|
|
424
|
+
if (publicAdds.length === 0) {
|
|
425
|
+
// Should never reach here — publish() short-circuits on empty
|
|
426
|
+
// mutations. Belt-and-braces in case the contract drifts.
|
|
427
|
+
throw new AithosSDKError("ethos_first_edition_empty", "first edition: stage at least one public-zone section before publishing");
|
|
428
|
+
}
|
|
429
|
+
const identity = this.#actor.signers._unsafeStoredIdentity();
|
|
430
|
+
const browserId = browserIdentityFromStored(identity);
|
|
431
|
+
const signedDoc = signedDidDocument(browserId);
|
|
432
|
+
const built = buildSignedFirstEditionFromSections({
|
|
433
|
+
identity: browserId,
|
|
434
|
+
signedDidDoc: signedDoc,
|
|
435
|
+
publicSections: publicAdds.map((a) => a.section),
|
|
436
|
+
});
|
|
437
|
+
const url = writeEndpoint();
|
|
438
|
+
const params = {
|
|
439
|
+
manifest: built.manifest,
|
|
440
|
+
zones: {
|
|
441
|
+
public: { bytes_base64: bytesToBase64Padded(built.publicMarkdownBytes) },
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
const envelope = buildSignedEnvelope({
|
|
445
|
+
iss: browserId.did,
|
|
446
|
+
aud: url,
|
|
447
|
+
method: "aithos.publish_ethos_edition",
|
|
448
|
+
verificationMethod: `${browserId.did}#public`,
|
|
449
|
+
params,
|
|
450
|
+
signer: browserId.public,
|
|
451
|
+
});
|
|
452
|
+
const body = JSON.stringify({
|
|
453
|
+
jsonrpc: "2.0",
|
|
454
|
+
id: "publish_ethos_edition",
|
|
455
|
+
method: "aithos.publish_ethos_edition",
|
|
456
|
+
params: { ...params, _envelope: envelope },
|
|
457
|
+
});
|
|
458
|
+
let res;
|
|
459
|
+
try {
|
|
460
|
+
res = await fetch(url, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { "content-type": "application/json" },
|
|
463
|
+
body,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
throw new AithosSDKError("ethos_publish_network", `publish_ethos_edition (first edition): network error: ${e.message ?? "unknown"}`);
|
|
468
|
+
}
|
|
469
|
+
let json;
|
|
470
|
+
try {
|
|
471
|
+
json = (await res.json());
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
throw new AithosSDKError("ethos_publish_invalid_response", `publish_ethos_edition (first edition): server returned non-JSON (HTTP ${res.status})`);
|
|
475
|
+
}
|
|
476
|
+
if (json.error) {
|
|
477
|
+
throw new AithosSDKError("ethos_first_edition_rejected", `publish_ethos_edition (first edition) rejected: ${json.error.message}`, {
|
|
478
|
+
status: res.status,
|
|
479
|
+
data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// Success: clear the no-edition flag so subsequent reads/publishes
|
|
483
|
+
// take the regular next-edition path.
|
|
484
|
+
this.#ethosHasNoEditionYet = false;
|
|
485
|
+
this.#afterPublish();
|
|
486
|
+
return {
|
|
487
|
+
editionHeight: 1,
|
|
488
|
+
manifestHash: "", // protocol-client surfaces this on later editions; not on first
|
|
489
|
+
subjectDid: browserId.did,
|
|
490
|
+
zonesPublished: ["public"],
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/* -------------------------------------------------------------------------- */
|
|
495
|
+
/* EthosZone — per-zone proxy */
|
|
496
|
+
/* -------------------------------------------------------------------------- */
|
|
497
|
+
export class EthosZone {
|
|
498
|
+
#parent;
|
|
499
|
+
#name;
|
|
500
|
+
constructor(parent, name) {
|
|
501
|
+
this.#parent = parent;
|
|
502
|
+
this.#name = name;
|
|
503
|
+
}
|
|
504
|
+
get name() {
|
|
505
|
+
return this.#name;
|
|
506
|
+
}
|
|
507
|
+
/** Effective sections (persisted + staged mutations applied). */
|
|
508
|
+
async sections() {
|
|
509
|
+
return this.#parent._readZone(this.#name);
|
|
510
|
+
}
|
|
511
|
+
addSection(input) {
|
|
512
|
+
this.#parent._stageAdd(this.#name, input);
|
|
513
|
+
}
|
|
514
|
+
updateSection(sectionId, patch) {
|
|
515
|
+
this.#parent._stageUpdate(this.#name, sectionId, patch);
|
|
516
|
+
}
|
|
517
|
+
deleteSection(sectionId) {
|
|
518
|
+
this.#parent._stageDelete(this.#name, sectionId);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
export class EthosNamespace {
|
|
522
|
+
#deps;
|
|
523
|
+
constructor(deps) {
|
|
524
|
+
this.#deps = deps;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* EthosClient for the currently signed-in owner. Throws if there is
|
|
528
|
+
* no owner — callers should check `auth.canSignAsOwner()` first or
|
|
529
|
+
* surface the error to the user as "please sign in".
|
|
530
|
+
*/
|
|
531
|
+
me() {
|
|
532
|
+
const signers = this.#deps.auth._getOwnerSigners();
|
|
533
|
+
if (!signers || signers.destroyed) {
|
|
534
|
+
throw new AithosSDKError("ethos_no_owner", "no owner signed in; sign in with email/password, Google, or a recovery file first");
|
|
535
|
+
}
|
|
536
|
+
return new EthosClient({
|
|
537
|
+
kind: "owner",
|
|
538
|
+
subjectDid: signers.did,
|
|
539
|
+
signers,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* EthosClient for an arbitrary subject DID. The mode is resolved at
|
|
544
|
+
* construction time:
|
|
545
|
+
* - if `did` matches the currently signed-in owner → owner mode
|
|
546
|
+
* - else if a mandate held by `auth` covers this subject → delegate mode
|
|
547
|
+
* - else → anonymous read-only mode
|
|
548
|
+
*
|
|
549
|
+
* Async signature so future implementations may do an eager manifest
|
|
550
|
+
* fetch (e.g. to fail fast on unknown DIDs); today resolution is sync
|
|
551
|
+
* and the actual fetch happens on the first `sections()` / `publish()`
|
|
552
|
+
* call.
|
|
553
|
+
*/
|
|
554
|
+
async of(did) {
|
|
555
|
+
const owner = this.#deps.auth._getOwnerSigners();
|
|
556
|
+
if (owner && !owner.destroyed && owner.did === did) {
|
|
557
|
+
return new EthosClient({
|
|
558
|
+
kind: "owner",
|
|
559
|
+
subjectDid: did,
|
|
560
|
+
signers: owner,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const delegate = this.#deps.auth._findDelegateForSubject(did);
|
|
564
|
+
if (delegate && !delegate.destroyed) {
|
|
565
|
+
return new EthosClient({
|
|
566
|
+
kind: "delegate",
|
|
567
|
+
subjectDid: did,
|
|
568
|
+
actor: delegate,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return new EthosClient({ kind: "anonymous", subjectDid: did });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/* -------------------------------------------------------------------------- */
|
|
575
|
+
/* Helpers */
|
|
576
|
+
/* -------------------------------------------------------------------------- */
|
|
577
|
+
function baseSectionsFromSnapshot(snap, zone) {
|
|
578
|
+
switch (zone) {
|
|
579
|
+
case "public":
|
|
580
|
+
return snap.publicSections;
|
|
581
|
+
case "circle":
|
|
582
|
+
return snap.circleSections ?? [];
|
|
583
|
+
case "self":
|
|
584
|
+
return snap.selfSections ?? [];
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function delegateActorToStored(a) {
|
|
588
|
+
const kp = delegateKeyPair(a);
|
|
589
|
+
let hex = "";
|
|
590
|
+
for (let i = 0; i < kp.seed.length; i++) {
|
|
591
|
+
hex += kp.seed[i].toString(16).padStart(2, "0");
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
version: "0.1.0",
|
|
595
|
+
subjectDid: a.subjectDid,
|
|
596
|
+
mandate: a.mandate,
|
|
597
|
+
mandateId: a.mandateId,
|
|
598
|
+
granteeId: a.granteeId,
|
|
599
|
+
granteePubkeyMultibase: a.granteePubkeyMultibase,
|
|
600
|
+
delegateSeedHex: hex,
|
|
601
|
+
importedAt: new Date().toISOString(),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function projectPublishResult(manifest, subjectDid, zones) {
|
|
605
|
+
return {
|
|
606
|
+
editionHeight: manifest.edition.height,
|
|
607
|
+
manifestHash: manifest.bundle_id ?? "",
|
|
608
|
+
subjectDid,
|
|
609
|
+
zonesPublished: zones,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Detect the server-side "no edition published yet" response. Matches the
|
|
614
|
+
* exact error shape emitted by primitives-read's `notFound("edition for
|
|
615
|
+
* <did>")` helper: JSON-RPC code -32020 + message starting with `not
|
|
616
|
+
* found: edition for `. Other -32020 cases (e.g. "not found: manifest
|
|
617
|
+
* <did>@<height>" raised when the index advertises an edition the S3
|
|
618
|
+
* bucket can't serve) deliberately fall through — they're symptoms, not
|
|
619
|
+
* the "fresh subject" case we're trying to swallow.
|
|
620
|
+
*/
|
|
621
|
+
function isNoEditionYetError(e) {
|
|
622
|
+
if (!(e instanceof AithosRpcError))
|
|
623
|
+
return false;
|
|
624
|
+
if (e.code !== -32020)
|
|
625
|
+
return false;
|
|
626
|
+
return typeof e.message === "string"
|
|
627
|
+
&& e.message.startsWith("not found: edition for ");
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Standard base64 with `=` padding — matches what protocol-client's
|
|
631
|
+
* publishZoneEdit uses for `zones.<zone>.bytes_base64`. The server is
|
|
632
|
+
* tolerant of either padded or unpadded variants per the API contract,
|
|
633
|
+
* but we mirror the existing wire to keep payloads byte-identical for
|
|
634
|
+
* easy diffing in dev tools.
|
|
635
|
+
*/
|
|
636
|
+
function bytesToBase64Padded(bytes) {
|
|
637
|
+
let bin = "";
|
|
638
|
+
for (let i = 0; i < bytes.length; i++)
|
|
639
|
+
bin += String.fromCharCode(bytes[i]);
|
|
640
|
+
return btoa(bin);
|
|
641
|
+
}
|
|
642
|
+
function randomHex(n) {
|
|
643
|
+
const bytes = new Uint8Array(Math.ceil(n / 2));
|
|
644
|
+
crypto.getRandomValues(bytes);
|
|
645
|
+
let hex = "";
|
|
646
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
647
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
648
|
+
}
|
|
649
|
+
return hex.slice(0, n);
|
|
650
|
+
}
|
|
21
651
|
//# sourceMappingURL=ethos.js.map
|