@aithos/sdk 0.1.0-alpha.4 → 0.1.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +45 -0
  2. package/dist/src/auth.d.ts +103 -134
  3. package/dist/src/auth.js +532 -157
  4. package/dist/src/compute.d.ts +8 -6
  5. package/dist/src/compute.js +19 -11
  6. package/dist/src/ethos.d.ts +117 -1
  7. package/dist/src/ethos.js +417 -16
  8. package/dist/src/index.d.ts +8 -5
  9. package/dist/src/index.js +18 -6
  10. package/dist/src/internal/delegate-bundle.d.ts +18 -0
  11. package/dist/src/internal/delegate-bundle.js +89 -0
  12. package/dist/src/internal/delegate-state.d.ts +45 -0
  13. package/dist/src/internal/delegate-state.js +120 -0
  14. package/dist/src/internal/owner-signers.d.ts +78 -0
  15. package/dist/src/internal/owner-signers.js +179 -0
  16. package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
  17. package/dist/src/internal/protocol-client-bridge.js +20 -0
  18. package/dist/src/internal/recovery-file.d.ts +29 -0
  19. package/dist/src/internal/recovery-file.js +98 -0
  20. package/dist/src/internal/signer.d.ts +59 -0
  21. package/dist/src/internal/signer.js +86 -0
  22. package/dist/src/key-store.d.ts +128 -0
  23. package/dist/src/key-store.js +244 -0
  24. package/dist/src/mandates.d.ts +151 -1
  25. package/dist/src/mandates.js +285 -8
  26. package/dist/src/sdk.d.ts +36 -3
  27. package/dist/src/sdk.js +27 -23
  28. package/dist/src/wallet.d.ts +4 -6
  29. package/dist/src/wallet.js +18 -8
  30. package/dist/test/auth-j3.test.d.ts +2 -0
  31. package/dist/test/auth-j3.test.js +360 -0
  32. package/dist/test/compute.test.js +22 -11
  33. package/dist/test/ethos.test.d.ts +2 -0
  34. package/dist/test/ethos.test.js +219 -0
  35. package/dist/test/key-store.test.d.ts +2 -0
  36. package/dist/test/key-store.test.js +161 -0
  37. package/dist/test/mandates-compute.test.d.ts +2 -0
  38. package/dist/test/mandates-compute.test.js +256 -0
  39. package/dist/test/mandates.test.d.ts +2 -0
  40. package/dist/test/mandates.test.js +93 -0
  41. package/dist/test/sdk.test.js +64 -30
  42. package/dist/test/signer.test.d.ts +2 -0
  43. package/dist/test/signer.test.js +117 -0
  44. package/dist/test/wallet.test.js +20 -9
  45. package/package.json +2 -1
package/dist/src/auth.js CHANGED
@@ -1,100 +1,242 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 Mathieu Colla
3
- // Aithos auth — sign-up, sign-in, sign-in-with-Google.
3
+ // Aithos auth — sign-up, sign-in (email+password / Google SSO /
4
+ // recovery file), mandate import, sign-out.
4
5
  //
5
- // One class, three flows, automatic session persistence. Apps shouldn't
6
- // need to touch Argon2id, AES-GCM, or {@link sessionStorage} directly :
6
+ // One stateful object per app. Holds the active {@link AithosSession}
7
+ // (JWT-backed when present), the loaded owner signers in memory (when
8
+ // signed in as an owner), and a registry of active delegate
9
+ // sessions. Persists across reloads via the configured
10
+ // {@link AithosSessionStore} (JWT) and {@link AithosKeyStore} (signing
11
+ // material).
7
12
  //
8
- // const auth = new AithosAuth();
13
+ // Strict mode (per design discussion): the two stores must agree about
14
+ // who's signed in. If sessionStore has a JWT for one DID and keyStore
15
+ // has an owner with a DIFFERENT DID, both are wiped. If sessionStore
16
+ // has a JWT but keyStore has no owner at all, the JWT is wiped (a
17
+ // JWT alone is useless without local signing capability for everything
18
+ // past compute/wallet).
9
19
  //
