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

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 (80) hide show
  1. package/README.md +211 -7
  2. package/dist/src/apps.d.ts +155 -0
  3. package/dist/src/apps.js +288 -0
  4. package/dist/src/assets.d.ts +207 -0
  5. package/dist/src/assets.js +533 -0
  6. package/dist/src/auth-api.d.ts +138 -0
  7. package/dist/src/auth-api.js +168 -0
  8. package/dist/src/auth.d.ts +536 -119
  9. package/dist/src/auth.js +1207 -152
  10. package/dist/src/compute.d.ts +251 -9
  11. package/dist/src/compute.js +293 -16
  12. package/dist/src/data-schema-contacts-v1.d.ts +14 -0
  13. package/dist/src/data-schema-contacts-v1.js +28 -0
  14. package/dist/src/data.d.ts +153 -0
  15. package/dist/src/data.js +670 -0
  16. package/dist/src/endpoints.d.ts +9 -0
  17. package/dist/src/endpoints.js +5 -0
  18. package/dist/src/ethos.d.ts +202 -1
  19. package/dist/src/ethos.js +821 -16
  20. package/dist/src/index.d.ts +18 -6
  21. package/dist/src/index.js +39 -6
  22. package/dist/src/internal/delegate-bundle.d.ts +18 -0
  23. package/dist/src/internal/delegate-bundle.js +94 -0
  24. package/dist/src/internal/delegate-state.d.ts +45 -0
  25. package/dist/src/internal/delegate-state.js +120 -0
  26. package/dist/src/internal/envelope.d.ts +77 -0
  27. package/dist/src/internal/envelope.js +154 -0
  28. package/dist/src/internal/owner-signers.d.ts +78 -0
  29. package/dist/src/internal/owner-signers.js +179 -0
  30. package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
  31. package/dist/src/internal/protocol-client-bridge.js +20 -0
  32. package/dist/src/internal/recovery-file.d.ts +29 -0
  33. package/dist/src/internal/recovery-file.js +98 -0
  34. package/dist/src/internal/signer.d.ts +59 -0
  35. package/dist/src/internal/signer.js +86 -0
  36. package/dist/src/key-store.d.ts +128 -0
  37. package/dist/src/key-store.js +244 -0
  38. package/dist/src/mandates.d.ts +163 -1
  39. package/dist/src/mandates.js +286 -8
  40. package/dist/src/react/AithosAsset.d.ts +66 -0
  41. package/dist/src/react/AithosAsset.js +67 -0
  42. package/dist/src/react/context.d.ts +29 -0
  43. package/dist/src/react/context.js +31 -0
  44. package/dist/src/react/index.d.ts +28 -0
  45. package/dist/src/react/index.js +30 -0
  46. package/dist/src/react/use-aithos-asset.d.ts +39 -0
  47. package/dist/src/react/use-aithos-asset.js +118 -0
  48. package/dist/src/sdk.d.ts +46 -3
  49. package/dist/src/sdk.js +49 -23
  50. package/dist/src/wallet.d.ts +4 -6
  51. package/dist/src/wallet.js +18 -8
  52. package/dist/src/web.d.ts +279 -0
  53. package/dist/src/web.js +186 -0
  54. package/dist/test/auth-j3.test.d.ts +2 -0
  55. package/dist/test/auth-j3.test.js +391 -0
  56. package/dist/test/compute-delegate-path.test.d.ts +2 -0
  57. package/dist/test/compute-delegate-path.test.js +183 -0
  58. package/dist/test/compute.test.js +26 -11
  59. package/dist/test/endpoints.test.js +20 -1
  60. package/dist/test/envelope.test.d.ts +2 -0
  61. package/dist/test/envelope.test.js +318 -0
  62. package/dist/test/ethos-first-edition.test.d.ts +2 -0
  63. package/dist/test/ethos-first-edition.test.js +248 -0
  64. package/dist/test/ethos.test.d.ts +2 -0
  65. package/dist/test/ethos.test.js +219 -0
  66. package/dist/test/key-store.test.d.ts +2 -0
  67. package/dist/test/key-store.test.js +161 -0
  68. package/dist/test/mandates-compute.test.d.ts +2 -0
  69. package/dist/test/mandates-compute.test.js +256 -0
  70. package/dist/test/mandates.test.d.ts +2 -0
  71. package/dist/test/mandates.test.js +93 -0
  72. package/dist/test/sdk.test.js +70 -30
  73. package/dist/test/signer.test.d.ts +2 -0
  74. package/dist/test/signer.test.js +117 -0
  75. package/dist/test/signup-bootstrap.test.d.ts +2 -0
  76. package/dist/test/signup-bootstrap.test.js +311 -0
  77. package/dist/test/wallet.test.js +20 -9
  78. package/dist/test/web.test.d.ts +2 -0
  79. package/dist/test/web.test.js +270 -0
  80. package/package.json +18 -3
