@aithos/sdk 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (66) hide show
  1. package/README.md +159 -0
  2. package/dist/src/auth-api.d.ts +149 -0
  3. package/dist/src/auth-api.js +226 -0
  4. package/dist/src/auth.d.ts +436 -67
  5. package/dist/src/auth.js +1098 -69
  6. package/dist/src/compute.d.ts +221 -9
  7. package/dist/src/compute.js +293 -16
  8. package/dist/src/data-schema-contacts-v1.d.ts +14 -0
  9. package/dist/src/data-schema-contacts-v1.js +28 -0
  10. package/dist/src/data.d.ts +97 -0
  11. package/dist/src/data.js +634 -0
  12. package/dist/src/endpoints.d.ts +9 -0
  13. package/dist/src/endpoints.js +5 -0
  14. package/dist/src/ethos.d.ts +202 -1
  15. package/dist/src/ethos.js +821 -16
  16. package/dist/src/index.d.ts +15 -6
  17. package/dist/src/index.js +36 -9
  18. package/dist/src/internal/delegate-bundle.d.ts +18 -0
  19. package/dist/src/internal/delegate-bundle.js +94 -0
  20. package/dist/src/internal/delegate-state.d.ts +45 -0
  21. package/dist/src/internal/delegate-state.js +120 -0
  22. package/dist/src/internal/owner-signers.d.ts +78 -0
  23. package/dist/src/internal/owner-signers.js +179 -0
  24. package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
  25. package/dist/src/internal/protocol-client-bridge.js +20 -0
  26. package/dist/src/internal/recovery-file.d.ts +29 -0
  27. package/dist/src/internal/recovery-file.js +98 -0
  28. package/dist/src/internal/signer.d.ts +59 -0
  29. package/dist/src/internal/signer.js +86 -0
  30. package/dist/src/key-store.d.ts +128 -0
  31. package/dist/src/key-store.js +244 -0
  32. package/dist/src/mandates.d.ts +163 -1
  33. package/dist/src/mandates.js +286 -8
  34. package/dist/src/sdk.d.ts +39 -3
  35. package/dist/src/sdk.js +36 -23
  36. package/dist/src/session-store.d.ts +58 -0
  37. package/dist/src/session-store.js +158 -0
  38. package/dist/src/wallet.d.ts +4 -6
  39. package/dist/src/wallet.js +18 -8
  40. package/dist/src/web.d.ts +279 -0
  41. package/dist/src/web.js +186 -0
  42. package/dist/test/auth-j3.test.d.ts +2 -0
  43. package/dist/test/auth-j3.test.js +391 -0
  44. package/dist/test/compute-delegate-path.test.d.ts +2 -0
  45. package/dist/test/compute-delegate-path.test.js +183 -0
  46. package/dist/test/compute.test.js +26 -11
  47. package/dist/test/endpoints.test.js +20 -1
  48. package/dist/test/ethos-first-edition.test.d.ts +2 -0
  49. package/dist/test/ethos-first-edition.test.js +248 -0
  50. package/dist/test/ethos.test.d.ts +2 -0
  51. package/dist/test/ethos.test.js +219 -0
  52. package/dist/test/key-store.test.d.ts +2 -0
  53. package/dist/test/key-store.test.js +161 -0
  54. package/dist/test/mandates-compute.test.d.ts +2 -0
  55. package/dist/test/mandates-compute.test.js +256 -0
  56. package/dist/test/mandates.test.d.ts +2 -0
  57. package/dist/test/mandates.test.js +93 -0
  58. package/dist/test/sdk.test.js +70 -30
  59. package/dist/test/signer.test.d.ts +2 -0
  60. package/dist/test/signer.test.js +117 -0
  61. package/dist/test/signup-bootstrap.test.d.ts +2 -0
  62. package/dist/test/signup-bootstrap.test.js +222 -0
  63. package/dist/test/wallet.test.js +20 -9
  64. package/dist/test/web.test.d.ts +2 -0
  65. package/dist/test/web.test.js +270 -0
  66. package/package.json +5 -4
package/dist/src/auth.js CHANGED
@@ -1,65 +1,469 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright 2026 Mathieu Colla
3
- // Sign in with Google for Aithos apps.
3
+ // Aithos auth — sign-up, sign-in (email+password / Google SSO /
4
+ // recovery file), mandate import, sign-out.
4
5
  //
5
- // The auth flow is intentionally separate from {@link AithosSDK}: at sign-in
6
- // time the caller doesn't have a `BrowserIdentity` yet that's precisely
7
- // what the flow returns (via the encrypted blob the response carries). So
8
- // we expose a standalone {@link AithosAuth} class that talks to the Aithos
9
- // auth backend (`auth.aithos.be`) over plain HTTPS.
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).
10
12
  //
11
- // Flow from the caller's point of view:
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).
12
19
  //
13
- // const auth = new AithosAuth();
14
- // // on a "Sign in" button:
15
- // auth.signInWithGoogle({ appState: "/dashboard" }); // navigates away
16
- //
17
- // // on the redirect-back page (e.g. /auth/callback):
18
- // const session = await auth.handleCallback();
19
- // if (session) {
20
- // // session.session — JWT, send as Bearer to /auth/blob etc.
21
- // // session.enc_key_b64 32-byte vault key, base64 (raw bytes for
22
- // // AES-GCM decryption of session.blob_b64)
23
- // // session.is_first_login true on the very first sign-in
24
- // }
25
- //
26
- // We deliberately don't persist anything in this module — storage is the
27
- // app's call (its threat model, its choices around localStorage vs
28
- // sessionStorage vs IndexedDB). See README "Auth — sessions and storage"
29
- // for the recommended patterns.
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";
24
+ import { custodialResendVerify, custodialResetFinalize, custodialResetRequest, custodialSignIn, custodialSignUp, custodialVerifyEmail, loginChallenge, loginVerify, putBlob, registerAccount, } from "./auth-api.js";
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";
30
31
  import { AithosSDKError } from "./types.js";
31
32
  /** Default URL of the Aithos auth backend. */
32
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";
33
36
  /* -------------------------------------------------------------------------- */
34
37
  /* AithosAuth */
35
38
  /* -------------------------------------------------------------------------- */