10
- // // Sign in (existing account, email + password)
11
- // const session = await auth.signIn({ email, password });
12
- //
13
- // // Sign up (creates an Aithos identity end-to-end)
14
- // const { recoveryFile, ...session } = await auth.signUp({
15
- // email, password, handle, displayName,
16
- // });
17
- //
18
- // // Sign in with Google — redirects to Google's consent screen
19
- // auth.signInWithGoogle({ appState: "/dashboard" });
20
- //
21
- // // Back on /auth/callback after Google
22
- // const session = await auth.handleCallback();
23
- //
24
- // // Anywhere — read the active session, null if signed out / expired
25
- // const current = auth.getCurrentSession();
26
- //
27
- // // Sign out — clears the session store
28
- // await auth.signOut();
29
- //
30
- // Storage : by default, sessions are persisted in `sessionStorage` (web)
31
- // or no-op (Node / SSR). Apps with different needs pass their own
32
- // `AithosSessionStore` to the constructor — see ./session-store.ts.
33
- import { DEFAULT_KDF, buildBlobPlaintext, createBrowserIdentity, deriveAuthAndEncKeys, encryptBlob, randomNonce, randomSalt, serializeBlob, zeroize, } from "@aithos/protocol-client";
20
+ // JWT-less sessions (recovery / mandate sign-ins) are valid: the
21
+ // keyStore is the source of truth for "is the user signed in", the
22
+ // JWT is auxiliary for compute/wallet.
23
+ import { buildBlobPlaintext, buildSignedEnvelope, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, signedDidDocument, zeroize, } from "@aithos/protocol-client";
34
24
  import { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
35
25
  import { defaultSessionStore, } from "./session-store.js";
26
+ import { defaultKeyStore, } from "./key-store.js";
27
+ import { parseDelegateBundle, readDelegateBundleText, } from "./internal/delegate-bundle.js";
28
+ import { DelegateActor, DelegateRegistry, } from "./internal/delegate-state.js";
29
+ import { OwnerSigners } from "./internal/owner-signers.js";
30
+ import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from "./internal/recovery-file.js";
36
31
  import { AithosSDKError } from "./types.js";
37
32
  /** Default URL of the Aithos auth backend. */
38
33
  export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
34
+ /** Default URL of the Aithos primitives API (publish_identity, publish_ethos_edition, etc.). */
35
+ export const DEFAULT_API_BASE_URL = "https://api.aithos.be";
39
36
  /* -------------------------------------------------------------------------- */
40
37
  /* AithosAuth */
41
38
  /* -------------------------------------------------------------------------- */
42
- /**
43
- * Authenticator for the Aithos identity service. One instance per app
44
- * is the recommended pattern (the constructor is cheap).
45
- *
46
- * The class is **stateful** in one specific way : it owns a session store
47
- * that gets written on every successful auth call and read by
48
- * {@link getCurrentSession}. Pass a custom store at construction time
49
- * if you need different persistence (localStorage, IndexedDB, no-op).
50
- */
51
39
  export class AithosAuth {
52
- /** Resolved auth base URL with a trailing slash trimmed. */
53
40
  authBaseUrl;
54
- fetchImpl;
55
- win;
56
- store;
41
+ apiBaseUrl;
42
+ #fetchImpl;
43
+ #win;
44
+ #sessionStore;
45
+ #keyStore;
46
+ /** In-memory owner signers — populated after sign-in or `resume`. */
47
+ #ownerSigners = null;
48
+ /** Active delegate registry. */
49
+ #delegates = new DelegateRegistry();
57
50
  constructor(config = {}) {
58
51
  this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
59
- this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
60
- this.win = config.window ?? (typeof window !== "undefined" ? window : undefined);
61
- this.store = config.sessionStore ?? defaultSessionStore();
52
+ this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
53
+ this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
54
+ this.#win =
55
+ config.window ??
56
+ (typeof window !== "undefined" ? window : undefined);
57
+ this.#sessionStore = config.sessionStore ?? defaultSessionStore();
58
+ this.#keyStore = config.keyStore ?? defaultKeyStore();
62
59
  }
63
60
  /* ------------------------------------------------------------------------ */
64
- /* Email + password */
61
+ /* Boot-time hydration */
65
62
  /* ------------------------------------------------------------------------ */
66
63
  /**
67
- * Sign in to an existing Aithos account. Two-round-trip flow under the
68
- * hood :
69
- *
70
- * 1. POST /auth/login/challenge server returns the salts + KDF params
71
- * 2. derive auth_key from password + salt (Argon2id)
72
- * 3. POST /auth/login/verify → server checks auth_key, returns JWT + blob
64
+ * Reload signing material and JWT session from the configured stores.
65
+ * Must be called once at app boot before relying on
66
+ * {@link getCurrentSession} / {@link getOwnerInfo} / {@link canSignAsOwner}
67
+ * until then they reflect only what's been done in-memory in the
68
+ * current tab.
73
69
  *
74
- * The returned `enc_key_b64` is empty by design : password sign-in
75
- * doesn't release the vault key over the wire (it's derived locally
76
- * but discarded after the call). Apps that need the seeds most
77
- * apps don't should use the (forthcoming) `loadEthos` helper
78
- * separately with the password still in hand.
70
+ * Strict consistency: if the JWT and the stored owner disagree about
71
+ * who's signed in, both are wiped and the user re-auths. JWT-less
72
+ * owner state (loaded from keyStore but no JWT) is a valid resumed
73
+ * statethe user signed in via recovery or imported a mandate at
74
+ * some earlier moment and never went through the JWT flow.
75
+ */
76
+ async resume() {
77
+ // 1. Owner side.
78
+ const stored = await this.#keyStore.loadOwner().catch(() => null);
79
+ const jwt = this.#sessionStore.get();
80
+ if (stored) {
81
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
82
+ // JWT must match the owner DID — otherwise it's stale state.
83
+ if (jwt && jwt.did !== stored.did) {
84
+ this.#sessionStore.clear();
85
+ }
86
+ }
87
+ else {
88
+ // No owner persisted. A lingering JWT is meaningless without local
89
+ // signing capability — wipe it (strict mode).
90
+ if (jwt)
91
+ this.#sessionStore.clear();
92
+ }
93
+ // 2. Delegate side. Independent of owner state — a user may hold
94
+ // only delegate bundles and no owner identity at all.
95
+ const storedDelegates = await this.#keyStore
96
+ .listDelegates()
97
+ .catch(() => []);
98
+ for (const d of storedDelegates) {
99
+ try {
100
+ this.#delegates.add(DelegateActor.fromStored(d));
101
+ }
102
+ catch {
103
+ // Skip corrupted entries silently — the keystore validators
104
+ // already filter most of these. A skip here means the keystore
105
+ // record passed validation but the seed couldn't be re-derived
106
+ // (e.g. zero-length, future migration); ignore and continue.
107
+ }
108
+ }
109
+ }
110
+ /* ------------------------------------------------------------------------ */
111
+ /* State accessors */
112
+ /* ------------------------------------------------------------------------ */
113
+ /** JWT-backed session. Null when signed in via recovery / mandate / not at all. */
114
+ getCurrentSession() {
115
+ const session = this.#sessionStore.get();
116
+ if (!session)
117
+ return null;
118
+ // Belt-and-braces: the session store auto-evicts on expiry, but we
119
+ // also re-check here in case the in-tab clock drifted post-load.
120
+ return session;
121
+ }
122
+ /** Loaded owner identity. Independent of JWT presence. */
123
+ getOwnerInfo() {
124
+ if (!this.#ownerSigners)
125
+ return null;
126
+ return {
127
+ did: this.#ownerSigners.did,
128
+ handle: this.#ownerSigners.handle,
129
+ displayName: this.#ownerSigners.displayName,
130
+ };
131
+ }
132
+ getDelegates() {
133
+ return this.#delegates.list().map(actorToInfo);
134
+ }
135
+ canSignAsOwner() {
136
+ return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
137
+ }
138
+ canSignAsDelegateFor(did) {
139
+ const a = this.#delegates.findForSubject(did);
140
+ return a !== undefined && !a.destroyed;
141
+ }
142
+ /**
143
+ * Internal accessor used by sibling SDK namespaces (compute, wallet,
144
+ * ethos) when they need to sign on behalf of the owner. Returns null
145
+ * if no owner is loaded.
79
146
  *
80
- * Persists the session in the configured store before returning.
147
+ * @internal
81
148
  */
149
+ _getOwnerSigners() {
150
+ return this.#ownerSigners;
151
+ }
152
+ /**
153
+ * Internal accessor — looks up an active delegate by mandate id.
154
+ * @internal
155
+ */
156
+ _getDelegateActor(mandateId) {
157
+ return this.#delegates.get(mandateId);
158
+ }
159
+ /**
160
+ * Internal accessor — finds the first active delegate whose subject
161
+ * matches `did`. Used by `sdk.ethos.of(did)` when the user holds a
162
+ * mandate for that subject.
163
+ * @internal
164
+ */
165
+ _findDelegateForSubject(did) {
166
+ return this.#delegates.findForSubject(did);
167
+ }
168
+ /* ------------------------------------------------------------------------ */
169
+ /* Email + password — signIn */
170
+ /* ------------------------------------------------------------------------ */
82
171
  async signIn(input) {
83
172
  if (!input.email || !input.password) {
84
173
  throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
85
174
  }
86
- const challenge = await loginChallenge({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
175
+ const challenge = await loginChallenge({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
87
176
  const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, challenge.authSalt, challenge.encSalt, challenge.kdf);
88
- // We don't keep enc_key around for the password flow — apps that
89
- // need it can call deriveAuthAndEncKeys themselves with the password
90
- // still available in their UI state. Wipe to be defensive.
91
- zeroize(encKey);
92
177
  let verify;
93
178
  try {
94
- verify = await loginVerify({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
179
+ verify = await loginVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
95
180
  }
96
- finally {
181
+ catch (e) {
182
+ // On failure, both keys must be wiped before propagating.
97
183
  zeroize(authKey);
184
+ zeroize(encKey);
185
+ throw e;
186
+ }
187
+ zeroize(authKey);
188
+ // Decrypt the vault blob → plaintext seeds + delegate bundles.
189
+ let plaintext;
190
+ try {
191
+ const blobBytes = decryptBlob(encKey, verify.blobNonce, verify.blob);
192
+ try {
193
+ plaintext = parseBlob(blobBytes);
194
+ }
195
+ finally {
196
+ zeroize(blobBytes);
197
+ }
198
+ }
199
+ catch (e) {
200
+ zeroize(encKey);
201
+ throw new AithosSDKError("auth_blob_decrypt_failed", `Could not decrypt the vault blob: ${e.message}`);
202
+ }
203
+ zeroize(encKey);
204
+ // Sanity check: blob's identity must agree with what verify returned.
205
+ if (plaintext.identity.did !== verify.did) {
206
+ throw new AithosSDKError("auth_blob_identity_mismatch", "vault blob's DID does not match the verified login DID", { data: { blobDid: plaintext.identity.did, verifyDid: verify.did } });
207
+ }
208
+ // Hydrate in-memory state.
209
+ if (this.#ownerSigners)
210
+ this.#ownerSigners.destroy();
211
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
212
+ // Persist to keyStore — owner first, then delegates.
213
+ const ownerStored = {
214
+ version: "0.1.0-hex",
215
+ did: plaintext.identity.did,
216
+ handle: plaintext.identity.handle,
217
+ displayName: plaintext.identity.displayName,
218
+ seedsHex: plaintext.seeds,
219
+ savedAt: new Date().toISOString(),
220
+ };
221
+ await this.#keyStore.saveOwner(ownerStored);
222
+ // Replace any prior delegate set with what the blob carries.
223
+ await this.#keyStore.clearAllDelegates();
224
+ this.#delegates.destroy();
225
+ for (const d of plaintext.delegates) {
226
+ const stored = storedDelegateFromBlob(d);
227
+ try {
228
+ await this.#keyStore.saveDelegate(stored);
229
+ }
230
+ catch {
231
+ // Persistence failure shouldn't block the sign-in. We still load
232
+ // the actor in memory so the session works for the current tab.
233
+ }
234
+ try {
235
+ this.#delegates.add(DelegateActor.fromStored(stored));
236
+ }
237
+ catch {
238
+ // Skip silently — keep going on remaining delegates.
239
+ }
98
240
  }
99
241
  const session = {
100
242
  session: verify.session,
@@ -107,25 +249,12 @@ export class AithosAuth {
107
249
  enc_key_b64: "",
108
250
  is_first_login: false,
109
251
  };
110
- this.store.set(session);
252
+ this.#sessionStore.set(session);
111
253
  return session;
112
254
  }
113
- /**
114
- * Create a new Aithos account end-to-end :
115
- *
116
- * 1. Generate a fresh `BrowserIdentity` (4 Ed25519/X25519 seeds)
117
- * 2. Build the recovery file (plaintext JSON, the user must save it)
118
- * 3. Derive auth_key + enc_key from the password (Argon2id, fresh salts)
119
- * 4. Encrypt the seeds in a vault blob (AES-GCM-256)
120
- * 5. POST /auth/register with everything → JWT
121
- * 6. Persist the session and return it + the recovery Blob
122
- *
123
- * The seeds are NOT published as an Aithos ethos here : the user's
124
- * profile on `app.aithos.be` won't appear until they (or another app)
125
- * publishes their first edition. This matches `aithos/app`'s design,
126
- * where the vault is the source of truth for keys and the published
127
- * edition is a separate concern.
128
- */
255
+ /* ------------------------------------------------------------------------ */
256
+ /* Email + password signUp */
257
+ /* ------------------------------------------------------------------------ */
129
258
  async signUp(input) {
130
259
  if (!input.email || !input.password) {
131
260
  throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
@@ -134,31 +263,16 @@ export class AithosAuth {
134
263
  throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
135
264
  }
136
265
  const displayName = input.displayName ?? input.handle;
137
- // 1) Identity.
138
266
  const identity = createBrowserIdentity(input.handle, displayName);
139
- // 2) Recovery file — same shape as @aithos/protocol-client emits in
140
- // `runOnboarding`, plaintext v0.1.0.
141
- const recoveryJson = {
142
- aithos_recovery_version: "0.1.0-plaintext",
143
- handle: identity.handle,
144
- display_name: identity.displayName,
145
- did: identity.did,
146
- seeds_hex: {
147
- root: bytesToHex(identity.root.seed),
148
- public: bytesToHex(identity.public.seed),
149
- circle: bytesToHex(identity.circle.seed),
150
- self: bytesToHex(identity.self.seed),
151
- },
152
- saved_at: new Date().toISOString(),
153
- };
154
- const recoveryFile = new Blob([JSON.stringify(recoveryJson, null, 2)], { type: "application/json" });
155
- const recoveryFilename = `aithos-recovery-${identity.handle}.json`;
156
- // 3) Derive password-based keys.
267
+ const recoverySerialized = serializeRecoveryFile(identity);
268
+ const recoveryFile = new Blob([recoverySerialized.text], {
269
+ type: "application/json",
270
+ });
271
+ // Derive password-based keys, encrypt the vault blob.
157
272
  const authSalt = randomSalt();
158
273
  const encSalt = randomSalt();
159
274
  const kdf = DEFAULT_KDF;
160
275
  const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
161
- // 4) Build & encrypt the vault blob.
162
276
  const plaintext = buildBlobPlaintext({
163
277
  identity: {
164
278
  did: identity.did,
@@ -176,10 +290,9 @@ export class AithosAuth {
176
290
  const blobBytes = serializeBlob(plaintext);
177
291
  const blobNonce = randomNonce();
178
292
  const blob = encryptBlob(encKey, blobNonce, blobBytes);
179
- // 5) Register.
180
293
  let registerResp;
181
294
  try {
182
- registerResp = await registerAccount({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, {
295
+ registerResp = await registerAccount({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
183
296
  email: input.email,
184
297
  handle: identity.handle,
185
298
  displayName: identity.displayName,
@@ -197,6 +310,33 @@ export class AithosAuth {
197
310
  zeroize(authKey);
198
311
  zeroize(encKey);
199
312
  }
313
+ // Bootstrap the Ethos on api.aithos.be. Without this, every subsequent
314
+ // write (publish_ethos_edition, etc.) errors out with -32020
315
+ // "subject identity not published". We do this BEFORE hydrating local
316
+ // state so a bootstrap failure leaves the SDK in a clean
317
+ // "not signed in" state — the dev shows an error, the user retries.
318
+ // The auth account on auth.aithos.be DOES exist at this point, but
319
+ // without the local hydrate the user can't act on it. Self-heal on
320
+ // signIn (re-attempt publish_identity if missing) is planned for a
321
+ // follow-up release.
322
+ await this.#publishIdentity(identity);
323
+ // Hydrate in-memory state from the fresh identity.
324
+ if (this.#ownerSigners)
325
+ this.#ownerSigners.destroy();
326
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
327
+ await this.#keyStore.saveOwner({
328
+ version: "0.1.0-hex",
329
+ did: identity.did,
330
+ handle: identity.handle,
331
+ displayName: identity.displayName,
332
+ seedsHex: {
333
+ root: bytesToHex(identity.root.seed),
334
+ public: bytesToHex(identity.public.seed),
335
+ circle: bytesToHex(identity.circle.seed),
336
+ self: bytesToHex(identity.self.seed),
337
+ },
338
+ savedAt: new Date().toISOString(),
339
+ });
200
340
  const session = {
201
341
  session: registerResp.session,
202
342
  exp: registerResp.exp,
@@ -208,22 +348,101 @@ export class AithosAuth {
208
348
  enc_key_b64: "",
209
349
  is_first_login: false,
210
350
  };
211
- this.store.set(session);
212
- return { session, recoveryFile, recoveryFilename };
351
+ this.#sessionStore.set(session);
352
+ return {
353
+ session,
354
+ recoveryFile,
355
+ recoveryFilename: recoverySerialized.filename,
356
+ };
213
357
  }
214
358
  /* ------------------------------------------------------------------------ */
215
- /* Google SSO */
359
+ /* Recovery file */
216
360
  /* ------------------------------------------------------------------------ */
217
361
  /**
218
- * Redirect the browser to Google's OAuth consent screen. Must be called
219
- * synchronously in response to a user gesture (button click) most
220
- * browsers block top-level navigation triggered from idle code.
362
+ * Sign in by uploading a recovery file. Hydrates the owner signers
363
+ * locally no JWT is obtained on this path because the recovery
364
+ * file alone doesn't authenticate against the auth backend (no
365
+ * password, no Google session). Apps that need compute/wallet
366
+ * access should follow up with an email+password sign-in or with
367
+ * Google SSO.
221
368
  *
222
- * Does not return : navigation tears the JS context down. The `never`
223
- * return type tells callers any code after the call is unreachable.
369
+ * The recovery file is ALWAYS the file produced by `signUp` (or the
370
+ * equivalent one emitted by `protocol-client`'s `runOnboarding`).
371
+ * Both shapes are accepted.
224
372
  */
373
+ async signInWithRecovery(input) {
374
+ const text = await readRecoveryFileText(input.file);
375
+ const parsed = parseRecoveryFile(text);
376
+ // Build a StoredOwnerKeys-shape on the spot, then push to keyStore +
377
+ // hydrate signers.
378
+ const stored = {
379
+ version: "0.1.0-hex",
380
+ did: parsed.did,
381
+ handle: parsed.handle,
382
+ displayName: parsed.displayName,
383
+ seedsHex: parsed.seedsHex,
384
+ savedAt: new Date().toISOString(),
385
+ };
386
+ // If a different owner is already loaded, refuse — apps must call
387
+ // signOut() first. Mixing two owners in one auth instance is a
388
+ // nonsense state we don't want to support.
389
+ if (this.#ownerSigners && this.#ownerSigners.did !== parsed.did) {
390
+ throw new AithosSDKError("auth_owner_already_loaded", "another owner is already signed in; call signOut first", { data: { current: this.#ownerSigners.did, incoming: parsed.did } });
391
+ }
392
+ if (this.#ownerSigners)
393
+ this.#ownerSigners.destroy();
394
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
395
+ await this.#keyStore.saveOwner(stored);
396
+ // Recovery flow doesn't yield a JWT — wipe any stale one to keep
397
+ // the two stores in sync.
398
+ this.#sessionStore.clear();
399
+ return {
400
+ did: parsed.did,
401
+ handle: parsed.handle,
402
+ displayName: parsed.displayName,
403
+ };
404
+ }
405
+ /* ------------------------------------------------------------------------ */
406
+ /* Mandate import */
407
+ /* ------------------------------------------------------------------------ */
408
+ /**
409
+ * Import a delegate bundle (`.aithos-delegate.json`). Works in any
410
+ * state: with no owner loaded (delegate-only session), or alongside
411
+ * an existing owner (the user holds mandates for other people's
412
+ * ethoses while also being an owner themselves).
413
+ */
414
+ async importMandate(input) {
415
+ const text = await readDelegateBundleText(input.bundle);
416
+ const parsed = parseDelegateBundle(text);
417
+ const stored = {
418
+ version: "0.1.0-hex",
419
+ subjectDid: parsed.subjectDid,
420
+ mandateId: parsed.mandateId,
421
+ mandate: parsed.mandate,
422
+ granteeId: parsed.granteeId,
423
+ granteePubkeyMultibase: parsed.granteePubkeyMultibase,
424
+ delegateSeedHex: parsed.delegateSeedHex,
425
+ importedAt: new Date().toISOString(),
426
+ };
427
+ await this.#keyStore.saveDelegate(stored);
428
+ this.#delegates.add(DelegateActor.fromStored(stored));
429
+ return {
430
+ mandateId: stored.mandateId,
431
+ subjectDid: stored.subjectDid,
432
+ granteeId: stored.granteeId,
433
+ scopes: scopesFromMandate(stored.mandate),
434
+ expiresAt: notAfterFromMandate(stored.mandate),
435
+ };
436
+ }
437
+ async removeMandate(mandateId) {
438
+ this.#delegates.remove(mandateId);
439
+ await this.#keyStore.removeDelegate(mandateId);
440
+ }
441
+ /* ------------------------------------------------------------------------ */
442
+ /* Google SSO */
443
+ /* ------------------------------------------------------------------------ */
225
444
  signInWithGoogle(opts) {
226
- if (!this.win) {
445
+ if (!this.#win) {
227
446
  throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
228
447
  }
229
448
  const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
@@ -233,50 +452,89 @@ export class AithosAuth {
233
452
  }
234
453
  url.searchParams.set("app_state", opts.appState);
235
454
  }
236
- this.win.location.assign(url.toString());
237
- // Unreachable : location.assign navigates synchronously. The throw is
238
- // belt-and-braces in case a caller awaits a microtask before unload.
455
+ this.#win.location.assign(url.toString());
239
456
  throw new AithosSDKError("auth_redirecting", "redirecting to google");
240
457
  }
241
- /**
242
- * Inspect the current URL for an `aithos_code` query parameter. If it's
243
- * present, exchange it at the backend, persist the session, and return
244
- * it. The query params are stripped from the URL via
245
- * `history.replaceState` so a page refresh doesn't replay the redeem
246
- * (which would 410 anyway).
247
- *
248
- * Returns `null` when there's no code in the URL — safe to call on every
249
- * page load. Throws {@link AithosSDKError} on backend errors or when
250
- * the URL carries `aithos_error=…`.
251
- */
252
458
  async handleCallback() {
253
- if (!this.win)
459
+ if (!this.#win)
254
460
  return null;
255
- const here = new URL(this.win.location.href);
461
+ const here = new URL(this.#win.location.href);
256
462
  const error = here.searchParams.get("aithos_error");
257
463
  const code = here.searchParams.get("aithos_code");
258
464
  const appState = here.searchParams.get("app_state");
259
465
  if (error) {
260
- cleanCallbackParams(this.win, here);
466
+ cleanCallbackParams(this.#win, here);
261
467
  throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
262
468
  }
263
469
  if (!code)
264
470
  return null;
265
471
  const session = await this.exchange(code);
266
- cleanCallbackParams(this.win, here);
267
- this.store.set(session);
472
+ cleanCallbackParams(this.#win, here);
473
+ // Hydrate signers if the SSO response carried an enc_key (Google flow
474
+ // gives us the AES-GCM key in plaintext, encrypted only in transit
475
+ // by TLS — see auth.aithos.be design doc).
476
+ if (session.enc_key_b64 &&
477
+ session.blob_b64 &&
478
+ session.blob_nonce_b64 &&
479
+ session.blob_version > 0) {
480
+ try {
481
+ const encKey = b64ToBytes(session.enc_key_b64);
482
+ const blob = b64ToBytes(session.blob_b64);
483
+ const nonce = b64ToBytes(session.blob_nonce_b64);
484
+ try {
485
+ const blobBytes = decryptBlob(encKey, nonce, blob);
486
+ try {
487
+ const plaintext = parseBlob(blobBytes);
488
+ if (plaintext.identity.did === session.did) {
489
+ if (this.#ownerSigners)
490
+ this.#ownerSigners.destroy();
491
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
492
+ await this.#keyStore.saveOwner({
493
+ version: "0.1.0-hex",
494
+ did: plaintext.identity.did,
495
+ handle: plaintext.identity.handle,
496
+ displayName: plaintext.identity.displayName,
497
+ seedsHex: plaintext.seeds,
498
+ savedAt: new Date().toISOString(),
499
+ });
500
+ await this.#keyStore.clearAllDelegates();
501
+ this.#delegates.destroy();
502
+ for (const d of plaintext.delegates) {
503
+ const stored = storedDelegateFromBlob(d);
504
+ try {
505
+ await this.#keyStore.saveDelegate(stored);
506
+ }
507
+ catch {
508
+ /* keep going */
509
+ }
510
+ try {
511
+ this.#delegates.add(DelegateActor.fromStored(stored));
512
+ }
513
+ catch {
514
+ /* keep going */
515
+ }
516
+ }
517
+ }
518
+ }
519
+ finally {
520
+ zeroize(blobBytes);
521
+ }
522
+ }
523
+ finally {
524
+ zeroize(encKey);
525
+ }
526
+ }
527
+ catch {
528
+ // Decryption failure is non-fatal here: the JWT still works for
529
+ // compute/wallet, the user will surface the issue if they try to
530
+ // edit their ethos.
531
+ }
532
+ }
533
+ this.#sessionStore.set(session);
268
534
  return session;
269
535
  }
270
- /**
271
- * Programmatically redeem an `aithos_code` for a session. `handleCallback`
272
- * calls this for you ; expose it directly for callers that already pulled
273
- * the code out of the URL via their own router.
274
- *
275
- * Note : this method does NOT persist the session — it's the lower-level
276
- * primitive. Use `handleCallback` for the full pipe.
277
- */
278
536
  async exchange(aithosCode) {
279
- const res = await this.fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
537
+ const res = await this.#fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
280
538
  method: "POST",
281
539
  headers: { "content-type": "application/json" },
282
540
  body: JSON.stringify({ aithos_code: aithosCode }),
@@ -303,23 +561,93 @@ export class AithosAuth {
303
561
  return (await res.json());
304
562
  }
305
563
  /* ------------------------------------------------------------------------ */
306
- /* Session lifecycle */
564
+ /* Sign-out */
307
565
  /* ------------------------------------------------------------------------ */
308
- /**
309
- * Read the active session from the configured store. Returns null if
310
- * the user is signed out, or if the JWT has expired (the store
311
- * auto-evicts expired entries — see ./session-store.ts).
312
- */
313
- getCurrentSession() {
314
- return this.store.get();
566
+ async signOut() {
567
+ if (this.#ownerSigners)
568
+ this.#ownerSigners.destroy();
569
+ this.#ownerSigners = null;
570
+ this.#delegates.destroy();
571
+ this.#sessionStore.clear();
572
+ await this.#keyStore.clearOwner().catch(() => { });
573
+ await this.#keyStore.clearAllDelegates().catch(() => { });
315
574
  }
575
+ /* ------------------------------------------------------------------------ */
576
+ /* Internal — Ethos bootstrap */
577
+ /* ------------------------------------------------------------------------ */
316
578
  /**
317
- * Stateless sign-out the Aithos backend doesn't track sessions, so
318
- * there's nothing to revoke server-side ; this method clears the
319
- * configured session store and resolves.
579
+ * Provision the user's Ethos on `api.aithos.be` by signing and POSTing an
580
+ * `aithos.publish_identity` envelope. Required after a fresh sign-up so
581
+ * subsequent edition publishes (`me.publish()`) don't fail with
582
+ * `-32020 subject identity not published`.
583
+ *
584
+ * Retries twice with exponential backoff on transient errors (network or
585
+ * 5xx). Throws {@link AithosSDKError} with code `ethos_bootstrap_failed`
586
+ * on definitive failure — the caller is expected to abort sign-up.
587
+ *
588
+ * @internal
320
589
  */
321
- async signOut() {
322
- this.store.clear();
590
+ async #publishIdentity(identity) {
591
+ const url = `${this.apiBaseUrl}/mcp/primitives/write`;
592
+ const signedDoc = signedDidDocument(identity);
593
+ const params = {
594
+ did_document: signedDoc,
595
+ handle: identity.handle,
596
+ display_name: identity.displayName,
597
+ };
598
+ const envelope = buildSignedEnvelope({
599
+ iss: identity.did,
600
+ aud: url,
601
+ method: "aithos.publish_identity",
602
+ verificationMethod: `${identity.did}#root`,
603
+ params,
604
+ signer: identity.root,
605
+ });
606
+ const body = JSON.stringify({
607
+ jsonrpc: "2.0",
608
+ id: "publish_identity",
609
+ method: "aithos.publish_identity",
610
+ params: { ...params, _envelope: envelope },
611
+ });
612
+ // Two retries with backoff (300ms, 1500ms). Idempotent on the server
613
+ // side — replaying the same publish_identity for an existing DID is a
614
+ // no-op, so retries are safe even if the first attempt actually
615
+ // succeeded but the response was lost.
616
+ const delays = [0, 300, 1500];
617
+ let lastError;
618
+ for (const delay of delays) {
619
+ if (delay > 0)
620
+ await sleep(delay);
621
+ try {
622
+ const res = await this.#fetchImpl(url, {
623
+ method: "POST",
624
+ headers: { "content-type": "application/json" },
625
+ body,
626
+ });
627
+ // Transport errors (5xx, no body) — retry. JSON-RPC errors come
628
+ // back with HTTP 200 and an `error` field.
629
+ if (!res.ok && res.status >= 500) {
630
+ lastError = new Error(`HTTP ${res.status}`);
631
+ continue;
632
+ }
633
+ const json = (await res.json());
634
+ if (json.error) {
635
+ // JSON-RPC error: don't retry — these are deterministic
636
+ // (validation, permission, identity-already-tombstoned, …).
637
+ throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
638
+ status: res.status,
639
+ data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
640
+ });
641
+ }
642
+ return; // success
643
+ }
644
+ catch (e) {
645
+ if (e instanceof AithosSDKError)
646
+ throw e;
647
+ lastError = e;
648
+ }
649
+ }
650
+ throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity unreachable after ${delays.length} attempts: ${lastError?.message ?? "unknown"}`);
323
651
  }
324
652
  }
325
653
  /* -------------------------------------------------------------------------- */
@@ -328,16 +656,15 @@ export class AithosAuth {
328
656
  function trimSlash(url) {
329
657
  return url.endsWith("/") ? url.slice(0, -1) : url;
330
658
  }
659
+ function sleep(ms) {
660
+ return new Promise((resolve) => setTimeout(resolve, ms));
661
+ }
331
662
  function cleanCallbackParams(win, url) {
332
663
  url.searchParams.delete("aithos_code");
333
664
  url.searchParams.delete("aithos_error");
334
665
  url.searchParams.delete("app_state");
335
666
  win.history.replaceState(null, "", url.toString());
336
667
  }
337
- // Local copy of bytesToB64 — protocol-client exports it but we keep a
338
- // thin local wrapper to avoid a name collision with public surface and
339
- // to flag this is the "encode for the wire" path, not the "encode an
340
- // auth_key" path.
341
668
  function bytesToB64Public(bytes) {
342
669
  if (bytes.length === 0)
343
670
  return "";
@@ -346,10 +673,58 @@ function bytesToB64Public(bytes) {
346
673
  bin += String.fromCharCode(bytes[i]);
347
674
  return btoa(bin).replace(/=+$/, "");
348
675
  }
676
+ function b64ToBytes(b64) {
677
+ if (!b64)
678
+ return new Uint8Array(0);
679
+ // standard b64 — pad to multiple of 4 if needed
680
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
681
+ const bin = atob(b64 + pad);
682
+ const out = new Uint8Array(bin.length);
683
+ for (let i = 0; i < bin.length; i++)
684
+ out[i] = bin.charCodeAt(i);
685
+ return out;
686
+ }
349
687
  function bytesToHex(b) {
350
688
  let out = "";
351
689
  for (let i = 0; i < b.length; i++)
352
690
  out += b[i].toString(16).padStart(2, "0");
353
691
  return out;
354
692
  }
693
+ /**
694
+ * Project a delegate as it appears in a `BlobPlaintext` (extension-kit
695
+ * `StoredDelegate` shape) onto the SDK's own {@link StoredDelegateKeys}.
696
+ */
697
+ function storedDelegateFromBlob(d) {
698
+ return {
699
+ version: "0.1.0-hex",
700
+ subjectDid: d.subjectDid,
701
+ mandateId: d.mandateId,
702
+ mandate: d.mandate,
703
+ granteeId: d.granteeId,
704
+ granteePubkeyMultibase: d.granteePubkeyMultibase,
705
+ delegateSeedHex: d.delegateSeedHex,
706
+ importedAt: new Date().toISOString(),
707
+ };
708
+ }
709
+ function actorToInfo(a) {
710
+ return {
711
+ mandateId: a.mandateId,
712
+ subjectDid: a.subjectDid,
713
+ granteeId: a.granteeId,
714
+ scopes: scopesFromMandate(a.mandate),
715
+ expiresAt: notAfterFromMandate(a.mandate),
716
+ };
717
+ }
718
+ function scopesFromMandate(m) {
719
+ const raw = m["scopes"];
720
+ if (!Array.isArray(raw))
721
+ return [];
722
+ return raw.filter((s) => typeof s === "string");
723
+ }
724
+ function notAfterFromMandate(m) {
725
+ const raw = m["not_after"];
726
+ if (typeof raw !== "string")
727
+ return null;
728
+ return raw;
729
+ }
355
730
  //# sourceMappingURL=auth.js.map