package/dist/src/auth.js CHANGED
@@ -1,100 +1,370 @@
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";
34
- import { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
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 { browserIdentityFromStored, 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";
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 { signOwnerEnvelope, } from "./internal/envelope.js";
30
+ import { OwnerSigners } from "./internal/owner-signers.js";
31
+ import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from "./internal/recovery-file.js";
36
32
  import { AithosSDKError } from "./types.js";
37
33
  /** Default URL of the Aithos auth backend. */
38
34
  export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
35
+ /** Default URL of the Aithos primitives API (publish_identity, publish_ethos_edition, etc.). */
36
+ export const DEFAULT_API_BASE_URL = "https://api.aithos.be";
39
37
  /* -------------------------------------------------------------------------- */
40
38
  /* AithosAuth */
41
39
  /* -------------------------------------------------------------------------- */
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
40
  export class AithosAuth {
52
- /** Resolved auth base URL with a trailing slash trimmed. */
53
41
  authBaseUrl;
54
- fetchImpl;
55
- win;
56
- store;
42
+ apiBaseUrl;
43
+ #fetchImpl;
44
+ #win;
45
+ #sessionStore;
46
+ #keyStore;
47
+ #publicKey;
48
+ /** In-memory owner signers — populated after sign-in or `resume`. */
49
+ #ownerSigners = null;
50
+ /** Active delegate registry. */
51
+ #delegates = new DelegateRegistry();
52
+ /**
53
+ * In-flight (or just-resolved) `handleCallback()` result. React
54
+ * StrictMode (dev) double-invokes the mount effect — the URL clean
55
+ * inside the first call makes the second invocation see a clean URL
56
+ * and resolve to `null`, with the session it just consumed locked
57
+ * inside the first promise. Caching the result here lets both
58
+ * invocations resolve to the same value. Cleared on next mount via
59
+ * the wrapper's once-per-instance dedup.
60
+ */
61
+ #handleCallbackPromise = null;
57
62
  constructor(config = {}) {
58
63
  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();
64
+ this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
65
+ this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
66
+ this.#win =
67
+ config.window ??
68
+ (typeof window !== "undefined" ? window : undefined);
69
+ this.#sessionStore = config.sessionStore ?? defaultSessionStore();
70
+ this.#keyStore = config.keyStore ?? defaultKeyStore();
71
+ this.#publicKey = config.publicKey;
72
+ }
73
+ /* ------------------------------------------------------------------------ */
74
+ /* Boot-time hydration */
75
+ /* ------------------------------------------------------------------------ */
76
+ /**
77
+ * Reload signing material and JWT session from the configured stores.
78
+ * Must be called once at app boot before relying on
79
+ * {@link getCurrentSession} / {@link getOwnerInfo} / {@link canSignAsOwner}
80
+ * — until then they reflect only what's been done in-memory in the
81
+ * current tab.
82
+ *
83
+ * Strict consistency: if the JWT and the stored owner disagree about
84
+ * who's signed in, both are wiped and the user re-auths. JWT-less
85
+ * owner state (loaded from keyStore but no JWT) is a valid resumed
86
+ * state — the user signed in via recovery or imported a mandate at
87
+ * some earlier moment and never went through the JWT flow.
88
+ */
89
+ async resume() {
90
+ // 1. Owner side.
91
+ const stored = await this.#keyStore.loadOwner().catch(() => null);
92
+ const jwt = this.#sessionStore.get();
93
+ if (stored) {
94
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
95
+ // JWT must match the owner DID — otherwise it's stale state.
96
+ if (jwt && jwt.did !== stored.did) {
97
+ this.#sessionStore.clear();
98
+ }
99
+ }
100
+ else {
101
+ // No owner persisted. A lingering JWT is meaningless without local
102
+ // signing capability — wipe it (strict mode).
103
+ if (jwt)
104
+ this.#sessionStore.clear();
105
+ }
106
+ // 2. Delegate side. Independent of owner state — a user may hold
107
+ // only delegate bundles and no owner identity at all.
108
+ const storedDelegates = await this.#keyStore
109
+ .listDelegates()
110
+ .catch(() => []);
111
+ for (const d of storedDelegates) {
112
+ try {
113
+ this.#delegates.add(DelegateActor.fromStored(d));
114
+ }
115
+ catch {
116
+ // Skip corrupted entries silently — the keystore validators
117
+ // already filter most of these. A skip here means the keystore
118
+ // record passed validation but the seed couldn't be re-derived
119
+ // (e.g. zero-length, future migration); ignore and continue.
120
+ }
121
+ }
122
+ }
123
+ /* ------------------------------------------------------------------------ */
124
+ /* State accessors */
125
+ /* ------------------------------------------------------------------------ */
126
+ /** JWT-backed session. Null when signed in via recovery / mandate / not at all. */
127
+ getCurrentSession() {
128
+ const session = this.#sessionStore.get();
129
+ if (!session)
130
+ return null;
131
+ // Belt-and-braces: the session store auto-evicts on expiry, but we
132
+ // also re-check here in case the in-tab clock drifted post-load.
133
+ return session;
134
+ }
135
+ /** Loaded owner identity. Independent of JWT presence. */
136
+ getOwnerInfo() {
137
+ if (!this.#ownerSigners)
138
+ return null;
139
+ return {
140
+ did: this.#ownerSigners.did,
141
+ handle: this.#ownerSigners.handle,
142
+ displayName: this.#ownerSigners.displayName,
143
+ };
144
+ }
145
+ getDelegates() {
146
+ return this.#delegates.list().map(actorToInfo);
147
+ }
148
+ canSignAsOwner() {
149
+ return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
150
+ }
151
+ /**
152
+ * Sign an envelope (spec §11.2) as the active owner, to authenticate
153
+ * a call to a third-party Aithos-aware backend.
154
+ *
155
+ * Same primitive that SDK namespaces (`sdk.data`, `sdk.ethos`,
156
+ * `sdk.mandates`, ...) use internally to sign their own writes to
157
+ * `api.aithos.be`. Exposed here so apps can sign envelopes for their
158
+ * own backends — any service that verifies a `SignedEnvelope` per
159
+ * spec §11.2 (typically using `@aithos/protocol-core/envelope`'s
160
+ * `verifyEnvelope`) accepts the resulting object.
161
+ *
162
+ * The envelope binds the signature to `(iss, aud, method,
163
+ * params_hash, nonce, iat, exp)`, so it cannot be replayed against a
164
+ * different endpoint, method, or payload, and expires after
165
+ * `ttlSeconds` (default 60s, server-side typically caps at 300s).
166
+ *
167
+ * Usage:
168
+ *
169
+ * ```ts
170
+ * const envelope = await sdk.auth.signEnvelope({
171
+ * aud: "https://api.example.com/v1/widgets",
172
+ * method: "myapp.widgets.create",
173
+ * params: { name: "Widget #1" },
174
+ * });
175
+ * await fetch("https://api.example.com/v1/widgets", {
176
+ * method: "POST",
177
+ * headers: { "content-type": "application/json" },
178
+ * body: JSON.stringify({ ...payload, _envelope: envelope }),
179
+ * });
180
+ * ```
181
+ *
182
+ * @throws {AithosSDKError} `auth_not_signed_in` if no owner identity
183
+ * is loaded (call `signIn` / `signUp` / `signInCustodial` first).
184
+ * @throws {AithosSDKError} `auth_invalid_sphere` if `sphere` is not
185
+ * one of `"root" | "public" | "circle" | "self"`.
186
+ */
187
+ async signEnvelope(args) {
188
+ if (!this.#ownerSigners || this.#ownerSigners.destroyed) {
189
+ throw new AithosSDKError("auth_not_signed_in", "signEnvelope: no owner is signed in. Call signIn / signUp / signInCustodial first.");
190
+ }
191
+ const sphere = args.sphere ?? "public";
192
+ if (sphere !== "root" &&
193
+ sphere !== "public" &&
194
+ sphere !== "circle" &&
195
+ sphere !== "self") {
196
+ throw new AithosSDKError("auth_invalid_sphere", `signEnvelope: invalid sphere "${sphere}". Expected one of: root, public, circle, self.`);
197
+ }
198
+ const signer = this.#ownerSigners.signerForSphere(sphere);
199
+ return signOwnerEnvelope({
200
+ iss: this.#ownerSigners.did,
201
+ aud: args.aud,
202
+ method: args.method,
203
+ params: args.params,
204
+ verificationMethod: `${this.#ownerSigners.did}#${sphere}`,
205
+ signer,
206
+ ttlSeconds: args.ttlSeconds,
207
+ });
208
+ }
209
+ canSignAsDelegateFor(did) {
210
+ const a = this.#delegates.findForSubject(did);
211
+ return a !== undefined && !a.destroyed;
212
+ }
213
+ /**
214
+ * Internal accessor used by sibling SDK namespaces (compute, wallet,
215
+ * ethos) when they need to sign on behalf of the owner. Returns null
216
+ * if no owner is loaded.
217
+ *
218
+ * @internal
219
+ */
220
+ _getOwnerSigners() {
221
+ return this.#ownerSigners;
222
+ }
223
+ /**
224
+ * Internal accessor — looks up an active delegate by mandate id.
225
+ * @internal
226
+ */
227
+ _getDelegateActor(mandateId) {
228
+ return this.#delegates.get(mandateId);
229
+ }
230
+ /**
231
+ * Internal accessor — finds the first active delegate whose subject
232
+ * matches `did`. Used by `sdk.ethos.of(did)` when the user holds a
233
+ * mandate for that subject.
234
+ * @internal
235
+ */
236
+ _findDelegateForSubject(did) {
237
+ return this.#delegates.findForSubject(did);
62
238
  }
63
239
  /* ------------------------------------------------------------------------ */
64
- /* Email + password */
240
+ /* Unified email + password — signInAuto (zk / custodial dispatch) */
65
241
  /* ------------------------------------------------------------------------ */
66
242
  /**
67
- * Sign in to an existing Aithos account. Two-round-trip flow under the
68
- * hood :
243
+ * Sign in with email + password, dispatching automatically between
244
+ * the legacy zero-knowledge flow ({@link signIn}) and the custodial
245
+ * flow ({@link signInCustodial}) based on which mode the account
246
+ * was provisioned with.
247
+ *
248
+ * Use this in apps that want a single sign-in form for users who
249
+ * may have been created under either mode (e.g. an app that's
250
+ * migrating from zk to custodial — pre-existing users stay zk
251
+ * forever, new ones go custodial, the SDK figures it out).
69
252
  *
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
253
+ * Strategy: try {@link signInCustodial} first (the modern path).
254
+ * If the backend reports `auth_invalid_credentials` which it
255
+ * uniformly returns for "wrong password", "unknown user", AND
256
+ * "user exists but not in custodial mode" (anti-enum) — fall
257
+ * back to {@link signIn} (zk).
73
258
  *
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.
259
+ * Other failure modes from the custodial path are NOT swallowed:
260
+ * - `auth_email_not_verified` propagate (user is custodial but
261
+ * hasn't clicked the confirmation link yet; the app should
262
+ * surface a "resend mail" CTA rather than retrying as zk,
263
+ * which would also fail and mask the real cause)
264
+ * - server / network errors → propagate (don't double the
265
+ * incident by retrying through the other flow)
79
266
  *
80
- * Persists the session in the configured store before returning.
267
+ * Latency profile:
268
+ * - Pure custodial (success or wrong pwd) : 1 round-trip
269
+ * - Pure zk (any outcome) : 1 custodial probe + 2 zk
270
+ * - Unknown email : same as zk worst case
271
+ *
272
+ * Anti-enum note: timing slightly leaks the mode (custodial path is
273
+ * faster than zk). Acceptable for V1 — rate limiting + strong
274
+ * passwords are the real defenses. A future strict-anti-enum mode
275
+ * could race both paths in parallel and accept the 2x backend load.
81
276
  */
277
+ async signInAuto(input) {
278
+ if (!input.email || !input.password) {
279
+ throw new AithosSDKError("auth_invalid_input", "signInAuto: email and password are required");
280
+ }
281
+ try {
282
+ const r = await this.signInCustodial(input);
283
+ return r.session;
284
+ }
285
+ catch (e) {
286
+ // Only fall back on the specific anti-enum sentinel — preserve
287
+ // other error codes (notably auth_email_not_verified) so the
288
+ // caller can surface the right UI hint.
289
+ if (e instanceof AithosSDKError &&
290
+ e.code === "auth_invalid_credentials") {
291
+ return await this.signIn(input);
292
+ }
293
+ throw e;
294
+ }
295
+ }
296
+ /* ------------------------------------------------------------------------ */
297
+ /* Email + password — signIn */
298
+ /* ------------------------------------------------------------------------ */
82
299
  async signIn(input) {
83
300
  if (!input.email || !input.password) {
84
301
  throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
85
302
  }
86
- const challenge = await loginChallenge({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
303
+ const challenge = await loginChallenge({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
87
304
  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
305
  let verify;
93
306
  try {
94
- verify = await loginVerify({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
307
+ verify = await loginVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
95
308
  }
96
- finally {
309
+ catch (e) {
310
+ // On failure, both keys must be wiped before propagating.
97
311
  zeroize(authKey);
312
+ zeroize(encKey);
313
+ throw e;
314
+ }
315
+ zeroize(authKey);
316
+ // Decrypt the vault blob → plaintext seeds + delegate bundles.
317
+ let plaintext;
318
+ try {
319
+ const blobBytes = decryptBlob(encKey, verify.blobNonce, verify.blob);
320
+ try {
321
+ plaintext = parseBlob(blobBytes);
322
+ }
323
+ finally {
324
+ zeroize(blobBytes);
325
+ }
326
+ }
327
+ catch (e) {
328
+ zeroize(encKey);
329
+ throw new AithosSDKError("auth_blob_decrypt_failed", `Could not decrypt the vault blob: ${e.message}`);
330
+ }
331
+ zeroize(encKey);
332
+ // Sanity check: blob's identity must agree with what verify returned.
333
+ if (plaintext.identity.did !== verify.did) {
334
+ 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 } });
335
+ }
336
+ // Hydrate in-memory state.
337
+ if (this.#ownerSigners)
338
+ this.#ownerSigners.destroy();
339
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
340
+ // Persist to keyStore — owner first, then delegates.
341
+ const ownerStored = {
342
+ version: "0.1.0-hex",
343
+ did: plaintext.identity.did,
344
+ handle: plaintext.identity.handle,
345
+ displayName: plaintext.identity.displayName,
346
+ seedsHex: plaintext.seeds,
347
+ savedAt: new Date().toISOString(),
348
+ };
349
+ await this.#keyStore.saveOwner(ownerStored);
350
+ // Replace any prior delegate set with what the blob carries.
351
+ await this.#keyStore.clearAllDelegates();
352
+ this.#delegates.destroy();
353
+ for (const d of plaintext.delegates) {
354
+ const stored = storedDelegateFromBlob(d);
355
+ try {
356
+ await this.#keyStore.saveDelegate(stored);
357
+ }
358
+ catch {
359
+ // Persistence failure shouldn't block the sign-in. We still load
360
+ // the actor in memory so the session works for the current tab.
361
+ }
362
+ try {
363
+ this.#delegates.add(DelegateActor.fromStored(stored));
364
+ }
365
+ catch {
366
+ // Skip silently — keep going on remaining delegates.
367
+ }
98
368
  }
99
369
  const session = {
100
370
  session: verify.session,
@@ -107,25 +377,12 @@ export class AithosAuth {
107
377
  enc_key_b64: "",
108
378
  is_first_login: false,
109
379
  };
110
- this.store.set(session);
380
+ this.#sessionStore.set(session);
111
381
  return session;
112
382
  }
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
- */
383
+ /* ------------------------------------------------------------------------ */
384
+ /* Email + password signUp */
385
+ /* ------------------------------------------------------------------------ */
129
386
  async signUp(input) {
130
387
  if (!input.email || !input.password) {
131
388
  throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
@@ -134,31 +391,16 @@ export class AithosAuth {
134
391
  throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
135
392
  }
136
393
  const displayName = input.displayName ?? input.handle;
137
- // 1) Identity.
138
394
  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.
395
+ const recoverySerialized = serializeRecoveryFile(identity);
396
+ const recoveryFile = new Blob([recoverySerialized.text], {
397
+ type: "application/json",
398
+ });
399
+ // Derive password-based keys, encrypt the vault blob.
157
400
  const authSalt = randomSalt();
158
401
  const encSalt = randomSalt();
159
402
  const kdf = DEFAULT_KDF;
160
403
  const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
161
- // 4) Build & encrypt the vault blob.
162
404
  const plaintext = buildBlobPlaintext({
163
405
  identity: {
164
406
  did: identity.did,
@@ -176,10 +418,9 @@ export class AithosAuth {
176
418
  const blobBytes = serializeBlob(plaintext);
177
419
  const blobNonce = randomNonce();
178
420
  const blob = encryptBlob(encKey, blobNonce, blobBytes);
179
- // 5) Register.
180
421
  let registerResp;
181
422
  try {
182
- registerResp = await registerAccount({ fetchImpl: this.fetchImpl, authBaseUrl: this.authBaseUrl }, {
423
+ registerResp = await registerAccount({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
183
424
  email: input.email,
184
425
  handle: identity.handle,
185
426
  displayName: identity.displayName,
@@ -197,6 +438,33 @@ export class AithosAuth {
197
438
  zeroize(authKey);
198
439
  zeroize(encKey);
199
440
  }
441
+ // Bootstrap the Ethos on api.aithos.be. Without this, every subsequent
442
+ // write (publish_ethos_edition, etc.) errors out with -32020
443
+ // "subject identity not published". We do this BEFORE hydrating local
444
+ // state so a bootstrap failure leaves the SDK in a clean
445
+ // "not signed in" state — the dev shows an error, the user retries.
446
+ // The auth account on auth.aithos.be DOES exist at this point, but
447
+ // without the local hydrate the user can't act on it. Self-heal on
448
+ // signIn (re-attempt publish_identity if missing) is planned for a
449
+ // follow-up release.
450
+ await this.#publishIdentity(identity);
451
+ // Hydrate in-memory state from the fresh identity.
452
+ if (this.#ownerSigners)
453
+ this.#ownerSigners.destroy();
454
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
455
+ await this.#keyStore.saveOwner({
456
+ version: "0.1.0-hex",
457
+ did: identity.did,
458
+ handle: identity.handle,
459
+ displayName: identity.displayName,
460
+ seedsHex: {
461
+ root: bytesToHex(identity.root.seed),
462
+ public: bytesToHex(identity.public.seed),
463
+ circle: bytesToHex(identity.circle.seed),
464
+ self: bytesToHex(identity.self.seed),
465
+ },
466
+ savedAt: new Date().toISOString(),
467
+ });
200
468
  const session = {
201
469
  session: registerResp.session,
202
470
  exp: registerResp.exp,
@@ -208,24 +476,110 @@ export class AithosAuth {
208
476
  enc_key_b64: "",
209
477
  is_first_login: false,
210
478
  };
211
- this.store.set(session);
212
- return { session, recoveryFile, recoveryFilename };
479
+ this.#sessionStore.set(session);
480
+ return {
481
+ session,
482
+ recoveryFile,
483
+ recoveryFilename: recoverySerialized.filename,
484
+ };
213
485
  }
214
486
  /* ------------------------------------------------------------------------ */
215
- /* Google SSO */
487
+ /* Recovery file */
216
488
  /* ------------------------------------------------------------------------ */
217
489
  /**
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.
490
+ * Sign in by uploading a recovery file. Hydrates the owner signers
491
+ * locally no JWT is obtained on this path because the recovery
492
+ * file alone doesn't authenticate against the auth backend (no
493
+ * password, no Google session). Apps that need compute/wallet
494
+ * access should follow up with an email+password sign-in or with
495
+ * Google SSO.
221
496
  *
222
- * Does not return : navigation tears the JS context down. The `never`
223
- * return type tells callers any code after the call is unreachable.
497
+ * The recovery file is ALWAYS the file produced by `signUp` (or the
498
+ * equivalent one emitted by `protocol-client`'s `runOnboarding`).
499
+ * Both shapes are accepted.
500
+ */
501
+ async signInWithRecovery(input) {
502
+ const text = await readRecoveryFileText(input.file);
503
+ const parsed = parseRecoveryFile(text);
504
+ // Build a StoredOwnerKeys-shape on the spot, then push to keyStore +
505
+ // hydrate signers.
506
+ const stored = {
507
+ version: "0.1.0-hex",
508
+ did: parsed.did,
509
+ handle: parsed.handle,
510
+ displayName: parsed.displayName,
511
+ seedsHex: parsed.seedsHex,
512
+ savedAt: new Date().toISOString(),
513
+ };
514
+ // If a different owner is already loaded, refuse — apps must call
515
+ // signOut() first. Mixing two owners in one auth instance is a
516
+ // nonsense state we don't want to support.
517
+ if (this.#ownerSigners && this.#ownerSigners.did !== parsed.did) {
518
+ throw new AithosSDKError("auth_owner_already_loaded", "another owner is already signed in; call signOut first", { data: { current: this.#ownerSigners.did, incoming: parsed.did } });
519
+ }
520
+ if (this.#ownerSigners)
521
+ this.#ownerSigners.destroy();
522
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
523
+ await this.#keyStore.saveOwner(stored);
524
+ // Recovery flow doesn't yield a JWT — wipe any stale one to keep
525
+ // the two stores in sync.
526
+ this.#sessionStore.clear();
527
+ return {
528
+ did: parsed.did,
529
+ handle: parsed.handle,
530
+ displayName: parsed.displayName,
531
+ };
532
+ }
533
+ /* ------------------------------------------------------------------------ */
534
+ /* Mandate import */
535
+ /* ------------------------------------------------------------------------ */
536
+ /**
537
+ * Import a delegate bundle (`.aithos-delegate.json`). Works in any
538
+ * state: with no owner loaded (delegate-only session), or alongside
539
+ * an existing owner (the user holds mandates for other people's
540
+ * ethoses while also being an owner themselves).
224
541
  */
542
+ async importMandate(input) {
543
+ const text = await readDelegateBundleText(input.bundle);
544
+ const parsed = parseDelegateBundle(text);
545
+ const stored = {
546
+ version: "0.1.0-hex",
547
+ subjectDid: parsed.subjectDid,
548
+ mandateId: parsed.mandateId,
549
+ mandate: parsed.mandate,
550
+ granteeId: parsed.granteeId,
551
+ granteePubkeyMultibase: parsed.granteePubkeyMultibase,
552
+ delegateSeedHex: parsed.delegateSeedHex,
553
+ importedAt: new Date().toISOString(),
554
+ };
555
+ await this.#keyStore.saveDelegate(stored);
556
+ this.#delegates.add(DelegateActor.fromStored(stored));
557
+ return {
558
+ mandateId: stored.mandateId,
559
+ subjectDid: stored.subjectDid,
560
+ granteeId: stored.granteeId,
561
+ scopes: scopesFromMandate(stored.mandate),
562
+ expiresAt: notAfterFromMandate(stored.mandate),
563
+ };
564
+ }
565
+ async removeMandate(mandateId) {
566
+ this.#delegates.remove(mandateId);
567
+ await this.#keyStore.removeDelegate(mandateId);
568
+ }
569
+ /* ------------------------------------------------------------------------ */
570
+ /* Google SSO */
571
+ /* ------------------------------------------------------------------------ */
225
572
  signInWithGoogle(opts) {
226
- if (!this.win) {
573
+ if (!this.#win) {
227
574
  throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
228
575
  }
576
+ // appId + returnTo must come together — the backend rejects
577
+ // half-presence at /sso/google/start. Surface that as a clean SDK
578
+ // error before the network round-trip rather than letting the user
579
+ // bounce to Google and back for nothing.
580
+ if ((opts?.appId && !opts?.returnTo) || (!opts?.appId && opts?.returnTo)) {
581
+ throw new AithosSDKError("auth_sso_app_redirect_pair_required", "appId and returnTo must be provided together (or both omitted to use the legacy redirect)");
582
+ }
229
583
  const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
230
584
  if (opts?.appState) {
231
585
  if (opts.appState.length > 1024) {
@@ -233,50 +587,145 @@ export class AithosAuth {
233
587
  }
234
588
  url.searchParams.set("app_state", opts.appState);
235
589
  }
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.
590
+ if (opts?.appId && opts?.returnTo) {
591
+ url.searchParams.set("app_id", opts.appId);
592
+ url.searchParams.set("redirect_uri", opts.returnTo);
593
+ }
594
+ this.#win.location.assign(url.toString());
239
595
  throw new AithosSDKError("auth_redirecting", "redirecting to google");
240
596
  }
241
597
  /**
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=…`.
598
+ * Public entrypoint dedupes concurrent calls (React StrictMode).
599
+ * The first call kicks off the actual exchange; subsequent calls
600
+ * before that promise resolves return the SAME promise so they all
601
+ * receive the same `AithosSession | null`. Otherwise StrictMode's
602
+ * second invocation would race against the URL clean done by the
603
+ * first call and resolve to `null`, robbing the AuthCallback page
604
+ * of the session it actually obtained.
251
605
  */
252
606
  async handleCallback() {
253
- if (!this.win)
607
+ if (!this.#win)
608
+ return null;
609
+ if (this.#handleCallbackPromise)
610
+ return this.#handleCallbackPromise;
611
+ const p = this.#doHandleCallback();
612
+ this.#handleCallbackPromise = p;
613
+ // Clear the cache once the promise settles so a subsequent
614
+ // signInWithGoogle round-trip on the same AithosAuth instance can
615
+ // process its own callback. We use `then(cleanup, cleanup)`
616
+ // rather than `finally(...)` because `finally` re-throws — without
617
+ // a downstream `.catch` the resulting promise becomes an
618
+ // unhandledrejection when `p` itself rejects (the caller already
619
+ // surfaces that rejection via the returned `p`). `then(success,
620
+ // error)` converts a rejection into a clean resolution on this
621
+ // side-effect chain so node:test doesn't flag the orphan as a
622
+ // failure.
623
+ const clear = () => {
624
+ if (this.#handleCallbackPromise === p) {
625
+ this.#handleCallbackPromise = null;
626
+ }
627
+ };
628
+ p.then(clear, clear);
629
+ return p;
630
+ }
631
+ async #doHandleCallback() {
632
+ if (!this.#win)
254
633
  return null;
255
- const here = new URL(this.win.location.href);
634
+ const here = new URL(this.#win.location.href);
256
635
  const error = here.searchParams.get("aithos_error");
257
636
  const code = here.searchParams.get("aithos_code");
258
637
  const appState = here.searchParams.get("app_state");
259
638
  if (error) {
260
- cleanCallbackParams(this.win, here);
639
+ cleanCallbackParams(this.#win, here);
261
640
  throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
262
641
  }
263
642
  if (!code)
264
643
  return null;
644
+ // Strip the aithos_code from the URL SYNCHRONOUSLY, before any
645
+ // await. React StrictMode (dev) invokes effects twice — without
646
+ // this, the first call awaits exchange (microtask, code still in
647
+ // the URL), the second invocation reads the same code and POSTs
648
+ // again, hitting `auth_code_consumed: aithos_code expired or
649
+ // already used`. Cleaning before the await makes the second
650
+ // invocation read a clean URL and return null without a network
651
+ // round-trip.
652
+ cleanCallbackParams(this.#win, here);
265
653
  const session = await this.exchange(code);
266
- cleanCallbackParams(this.win, here);
267
- this.store.set(session);
654
+ // Hydrate signers if the SSO response carried an enc_key (Google flow
655
+ // gives us the AES-GCM key in plaintext, encrypted only in transit
656
+ // by TLS — see auth.aithos.be design doc).
657
+ if (session.enc_key_b64 &&
658
+ session.blob_b64 &&
659
+ session.blob_nonce_b64 &&
660
+ session.blob_version > 0) {
661
+ try {
662
+ const encKey = b64ToBytes(session.enc_key_b64);
663
+ const blob = b64ToBytes(session.blob_b64);
664
+ const nonce = b64ToBytes(session.blob_nonce_b64);
665
+ try {
666
+ const blobBytes = decryptBlob(encKey, nonce, blob);
667
+ try {
668
+ const plaintext = parseBlob(blobBytes);
669
+ // Earlier versions of the SDK gated hydration on
670
+ // `plaintext.identity.did === session.did` as a defense
671
+ // against tampered sessionStores. The check breaks SSO
672
+ // flows: the auth backend assigns a placeholder random
673
+ // DID at user-record creation time (no client keypair on
674
+ // hand), but the BLOB is built around a real
675
+ // BrowserIdentity whose DID is derived from its root
676
+ // pubkey. The two intentionally differ — the blob is the
677
+ // truth source for everything downstream (signing, DID
678
+ // resolution against api.aithos.be), the session.did is
679
+ // just auth-side bookkeeping. Drop the check and trust
680
+ // the blob.
681
+ if (this.#ownerSigners)
682
+ this.#ownerSigners.destroy();
683
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
684
+ await this.#keyStore.saveOwner({
685
+ version: "0.1.0-hex",
686
+ did: plaintext.identity.did,
687
+ handle: plaintext.identity.handle,
688
+ displayName: plaintext.identity.displayName,
689
+ seedsHex: plaintext.seeds,
690
+ savedAt: new Date().toISOString(),
691
+ });
692
+ await this.#keyStore.clearAllDelegates();
693
+ this.#delegates.destroy();
694
+ for (const d of plaintext.delegates) {
695
+ const stored = storedDelegateFromBlob(d);
696
+ try {
697
+ await this.#keyStore.saveDelegate(stored);
698
+ }
699
+ catch {
700
+ /* keep going */
701
+ }
702
+ try {
703
+ this.#delegates.add(DelegateActor.fromStored(stored));
704
+ }
705
+ catch {
706
+ /* keep going */
707
+ }
708
+ }
709
+ }
710
+ finally {
711
+ zeroize(blobBytes);
712
+ }
713
+ }
714
+ finally {
715
+ zeroize(encKey);
716
+ }
717
+ }
718
+ catch {
719
+ // Decryption failure is non-fatal here: the JWT still works for
720
+ // compute/wallet, the user will surface the issue if they try to
721
+ // edit their ethos.
722
+ }
723
+ }
724
+ this.#sessionStore.set(session);
268
725
  return session;
269
726
  }
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
727
  async exchange(aithosCode) {
279
- const res = await this.fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
728
+ const res = await this.#fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
280
729
  method: "POST",
281
730
  headers: { "content-type": "application/json" },
282
731
  body: JSON.stringify({ aithos_code: aithosCode }),
@@ -303,23 +752,582 @@ export class AithosAuth {
303
752
  return (await res.json());
304
753
  }
305
754
  /* ------------------------------------------------------------------------ */
306
- /* Session lifecycle */
755
+ /* Complete SSO first login */
307
756
  /* ------------------------------------------------------------------------ */
308
757
  /**
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).
758
+ * Finish the first-time Google SSO bootstrap. After
759
+ * `signInWithGoogle()` + `handleCallback()`, a brand-new SSO user has
760
+ * a session JWT and an `enc_key` released by the auth backend, but
761
+ * NO Aithos identity yet (no Ed25519 seeds, no published did.json,
762
+ * no blob in the auth vault). This method closes that gap:
763
+ *
764
+ * 1. Generates a fresh {@link BrowserIdentity} client-side (4
765
+ * Ed25519 keypairs, derived DID).
766
+ * 2. Calls `aithos.publish_identity` on api.aithos.be so reads
767
+ * and writes against the Aithos primitives have an ethos to
768
+ * anchor to.
769
+ * 3. AES-GCM-encrypts the seeds with the session's `enc_key`,
770
+ * PUTs the result to `/auth/blob`. From now on, every Google
771
+ * sign-in for this user will receive the encrypted blob and
772
+ * hydrate locally.
773
+ * 4. Hydrates `ownerSigners` + `keyStore` so `canSignAsOwner()`
774
+ * flips to true.
775
+ * 5. Returns a recovery-file Blob — the only material that can
776
+ * restore this ethos if Google access is lost.
777
+ *
778
+ * Preconditions:
779
+ * - `getCurrentSession()` returns a non-null session (caller went
780
+ * through `handleCallback()` already).
781
+ * - The session's `blob_version` is 0 (i.e. no blob yet).
782
+ * - The session's `enc_key_b64` is non-empty.
783
+ *
784
+ * Throws `AithosSDKError("auth_sso_no_pending_first_login", …)` if
785
+ * preconditions don't hold (e.g. blob_version > 0 means the user has
786
+ * already completed setup; nothing to do).
312
787
  */
313
- getCurrentSession() {
314
- return this.store.get();
788
+ async completeSsoFirstLogin(input) {
789
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
790
+ throw new AithosSDKError("auth_invalid_handle", "handle must be 1–63 alphanumeric chars + _ -");
791
+ }
792
+ const displayName = input.displayName ?? input.handle;
793
+ const session = this.#sessionStore.get();
794
+ if (!session) {
795
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "no active session — sign in via Google first");
796
+ }
797
+ if (!session.enc_key_b64) {
798
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "session does not carry an enc_key (not an SSO-flow session?)");
799
+ }
800
+ if (session.blob_version > 0) {
801
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "this session already has a published blob — nothing to bootstrap");
802
+ }
803
+ // 1. Fresh identity client-side. The DID derived here is the
804
+ // truth source from now on — the placeholder DID stamped in
805
+ // the user record by the auth Lambda is left as-is (auth-side
806
+ // bookkeeping; never used for signing).
807
+ const identity = createBrowserIdentity(input.handle, displayName);
808
+ const recoverySerialized = serializeRecoveryFile(identity);
809
+ const recoveryFile = new Blob([recoverySerialized.text], {
810
+ type: "application/json",
811
+ });
812
+ // 2. publish_identity on api.aithos.be — reuses the alpha.6
813
+ // helper. Must succeed before we persist anything locally:
814
+ // a half-completed bootstrap (blob uploaded but identity not
815
+ // published) would leave the user with seeds they can't use.
816
+ await this.#publishIdentity(identity);
817
+ // 3. Encrypt the seeds with the SSO-released enc_key and PUT
818
+ // /auth/blob. The auth Lambda accepts the new blob_version=1
819
+ // and stores the bytes verbatim.
820
+ const encKey = b64ToBytes(session.enc_key_b64);
821
+ let blob;
822
+ let blobNonce;
823
+ let plaintext;
824
+ try {
825
+ plaintext = buildBlobPlaintext({
826
+ identity: {
827
+ did: identity.did,
828
+ handle: identity.handle,
829
+ displayName: identity.displayName,
830
+ },
831
+ seeds: {
832
+ root: identity.root.seed,
833
+ public: identity.public.seed,
834
+ circle: identity.circle.seed,
835
+ self: identity.self.seed,
836
+ },
837
+ delegates: [],
838
+ });
839
+ const blobBytes = serializeBlob(plaintext);
840
+ blobNonce = randomNonce();
841
+ blob = encryptBlob(encKey, blobNonce, blobBytes);
842
+ }
843
+ finally {
844
+ zeroize(encKey);
845
+ }
846
+ const newBlobVersion = 1;
847
+ try {
848
+ await putBlob({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
849
+ jwt: session.session,
850
+ blob,
851
+ blobNonce,
852
+ blobVersion: newBlobVersion,
853
+ });
854
+ }
855
+ catch (e) {
856
+ throw new AithosSDKError("auth_sso_blob_upload_failed", `couldn't store the encrypted vault on auth.aithos.be: ${e.message ?? "unknown"}`);
857
+ }
858
+ // 4. Hydrate in-memory state from the fresh identity.
859
+ if (this.#ownerSigners)
860
+ this.#ownerSigners.destroy();
861
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
862
+ await this.#keyStore.saveOwner({
863
+ version: "0.1.0-hex",
864
+ did: identity.did,
865
+ handle: identity.handle,
866
+ displayName: identity.displayName,
867
+ seedsHex: {
868
+ root: bytesToHex(identity.root.seed),
869
+ public: bytesToHex(identity.public.seed),
870
+ circle: bytesToHex(identity.circle.seed),
871
+ self: bytesToHex(identity.self.seed),
872
+ },
873
+ savedAt: new Date().toISOString(),
874
+ });
875
+ // 5. Persist the updated session — same JWT, but now carrying
876
+ // the freshly-built blob bytes so a subsequent `resume()` can
877
+ // rehydrate without another /auth/blob round-trip.
878
+ const refreshed = {
879
+ ...session,
880
+ blob_b64: bytesToB64Public(blob),
881
+ blob_nonce_b64: bytesToB64Public(blobNonce),
882
+ blob_version: newBlobVersion,
883
+ };
884
+ this.#sessionStore.set(refreshed);
885
+ return {
886
+ session: refreshed,
887
+ recoveryFile,
888
+ recoveryFilename: recoverySerialized.filename,
889
+ };
890
+ }
891
+ /* ------------------------------------------------------------------------ */
892
+ /* Custodial flow (V2 — see PLATFORM-AUTH-PASSWORD-V2-PLAN.md) */
893
+ /* ------------------------------------------------------------------------ */
894
+ /**
895
+ * Provision a custodial-mode account on behalf of a registered app.
896
+ *
897
+ * Two integration patterns:
898
+ * - **Frontend-only** apps : set `publicKey` on the constructor
899
+ * (or on this call). Safe to ship in browser bundles — the
900
+ * backend gates each request by Origin + IP rate limit.
901
+ * - **Backend-fronted** apps : the backend passes `apiKey` (secret
902
+ * Bearer); the browser never sees the credential.
903
+ *
904
+ * The created account is in a *pending* state — sign-in stays blocked
905
+ * until the user clicks the confirmation link sent to their inbox.
906
+ * Call {@link verifyEmail} from the page mounted on
907
+ * `app.verify_base_url` to consume the token; afterwards
908
+ * {@link signInCustodial} works.
909
+ *
910
+ * Errors map to `AithosSDKError` codes:
911
+ * - `auth_missing_api_key` (no credential provided)
912
+ * - `auth_invalid_api_key` (Bearer rejected by backend)
913
+ * - `auth_invalid_public_key` (public key rejected by backend)
914
+ * - `auth_api_key_revoked` / `auth_public_key_revoked`
915
+ * - `auth_origin_not_allowed` (public key + Origin not in allowlist)
916
+ * - `auth_password_too_weak` (400 — server-side strength check)
917
+ * - `auth_email_exists` (409 — email already registered)
918
+ * - `auth_email_invalid` (400 — bad email format)
919
+ * - `auth_mail_send_failed` (502 — DDB row exists but SES failed)
920
+ * - `auth_custodial_signup_failed` (catch-all)
921
+ */
922
+ async signUpCustodial(input) {
923
+ if (!input.email) {
924
+ throw new AithosSDKError("auth_invalid_input", "signUpCustodial: email is required");
925
+ }
926
+ if (!input.password) {
927
+ throw new AithosSDKError("auth_invalid_input", "signUpCustodial: password is required");
928
+ }
929
+ const apiKey = input.apiKey;
930
+ const publicKey = input.publicKey ?? this.#publicKey;
931
+ if (!apiKey && !publicKey) {
932
+ throw new AithosSDKError("auth_missing_api_key", "signUpCustodial: pass apiKey, or publicKey, or set publicKey on the AithosAuth constructor");
933
+ }
934
+ return custodialSignUp({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
935
+ email: input.email,
936
+ password: input.password,
937
+ ...(apiKey ? { apiKey } : {}),
938
+ ...(apiKey ? {} : publicKey ? { publicKey } : {}),
939
+ ...(input.displayName ? { displayName: input.displayName } : {}),
940
+ ...(input.handleHint ? { handleHint: input.handleHint } : {}),
941
+ });
942
+ }
943
+ /**
944
+ * Magic-link auto-signin: consume the verification token from the
945
+ * confirmation link, KMS-unwrap the seed bundle server-side, and
946
+ * hydrate the local session + keystore in one round-trip.
947
+ *
948
+ * Outcome depends on the link's state:
949
+ * - First click on a fresh link → returns
950
+ * `{ status: "signed_in", session, … }`. The session store is
951
+ * populated, the owner signers are loaded — the user is signed
952
+ * in. The caller should navigate them to a logged-in route.
953
+ * - Click of an already-consumed link → returns
954
+ * `{ status: "already_verified", email }`. No session is minted;
955
+ * the user must sign in via {@link signInCustodial}.
956
+ *
957
+ * Mount this on the page declared as `verify_base_url` in your app's
958
+ * registration. Read `email` + `token` from `window.location.search`,
959
+ * call this, branch on `result.status`.
960
+ *
961
+ * Throws `auth_token_invalid_or_expired` if the token is wrong or
962
+ * past its 1h TTL — surface a "request a fresh link" CTA in that case.
963
+ */
964
+ async verifyEmail(input) {
965
+ if (!input.email || !input.token) {
966
+ throw new AithosSDKError("auth_invalid_input", "verifyEmail: email and token are required");
967
+ }
968
+ const resp = await custodialVerifyEmail({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
969
+ if (resp.status === "already_verified") {
970
+ return { status: "already_verified", email: resp.email };
971
+ }
972
+ // Magic-link sign-in path. Mirror `signInCustodial` to materialise
973
+ // the 4 sphere seeds in the keystore.
974
+ if (resp.seed.byteLength !== 128) {
975
+ zeroize(resp.seed);
976
+ zeroize(resp.encKey);
977
+ throw new AithosSDKError("auth_custodial_seed_format", `verifyEmail: expected 128-byte seed bundle, got ${resp.seed.byteLength}`);
978
+ }
979
+ const seedRoot = resp.seed.slice(0, 32);
980
+ const seedPublic = resp.seed.slice(32, 64);
981
+ const seedCircle = resp.seed.slice(64, 96);
982
+ const seedSelf = resp.seed.slice(96, 128);
983
+ const stored = {
984
+ version: "0.1.0-hex",
985
+ did: resp.did,
986
+ handle: resp.handle,
987
+ displayName: resp.displayName,
988
+ seedsHex: {
989
+ root: bytesToHex(seedRoot),
990
+ public: bytesToHex(seedPublic),
991
+ circle: bytesToHex(seedCircle),
992
+ self: bytesToHex(seedSelf),
993
+ },
994
+ savedAt: new Date().toISOString(),
995
+ };
996
+ zeroize(resp.seed);
997
+ zeroize(seedRoot);
998
+ zeroize(seedPublic);
999
+ zeroize(seedCircle);
1000
+ zeroize(seedSelf);
1001
+ zeroize(resp.encKey);
1002
+ // Bootstrap the Ethos on api.aithos.be (cf. notes in signInCustodial).
1003
+ // The magic-link flow is the FIRST time the user actually has
1004
+ // hydrated keys client-side, so this is typically when the identity
1005
+ // gets published. Idempotent — safe to call again on subsequent
1006
+ // clicks (which won't get here normally, but defensively).
1007
+ const identity = browserIdentityFromStored({
1008
+ handle: stored.handle,
1009
+ displayName: stored.displayName,
1010
+ did: stored.did,
1011
+ seeds: stored.seedsHex,
1012
+ });
1013
+ await this.#publishIdentity(identity);
1014
+ if (this.#ownerSigners)
1015
+ this.#ownerSigners.destroy();
1016
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
1017
+ await this.#keyStore.saveOwner(stored);
1018
+ const session = {
1019
+ session: resp.session,
1020
+ exp: resp.exp,
1021
+ did: resp.did,
1022
+ handle: resp.handle,
1023
+ blob_b64: bytesToB64Public(resp.blob),
1024
+ blob_nonce_b64: bytesToB64Public(resp.blobNonce),
1025
+ blob_version: resp.blobVersion,
1026
+ enc_key_b64: "",
1027
+ is_first_login: false,
1028
+ };
1029
+ this.#sessionStore.set(session);
1030
+ return { status: "signed_in", session, passwordMustChange: false };
1031
+ }
1032
+ /**
1033
+ * Re-send the verification mail for a pending account. Use when the
1034
+ * user reports never having received the welcome mail, or when their
1035
+ * verification token expired (24h TTL).
1036
+ *
1037
+ * The backend is anti-enumeration (always 200) and rate-limited
1038
+ * 1/h/account, so it's safe to call even when the state of `email`
1039
+ * is unknown. Accepts the same credential families as
1040
+ * {@link signUpCustodial}; falls back to the constructor's
1041
+ * `publicKey` when neither override is set.
1042
+ */
1043
+ async resendVerificationEmail(input) {
1044
+ if (!input.email) {
1045
+ throw new AithosSDKError("auth_invalid_input", "resendVerificationEmail: email is required");
1046
+ }
1047
+ const apiKey = input.apiKey;
1048
+ const publicKey = input.publicKey ?? this.#publicKey;
1049
+ if (!apiKey && !publicKey) {
1050
+ throw new AithosSDKError("auth_missing_api_key", "resendVerificationEmail: pass apiKey, publicKey, or set publicKey on the AithosAuth constructor");
1051
+ }
1052
+ await custodialResendVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
1053
+ email: input.email,
1054
+ ...(apiKey ? { apiKey } : {}),
1055
+ ...(apiKey ? {} : publicKey ? { publicKey } : {}),
1056
+ });
1057
+ }
1058
+ /**
1059
+ * Authenticate a custodial-mode user with email + password. Single
1060
+ * round-trip: returns a fresh JWT session AND hydrates the local
1061
+ * KeyStore with the user's 4 Ed25519 seeds (KMS-unwrapped server-side
1062
+ * after Argon2id verify).
1063
+ *
1064
+ * After this returns, the SDK is ready to publish ethos editions,
1065
+ * invoke compute, mint mandates, etc. — exactly as if the user had
1066
+ * signed in via {@link signIn} (zk) or {@link handleCallback} (SSO).
1067
+ *
1068
+ * Errors map to `AithosSDKError` codes:
1069
+ * - `auth_invalid_input` (your code passed empty fields)
1070
+ * - `auth_invalid_credentials` (401 — wrong email / wrong password)
1071
+ * - `auth_wrong_auth_mode` (403 — user exists in another flow)
1072
+ */
1073
+ async signInCustodial(input) {
1074
+ if (!input.email || !input.password) {
1075
+ throw new AithosSDKError("auth_invalid_input", "signInCustodial: email and password are required");
1076
+ }
1077
+ const resp = await custodialSignIn({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
1078
+ // Split the 128-byte seed bundle into the four sphere seeds. The
1079
+ // backend lays them out in the canonical order
1080
+ // [root || public || circle || self] (cf. seed-wrapper.ts).
1081
+ if (resp.seed.byteLength !== 128) {
1082
+ // Legacy 32-byte rows shouldn't happen in production (we wiped the
1083
+ // single test row before redeploying with the 4-seed bundle), but
1084
+ // we surface a clear error rather than silently corrupting the
1085
+ // identity.
1086
+ zeroize(resp.seed);
1087
+ zeroize(resp.encKey);
1088
+ throw new AithosSDKError("auth_custodial_seed_format", `signInCustodial: expected 128-byte seed bundle, got ${resp.seed.byteLength}`);
1089
+ }
1090
+ const seedRoot = resp.seed.slice(0, 32);
1091
+ const seedPublic = resp.seed.slice(32, 64);
1092
+ const seedCircle = resp.seed.slice(64, 96);
1093
+ const seedSelf = resp.seed.slice(96, 128);
1094
+ // Stored shape uses hex strings; round-trip through bytesToHex
1095
+ // so the keyStore record is identical to what signUp(zk) writes.
1096
+ const stored = {
1097
+ version: "0.1.0-hex",
1098
+ did: resp.did,
1099
+ handle: resp.handle,
1100
+ displayName: resp.displayName,
1101
+ seedsHex: {
1102
+ root: bytesToHex(seedRoot),
1103
+ public: bytesToHex(seedPublic),
1104
+ circle: bytesToHex(seedCircle),
1105
+ self: bytesToHex(seedSelf),
1106
+ },
1107
+ savedAt: new Date().toISOString(),
1108
+ };
1109
+ // Zeroize the raw bundle + the split copies now that they've been
1110
+ // serialised into the keyStore record (hex strings live in the
1111
+ // record; the original bytes can go).
1112
+ zeroize(resp.seed);
1113
+ zeroize(seedRoot);
1114
+ zeroize(seedPublic);
1115
+ zeroize(seedCircle);
1116
+ zeroize(seedSelf);
1117
+ // The enc_key is informational here — the custodial blob is empty
1118
+ // at first login. We still don't keep it in memory.
1119
+ zeroize(resp.encKey);
1120
+ // Bootstrap the Ethos on api.aithos.be — same as signUp(zk). Without
1121
+ // this, the DID returned by signInCustodial isn't resolvable on the
1122
+ // platform (feed / profile lookups return "not found: did …"). The
1123
+ // call is idempotent server-side: a published identity replays as a
1124
+ // no-op. We do it here (rather than only on a "first login" flag)
1125
+ // because the auth Lambda doesn't know whether the api.aithos.be
1126
+ // side has been populated — the SDK is the single source of truth
1127
+ // for "the user's Ethos is bootstrapped".
1128
+ //
1129
+ // Failure aborts the sign-in: the user can retry (same behaviour as
1130
+ // signUp(zk)), and the local keystore is NOT populated half-way.
1131
+ const identity = browserIdentityFromStored({
1132
+ handle: stored.handle,
1133
+ displayName: stored.displayName,
1134
+ did: stored.did,
1135
+ seeds: stored.seedsHex,
1136
+ });
1137
+ await this.#publishIdentity(identity);
1138
+ // Hydrate in-memory owner signers from the freshly-stored material.
1139
+ if (this.#ownerSigners)
1140
+ this.#ownerSigners.destroy();
1141
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
1142
+ await this.#keyStore.saveOwner(stored);
1143
+ const session = {
1144
+ session: resp.session,
1145
+ exp: resp.exp,
1146
+ did: resp.did,
1147
+ handle: resp.handle,
1148
+ blob_b64: bytesToB64Public(resp.blob),
1149
+ blob_nonce_b64: bytesToB64Public(resp.blobNonce),
1150
+ blob_version: resp.blobVersion,
1151
+ enc_key_b64: "",
1152
+ is_first_login: resp.passwordMustChange,
1153
+ };
1154
+ this.#sessionStore.set(session);
1155
+ return { session, passwordMustChange: resp.passwordMustChange };
1156
+ }
1157
+ /**
1158
+ * Trigger a password-reset email to the given address. Backend ALWAYS
1159
+ * resolves silently (no enumeration) — caller cannot tell whether the
1160
+ * email is registered or not. The mail itself, if sent, contains a
1161
+ * magic-link URL of shape `<resetBaseUrl>?token=<raw>&email=<email>`.
1162
+ *
1163
+ * Per-email rate limits apply server-side (5 mails/day, 5 min cooldown
1164
+ * between consecutive requests). Calls during cooldown silently no-op
1165
+ * the mail send while still returning success here.
1166
+ */
1167
+ async requestPasswordReset(input) {
1168
+ if (!input.email) {
1169
+ throw new AithosSDKError("auth_invalid_input", "requestPasswordReset: email is required");
1170
+ }
1171
+ await custodialResetRequest({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
315
1172
  }
316
1173
  /**
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.
1174
+ * Finalise a password reset using the magic-link token sent to the
1175
+ * user's inbox by {@link requestPasswordReset}.
1176
+ *
1177
+ * Typical use site: the page mounted on the reset URL declared in
1178
+ * `aithos-auth-apps.reset_base_url`. The page reads `email` and
1179
+ * `token` from `window.location.search`, prompts the user for a new
1180
+ * password, then calls this method.
1181
+ *
1182
+ * On success, the returned {@link AithosSession} is persisted to the
1183
+ * session store but the local keystore is NOT hydrated — the backend
1184
+ * does not return the seed bundle on this endpoint. To get a fully
1185
+ * usable session (one that can sign envelopes), follow up with
1186
+ * {@link signInCustodial} using the email + new password. The two
1187
+ * round-trips can be hidden inside a single UI action: reset → auto
1188
+ * sign-in → redirect to dashboard.
1189
+ *
1190
+ * Errors map to `AithosSDKError` codes:
1191
+ * - `auth_invalid_input` (your code passed empty fields)
1192
+ * - `auth_reset_token_invalid` (400 — token forged / wrong email)
1193
+ * - `auth_reset_token_expired` (410 — token TTL elapsed)
1194
+ * - `auth_reset_token_consumed` (409 — already used)
1195
+ * - `auth_password_too_short` (400 — < 10 chars)
1196
+ * - `auth_custodial_reset_failed` (catch-all)
320
1197
  */
1198
+ async applyPasswordReset(input) {
1199
+ if (!input.email || !input.token || !input.newPassword) {
1200
+ throw new AithosSDKError("auth_invalid_input", "applyPasswordReset: email, token and newPassword are required");
1201
+ }
1202
+ const resp = await custodialResetFinalize({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
1203
+ // The reset endpoint mints a JWT but doesn't ship the seed bundle —
1204
+ // the caller still has to signInCustodial() to materialise the keys
1205
+ // locally. We persist the session anyway so any code that reads
1206
+ // `getCurrentSession()` between the reset and the follow-up sign-in
1207
+ // sees the new JWT (e.g. an analytics hook).
1208
+ const session = {
1209
+ session: resp.session,
1210
+ exp: resp.exp,
1211
+ did: resp.did,
1212
+ handle: resp.handle,
1213
+ // No blob / enc_key on this path — the reset endpoint doesn't
1214
+ // re-issue the vault. Leave the blob slots empty; the follow-up
1215
+ // signInCustodial() will populate them.
1216
+ blob_b64: "",
1217
+ blob_nonce_b64: "",
1218
+ blob_version: 0,
1219
+ enc_key_b64: "",
1220
+ is_first_login: false,
1221
+ };
1222
+ this.#sessionStore.set(session);
1223
+ return { session };
1224
+ }
1225
+ /* ------------------------------------------------------------------------ */
1226
+ /* Sign-out */
1227
+ /* ------------------------------------------------------------------------ */
321
1228
  async signOut() {
322
- this.store.clear();
1229
+ if (this.#ownerSigners)
1230
+ this.#ownerSigners.destroy();
1231
+ this.#ownerSigners = null;
1232
+ this.#delegates.destroy();
1233
+ this.#sessionStore.clear();
1234
+ await this.#keyStore.clearOwner().catch(() => { });
1235
+ await this.#keyStore.clearAllDelegates().catch(() => { });
1236
+ }
1237
+ /* ------------------------------------------------------------------------ */
1238
+ /* Internal — Ethos bootstrap */
1239
+ /* ------------------------------------------------------------------------ */
1240
+ /**
1241
+ * Provision the user's Ethos on `api.aithos.be` by signing and POSTing an
1242
+ * `aithos.publish_identity` envelope. Required after a fresh sign-up so
1243
+ * subsequent edition publishes (`me.publish()`) don't fail with
1244
+ * `-32020 subject identity not published`.
1245
+ *
1246
+ * Retries twice with exponential backoff on transient errors (network or
1247
+ * 5xx). Throws {@link AithosSDKError} with code `ethos_bootstrap_failed`
1248
+ * on definitive failure — the caller is expected to abort sign-up.
1249
+ *
1250
+ * @internal
1251
+ */
1252
+ async #publishIdentity(identity) {
1253
+ const url = `${this.apiBaseUrl}/mcp/primitives/write`;
1254
+ const signedDoc = signedDidDocument(identity);
1255
+ const params = {
1256
+ did_document: signedDoc,
1257
+ handle: identity.handle,
1258
+ display_name: identity.displayName,
1259
+ };
1260
+ const envelope = buildSignedEnvelope({
1261
+ iss: identity.did,
1262
+ aud: url,
1263
+ method: "aithos.publish_identity",
1264
+ verificationMethod: `${identity.did}#root`,
1265
+ params,
1266
+ signer: identity.root,
1267
+ });
1268
+ const body = JSON.stringify({
1269
+ jsonrpc: "2.0",
1270
+ id: "publish_identity",
1271
+ method: "aithos.publish_identity",
1272
+ params: { ...params, _envelope: envelope },
1273
+ });
1274
+ // Two retries with backoff (300ms, 1500ms). Idempotent on the server
1275
+ // side — replaying the same publish_identity for an existing DID is a
1276
+ // no-op, so retries are safe even if the first attempt actually
1277
+ // succeeded but the response was lost.
1278
+ const delays = [0, 300, 1500];
1279
+ let lastError;
1280
+ for (const delay of delays) {
1281
+ if (delay > 0)
1282
+ await sleep(delay);
1283
+ try {
1284
+ const res = await this.#fetchImpl(url, {
1285
+ method: "POST",
1286
+ headers: { "content-type": "application/json" },
1287
+ body,
1288
+ });
1289
+ // Transport errors (5xx, no body) — retry. JSON-RPC errors come
1290
+ // back with HTTP 200 and an `error` field.
1291
+ if (!res.ok && res.status >= 500) {
1292
+ lastError = new Error(`HTTP ${res.status}`);
1293
+ continue;
1294
+ }
1295
+ const json = (await res.json());
1296
+ if (json.error) {
1297
+ // Backward-compat shim for backends without the semantic-equality
1298
+ // fix on publish-identity (alpha.33+ regression): the server may
1299
+ // reject a republish with -32022 because the client regenerates
1300
+ // `aithos.created_at` (and `proof.created`) on every
1301
+ // `signedDidDocument()` call, breaking the strict byte-equal
1302
+ // idempotence the server enforces. For an honest signer (same
1303
+ // root key, same DID) the only way to hit this code path is the
1304
+ // timestamp-drift case — which is semantically a no-op. Treat as
1305
+ // success.
1306
+ //
1307
+ // Server-side fix (publish-identity.ts switched to semantic
1308
+ // equality on cryptographic fields only) makes this branch dead
1309
+ // code on upgraded backends. Kept here as defense-in-depth for
1310
+ // SDK consumers pointing at older deployments.
1311
+ if (json.error.code === -32022 &&
1312
+ /different did\.json already published/i.test(json.error.message)) {
1313
+ return; // already published with same crypto material — no-op
1314
+ }
1315
+ // JSON-RPC error: don't retry — these are deterministic
1316
+ // (validation, permission, identity-already-tombstoned, …).
1317
+ throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
1318
+ status: res.status,
1319
+ data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
1320
+ });
1321
+ }
1322
+ return; // success
1323
+ }
1324
+ catch (e) {
1325
+ if (e instanceof AithosSDKError)
1326
+ throw e;
1327
+ lastError = e;
1328
+ }
1329
+ }
1330
+ throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity unreachable after ${delays.length} attempts: ${lastError?.message ?? "unknown"}`);
323
1331
  }
324
1332
  }
325
1333
  /* -------------------------------------------------------------------------- */
@@ -328,16 +1336,15 @@ export class AithosAuth {
328
1336
  function trimSlash(url) {
329
1337
  return url.endsWith("/") ? url.slice(0, -1) : url;
330
1338
  }
1339
+ function sleep(ms) {
1340
+ return new Promise((resolve) => setTimeout(resolve, ms));
1341
+ }
331
1342
  function cleanCallbackParams(win, url) {
332
1343
  url.searchParams.delete("aithos_code");
333
1344
  url.searchParams.delete("aithos_error");
334
1345
  url.searchParams.delete("app_state");
335
1346
  win.history.replaceState(null, "", url.toString());
336
1347
  }
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
1348
  function bytesToB64Public(bytes) {
342
1349
  if (bytes.length === 0)
343
1350
  return "";
@@ -346,10 +1353,58 @@ function bytesToB64Public(bytes) {
346
1353
  bin += String.fromCharCode(bytes[i]);
347
1354
  return btoa(bin).replace(/=+$/, "");
348
1355
  }
1356
+ function b64ToBytes(b64) {
1357
+ if (!b64)
1358
+ return new Uint8Array(0);
1359
+ // standard b64 — pad to multiple of 4 if needed
1360
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
1361
+ const bin = atob(b64 + pad);
1362
+ const out = new Uint8Array(bin.length);
1363
+ for (let i = 0; i < bin.length; i++)
1364
+ out[i] = bin.charCodeAt(i);
1365
+ return out;
1366
+ }
349
1367
  function bytesToHex(b) {
350
1368
  let out = "";
351
1369
  for (let i = 0; i < b.length; i++)
352
1370
  out += b[i].toString(16).padStart(2, "0");
353
1371
  return out;
354
1372
  }
1373
+ /**
1374
+ * Project a delegate as it appears in a `BlobPlaintext` (extension-kit
1375
+ * `StoredDelegate` shape) onto the SDK's own {@link StoredDelegateKeys}.
1376
+ */
1377
+ function storedDelegateFromBlob(d) {
1378
+ return {
1379
+ version: "0.1.0-hex",
1380
+ subjectDid: d.subjectDid,
1381
+ mandateId: d.mandateId,
1382
+ mandate: d.mandate,
1383
+ granteeId: d.granteeId,
1384
+ granteePubkeyMultibase: d.granteePubkeyMultibase,
1385
+ delegateSeedHex: d.delegateSeedHex,
1386
+ importedAt: new Date().toISOString(),
1387
+ };
1388
+ }
1389
+ function actorToInfo(a) {
1390
+ return {
1391
+ mandateId: a.mandateId,
1392
+ subjectDid: a.subjectDid,
1393
+ granteeId: a.granteeId,
1394
+ scopes: scopesFromMandate(a.mandate),
1395
+ expiresAt: notAfterFromMandate(a.mandate),
1396
+ };
1397
+ }
1398
+ function scopesFromMandate(m) {
1399
+ const raw = m["scopes"];
1400
+ if (!Array.isArray(raw))
1401
+ return [];
1402
+ return raw.filter((s) => typeof s === "string");
1403
+ }
1404
+ function notAfterFromMandate(m) {
1405
+ const raw = m["not_after"];
1406
+ if (typeof raw !== "string")
1407
+ return null;
1408
+ return raw;
1409
+ }
355
1410
  //# sourceMappingURL=auth.js.map