36
- /**
37
- * Authenticator for the Aithos identity service. One instance per app
38
- * is the recommended pattern (the constructor is cheap; it just trims the
39
- * URL). All methods are pure — no module-global state.
40
- */
41
39
  export class AithosAuth {
42
- /** Resolved auth base URL with a trailing slash trimmed. */
43
40
  authBaseUrl;
44
- fetchImpl;
45
- win;
41
+ apiBaseUrl;
42
+ #fetchImpl;
43
+ #win;
44
+ #sessionStore;
45
+ #keyStore;
46
+ #publicKey;
47
+ /** In-memory owner signers — populated after sign-in or `resume`. */
48
+ #ownerSigners = null;
49
+ /** Active delegate registry. */
50
+ #delegates = new DelegateRegistry();
51
+ /**
52
+ * In-flight (or just-resolved) `handleCallback()` result. React
53
+ * StrictMode (dev) double-invokes the mount effect — the URL clean
54
+ * inside the first call makes the second invocation see a clean URL
55
+ * and resolve to `null`, with the session it just consumed locked
56
+ * inside the first promise. Caching the result here lets both
57
+ * invocations resolve to the same value. Cleared on next mount via
58
+ * the wrapper's once-per-instance dedup.
59
+ */
60
+ #handleCallbackPromise = null;
46
61
  constructor(config = {}) {
47
62
  this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
48
- this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
49
- this.win = config.window ?? (typeof window !== "undefined" ? window : undefined);
63
+ this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
64
+ this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
65
+ this.#win =
66
+ config.window ??
67
+ (typeof window !== "undefined" ? window : undefined);
68
+ this.#sessionStore = config.sessionStore ?? defaultSessionStore();
69
+ this.#keyStore = config.keyStore ?? defaultKeyStore();
70
+ this.#publicKey = config.publicKey;
50
71
  }
72
+ /* ------------------------------------------------------------------------ */
73
+ /* Boot-time hydration */
74
+ /* ------------------------------------------------------------------------ */
51
75
  /**
52
- * Redirect the browser to Google's OAuth consent screen. Must be called
53
- * synchronously in response to a user gesture (button click) — most
54
- * browsers block top-level navigation triggered from idle code.
76
+ * Reload signing material and JWT session from the configured stores.
77
+ * Must be called once at app boot before relying on
78
+ * {@link getCurrentSession} / {@link getOwnerInfo} / {@link canSignAsOwner}
79
+ * — until then they reflect only what's been done in-memory in the
80
+ * current tab.
55
81
  *
56
- * Does not return: navigation tears the JS context down. The `never`
57
- * return type tells callers any code after the call is unreachable.
82
+ * Strict consistency: if the JWT and the stored owner disagree about
83
+ * who's signed in, both are wiped and the user re-auths. JWT-less
84
+ * owner state (loaded from keyStore but no JWT) is a valid resumed
85
+ * state — the user signed in via recovery or imported a mandate at
86
+ * some earlier moment and never went through the JWT flow.
58
87
  */
88
+ async resume() {
89
+ // 1. Owner side.
90
+ const stored = await this.#keyStore.loadOwner().catch(() => null);
91
+ const jwt = this.#sessionStore.get();
92
+ if (stored) {
93
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
94
+ // JWT must match the owner DID — otherwise it's stale state.
95
+ if (jwt && jwt.did !== stored.did) {
96
+ this.#sessionStore.clear();
97
+ }
98
+ }
99
+ else {
100
+ // No owner persisted. A lingering JWT is meaningless without local
101
+ // signing capability — wipe it (strict mode).
102
+ if (jwt)
103
+ this.#sessionStore.clear();
104
+ }
105
+ // 2. Delegate side. Independent of owner state — a user may hold
106
+ // only delegate bundles and no owner identity at all.
107
+ const storedDelegates = await this.#keyStore
108
+ .listDelegates()
109
+ .catch(() => []);
110
+ for (const d of storedDelegates) {
111
+ try {
112
+ this.#delegates.add(DelegateActor.fromStored(d));
113
+ }
114
+ catch {
115
+ // Skip corrupted entries silently — the keystore validators
116
+ // already filter most of these. A skip here means the keystore
117
+ // record passed validation but the seed couldn't be re-derived
118
+ // (e.g. zero-length, future migration); ignore and continue.
119
+ }
120
+ }
121
+ }
122
+ /* ------------------------------------------------------------------------ */
123
+ /* State accessors */
124
+ /* ------------------------------------------------------------------------ */
125
+ /** JWT-backed session. Null when signed in via recovery / mandate / not at all. */
126
+ getCurrentSession() {
127
+ const session = this.#sessionStore.get();
128
+ if (!session)
129
+ return null;
130
+ // Belt-and-braces: the session store auto-evicts on expiry, but we
131
+ // also re-check here in case the in-tab clock drifted post-load.
132
+ return session;
133
+ }
134
+ /** Loaded owner identity. Independent of JWT presence. */
135
+ getOwnerInfo() {
136
+ if (!this.#ownerSigners)
137
+ return null;
138
+ return {
139
+ did: this.#ownerSigners.did,
140
+ handle: this.#ownerSigners.handle,
141
+ displayName: this.#ownerSigners.displayName,
142
+ };
143
+ }
144
+ getDelegates() {
145
+ return this.#delegates.list().map(actorToInfo);
146
+ }
147
+ canSignAsOwner() {
148
+ return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
149
+ }
150
+ canSignAsDelegateFor(did) {
151
+ const a = this.#delegates.findForSubject(did);
152
+ return a !== undefined && !a.destroyed;
153
+ }
154
+ /**
155
+ * Internal accessor used by sibling SDK namespaces (compute, wallet,
156
+ * ethos) when they need to sign on behalf of the owner. Returns null
157
+ * if no owner is loaded.
158
+ *
159
+ * @internal
160
+ */
161
+ _getOwnerSigners() {
162
+ return this.#ownerSigners;
163
+ }
164
+ /**
165
+ * Internal accessor — looks up an active delegate by mandate id.
166
+ * @internal
167
+ */
168
+ _getDelegateActor(mandateId) {
169
+ return this.#delegates.get(mandateId);
170
+ }
171
+ /**
172
+ * Internal accessor — finds the first active delegate whose subject
173
+ * matches `did`. Used by `sdk.ethos.of(did)` when the user holds a
174
+ * mandate for that subject.
175
+ * @internal
176
+ */
177
+ _findDelegateForSubject(did) {
178
+ return this.#delegates.findForSubject(did);
179
+ }
180
+ /* ------------------------------------------------------------------------ */
181
+ /* Email + password — signIn */
182
+ /* ------------------------------------------------------------------------ */
183
+ async signIn(input) {
184
+ if (!input.email || !input.password) {
185
+ throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
186
+ }
187
+ const challenge = await loginChallenge({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
188
+ const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, challenge.authSalt, challenge.encSalt, challenge.kdf);
189
+ let verify;
190
+ try {
191
+ verify = await loginVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
192
+ }
193
+ catch (e) {
194
+ // On failure, both keys must be wiped before propagating.
195
+ zeroize(authKey);
196
+ zeroize(encKey);
197
+ throw e;
198
+ }
199
+ zeroize(authKey);
200
+ // Decrypt the vault blob → plaintext seeds + delegate bundles.
201
+ let plaintext;
202
+ try {
203
+ const blobBytes = decryptBlob(encKey, verify.blobNonce, verify.blob);
204
+ try {
205
+ plaintext = parseBlob(blobBytes);
206
+ }
207
+ finally {
208
+ zeroize(blobBytes);
209
+ }
210
+ }
211
+ catch (e) {
212
+ zeroize(encKey);
213
+ throw new AithosSDKError("auth_blob_decrypt_failed", `Could not decrypt the vault blob: ${e.message}`);
214
+ }
215
+ zeroize(encKey);
216
+ // Sanity check: blob's identity must agree with what verify returned.
217
+ if (plaintext.identity.did !== verify.did) {
218
+ 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 } });
219
+ }
220
+ // Hydrate in-memory state.
221
+ if (this.#ownerSigners)
222
+ this.#ownerSigners.destroy();
223
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
224
+ // Persist to keyStore — owner first, then delegates.
225
+ const ownerStored = {
226
+ version: "0.1.0-hex",
227
+ did: plaintext.identity.did,
228
+ handle: plaintext.identity.handle,
229
+ displayName: plaintext.identity.displayName,
230
+ seedsHex: plaintext.seeds,
231
+ savedAt: new Date().toISOString(),
232
+ };
233
+ await this.#keyStore.saveOwner(ownerStored);
234
+ // Replace any prior delegate set with what the blob carries.
235
+ await this.#keyStore.clearAllDelegates();
236
+ this.#delegates.destroy();
237
+ for (const d of plaintext.delegates) {
238
+ const stored = storedDelegateFromBlob(d);
239
+ try {
240
+ await this.#keyStore.saveDelegate(stored);
241
+ }
242
+ catch {
243
+ // Persistence failure shouldn't block the sign-in. We still load
244
+ // the actor in memory so the session works for the current tab.
245
+ }
246
+ try {
247
+ this.#delegates.add(DelegateActor.fromStored(stored));
248
+ }
249
+ catch {
250
+ // Skip silently — keep going on remaining delegates.
251
+ }
252
+ }
253
+ const session = {
254
+ session: verify.session,
255
+ exp: verify.exp,
256
+ did: verify.did,
257
+ handle: verify.handle,
258
+ blob_b64: bytesToB64Public(verify.blob),
259
+ blob_nonce_b64: bytesToB64Public(verify.blobNonce),
260
+ blob_version: verify.blobVersion,
261
+ enc_key_b64: "",
262
+ is_first_login: false,
263
+ };
264
+ this.#sessionStore.set(session);
265
+ return session;
266
+ }
267
+ /* ------------------------------------------------------------------------ */
268
+ /* Email + password — signUp */
269
+ /* ------------------------------------------------------------------------ */
270
+ async signUp(input) {
271
+ if (!input.email || !input.password) {
272
+ throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
273
+ }
274
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
275
+ throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
276
+ }
277
+ const displayName = input.displayName ?? input.handle;
278
+ const identity = createBrowserIdentity(input.handle, displayName);
279
+ const recoverySerialized = serializeRecoveryFile(identity);
280
+ const recoveryFile = new Blob([recoverySerialized.text], {
281
+ type: "application/json",
282
+ });
283
+ // Derive password-based keys, encrypt the vault blob.
284
+ const authSalt = randomSalt();
285
+ const encSalt = randomSalt();
286
+ const kdf = DEFAULT_KDF;
287
+ const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
288
+ const plaintext = buildBlobPlaintext({
289
+ identity: {
290
+ did: identity.did,
291
+ handle: identity.handle,
292
+ displayName: identity.displayName,
293
+ },
294
+ seeds: {
295
+ root: identity.root.seed,
296
+ public: identity.public.seed,
297
+ circle: identity.circle.seed,
298
+ self: identity.self.seed,
299
+ },
300
+ delegates: [],
301
+ });
302
+ const blobBytes = serializeBlob(plaintext);
303
+ const blobNonce = randomNonce();
304
+ const blob = encryptBlob(encKey, blobNonce, blobBytes);
305
+ let registerResp;
306
+ try {
307
+ registerResp = await registerAccount({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
308
+ email: input.email,
309
+ handle: identity.handle,
310
+ displayName: identity.displayName,
311
+ did: identity.did,
312
+ authKey,
313
+ authSalt,
314
+ encSalt,
315
+ kdf,
316
+ blob,
317
+ blobNonce,
318
+ blobVersion: 1,
319
+ });
320
+ }
321
+ finally {
322
+ zeroize(authKey);
323
+ zeroize(encKey);
324
+ }
325
+ // Bootstrap the Ethos on api.aithos.be. Without this, every subsequent
326
+ // write (publish_ethos_edition, etc.) errors out with -32020
327
+ // "subject identity not published". We do this BEFORE hydrating local
328
+ // state so a bootstrap failure leaves the SDK in a clean
329
+ // "not signed in" state — the dev shows an error, the user retries.
330
+ // The auth account on auth.aithos.be DOES exist at this point, but
331
+ // without the local hydrate the user can't act on it. Self-heal on
332
+ // signIn (re-attempt publish_identity if missing) is planned for a
333
+ // follow-up release.
334
+ await this.#publishIdentity(identity);
335
+ // Hydrate in-memory state from the fresh identity.
336
+ if (this.#ownerSigners)
337
+ this.#ownerSigners.destroy();
338
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
339
+ await this.#keyStore.saveOwner({
340
+ version: "0.1.0-hex",
341
+ did: identity.did,
342
+ handle: identity.handle,
343
+ displayName: identity.displayName,
344
+ seedsHex: {
345
+ root: bytesToHex(identity.root.seed),
346
+ public: bytesToHex(identity.public.seed),
347
+ circle: bytesToHex(identity.circle.seed),
348
+ self: bytesToHex(identity.self.seed),
349
+ },
350
+ savedAt: new Date().toISOString(),
351
+ });
352
+ const session = {
353
+ session: registerResp.session,
354
+ exp: registerResp.exp,
355
+ did: identity.did,
356
+ handle: identity.handle,
357
+ blob_b64: bytesToB64Public(blob),
358
+ blob_nonce_b64: bytesToB64Public(blobNonce),
359
+ blob_version: 1,
360
+ enc_key_b64: "",
361
+ is_first_login: false,
362
+ };
363
+ this.#sessionStore.set(session);
364
+ return {
365
+ session,
366
+ recoveryFile,
367
+ recoveryFilename: recoverySerialized.filename,
368
+ };
369
+ }
370
+ /* ------------------------------------------------------------------------ */
371
+ /* Recovery file */
372
+ /* ------------------------------------------------------------------------ */
373
+ /**
374
+ * Sign in by uploading a recovery file. Hydrates the owner signers
375
+ * locally — no JWT is obtained on this path because the recovery
376
+ * file alone doesn't authenticate against the auth backend (no
377
+ * password, no Google session). Apps that need compute/wallet
378
+ * access should follow up with an email+password sign-in or with
379
+ * Google SSO.
380
+ *
381
+ * The recovery file is ALWAYS the file produced by `signUp` (or the
382
+ * equivalent one emitted by `protocol-client`'s `runOnboarding`).
383
+ * Both shapes are accepted.
384
+ */
385
+ async signInWithRecovery(input) {
386
+ const text = await readRecoveryFileText(input.file);
387
+ const parsed = parseRecoveryFile(text);
388
+ // Build a StoredOwnerKeys-shape on the spot, then push to keyStore +
389
+ // hydrate signers.
390
+ const stored = {
391
+ version: "0.1.0-hex",
392
+ did: parsed.did,
393
+ handle: parsed.handle,
394
+ displayName: parsed.displayName,
395
+ seedsHex: parsed.seedsHex,
396
+ savedAt: new Date().toISOString(),
397
+ };
398
+ // If a different owner is already loaded, refuse — apps must call
399
+ // signOut() first. Mixing two owners in one auth instance is a
400
+ // nonsense state we don't want to support.
401
+ if (this.#ownerSigners && this.#ownerSigners.did !== parsed.did) {
402
+ throw new AithosSDKError("auth_owner_already_loaded", "another owner is already signed in; call signOut first", { data: { current: this.#ownerSigners.did, incoming: parsed.did } });
403
+ }
404
+ if (this.#ownerSigners)
405
+ this.#ownerSigners.destroy();
406
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
407
+ await this.#keyStore.saveOwner(stored);
408
+ // Recovery flow doesn't yield a JWT — wipe any stale one to keep
409
+ // the two stores in sync.
410
+ this.#sessionStore.clear();
411
+ return {
412
+ did: parsed.did,
413
+ handle: parsed.handle,
414
+ displayName: parsed.displayName,
415
+ };
416
+ }
417
+ /* ------------------------------------------------------------------------ */
418
+ /* Mandate import */
419
+ /* ------------------------------------------------------------------------ */
420
+ /**
421
+ * Import a delegate bundle (`.aithos-delegate.json`). Works in any
422
+ * state: with no owner loaded (delegate-only session), or alongside
423
+ * an existing owner (the user holds mandates for other people's
424
+ * ethoses while also being an owner themselves).
425
+ */
426
+ async importMandate(input) {
427
+ const text = await readDelegateBundleText(input.bundle);
428
+ const parsed = parseDelegateBundle(text);
429
+ const stored = {
430
+ version: "0.1.0-hex",
431
+ subjectDid: parsed.subjectDid,
432
+ mandateId: parsed.mandateId,
433
+ mandate: parsed.mandate,
434
+ granteeId: parsed.granteeId,
435
+ granteePubkeyMultibase: parsed.granteePubkeyMultibase,
436
+ delegateSeedHex: parsed.delegateSeedHex,
437
+ importedAt: new Date().toISOString(),
438
+ };
439
+ await this.#keyStore.saveDelegate(stored);
440
+ this.#delegates.add(DelegateActor.fromStored(stored));
441
+ return {
442
+ mandateId: stored.mandateId,
443
+ subjectDid: stored.subjectDid,
444
+ granteeId: stored.granteeId,
445
+ scopes: scopesFromMandate(stored.mandate),
446
+ expiresAt: notAfterFromMandate(stored.mandate),
447
+ };
448
+ }
449
+ async removeMandate(mandateId) {
450
+ this.#delegates.remove(mandateId);
451
+ await this.#keyStore.removeDelegate(mandateId);
452
+ }
453
+ /* ------------------------------------------------------------------------ */
454
+ /* Google SSO */
455
+ /* ------------------------------------------------------------------------ */
59
456
  signInWithGoogle(opts) {
60
- if (!this.win) {
457
+ if (!this.#win) {
61
458
  throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
62
459
  }
460
+ // appId + returnTo must come together — the backend rejects
461
+ // half-presence at /sso/google/start. Surface that as a clean SDK
462
+ // error before the network round-trip rather than letting the user
463
+ // bounce to Google and back for nothing.
464
+ if ((opts?.appId && !opts?.returnTo) || (!opts?.appId && opts?.returnTo)) {
465
+ throw new AithosSDKError("auth_sso_app_redirect_pair_required", "appId and returnTo must be provided together (or both omitted to use the legacy redirect)");
466
+ }
63
467
  const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
64
468
  if (opts?.appState) {
65
469
  if (opts.appState.length > 1024) {
@@ -67,47 +471,145 @@ export class AithosAuth {
67
471
  }
68
472
  url.searchParams.set("app_state", opts.appState);
69
473
  }
70
- this.win.location.assign(url.toString());
71
- // Unreachable: location.assign navigates synchronously. The throw is
72
- // belt-and-braces in case a caller awaits a microtask before unload.
474
+ if (opts?.appId && opts?.returnTo) {
475
+ url.searchParams.set("app_id", opts.appId);
476
+ url.searchParams.set("redirect_uri", opts.returnTo);
477
+ }
478
+ this.#win.location.assign(url.toString());
73
479
  throw new AithosSDKError("auth_redirecting", "redirecting to google");
74
480
  }
75
481
  /**
76
- * Inspect the current URL for an `aithos_code` query parameter. If it's
77
- * present, exchange it at the backend and return the resulting
78
- * {@link AithosSession}. The query params are stripped from the URL via
79
- * `history.replaceState` so a page refresh doesn't replay the redeem
80
- * (which would 410 anyway).
81
- *
82
- * Returns `null` when there's no code in the URL safe to call on every
83
- * page load. Throws {@link AithosSDKError} on backend errors or when
84
- * the URL carries `aithos_error=…` (Google denial, token-exchange
85
- * failure, etc.).
482
+ * Public entrypoint dedupes concurrent calls (React StrictMode).
483
+ * The first call kicks off the actual exchange; subsequent calls
484
+ * before that promise resolves return the SAME promise so they all
485
+ * receive the same `AithosSession | null`. Otherwise StrictMode's
486
+ * second invocation would race against the URL clean done by the
487
+ * first call and resolve to `null`, robbing the AuthCallback page
488
+ * of the session it actually obtained.
86
489
  */
87
490
  async handleCallback() {
88
- if (!this.win)
491
+ if (!this.#win)
492
+ return null;
493
+ if (this.#handleCallbackPromise)
494
+ return this.#handleCallbackPromise;
495
+ const p = this.#doHandleCallback();
496
+ this.#handleCallbackPromise = p;
497
+ // Clear the cache once the promise settles so a subsequent
498
+ // signInWithGoogle round-trip on the same AithosAuth instance can
499
+ // process its own callback. We use `then(cleanup, cleanup)`
500
+ // rather than `finally(...)` because `finally` re-throws — without
501
+ // a downstream `.catch` the resulting promise becomes an
502
+ // unhandledrejection when `p` itself rejects (the caller already
503
+ // surfaces that rejection via the returned `p`). `then(success,
504
+ // error)` converts a rejection into a clean resolution on this
505
+ // side-effect chain so node:test doesn't flag the orphan as a
506
+ // failure.
507
+ const clear = () => {
508
+ if (this.#handleCallbackPromise === p) {
509
+ this.#handleCallbackPromise = null;
510
+ }
511
+ };
512
+ p.then(clear, clear);
513
+ return p;
514
+ }
515
+ async #doHandleCallback() {
516
+ if (!this.#win)
89
517
  return null;
90
- const here = new URL(this.win.location.href);
518
+ const here = new URL(this.#win.location.href);
91
519
  const error = here.searchParams.get("aithos_error");
92
520
  const code = here.searchParams.get("aithos_code");
93
521
  const appState = here.searchParams.get("app_state");
94
522
  if (error) {
95
- cleanCallbackParams(this.win, here);
523
+ cleanCallbackParams(this.#win, here);
96
524
  throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
97
525
  }
98
526
  if (!code)
99
527
  return null;
528
+ // Strip the aithos_code from the URL SYNCHRONOUSLY, before any
529
+ // await. React StrictMode (dev) invokes effects twice — without
530
+ // this, the first call awaits exchange (microtask, code still in
531
+ // the URL), the second invocation reads the same code and POSTs
532
+ // again, hitting `auth_code_consumed: aithos_code expired or
533
+ // already used`. Cleaning before the await makes the second
534
+ // invocation read a clean URL and return null without a network
535
+ // round-trip.
536
+ cleanCallbackParams(this.#win, here);
100
537
  const session = await this.exchange(code);
101
- cleanCallbackParams(this.win, here);
538
+ // Hydrate signers if the SSO response carried an enc_key (Google flow
539
+ // gives us the AES-GCM key in plaintext, encrypted only in transit
540
+ // by TLS — see auth.aithos.be design doc).
541
+ if (session.enc_key_b64 &&
542
+ session.blob_b64 &&
543
+ session.blob_nonce_b64 &&
544
+ session.blob_version > 0) {
545
+ try {
546
+ const encKey = b64ToBytes(session.enc_key_b64);
547
+ const blob = b64ToBytes(session.blob_b64);
548
+ const nonce = b64ToBytes(session.blob_nonce_b64);
549
+ try {
550
+ const blobBytes = decryptBlob(encKey, nonce, blob);
551
+ try {
552
+ const plaintext = parseBlob(blobBytes);
553
+ // Earlier versions of the SDK gated hydration on
554
+ // `plaintext.identity.did === session.did` as a defense
555
+ // against tampered sessionStores. The check breaks SSO
556
+ // flows: the auth backend assigns a placeholder random
557
+ // DID at user-record creation time (no client keypair on
558
+ // hand), but the BLOB is built around a real
559
+ // BrowserIdentity whose DID is derived from its root
560
+ // pubkey. The two intentionally differ — the blob is the
561
+ // truth source for everything downstream (signing, DID
562
+ // resolution against api.aithos.be), the session.did is
563
+ // just auth-side bookkeeping. Drop the check and trust
564
+ // the blob.
565
+ if (this.#ownerSigners)
566
+ this.#ownerSigners.destroy();
567
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
568
+ await this.#keyStore.saveOwner({
569
+ version: "0.1.0-hex",
570
+ did: plaintext.identity.did,
571
+ handle: plaintext.identity.handle,
572
+ displayName: plaintext.identity.displayName,
573
+ seedsHex: plaintext.seeds,
574
+ savedAt: new Date().toISOString(),
575
+ });
576
+ await this.#keyStore.clearAllDelegates();
577
+ this.#delegates.destroy();
578
+ for (const d of plaintext.delegates) {
579
+ const stored = storedDelegateFromBlob(d);
580
+ try {
581
+ await this.#keyStore.saveDelegate(stored);
582
+ }
583
+ catch {
584
+ /* keep going */
585
+ }
586
+ try {
587
+ this.#delegates.add(DelegateActor.fromStored(stored));
588
+ }
589
+ catch {
590
+ /* keep going */
591
+ }
592
+ }
593
+ }
594
+ finally {
595
+ zeroize(blobBytes);
596
+ }
597
+ }
598
+ finally {
599
+ zeroize(encKey);
600
+ }
601
+ }
602
+ catch {
603
+ // Decryption failure is non-fatal here: the JWT still works for
604
+ // compute/wallet, the user will surface the issue if they try to
605
+ // edit their ethos.
606
+ }
607
+ }
608
+ this.#sessionStore.set(session);
102
609
  return session;
103
610
  }
104
- /**
105
- * Programmatically redeem an `aithos_code` for a session. `handleCallback`
106
- * calls this for you; expose it directly for callers that already pulled
107
- * the code out of the URL via their own router.
108
- */
109
611
  async exchange(aithosCode) {
110
- const res = await this.fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
612
+ const res = await this.#fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
111
613
  method: "POST",
112
614
  headers: { "content-type": "application/json" },
113
615
  body: JSON.stringify({ aithos_code: aithosCode }),
@@ -120,7 +622,9 @@ export class AithosAuth {
120
622
  catch {
121
623
  // ignore non-JSON error body
122
624
  }
123
- const code = typeof body?.["code"] === "string" ? `auth_${body["code"]}` : "auth_exchange_failed";
625
+ const code = typeof body?.["code"] === "string"
626
+ ? `auth_${body["code"]}`
627
+ : "auth_exchange_failed";
124
628
  const message = typeof body?.["error"] === "string"
125
629
  ? body["error"]
126
630
  : `aithos_code redemption failed (${res.status})`;
@@ -131,14 +635,474 @@ export class AithosAuth {
131
635
  }
132
636
  return (await res.json());
133
637
  }
638
+ /* ------------------------------------------------------------------------ */
639
+ /* Complete SSO first login */
640
+ /* ------------------------------------------------------------------------ */
134
641
  /**
135
- * Stateless sign-out. The Aithos backend doesn't track sessions, so
136
- * there's nothing to revoke server-side; this method exists so the app
137
- * has a symmetric API surface and to remind callers to clear their
138
- * own storage. The Promise always resolves.
642
+ * Finish the first-time Google SSO bootstrap. After
643
+ * `signInWithGoogle()` + `handleCallback()`, a brand-new SSO user has
644
+ * a session JWT and an `enc_key` released by the auth backend, but
645
+ * NO Aithos identity yet (no Ed25519 seeds, no published did.json,
646
+ * no blob in the auth vault). This method closes that gap:
647
+ *
648
+ * 1. Generates a fresh {@link BrowserIdentity} client-side (4
649
+ * Ed25519 keypairs, derived DID).
650
+ * 2. Calls `aithos.publish_identity` on api.aithos.be so reads
651
+ * and writes against the Aithos primitives have an ethos to
652
+ * anchor to.
653
+ * 3. AES-GCM-encrypts the seeds with the session's `enc_key`,
654
+ * PUTs the result to `/auth/blob`. From now on, every Google
655
+ * sign-in for this user will receive the encrypted blob and
656
+ * hydrate locally.
657
+ * 4. Hydrates `ownerSigners` + `keyStore` so `canSignAsOwner()`
658
+ * flips to true.
659
+ * 5. Returns a recovery-file Blob — the only material that can
660
+ * restore this ethos if Google access is lost.
661
+ *
662
+ * Preconditions:
663
+ * - `getCurrentSession()` returns a non-null session (caller went
664
+ * through `handleCallback()` already).
665
+ * - The session's `blob_version` is 0 (i.e. no blob yet).
666
+ * - The session's `enc_key_b64` is non-empty.
667
+ *
668
+ * Throws `AithosSDKError("auth_sso_no_pending_first_login", …)` if
669
+ * preconditions don't hold (e.g. blob_version > 0 means the user has
670
+ * already completed setup; nothing to do).
139
671
  */
672
+ async completeSsoFirstLogin(input) {
673
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
674
+ throw new AithosSDKError("auth_invalid_handle", "handle must be 1–63 alphanumeric chars + _ -");
675
+ }
676
+ const displayName = input.displayName ?? input.handle;
677
+ const session = this.#sessionStore.get();
678
+ if (!session) {
679
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "no active session — sign in via Google first");
680
+ }
681
+ if (!session.enc_key_b64) {
682
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "session does not carry an enc_key (not an SSO-flow session?)");
683
+ }
684
+ if (session.blob_version > 0) {
685
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "this session already has a published blob — nothing to bootstrap");
686
+ }
687
+ // 1. Fresh identity client-side. The DID derived here is the
688
+ // truth source from now on — the placeholder DID stamped in
689
+ // the user record by the auth Lambda is left as-is (auth-side
690
+ // bookkeeping; never used for signing).
691
+ const identity = createBrowserIdentity(input.handle, displayName);
692
+ const recoverySerialized = serializeRecoveryFile(identity);
693
+ const recoveryFile = new Blob([recoverySerialized.text], {
694
+ type: "application/json",
695
+ });
696
+ // 2. publish_identity on api.aithos.be — reuses the alpha.6
697
+ // helper. Must succeed before we persist anything locally:
698
+ // a half-completed bootstrap (blob uploaded but identity not
699
+ // published) would leave the user with seeds they can't use.
700
+ await this.#publishIdentity(identity);
701
+ // 3. Encrypt the seeds with the SSO-released enc_key and PUT
702
+ // /auth/blob. The auth Lambda accepts the new blob_version=1
703
+ // and stores the bytes verbatim.
704
+ const encKey = b64ToBytes(session.enc_key_b64);
705
+ let blob;
706
+ let blobNonce;
707
+ let plaintext;
708
+ try {
709
+ plaintext = buildBlobPlaintext({
710
+ identity: {
711
+ did: identity.did,
712
+ handle: identity.handle,
713
+ displayName: identity.displayName,
714
+ },
715
+ seeds: {
716
+ root: identity.root.seed,
717
+ public: identity.public.seed,
718
+ circle: identity.circle.seed,
719
+ self: identity.self.seed,
720
+ },
721
+ delegates: [],
722
+ });
723
+ const blobBytes = serializeBlob(plaintext);
724
+ blobNonce = randomNonce();
725
+ blob = encryptBlob(encKey, blobNonce, blobBytes);
726
+ }
727
+ finally {
728
+ zeroize(encKey);
729
+ }
730
+ const newBlobVersion = 1;
731
+ try {
732
+ await putBlob({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
733
+ jwt: session.session,
734
+ blob,
735
+ blobNonce,
736
+ blobVersion: newBlobVersion,
737
+ });
738
+ }
739
+ catch (e) {
740
+ throw new AithosSDKError("auth_sso_blob_upload_failed", `couldn't store the encrypted vault on auth.aithos.be: ${e.message ?? "unknown"}`);
741
+ }
742
+ // 4. Hydrate in-memory state from the fresh identity.
743
+ if (this.#ownerSigners)
744
+ this.#ownerSigners.destroy();
745
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
746
+ await this.#keyStore.saveOwner({
747
+ version: "0.1.0-hex",
748
+ did: identity.did,
749
+ handle: identity.handle,
750
+ displayName: identity.displayName,
751
+ seedsHex: {
752
+ root: bytesToHex(identity.root.seed),
753
+ public: bytesToHex(identity.public.seed),
754
+ circle: bytesToHex(identity.circle.seed),
755
+ self: bytesToHex(identity.self.seed),
756
+ },
757
+ savedAt: new Date().toISOString(),
758
+ });
759
+ // 5. Persist the updated session — same JWT, but now carrying
760
+ // the freshly-built blob bytes so a subsequent `resume()` can
761
+ // rehydrate without another /auth/blob round-trip.
762
+ const refreshed = {
763
+ ...session,
764
+ blob_b64: bytesToB64Public(blob),
765
+ blob_nonce_b64: bytesToB64Public(blobNonce),
766
+ blob_version: newBlobVersion,
767
+ };
768
+ this.#sessionStore.set(refreshed);
769
+ return {
770
+ session: refreshed,
771
+ recoveryFile,
772
+ recoveryFilename: recoverySerialized.filename,
773
+ };
774
+ }
775
+ /* ------------------------------------------------------------------------ */
776
+ /* Custodial flow (V2 — see PLATFORM-AUTH-PASSWORD-V2-PLAN.md) */
777
+ /* ------------------------------------------------------------------------ */
778
+ /**
779
+ * Provision a custodial-mode account on behalf of a registered app.
780
+ *
781
+ * Two integration patterns:
782
+ * - **Frontend-only** apps : set `publicKey` on the constructor
783
+ * (or on this call). Safe to ship in browser bundles — the
784
+ * backend gates each request by Origin + IP rate limit.
785
+ * - **Backend-fronted** apps : the backend passes `apiKey` (secret
786
+ * Bearer); the browser never sees the credential.
787
+ *
788
+ * The created account is in a *pending* state — sign-in stays blocked
789
+ * until the user clicks the confirmation link sent to their inbox.
790
+ * Call {@link verifyEmail} from the page mounted on
791
+ * `app.verify_base_url` to consume the token; afterwards
792
+ * {@link signInCustodial} works.
793
+ *
794
+ * Errors map to `AithosSDKError` codes:
795
+ * - `auth_missing_api_key` (no credential provided)
796
+ * - `auth_invalid_api_key` (Bearer rejected by backend)
797
+ * - `auth_invalid_public_key` (public key rejected by backend)
798
+ * - `auth_api_key_revoked` / `auth_public_key_revoked`
799
+ * - `auth_origin_not_allowed` (public key + Origin not in allowlist)
800
+ * - `auth_password_too_weak` (400 — server-side strength check)
801
+ * - `auth_email_exists` (409 — email already registered)
802
+ * - `auth_email_invalid` (400 — bad email format)
803
+ * - `auth_mail_send_failed` (502 — DDB row exists but SES failed)
804
+ * - `auth_custodial_signup_failed` (catch-all)
805
+ */
806
+ async signUpCustodial(input) {
807
+ if (!input.email) {
808
+ throw new AithosSDKError("auth_invalid_input", "signUpCustodial: email is required");
809
+ }
810
+ if (!input.password) {
811
+ throw new AithosSDKError("auth_invalid_input", "signUpCustodial: password is required");
812
+ }
813
+ const apiKey = input.apiKey;
814
+ const publicKey = input.publicKey ?? this.#publicKey;
815
+ if (!apiKey && !publicKey) {
816
+ throw new AithosSDKError("auth_missing_api_key", "signUpCustodial: pass apiKey, or publicKey, or set publicKey on the AithosAuth constructor");
817
+ }
818
+ return custodialSignUp({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
819
+ email: input.email,
820
+ password: input.password,
821
+ ...(apiKey ? { apiKey } : {}),
822
+ ...(apiKey ? {} : publicKey ? { publicKey } : {}),
823
+ ...(input.displayName ? { displayName: input.displayName } : {}),
824
+ ...(input.handleHint ? { handleHint: input.handleHint } : {}),
825
+ });
826
+ }
827
+ /**
828
+ * Confirm the user's email address by consuming the one-time token
829
+ * from the confirmation link. Idempotent on repeated clicks; throws
830
+ * `auth_token_invalid_or_expired` if the token is wrong, already
831
+ * consumed, or past its 24h TTL.
832
+ *
833
+ * Mount this on the page declared as `verify_base_url` in your app's
834
+ * registration. Read `email` + `token` from `window.location.search`,
835
+ * call this, then redirect to your sign-in page on success.
836
+ */
837
+ async verifyEmail(input) {
838
+ if (!input.email || !input.token) {
839
+ throw new AithosSDKError("auth_invalid_input", "verifyEmail: email and token are required");
840
+ }
841
+ return custodialVerifyEmail({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
842
+ }
843
+ /**
844
+ * Re-send the verification mail for a pending account. Use when the
845
+ * user reports never having received the welcome mail, or when their
846
+ * verification token expired (24h TTL).
847
+ *
848
+ * The backend is anti-enumeration (always 200) and rate-limited
849
+ * 1/h/account, so it's safe to call even when the state of `email`
850
+ * is unknown. Accepts the same credential families as
851
+ * {@link signUpCustodial}; falls back to the constructor's
852
+ * `publicKey` when neither override is set.
853
+ */
854
+ async resendVerificationEmail(input) {
855
+ if (!input.email) {
856
+ throw new AithosSDKError("auth_invalid_input", "resendVerificationEmail: email is required");
857
+ }
858
+ const apiKey = input.apiKey;
859
+ const publicKey = input.publicKey ?? this.#publicKey;
860
+ if (!apiKey && !publicKey) {
861
+ throw new AithosSDKError("auth_missing_api_key", "resendVerificationEmail: pass apiKey, publicKey, or set publicKey on the AithosAuth constructor");
862
+ }
863
+ await custodialResendVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
864
+ email: input.email,
865
+ ...(apiKey ? { apiKey } : {}),
866
+ ...(apiKey ? {} : publicKey ? { publicKey } : {}),
867
+ });
868
+ }
869
+ /**
870
+ * Authenticate a custodial-mode user with email + password. Single
871
+ * round-trip: returns a fresh JWT session AND hydrates the local
872
+ * KeyStore with the user's 4 Ed25519 seeds (KMS-unwrapped server-side
873
+ * after Argon2id verify).
874
+ *
875
+ * After this returns, the SDK is ready to publish ethos editions,
876
+ * invoke compute, mint mandates, etc. — exactly as if the user had
877
+ * signed in via {@link signIn} (zk) or {@link handleCallback} (SSO).
878
+ *
879
+ * Errors map to `AithosSDKError` codes:
880
+ * - `auth_invalid_input` (your code passed empty fields)
881
+ * - `auth_invalid_credentials` (401 — wrong email / wrong password)
882
+ * - `auth_wrong_auth_mode` (403 — user exists in another flow)
883
+ */
884
+ async signInCustodial(input) {
885
+ if (!input.email || !input.password) {
886
+ throw new AithosSDKError("auth_invalid_input", "signInCustodial: email and password are required");
887
+ }
888
+ const resp = await custodialSignIn({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
889
+ // Split the 128-byte seed bundle into the four sphere seeds. The
890
+ // backend lays them out in the canonical order
891
+ // [root || public || circle || self] (cf. seed-wrapper.ts).
892
+ if (resp.seed.byteLength !== 128) {
893
+ // Legacy 32-byte rows shouldn't happen in production (we wiped the
894
+ // single test row before redeploying with the 4-seed bundle), but
895
+ // we surface a clear error rather than silently corrupting the
896
+ // identity.
897
+ zeroize(resp.seed);
898
+ zeroize(resp.encKey);
899
+ throw new AithosSDKError("auth_custodial_seed_format", `signInCustodial: expected 128-byte seed bundle, got ${resp.seed.byteLength}`);
900
+ }
901
+ const seedRoot = resp.seed.slice(0, 32);
902
+ const seedPublic = resp.seed.slice(32, 64);
903
+ const seedCircle = resp.seed.slice(64, 96);
904
+ const seedSelf = resp.seed.slice(96, 128);
905
+ // Stored shape uses hex strings; round-trip through bytesToHex
906
+ // so the keyStore record is identical to what signUp(zk) writes.
907
+ const stored = {
908
+ version: "0.1.0-hex",
909
+ did: resp.did,
910
+ handle: resp.handle,
911
+ displayName: resp.displayName,
912
+ seedsHex: {
913
+ root: bytesToHex(seedRoot),
914
+ public: bytesToHex(seedPublic),
915
+ circle: bytesToHex(seedCircle),
916
+ self: bytesToHex(seedSelf),
917
+ },
918
+ savedAt: new Date().toISOString(),
919
+ };
920
+ // Zeroize the raw bundle + the split copies now that they've been
921
+ // serialised into the keyStore record (hex strings live in the
922
+ // record; the original bytes can go).
923
+ zeroize(resp.seed);
924
+ zeroize(seedRoot);
925
+ zeroize(seedPublic);
926
+ zeroize(seedCircle);
927
+ zeroize(seedSelf);
928
+ // The enc_key is informational here — the custodial blob is empty
929
+ // at first login. We still don't keep it in memory.
930
+ zeroize(resp.encKey);
931
+ // Hydrate in-memory owner signers from the freshly-stored material.
932
+ if (this.#ownerSigners)
933
+ this.#ownerSigners.destroy();
934
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
935
+ await this.#keyStore.saveOwner(stored);
936
+ const session = {
937
+ session: resp.session,
938
+ exp: resp.exp,
939
+ did: resp.did,
940
+ handle: resp.handle,
941
+ blob_b64: bytesToB64Public(resp.blob),
942
+ blob_nonce_b64: bytesToB64Public(resp.blobNonce),
943
+ blob_version: resp.blobVersion,
944
+ enc_key_b64: "",
945
+ is_first_login: resp.passwordMustChange,
946
+ };
947
+ this.#sessionStore.set(session);
948
+ return { session, passwordMustChange: resp.passwordMustChange };
949
+ }
950
+ /**
951
+ * Trigger a password-reset email to the given address. Backend ALWAYS
952
+ * resolves silently (no enumeration) — caller cannot tell whether the
953
+ * email is registered or not. The mail itself, if sent, contains a
954
+ * magic-link URL of shape `<resetBaseUrl>?token=<raw>&email=<email>`.
955
+ *
956
+ * Per-email rate limits apply server-side (5 mails/day, 5 min cooldown
957
+ * between consecutive requests). Calls during cooldown silently no-op
958
+ * the mail send while still returning success here.
959
+ */
960
+ async requestPasswordReset(input) {
961
+ if (!input.email) {
962
+ throw new AithosSDKError("auth_invalid_input", "requestPasswordReset: email is required");
963
+ }
964
+ await custodialResetRequest({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
965
+ }
966
+ /**
967
+ * Finalise a password reset using the magic-link token sent to the
968
+ * user's inbox by {@link requestPasswordReset}.
969
+ *
970
+ * Typical use site: the page mounted on the reset URL declared in
971
+ * `aithos-auth-apps.reset_base_url`. The page reads `email` and
972
+ * `token` from `window.location.search`, prompts the user for a new
973
+ * password, then calls this method.
974
+ *
975
+ * On success, the returned {@link AithosSession} is persisted to the
976
+ * session store but the local keystore is NOT hydrated — the backend
977
+ * does not return the seed bundle on this endpoint. To get a fully
978
+ * usable session (one that can sign envelopes), follow up with
979
+ * {@link signInCustodial} using the email + new password. The two
980
+ * round-trips can be hidden inside a single UI action: reset → auto
981
+ * sign-in → redirect to dashboard.
982
+ *
983
+ * Errors map to `AithosSDKError` codes:
984
+ * - `auth_invalid_input` (your code passed empty fields)
985
+ * - `auth_reset_token_invalid` (400 — token forged / wrong email)
986
+ * - `auth_reset_token_expired` (410 — token TTL elapsed)
987
+ * - `auth_reset_token_consumed` (409 — already used)
988
+ * - `auth_password_too_short` (400 — < 10 chars)
989
+ * - `auth_custodial_reset_failed` (catch-all)
990
+ */
991
+ async applyPasswordReset(input) {
992
+ if (!input.email || !input.token || !input.newPassword) {
993
+ throw new AithosSDKError("auth_invalid_input", "applyPasswordReset: email, token and newPassword are required");
994
+ }
995
+ const resp = await custodialResetFinalize({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
996
+ // The reset endpoint mints a JWT but doesn't ship the seed bundle —
997
+ // the caller still has to signInCustodial() to materialise the keys
998
+ // locally. We persist the session anyway so any code that reads
999
+ // `getCurrentSession()` between the reset and the follow-up sign-in
1000
+ // sees the new JWT (e.g. an analytics hook).
1001
+ const session = {
1002
+ session: resp.session,
1003
+ exp: resp.exp,
1004
+ did: resp.did,
1005
+ handle: resp.handle,
1006
+ // No blob / enc_key on this path — the reset endpoint doesn't
1007
+ // re-issue the vault. Leave the blob slots empty; the follow-up
1008
+ // signInCustodial() will populate them.
1009
+ blob_b64: "",
1010
+ blob_nonce_b64: "",
1011
+ blob_version: 0,
1012
+ enc_key_b64: "",
1013
+ is_first_login: false,
1014
+ };
1015
+ this.#sessionStore.set(session);
1016
+ return { session };
1017
+ }
1018
+ /* ------------------------------------------------------------------------ */
1019
+ /* Sign-out */
1020
+ /* ------------------------------------------------------------------------ */
140
1021
  async signOut() {
141
- /* no-op; sessions are stateless JWTs. */
1022
+ if (this.#ownerSigners)
1023
+ this.#ownerSigners.destroy();
1024
+ this.#ownerSigners = null;
1025
+ this.#delegates.destroy();
1026
+ this.#sessionStore.clear();
1027
+ await this.#keyStore.clearOwner().catch(() => { });
1028
+ await this.#keyStore.clearAllDelegates().catch(() => { });
1029
+ }
1030
+ /* ------------------------------------------------------------------------ */
1031
+ /* Internal — Ethos bootstrap */
1032
+ /* ------------------------------------------------------------------------ */
1033
+ /**
1034
+ * Provision the user's Ethos on `api.aithos.be` by signing and POSTing an
1035
+ * `aithos.publish_identity` envelope. Required after a fresh sign-up so
1036
+ * subsequent edition publishes (`me.publish()`) don't fail with
1037
+ * `-32020 subject identity not published`.
1038
+ *
1039
+ * Retries twice with exponential backoff on transient errors (network or
1040
+ * 5xx). Throws {@link AithosSDKError} with code `ethos_bootstrap_failed`
1041
+ * on definitive failure — the caller is expected to abort sign-up.
1042
+ *
1043
+ * @internal
1044
+ */
1045
+ async #publishIdentity(identity) {
1046
+ const url = `${this.apiBaseUrl}/mcp/primitives/write`;
1047
+ const signedDoc = signedDidDocument(identity);
1048
+ const params = {
1049
+ did_document: signedDoc,
1050
+ handle: identity.handle,
1051
+ display_name: identity.displayName,
1052
+ };
1053
+ const envelope = buildSignedEnvelope({
1054
+ iss: identity.did,
1055
+ aud: url,
1056
+ method: "aithos.publish_identity",
1057
+ verificationMethod: `${identity.did}#root`,
1058
+ params,
1059
+ signer: identity.root,
1060
+ });
1061
+ const body = JSON.stringify({
1062
+ jsonrpc: "2.0",
1063
+ id: "publish_identity",
1064
+ method: "aithos.publish_identity",
1065
+ params: { ...params, _envelope: envelope },
1066
+ });
1067
+ // Two retries with backoff (300ms, 1500ms). Idempotent on the server
1068
+ // side — replaying the same publish_identity for an existing DID is a
1069
+ // no-op, so retries are safe even if the first attempt actually
1070
+ // succeeded but the response was lost.
1071
+ const delays = [0, 300, 1500];
1072
+ let lastError;
1073
+ for (const delay of delays) {
1074
+ if (delay > 0)
1075
+ await sleep(delay);
1076
+ try {
1077
+ const res = await this.#fetchImpl(url, {
1078
+ method: "POST",
1079
+ headers: { "content-type": "application/json" },
1080
+ body,
1081
+ });
1082
+ // Transport errors (5xx, no body) — retry. JSON-RPC errors come
1083
+ // back with HTTP 200 and an `error` field.
1084
+ if (!res.ok && res.status >= 500) {
1085
+ lastError = new Error(`HTTP ${res.status}`);
1086
+ continue;
1087
+ }
1088
+ const json = (await res.json());
1089
+ if (json.error) {
1090
+ // JSON-RPC error: don't retry — these are deterministic
1091
+ // (validation, permission, identity-already-tombstoned, …).
1092
+ throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
1093
+ status: res.status,
1094
+ data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
1095
+ });
1096
+ }
1097
+ return; // success
1098
+ }
1099
+ catch (e) {
1100
+ if (e instanceof AithosSDKError)
1101
+ throw e;
1102
+ lastError = e;
1103
+ }
1104
+ }
1105
+ throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity unreachable after ${delays.length} attempts: ${lastError?.message ?? "unknown"}`);
142
1106
  }
143
1107
  }
144
1108
  /* -------------------------------------------------------------------------- */
@@ -147,10 +1111,75 @@ export class AithosAuth {
147
1111
  function trimSlash(url) {
148
1112
  return url.endsWith("/") ? url.slice(0, -1) : url;
149
1113
  }
1114
+ function sleep(ms) {
1115
+ return new Promise((resolve) => setTimeout(resolve, ms));
1116
+ }
150
1117
  function cleanCallbackParams(win, url) {
151
1118
  url.searchParams.delete("aithos_code");
152
1119
  url.searchParams.delete("aithos_error");
153
1120
  url.searchParams.delete("app_state");
154
1121
  win.history.replaceState(null, "", url.toString());
155
1122
  }
1123
+ function bytesToB64Public(bytes) {
1124
+ if (bytes.length === 0)
1125
+ return "";
1126
+ let bin = "";
1127
+ for (let i = 0; i < bytes.length; i++)
1128
+ bin += String.fromCharCode(bytes[i]);
1129
+ return btoa(bin).replace(/=+$/, "");
1130
+ }
1131
+ function b64ToBytes(b64) {
1132
+ if (!b64)
1133
+ return new Uint8Array(0);
1134
+ // standard b64 — pad to multiple of 4 if needed
1135
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
1136
+ const bin = atob(b64 + pad);
1137
+ const out = new Uint8Array(bin.length);
1138
+ for (let i = 0; i < bin.length; i++)
1139
+ out[i] = bin.charCodeAt(i);
1140
+ return out;
1141
+ }
1142
+ function bytesToHex(b) {
1143
+ let out = "";
1144
+ for (let i = 0; i < b.length; i++)
1145
+ out += b[i].toString(16).padStart(2, "0");
1146
+ return out;
1147
+ }
1148
+ /**
1149
+ * Project a delegate as it appears in a `BlobPlaintext` (extension-kit
1150
+ * `StoredDelegate` shape) onto the SDK's own {@link StoredDelegateKeys}.
1151
+ */
1152
+ function storedDelegateFromBlob(d) {
1153
+ return {
1154
+ version: "0.1.0-hex",
1155
+ subjectDid: d.subjectDid,
1156
+ mandateId: d.mandateId,
1157
+ mandate: d.mandate,
1158
+ granteeId: d.granteeId,
1159
+ granteePubkeyMultibase: d.granteePubkeyMultibase,
1160
+ delegateSeedHex: d.delegateSeedHex,
1161
+ importedAt: new Date().toISOString(),
1162
+ };
1163
+ }
1164
+ function actorToInfo(a) {
1165
+ return {
1166
+ mandateId: a.mandateId,
1167
+ subjectDid: a.subjectDid,
1168
+ granteeId: a.granteeId,
1169
+ scopes: scopesFromMandate(a.mandate),
1170
+ expiresAt: notAfterFromMandate(a.mandate),
1171
+ };
1172
+ }
1173
+ function scopesFromMandate(m) {
1174
+ const raw = m["scopes"];
1175
+ if (!Array.isArray(raw))
1176
+ return [];
1177
+ return raw.filter((s) => typeof s === "string");
1178
+ }
1179
+ function notAfterFromMandate(m) {
1180
+ const raw = m["not_after"];
1181
+ if (typeof raw !== "string")
1182
+ return null;
1183
+ return raw;
1184
+ }
156
1185
  //# sourceMappingURL=auth.js.map