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

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 (105) hide show
  1. package/README.md +202 -7
  2. package/dist/src/agent-dispatch.d.ts +18 -0
  3. package/dist/src/agent-dispatch.js +178 -0
  4. package/dist/src/agent-loop.d.ts +94 -0
  5. package/dist/src/agent-loop.js +95 -0
  6. package/dist/src/agent-tools.d.ts +24 -0
  7. package/dist/src/agent-tools.js +147 -0
  8. package/dist/src/apps.d.ts +224 -0
  9. package/dist/src/apps.js +432 -0
  10. package/dist/src/assets.d.ts +225 -0
  11. package/dist/src/assets.js +534 -0
  12. package/dist/src/auth-api.d.ts +219 -0
  13. package/dist/src/auth-api.js +248 -0
  14. package/dist/src/auth.d.ts +591 -0
  15. package/dist/src/auth.js +947 -31
  16. package/dist/src/compute.d.ts +674 -6
  17. package/dist/src/compute.js +968 -20
  18. package/dist/src/data-schema-contacts-v1.d.ts +14 -0
  19. package/dist/src/data-schema-contacts-v1.js +28 -0
  20. package/dist/src/data.d.ts +368 -0
  21. package/dist/src/data.js +1124 -0
  22. package/dist/src/endpoints.d.ts +43 -0
  23. package/dist/src/endpoints.js +23 -0
  24. package/dist/src/ethos.d.ts +85 -0
  25. package/dist/src/ethos.js +463 -7
  26. package/dist/src/index.d.ts +22 -4
  27. package/dist/src/index.js +47 -2
  28. package/dist/src/internal/cmk-wrap.d.ts +41 -0
  29. package/dist/src/internal/cmk-wrap.js +132 -0
  30. package/dist/src/internal/delegate-bundle.js +7 -2
  31. package/dist/src/internal/envelope.d.ts +93 -0
  32. package/dist/src/internal/envelope.js +59 -0
  33. package/dist/src/internal/owner-signers.d.ts +5 -2
  34. package/dist/src/internal/owner-signers.js +22 -1
  35. package/dist/src/internal/recovery-file.d.ts +2 -0
  36. package/dist/src/internal/recovery-file.js +7 -0
  37. package/dist/src/key-store.d.ts +10 -0
  38. package/dist/src/key-store.js +6 -0
  39. package/dist/src/mandates.d.ts +58 -1
  40. package/dist/src/mandates.js +46 -3
  41. package/dist/src/migrate.d.ts +105 -0
  42. package/dist/src/migrate.js +367 -0
  43. package/dist/src/react/AithosAsset.d.ts +66 -0
  44. package/dist/src/react/AithosAsset.js +67 -0
  45. package/dist/src/react/context.d.ts +29 -0
  46. package/dist/src/react/context.js +31 -0
  47. package/dist/src/react/index.d.ts +29 -0
  48. package/dist/src/react/index.js +31 -0
  49. package/dist/src/react/use-aithos-asset.d.ts +39 -0
  50. package/dist/src/react/use-aithos-asset.js +118 -0
  51. package/dist/src/react/use-transcribe-pending.d.ts +21 -0
  52. package/dist/src/react/use-transcribe-pending.js +47 -0
  53. package/dist/src/rotate.d.ts +94 -0
  54. package/dist/src/rotate.js +298 -0
  55. package/dist/src/sdk.d.ts +36 -2
  56. package/dist/src/sdk.js +72 -1
  57. package/dist/src/transcribe-resilience.d.ts +57 -0
  58. package/dist/src/transcribe-resilience.js +203 -0
  59. package/dist/src/web.d.ts +279 -0
  60. package/dist/src/web.js +186 -0
  61. package/dist/test/agent-dispatch.test.d.ts +2 -0
  62. package/dist/test/agent-dispatch.test.js +222 -0
  63. package/dist/test/agent-loop.test.d.ts +2 -0
  64. package/dist/test/agent-loop.test.js +117 -0
  65. package/dist/test/agent-tools.test.d.ts +2 -0
  66. package/dist/test/agent-tools.test.js +50 -0
  67. package/dist/test/auth-j3.test.js +32 -1
  68. package/dist/test/canonical-conformance.test.d.ts +2 -0
  69. package/dist/test/canonical-conformance.test.js +86 -0
  70. package/dist/test/compute-delegate-path.test.d.ts +2 -0
  71. package/dist/test/compute-delegate-path.test.js +183 -0
  72. package/dist/test/compute.test.js +4 -0
  73. package/dist/test/converse.test.d.ts +2 -0
  74. package/dist/test/converse.test.js +162 -0
  75. package/dist/test/data-sphere.test.d.ts +2 -0
  76. package/dist/test/data-sphere.test.js +57 -0
  77. package/dist/test/endpoints.test.js +40 -1
  78. package/dist/test/envelope-core-conformance.test.d.ts +2 -0
  79. package/dist/test/envelope-core-conformance.test.js +75 -0
  80. package/dist/test/envelope.test.d.ts +2 -0
  81. package/dist/test/envelope.test.js +318 -0
  82. package/dist/test/ethos-first-edition.test.d.ts +2 -0
  83. package/dist/test/ethos-first-edition.test.js +371 -0
  84. package/dist/test/invoke-turn-sdk.test.d.ts +2 -0
  85. package/dist/test/invoke-turn-sdk.test.js +177 -0
  86. package/dist/test/migrate.test.d.ts +2 -0
  87. package/dist/test/migrate.test.js +340 -0
  88. package/dist/test/owner-data-client.test.d.ts +2 -0
  89. package/dist/test/owner-data-client.test.js +88 -0
  90. package/dist/test/rotate-ethos.test.d.ts +2 -0
  91. package/dist/test/rotate-ethos.test.js +151 -0
  92. package/dist/test/rotate.test.d.ts +2 -0
  93. package/dist/test/rotate.test.js +63 -0
  94. package/dist/test/schema-autoresolve.test.d.ts +2 -0
  95. package/dist/test/schema-autoresolve.test.js +146 -0
  96. package/dist/test/sdk.test.js +11 -2
  97. package/dist/test/signup-bootstrap.test.d.ts +2 -0
  98. package/dist/test/signup-bootstrap.test.js +311 -0
  99. package/dist/test/transcribe-invoke.test.d.ts +2 -0
  100. package/dist/test/transcribe-invoke.test.js +204 -0
  101. package/dist/test/transcribe.test.d.ts +2 -0
  102. package/dist/test/transcribe.test.js +186 -0
  103. package/dist/test/web.test.d.ts +2 -0
  104. package/dist/test/web.test.js +270 -0
  105. package/package.json +20 -3
package/dist/src/auth.js CHANGED
@@ -20,13 +20,17 @@
20
20
  // JWT-less sessions (recovery / mandate sign-ins) are valid: the
21
21
  // keyStore is the source of truth for "is the user signed in", the
22
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 { loginChallenge, loginVerify, registerAccount, } from "./auth-api.js";
23
+ import { browserIdentityFromStored, buildBlobPlaintext, buildSignedEnvelope, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, signedDidDocument, zeroize, } from "@aithos/protocol-client";
24
+ import { custodialAccept, custodialInvite, custodialResendVerify, custodialResetFinalize, custodialResetRequest, custodialSignIn, custodialSignUp, custodialVerifyEmail, loginChallenge, loginVerify, putBlob, registerAccount, } from "./auth-api.js";
25
25
  import { defaultSessionStore, } from "./session-store.js";
26
26
  import { defaultKeyStore, } from "./key-store.js";
27
27
  import { parseDelegateBundle, readDelegateBundleText, } from "./internal/delegate-bundle.js";
28
28
  import { DelegateActor, DelegateRegistry, } from "./internal/delegate-state.js";
29
+ import { signOwnerEnvelope, } from "./internal/envelope.js";
29
30
  import { OwnerSigners } from "./internal/owner-signers.js";
31
+ import { createDataClient, createDelegateDataClient, } from "./data.js";
32
+ import { delegateKeyPair } from "./internal/protocol-client-bridge.js";
33
+ import { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
30
34
  import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from "./internal/recovery-file.js";
31
35
  import { AithosSDKError } from "./types.js";
32
36
  /** Default URL of the Aithos auth backend. */
@@ -43,10 +47,21 @@ export class AithosAuth {
43
47
  #win;
44
48
  #sessionStore;
45
49
  #keyStore;
50
+ #publicKey;
46
51
  /** In-memory owner signers — populated after sign-in or `resume`. */
47
52
  #ownerSigners = null;
48
53
  /** Active delegate registry. */
49
54
  #delegates = new DelegateRegistry();
55
+ /**
56
+ * In-flight (or just-resolved) `handleCallback()` result. React
57
+ * StrictMode (dev) double-invokes the mount effect — the URL clean
58
+ * inside the first call makes the second invocation see a clean URL
59
+ * and resolve to `null`, with the session it just consumed locked
60
+ * inside the first promise. Caching the result here lets both
61
+ * invocations resolve to the same value. Cleared on next mount via
62
+ * the wrapper's once-per-instance dedup.
63
+ */
64
+ #handleCallbackPromise = null;
50
65
  constructor(config = {}) {
51
66
  this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
52
67
  this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
@@ -56,6 +71,7 @@ export class AithosAuth {
56
71
  (typeof window !== "undefined" ? window : undefined);
57
72
  this.#sessionStore = config.sessionStore ?? defaultSessionStore();
58
73
  this.#keyStore = config.keyStore ?? defaultKeyStore();
74
+ this.#publicKey = config.publicKey;
59
75
  }
60
76
  /* ------------------------------------------------------------------------ */
61
77
  /* Boot-time hydration */
@@ -135,6 +151,65 @@ export class AithosAuth {
135
151
  canSignAsOwner() {
136
152
  return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
137
153
  }
154
+ /**
155
+ * Sign an envelope (spec §11.2) as the active owner, to authenticate
156
+ * a call to a third-party Aithos-aware backend.
157
+ *
158
+ * Same primitive that SDK namespaces (`sdk.data`, `sdk.ethos`,
159
+ * `sdk.mandates`, ...) use internally to sign their own writes to
160
+ * `api.aithos.be`. Exposed here so apps can sign envelopes for their
161
+ * own backends — any service that verifies a `SignedEnvelope` per
162
+ * spec §11.2 (typically using `@aithos/protocol-core/envelope`'s
163
+ * `verifyEnvelope`) accepts the resulting object.
164
+ *
165
+ * The envelope binds the signature to `(iss, aud, method,
166
+ * params_hash, nonce, iat, exp)`, so it cannot be replayed against a
167
+ * different endpoint, method, or payload, and expires after
168
+ * `ttlSeconds` (default 60s, server-side typically caps at 300s).
169
+ *
170
+ * Usage:
171
+ *
172
+ * ```ts
173
+ * const envelope = await sdk.auth.signEnvelope({
174
+ * aud: "https://api.example.com/v1/widgets",
175
+ * method: "myapp.widgets.create",
176
+ * params: { name: "Widget #1" },
177
+ * });
178
+ * await fetch("https://api.example.com/v1/widgets", {
179
+ * method: "POST",
180
+ * headers: { "content-type": "application/json" },
181
+ * body: JSON.stringify({ ...payload, _envelope: envelope }),
182
+ * });
183
+ * ```
184
+ *
185
+ * @throws {AithosSDKError} `auth_not_signed_in` if no owner identity
186
+ * is loaded (call `signIn` / `signUp` / `signInCustodial` first).
187
+ * @throws {AithosSDKError} `auth_invalid_sphere` if `sphere` is not
188
+ * one of `"root" | "public" | "circle" | "self"`.
189
+ */
190
+ async signEnvelope(args) {
191
+ if (!this.#ownerSigners || this.#ownerSigners.destroyed) {
192
+ throw new AithosSDKError("auth_not_signed_in", "signEnvelope: no owner is signed in. Call signIn / signUp / signInCustodial first.");
193
+ }
194
+ const sphere = args.sphere ?? "public";
195
+ if (sphere !== "root" &&
196
+ sphere !== "public" &&
197
+ sphere !== "circle" &&
198
+ sphere !== "self" &&
199
+ sphere !== "data") {
200
+ throw new AithosSDKError("auth_invalid_sphere", `signEnvelope: invalid sphere "${sphere}". Expected one of: root, public, circle, self, data.`);
201
+ }
202
+ const signer = this.#ownerSigners.signerForSphere(sphere);
203
+ return signOwnerEnvelope({
204
+ iss: this.#ownerSigners.did,
205
+ aud: args.aud,
206
+ method: args.method,
207
+ params: args.params,
208
+ verificationMethod: `${this.#ownerSigners.did}#${sphere}`,
209
+ signer,
210
+ ttlSeconds: args.ttlSeconds,
211
+ });
212
+ }
138
213
  canSignAsDelegateFor(did) {
139
214
  const a = this.#delegates.findForSubject(did);
140
215
  return a !== undefined && !a.destroyed;
@@ -149,6 +224,118 @@ export class AithosAuth {
149
224
  _getOwnerSigners() {
150
225
  return this.#ownerSigners;
151
226
  }
227
+ /**
228
+ * Ready-made owner data client bound to the signed-in account, signing +
229
+ * sealing under the dedicated **`#data`** sphere (the protocol-intended owner
230
+ * data key). This is the one-liner apps should use instead of hand-rolling
231
+ * `createDataClient` with a raw seed — hand-rolling with `#root` is exactly
232
+ * what left legacy collections sealed to the wrong key.
233
+ *
234
+ * const data = auth.ownerDataClient({ schemas: [myVendorLite] });
235
+ * await data.collection("notes").insert({ ... }); // owned under #data
236
+ *
237
+ * Throws when no owner is signed in, or when the account has no `#data`
238
+ * sphere (legacy accounts created before #data, or imported from a 4-seed
239
+ * recovery). Add one first with `rotateEthos` / the migration scripts, then
240
+ * re-import the resulting recovery — the error message says so.
241
+ *
242
+ * @param args.pdsUrl PDS base URL. Defaults to the SDK default (pds.aithos.be).
243
+ * @param args.schemas Vendor `AithosSchemaLite` definitions to register for
244
+ * WRITES (reads auto-resolve published schemas from the PDS).
245
+ */
246
+ ownerDataClient(args = {}) {
247
+ if (!this.#ownerSigners || this.#ownerSigners.destroyed) {
248
+ throw new AithosSDKError("auth_not_signed_in", "ownerDataClient: no owner is signed in. Call signIn / signUp / signInCustodial first.");
249
+ }
250
+ const stored = this.#ownerSigners._unsafeStoredIdentity();
251
+ if (!stored.seeds.data) {
252
+ throw new AithosSDKError("auth_no_data_sphere", "ownerDataClient: this account has no #data sphere, so it cannot own collections under " +
253
+ "the #data convention. New accounts get one at sign-up; a legacy account (or a 4-seed " +
254
+ "recovery) must add it first via rotateEthos / the migration scripts, then re-import the " +
255
+ "resulting recovery (which carries the #data seed).");
256
+ }
257
+ return createDataClient({
258
+ pdsUrl: args.pdsUrl ?? DEFAULT_SDK_ENDPOINTS.pds,
259
+ did: stored.did,
260
+ sphereSeed: hexToBytesLocal(stored.seeds.data),
261
+ verificationMethod: `${stored.did}#data`,
262
+ ...(args.schemas ? { schemas: args.schemas } : {}),
263
+ fetch: this.#fetchImpl,
264
+ });
265
+ }
266
+ /**
267
+ * Ready-made DELEGATE data client, bound to a mandate held in this session
268
+ * (imported via `importMandate` / an accepted invite). Same record-CRUD
269
+ * surface as the owner client, bounded by the mandate's scope — you never
270
+ * pass a key, a sphere, or the mandate itself to the data calls.
271
+ *
272
+ * const db = auth.delegateDataClient(); // single active mandate
273
+ * await db.collection("prospects").insert({ ... }); // needs data.prospects.write
274
+ *
275
+ * With several active mandates, pass `{ subjectDid }` or `{ mandateId }`.
276
+ * Owner-only ops (createCollection, authorizeDelegate, …) throw -32042 — the
277
+ * owner does those once, at onboarding.
278
+ */
279
+ delegateDataClient(args = {}) {
280
+ let actor;
281
+ if (args.mandateId) {
282
+ actor = this.#delegates.get(args.mandateId);
283
+ }
284
+ else if (args.subjectDid) {
285
+ actor = this.#delegates.findForSubject(args.subjectDid);
286
+ }
287
+ else {
288
+ const all = this.#delegates.list();
289
+ if (all.length === 1) {
290
+ actor = all[0];
291
+ }
292
+ else if (all.length === 0) {
293
+ throw new AithosSDKError("auth_no_delegate", "delegateDataClient: no mandate imported in this session. Call importMandate / accept an invite first.");
294
+ }
295
+ else {
296
+ throw new AithosSDKError("auth_ambiguous_delegate", `delegateDataClient: ${all.length} mandates are active — pass { subjectDid } or { mandateId } to choose one.`);
297
+ }
298
+ }
299
+ if (!actor || actor.destroyed) {
300
+ throw new AithosSDKError("auth_no_delegate", "delegateDataClient: the requested mandate is not active in this session.");
301
+ }
302
+ return createDelegateDataClient({
303
+ pdsUrl: args.pdsUrl ?? DEFAULT_SDK_ENDPOINTS.pds,
304
+ subjectDid: actor.subjectDid,
305
+ mandate: actor.mandate,
306
+ delegateSeed: delegateKeyPair(actor).seed,
307
+ granteePubkeyMultibase: actor.granteePubkeyMultibase,
308
+ ...(args.schemas ? { schemas: args.schemas } : {}),
309
+ fetch: this.#fetchImpl,
310
+ });
311
+ }
312
+ /**
313
+ * Unified data accessor — the database for "however you connected":
314
+ * - signed in as owner → your own collections under `#data`;
315
+ * - acting under an imported mandate → the subject's collections (per scope).
316
+ *
317
+ * Identical CRUD surface either way; the developer never sees a sphere, a
318
+ * key, or the mandate. The mode follows how you authenticated, not a flag on
319
+ * the data calls.
320
+ *
321
+ * const db = auth.data;
322
+ * await db.collection("prospects").insert({ ... });
323
+ *
324
+ * Only ambiguous when you are BOTH signed in as owner AND holding mandates;
325
+ * then call `ownerDataClient()` / `delegateDataClient({ … })` explicitly.
326
+ */
327
+ get data() {
328
+ const ownerActive = !!this.#ownerSigners && !this.#ownerSigners.destroyed;
329
+ const delegateCount = this.#delegates.list().length;
330
+ if (ownerActive && delegateCount === 0)
331
+ return this.ownerDataClient();
332
+ if (!ownerActive && delegateCount >= 1)
333
+ return this.delegateDataClient();
334
+ if (ownerActive && delegateCount >= 1) {
335
+ throw new AithosSDKError("auth_data_ambiguous", "auth.data: both an owner and delegate mandate(s) are active. Use ownerDataClient() or delegateDataClient({ … }) explicitly.");
336
+ }
337
+ throw new AithosSDKError("auth_not_signed_in", "auth.data: no owner signed in and no mandate imported. Call signIn / signUp or importMandate first.");
338
+ }
152
339
  /**
153
340
  * Internal accessor — looks up an active delegate by mandate id.
154
341
  * @internal
@@ -166,6 +353,63 @@ export class AithosAuth {
166
353
  return this.#delegates.findForSubject(did);
167
354
  }
168
355
  /* ------------------------------------------------------------------------ */
356
+ /* Unified email + password — signInAuto (zk / custodial dispatch) */
357
+ /* ------------------------------------------------------------------------ */
358
+ /**
359
+ * Sign in with email + password, dispatching automatically between
360
+ * the legacy zero-knowledge flow ({@link signIn}) and the custodial
361
+ * flow ({@link signInCustodial}) based on which mode the account
362
+ * was provisioned with.
363
+ *
364
+ * Use this in apps that want a single sign-in form for users who
365
+ * may have been created under either mode (e.g. an app that's
366
+ * migrating from zk to custodial — pre-existing users stay zk
367
+ * forever, new ones go custodial, the SDK figures it out).
368
+ *
369
+ * Strategy: try {@link signInCustodial} first (the modern path).
370
+ * If the backend reports `auth_invalid_credentials` — which it
371
+ * uniformly returns for "wrong password", "unknown user", AND
372
+ * "user exists but not in custodial mode" (anti-enum) — fall
373
+ * back to {@link signIn} (zk).
374
+ *
375
+ * Other failure modes from the custodial path are NOT swallowed:
376
+ * - `auth_email_not_verified` → propagate (user is custodial but
377
+ * hasn't clicked the confirmation link yet; the app should
378
+ * surface a "resend mail" CTA rather than retrying as zk,
379
+ * which would also fail and mask the real cause)
380
+ * - server / network errors → propagate (don't double the
381
+ * incident by retrying through the other flow)
382
+ *
383
+ * Latency profile:
384
+ * - Pure custodial (success or wrong pwd) : 1 round-trip
385
+ * - Pure zk (any outcome) : 1 custodial probe + 2 zk
386
+ * - Unknown email : same as zk worst case
387
+ *
388
+ * Anti-enum note: timing slightly leaks the mode (custodial path is
389
+ * faster than zk). Acceptable for V1 — rate limiting + strong
390
+ * passwords are the real defenses. A future strict-anti-enum mode
391
+ * could race both paths in parallel and accept the 2x backend load.
392
+ */
393
+ async signInAuto(input) {
394
+ if (!input.email || !input.password) {
395
+ throw new AithosSDKError("auth_invalid_input", "signInAuto: email and password are required");
396
+ }
397
+ try {
398
+ const r = await this.signInCustodial(input);
399
+ return r.session;
400
+ }
401
+ catch (e) {
402
+ // Only fall back on the specific anti-enum sentinel — preserve
403
+ // other error codes (notably auth_email_not_verified) so the
404
+ // caller can surface the right UI hint.
405
+ if (e instanceof AithosSDKError &&
406
+ e.code === "auth_invalid_credentials") {
407
+ return await this.signIn(input);
408
+ }
409
+ throw e;
410
+ }
411
+ }
412
+ /* ------------------------------------------------------------------------ */
169
413
  /* Email + password — signIn */
170
414
  /* ------------------------------------------------------------------------ */
171
415
  async signIn(input) {
@@ -284,6 +528,7 @@ export class AithosAuth {
284
528
  public: identity.public.seed,
285
529
  circle: identity.circle.seed,
286
530
  self: identity.self.seed,
531
+ ...(identity.data ? { data: identity.data.seed } : {}),
287
532
  },
288
533
  delegates: [],
289
534
  });
@@ -334,6 +579,7 @@ export class AithosAuth {
334
579
  public: bytesToHex(identity.public.seed),
335
580
  circle: bytesToHex(identity.circle.seed),
336
581
  self: bytesToHex(identity.self.seed),
582
+ ...(identity.data ? { data: bytesToHex(identity.data.seed) } : {}),
337
583
  },
338
584
  savedAt: new Date().toISOString(),
339
585
  });
@@ -445,6 +691,13 @@ export class AithosAuth {
445
691
  if (!this.#win) {
446
692
  throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
447
693
  }
694
+ // appId + returnTo must come together — the backend rejects
695
+ // half-presence at /sso/google/start. Surface that as a clean SDK
696
+ // error before the network round-trip rather than letting the user
697
+ // bounce to Google and back for nothing.
698
+ if ((opts?.appId && !opts?.returnTo) || (!opts?.appId && opts?.returnTo)) {
699
+ throw new AithosSDKError("auth_sso_app_redirect_pair_required", "appId and returnTo must be provided together (or both omitted to use the legacy redirect)");
700
+ }
448
701
  const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
449
702
  if (opts?.appState) {
450
703
  if (opts.appState.length > 1024) {
@@ -452,10 +705,48 @@ export class AithosAuth {
452
705
  }
453
706
  url.searchParams.set("app_state", opts.appState);
454
707
  }
708
+ if (opts?.appId && opts?.returnTo) {
709
+ url.searchParams.set("app_id", opts.appId);
710
+ url.searchParams.set("redirect_uri", opts.returnTo);
711
+ }
455
712
  this.#win.location.assign(url.toString());
456
713
  throw new AithosSDKError("auth_redirecting", "redirecting to google");
457
714
  }
715
+ /**
716
+ * Public entrypoint — dedupes concurrent calls (React StrictMode).
717
+ * The first call kicks off the actual exchange; subsequent calls
718
+ * before that promise resolves return the SAME promise so they all
719
+ * receive the same `AithosSession | null`. Otherwise StrictMode's
720
+ * second invocation would race against the URL clean done by the
721
+ * first call and resolve to `null`, robbing the AuthCallback page
722
+ * of the session it actually obtained.
723
+ */
458
724
  async handleCallback() {
725
+ if (!this.#win)
726
+ return null;
727
+ if (this.#handleCallbackPromise)
728
+ return this.#handleCallbackPromise;
729
+ const p = this.#doHandleCallback();
730
+ this.#handleCallbackPromise = p;
731
+ // Clear the cache once the promise settles so a subsequent
732
+ // signInWithGoogle round-trip on the same AithosAuth instance can
733
+ // process its own callback. We use `then(cleanup, cleanup)`
734
+ // rather than `finally(...)` because `finally` re-throws — without
735
+ // a downstream `.catch` the resulting promise becomes an
736
+ // unhandledrejection when `p` itself rejects (the caller already
737
+ // surfaces that rejection via the returned `p`). `then(success,
738
+ // error)` converts a rejection into a clean resolution on this
739
+ // side-effect chain so node:test doesn't flag the orphan as a
740
+ // failure.
741
+ const clear = () => {
742
+ if (this.#handleCallbackPromise === p) {
743
+ this.#handleCallbackPromise = null;
744
+ }
745
+ };
746
+ p.then(clear, clear);
747
+ return p;
748
+ }
749
+ async #doHandleCallback() {
459
750
  if (!this.#win)
460
751
  return null;
461
752
  const here = new URL(this.#win.location.href);
@@ -468,8 +759,16 @@ export class AithosAuth {
468
759
  }
469
760
  if (!code)
470
761
  return null;
471
- const session = await this.exchange(code);
762
+ // Strip the aithos_code from the URL SYNCHRONOUSLY, before any
763
+ // await. React StrictMode (dev) invokes effects twice — without
764
+ // this, the first call awaits exchange (microtask, code still in
765
+ // the URL), the second invocation reads the same code and POSTs
766
+ // again, hitting `auth_code_consumed: aithos_code expired or
767
+ // already used`. Cleaning before the await makes the second
768
+ // invocation read a clean URL and return null without a network
769
+ // round-trip.
472
770
  cleanCallbackParams(this.#win, here);
771
+ const session = await this.exchange(code);
473
772
  // Hydrate signers if the SSO response carried an enc_key (Google flow
474
773
  // gives us the AES-GCM key in plaintext, encrypted only in transit
475
774
  // by TLS — see auth.aithos.be design doc).
@@ -485,34 +784,44 @@ export class AithosAuth {
485
784
  const blobBytes = decryptBlob(encKey, nonce, blob);
486
785
  try {
487
786
  const plaintext = parseBlob(blobBytes);
488
- if (plaintext.identity.did === session.did) {
489
- if (this.#ownerSigners)
490
- this.#ownerSigners.destroy();
491
- this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
492
- await this.#keyStore.saveOwner({
493
- version: "0.1.0-hex",
494
- did: plaintext.identity.did,
495
- handle: plaintext.identity.handle,
496
- displayName: plaintext.identity.displayName,
497
- seedsHex: plaintext.seeds,
498
- savedAt: new Date().toISOString(),
499
- });
500
- await this.#keyStore.clearAllDelegates();
501
- this.#delegates.destroy();
502
- for (const d of plaintext.delegates) {
503
- const stored = storedDelegateFromBlob(d);
504
- try {
505
- await this.#keyStore.saveDelegate(stored);
506
- }
507
- catch {
508
- /* keep going */
509
- }
510
- try {
511
- this.#delegates.add(DelegateActor.fromStored(stored));
512
- }
513
- catch {
514
- /* keep going */
515
- }
787
+ // Earlier versions of the SDK gated hydration on
788
+ // `plaintext.identity.did === session.did` as a defense
789
+ // against tampered sessionStores. The check breaks SSO
790
+ // flows: the auth backend assigns a placeholder random
791
+ // DID at user-record creation time (no client keypair on
792
+ // hand), but the BLOB is built around a real
793
+ // BrowserIdentity whose DID is derived from its root
794
+ // pubkey. The two intentionally differ — the blob is the
795
+ // truth source for everything downstream (signing, DID
796
+ // resolution against api.aithos.be), the session.did is
797
+ // just auth-side bookkeeping. Drop the check and trust
798
+ // the blob.
799
+ if (this.#ownerSigners)
800
+ this.#ownerSigners.destroy();
801
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
802
+ await this.#keyStore.saveOwner({
803
+ version: "0.1.0-hex",
804
+ did: plaintext.identity.did,
805
+ handle: plaintext.identity.handle,
806
+ displayName: plaintext.identity.displayName,
807
+ seedsHex: plaintext.seeds,
808
+ savedAt: new Date().toISOString(),
809
+ });
810
+ await this.#keyStore.clearAllDelegates();
811
+ this.#delegates.destroy();
812
+ for (const d of plaintext.delegates) {
813
+ const stored = storedDelegateFromBlob(d);
814
+ try {
815
+ await this.#keyStore.saveDelegate(stored);
816
+ }
817
+ catch {
818
+ /* keep going */
819
+ }
820
+ try {
821
+ this.#delegates.add(DelegateActor.fromStored(stored));
822
+ }
823
+ catch {
824
+ /* keep going */
516
825
  }
517
826
  }
518
827
  }
@@ -561,6 +870,549 @@ export class AithosAuth {
561
870
  return (await res.json());
562
871
  }
563
872
  /* ------------------------------------------------------------------------ */
873
+ /* Complete SSO first login */
874
+ /* ------------------------------------------------------------------------ */
875
+ /**
876
+ * Finish the first-time Google SSO bootstrap. After
877
+ * `signInWithGoogle()` + `handleCallback()`, a brand-new SSO user has
878
+ * a session JWT and an `enc_key` released by the auth backend, but
879
+ * NO Aithos identity yet (no Ed25519 seeds, no published did.json,
880
+ * no blob in the auth vault). This method closes that gap:
881
+ *
882
+ * 1. Generates a fresh {@link BrowserIdentity} client-side (4
883
+ * Ed25519 keypairs, derived DID).
884
+ * 2. Calls `aithos.publish_identity` on api.aithos.be so reads
885
+ * and writes against the Aithos primitives have an ethos to
886
+ * anchor to.
887
+ * 3. AES-GCM-encrypts the seeds with the session's `enc_key`,
888
+ * PUTs the result to `/auth/blob`. From now on, every Google
889
+ * sign-in for this user will receive the encrypted blob and
890
+ * hydrate locally.
891
+ * 4. Hydrates `ownerSigners` + `keyStore` so `canSignAsOwner()`
892
+ * flips to true.
893
+ * 5. Returns a recovery-file Blob — the only material that can
894
+ * restore this ethos if Google access is lost.
895
+ *
896
+ * Preconditions:
897
+ * - `getCurrentSession()` returns a non-null session (caller went
898
+ * through `handleCallback()` already).
899
+ * - The session's `blob_version` is 0 (i.e. no blob yet).
900
+ * - The session's `enc_key_b64` is non-empty.
901
+ *
902
+ * Throws `AithosSDKError("auth_sso_no_pending_first_login", …)` if
903
+ * preconditions don't hold (e.g. blob_version > 0 means the user has
904
+ * already completed setup; nothing to do).
905
+ */
906
+ async completeSsoFirstLogin(input) {
907
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
908
+ throw new AithosSDKError("auth_invalid_handle", "handle must be 1–63 alphanumeric chars + _ -");
909
+ }
910
+ const displayName = input.displayName ?? input.handle;
911
+ const session = this.#sessionStore.get();
912
+ if (!session) {
913
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "no active session — sign in via Google first");
914
+ }
915
+ if (!session.enc_key_b64) {
916
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "session does not carry an enc_key (not an SSO-flow session?)");
917
+ }
918
+ if (session.blob_version > 0) {
919
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "this session already has a published blob — nothing to bootstrap");
920
+ }
921
+ // 1. Fresh identity client-side. The DID derived here is the
922
+ // truth source from now on — the placeholder DID stamped in
923
+ // the user record by the auth Lambda is left as-is (auth-side
924
+ // bookkeeping; never used for signing).
925
+ const identity = createBrowserIdentity(input.handle, displayName);
926
+ const recoverySerialized = serializeRecoveryFile(identity);
927
+ const recoveryFile = new Blob([recoverySerialized.text], {
928
+ type: "application/json",
929
+ });
930
+ // 2. publish_identity on api.aithos.be — reuses the alpha.6
931
+ // helper. Must succeed before we persist anything locally:
932
+ // a half-completed bootstrap (blob uploaded but identity not
933
+ // published) would leave the user with seeds they can't use.
934
+ await this.#publishIdentity(identity);
935
+ // 3. Encrypt the seeds with the SSO-released enc_key and PUT
936
+ // /auth/blob. The auth Lambda accepts the new blob_version=1
937
+ // and stores the bytes verbatim.
938
+ const encKey = b64ToBytes(session.enc_key_b64);
939
+ let blob;
940
+ let blobNonce;
941
+ let plaintext;
942
+ try {
943
+ plaintext = buildBlobPlaintext({
944
+ identity: {
945
+ did: identity.did,
946
+ handle: identity.handle,
947
+ displayName: identity.displayName,
948
+ },
949
+ seeds: {
950
+ root: identity.root.seed,
951
+ public: identity.public.seed,
952
+ circle: identity.circle.seed,
953
+ self: identity.self.seed,
954
+ ...(identity.data ? { data: identity.data.seed } : {}),
955
+ },
956
+ delegates: [],
957
+ });
958
+ const blobBytes = serializeBlob(plaintext);
959
+ blobNonce = randomNonce();
960
+ blob = encryptBlob(encKey, blobNonce, blobBytes);
961
+ }
962
+ finally {
963
+ zeroize(encKey);
964
+ }
965
+ const newBlobVersion = 1;
966
+ try {
967
+ await putBlob({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
968
+ jwt: session.session,
969
+ blob,
970
+ blobNonce,
971
+ blobVersion: newBlobVersion,
972
+ });
973
+ }
974
+ catch (e) {
975
+ throw new AithosSDKError("auth_sso_blob_upload_failed", `couldn't store the encrypted vault on auth.aithos.be: ${e.message ?? "unknown"}`);
976
+ }
977
+ // 4. Hydrate in-memory state from the fresh identity.
978
+ if (this.#ownerSigners)
979
+ this.#ownerSigners.destroy();
980
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
981
+ await this.#keyStore.saveOwner({
982
+ version: "0.1.0-hex",
983
+ did: identity.did,
984
+ handle: identity.handle,
985
+ displayName: identity.displayName,
986
+ seedsHex: {
987
+ root: bytesToHex(identity.root.seed),
988
+ public: bytesToHex(identity.public.seed),
989
+ circle: bytesToHex(identity.circle.seed),
990
+ self: bytesToHex(identity.self.seed),
991
+ ...(identity.data ? { data: bytesToHex(identity.data.seed) } : {}),
992
+ },
993
+ savedAt: new Date().toISOString(),
994
+ });
995
+ // 5. Persist the updated session — same JWT, but now carrying
996
+ // the freshly-built blob bytes so a subsequent `resume()` can
997
+ // rehydrate without another /auth/blob round-trip.
998
+ const refreshed = {
999
+ ...session,
1000
+ blob_b64: bytesToB64Public(blob),
1001
+ blob_nonce_b64: bytesToB64Public(blobNonce),
1002
+ blob_version: newBlobVersion,
1003
+ };
1004
+ this.#sessionStore.set(refreshed);
1005
+ return {
1006
+ session: refreshed,
1007
+ recoveryFile,
1008
+ recoveryFilename: recoverySerialized.filename,
1009
+ };
1010
+ }
1011
+ /* ------------------------------------------------------------------------ */
1012
+ /* Custodial flow (V2 — see PLATFORM-AUTH-PASSWORD-V2-PLAN.md) */
1013
+ /* ------------------------------------------------------------------------ */
1014
+ /**
1015
+ * Provision a custodial-mode account on behalf of a registered app.
1016
+ *
1017
+ * Two integration patterns:
1018
+ * - **Frontend-only** apps : set `publicKey` on the constructor
1019
+ * (or on this call). Safe to ship in browser bundles — the
1020
+ * backend gates each request by Origin + IP rate limit.
1021
+ * - **Backend-fronted** apps : the backend passes `apiKey` (secret
1022
+ * Bearer); the browser never sees the credential.
1023
+ *
1024
+ * The created account is in a *pending* state — sign-in stays blocked
1025
+ * until the user clicks the confirmation link sent to their inbox.
1026
+ * Call {@link verifyEmail} from the page mounted on
1027
+ * `app.verify_base_url` to consume the token; afterwards
1028
+ * {@link signInCustodial} works.
1029
+ *
1030
+ * Errors map to `AithosSDKError` codes:
1031
+ * - `auth_missing_api_key` (no credential provided)
1032
+ * - `auth_invalid_api_key` (Bearer rejected by backend)
1033
+ * - `auth_invalid_public_key` (public key rejected by backend)
1034
+ * - `auth_api_key_revoked` / `auth_public_key_revoked`
1035
+ * - `auth_origin_not_allowed` (public key + Origin not in allowlist)
1036
+ * - `auth_password_too_weak` (400 — server-side strength check)
1037
+ * - `auth_email_exists` (409 — email already registered)
1038
+ * - `auth_email_invalid` (400 — bad email format)
1039
+ * - `auth_mail_send_failed` (502 — DDB row exists but SES failed)
1040
+ * - `auth_custodial_signup_failed` (catch-all)
1041
+ */
1042
+ async signUpCustodial(input) {
1043
+ if (!input.email) {
1044
+ throw new AithosSDKError("auth_invalid_input", "signUpCustodial: email is required");
1045
+ }
1046
+ if (!input.password) {
1047
+ throw new AithosSDKError("auth_invalid_input", "signUpCustodial: password is required");
1048
+ }
1049
+ const apiKey = input.apiKey;
1050
+ const publicKey = input.publicKey ?? this.#publicKey;
1051
+ if (!apiKey && !publicKey) {
1052
+ throw new AithosSDKError("auth_missing_api_key", "signUpCustodial: pass apiKey, or publicKey, or set publicKey on the AithosAuth constructor");
1053
+ }
1054
+ return custodialSignUp({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
1055
+ email: input.email,
1056
+ password: input.password,
1057
+ ...(apiKey ? { apiKey } : {}),
1058
+ ...(apiKey ? {} : publicKey ? { publicKey } : {}),
1059
+ ...(input.displayName ? { displayName: input.displayName } : {}),
1060
+ ...(input.handleHint ? { handleHint: input.handleHint } : {}),
1061
+ });
1062
+ }
1063
+ /**
1064
+ * Magic-link auto-signin: consume the verification token from the
1065
+ * confirmation link, KMS-unwrap the seed bundle server-side, and
1066
+ * hydrate the local session + keystore in one round-trip.
1067
+ *
1068
+ * Outcome depends on the link's state:
1069
+ * - First click on a fresh link → returns
1070
+ * `{ status: "signed_in", session, … }`. The session store is
1071
+ * populated, the owner signers are loaded — the user is signed
1072
+ * in. The caller should navigate them to a logged-in route.
1073
+ * - Click of an already-consumed link → returns
1074
+ * `{ status: "already_verified", email }`. No session is minted;
1075
+ * the user must sign in via {@link signInCustodial}.
1076
+ *
1077
+ * Mount this on the page declared as `verify_base_url` in your app's
1078
+ * registration. Read `email` + `token` from `window.location.search`,
1079
+ * call this, branch on `result.status`.
1080
+ *
1081
+ * Throws `auth_token_invalid_or_expired` if the token is wrong or
1082
+ * past its 1h TTL — surface a "request a fresh link" CTA in that case.
1083
+ */
1084
+ async verifyEmail(input) {
1085
+ if (!input.email || !input.token) {
1086
+ throw new AithosSDKError("auth_invalid_input", "verifyEmail: email and token are required");
1087
+ }
1088
+ const resp = await custodialVerifyEmail({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
1089
+ if (resp.status === "already_verified") {
1090
+ return { status: "already_verified", email: resp.email };
1091
+ }
1092
+ // Magic-link sign-in path. Mirror `signInCustodial` to materialise
1093
+ // the 4 sphere seeds in the keystore.
1094
+ const stored = {
1095
+ version: "0.1.0-hex",
1096
+ did: resp.did,
1097
+ handle: resp.handle,
1098
+ displayName: resp.displayName,
1099
+ // Accepts 128 (legacy) or 160 (with #data); zeroizes resp.seed.
1100
+ seedsHex: custodialSeedsHex(resp.seed),
1101
+ savedAt: new Date().toISOString(),
1102
+ };
1103
+ zeroize(resp.encKey);
1104
+ // Bootstrap the Ethos on api.aithos.be (cf. notes in signInCustodial).
1105
+ // The magic-link flow is the FIRST time the user actually has
1106
+ // hydrated keys client-side, so this is typically when the identity
1107
+ // gets published. Idempotent — safe to call again on subsequent
1108
+ // clicks (which won't get here normally, but defensively).
1109
+ const identity = browserIdentityFromStored({
1110
+ handle: stored.handle,
1111
+ displayName: stored.displayName,
1112
+ did: stored.did,
1113
+ seeds: stored.seedsHex,
1114
+ });
1115
+ await this.#publishIdentity(identity);
1116
+ if (this.#ownerSigners)
1117
+ this.#ownerSigners.destroy();
1118
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
1119
+ await this.#keyStore.saveOwner(stored);
1120
+ const session = {
1121
+ session: resp.session,
1122
+ exp: resp.exp,
1123
+ did: resp.did,
1124
+ handle: resp.handle,
1125
+ blob_b64: bytesToB64Public(resp.blob),
1126
+ blob_nonce_b64: bytesToB64Public(resp.blobNonce),
1127
+ blob_version: resp.blobVersion,
1128
+ enc_key_b64: "",
1129
+ is_first_login: false,
1130
+ };
1131
+ this.#sessionStore.set(session);
1132
+ return { status: "signed_in", session, passwordMustChange: false };
1133
+ }
1134
+ /**
1135
+ * Send an invitation magic link carrying a mandate. The issuer (owner)
1136
+ * mints any mandate via {@link AithosSDK.mandates} (read/write/append/…),
1137
+ * then calls this with the bundle: the auth backend stores it bound to a
1138
+ * single-use token and emails the magic link. The mandate (and its delegate
1139
+ * seed) never ride the email URL. The invitee redeems it via
1140
+ * {@link acceptInvite}.
1141
+ *
1142
+ * Generic — knows nothing about the mandate's scope. Authenticate with
1143
+ * `apiKey` (server) or `publicKey` (browser, Origin-gated).
1144
+ */
1145
+ async inviteCustodial(input) {
1146
+ if (!input.email) {
1147
+ throw new AithosSDKError("auth_invalid_input", "inviteCustodial: email is required");
1148
+ }
1149
+ if (input.mandateBundle === undefined || input.mandateBundle === null) {
1150
+ throw new AithosSDKError("auth_invalid_input", "inviteCustodial: mandateBundle is required");
1151
+ }
1152
+ const apiKey = input.apiKey;
1153
+ const publicKey = input.publicKey ?? this.#publicKey;
1154
+ if (!apiKey && !publicKey) {
1155
+ throw new AithosSDKError("auth_missing_api_key", "inviteCustodial: pass apiKey, or publicKey, or set publicKey on the AithosAuth constructor");
1156
+ }
1157
+ // Normalize the bundle to a JSON string (the opaque invite payload).
1158
+ let invitePayload;
1159
+ if (typeof input.mandateBundle === "string") {
1160
+ invitePayload = input.mandateBundle;
1161
+ }
1162
+ else if (input.mandateBundle instanceof Blob) {
1163
+ invitePayload = await input.mandateBundle.text();
1164
+ }
1165
+ else {
1166
+ invitePayload = JSON.stringify(input.mandateBundle);
1167
+ }
1168
+ const result = await custodialInvite({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
1169
+ email: input.email,
1170
+ invitePayload,
1171
+ ...(apiKey ? { apiKey } : publicKey ? { publicKey } : {}),
1172
+ ...(input.ttlSeconds !== undefined ? { ttlSeconds: input.ttlSeconds } : {}),
1173
+ ...(input.displayName ? { displayName: input.displayName } : {}),
1174
+ });
1175
+ return {
1176
+ status: "invited",
1177
+ email: result.email,
1178
+ mailSent: result.mailSent,
1179
+ ...(result.mailMessageId !== undefined ? { mailMessageId: result.mailMessageId } : {}),
1180
+ };
1181
+ }
1182
+ /**
1183
+ * Redeem an invitation from the magic link: consume the token, sign in
1184
+ * (create the account with `password`, or authenticate an existing one),
1185
+ * and AUTO-IMPORT the mandate the inviter attached. Returns the session and
1186
+ * the imported {@link DelegateInfo}.
1187
+ *
1188
+ * Mount this on the page declared as the invitation's verify/redirect URL;
1189
+ * read `email` + `token` from `window.location.search`, collect the
1190
+ * `password`, call this.
1191
+ *
1192
+ * Throws `auth_token_invalid_or_expired` (bad/consumed/expired token) or an
1193
+ * auth error if an existing account's password is wrong / a new one is weak.
1194
+ */
1195
+ async acceptInvite(input) {
1196
+ if (!input.email || !input.token) {
1197
+ throw new AithosSDKError("auth_invalid_input", "acceptInvite: email and token are required");
1198
+ }
1199
+ const resp = await custodialAccept({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
1200
+ email: input.email,
1201
+ token: input.token,
1202
+ ...(input.password ? { password: input.password } : {}),
1203
+ });
1204
+ // Materialise the 4 sphere seeds + session — same shape as the verifyEmail
1205
+ // magic-link path. Kept inline (additive; verifyEmail is left untouched).
1206
+ const stored = {
1207
+ version: "0.1.0-hex",
1208
+ did: resp.did,
1209
+ handle: resp.handle,
1210
+ displayName: resp.displayName,
1211
+ // Accepts 128 (legacy) or 160 (with #data); zeroizes resp.seed.
1212
+ seedsHex: custodialSeedsHex(resp.seed),
1213
+ savedAt: new Date().toISOString(),
1214
+ };
1215
+ zeroize(resp.encKey);
1216
+ const identity = browserIdentityFromStored({
1217
+ handle: stored.handle,
1218
+ displayName: stored.displayName,
1219
+ did: stored.did,
1220
+ seeds: stored.seedsHex,
1221
+ });
1222
+ await this.#publishIdentity(identity);
1223
+ if (this.#ownerSigners)
1224
+ this.#ownerSigners.destroy();
1225
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
1226
+ await this.#keyStore.saveOwner(stored);
1227
+ const session = {
1228
+ session: resp.session,
1229
+ exp: resp.exp,
1230
+ did: resp.did,
1231
+ handle: resp.handle,
1232
+ blob_b64: bytesToB64Public(resp.blob),
1233
+ blob_nonce_b64: bytesToB64Public(resp.blobNonce),
1234
+ blob_version: resp.blobVersion,
1235
+ enc_key_b64: "",
1236
+ is_first_login: false,
1237
+ };
1238
+ this.#sessionStore.set(session);
1239
+ // Import the invited mandate into the keystore (generic — any scope).
1240
+ const delegate = await this.importMandate({ bundle: resp.invitePayload });
1241
+ return {
1242
+ status: "signed_in",
1243
+ session,
1244
+ delegate,
1245
+ accountCreated: resp.accountCreated,
1246
+ };
1247
+ }
1248
+ /**
1249
+ * Re-send the verification mail for a pending account. Use when the
1250
+ * user reports never having received the welcome mail, or when their
1251
+ * verification token expired (24h TTL).
1252
+ *
1253
+ * The backend is anti-enumeration (always 200) and rate-limited
1254
+ * 1/h/account, so it's safe to call even when the state of `email`
1255
+ * is unknown. Accepts the same credential families as
1256
+ * {@link signUpCustodial}; falls back to the constructor's
1257
+ * `publicKey` when neither override is set.
1258
+ */
1259
+ async resendVerificationEmail(input) {
1260
+ if (!input.email) {
1261
+ throw new AithosSDKError("auth_invalid_input", "resendVerificationEmail: email is required");
1262
+ }
1263
+ const apiKey = input.apiKey;
1264
+ const publicKey = input.publicKey ?? this.#publicKey;
1265
+ if (!apiKey && !publicKey) {
1266
+ throw new AithosSDKError("auth_missing_api_key", "resendVerificationEmail: pass apiKey, publicKey, or set publicKey on the AithosAuth constructor");
1267
+ }
1268
+ await custodialResendVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
1269
+ email: input.email,
1270
+ ...(apiKey ? { apiKey } : {}),
1271
+ ...(apiKey ? {} : publicKey ? { publicKey } : {}),
1272
+ });
1273
+ }
1274
+ /**
1275
+ * Authenticate a custodial-mode user with email + password. Single
1276
+ * round-trip: returns a fresh JWT session AND hydrates the local
1277
+ * KeyStore with the user's 4 Ed25519 seeds (KMS-unwrapped server-side
1278
+ * after Argon2id verify).
1279
+ *
1280
+ * After this returns, the SDK is ready to publish ethos editions,
1281
+ * invoke compute, mint mandates, etc. — exactly as if the user had
1282
+ * signed in via {@link signIn} (zk) or {@link handleCallback} (SSO).
1283
+ *
1284
+ * Errors map to `AithosSDKError` codes:
1285
+ * - `auth_invalid_input` (your code passed empty fields)
1286
+ * - `auth_invalid_credentials` (401 — wrong email / wrong password)
1287
+ * - `auth_wrong_auth_mode` (403 — user exists in another flow)
1288
+ */
1289
+ async signInCustodial(input) {
1290
+ if (!input.email || !input.password) {
1291
+ throw new AithosSDKError("auth_invalid_input", "signInCustodial: email and password are required");
1292
+ }
1293
+ const resp = await custodialSignIn({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
1294
+ // Split the custodial seed bundle into the sphere seeds. The backend
1295
+ // lays them out in the canonical order [root || public || circle || self]
1296
+ // (128 bytes), plus the dedicated #data sphere when migrated (160 bytes).
1297
+ // See seed-wrapper.ts / custodialSeedsHex.
1298
+ const stored = {
1299
+ version: "0.1.0-hex",
1300
+ did: resp.did,
1301
+ handle: resp.handle,
1302
+ displayName: resp.displayName,
1303
+ // Accepts 128 (legacy, no #data) or 160 (with #data); zeroizes resp.seed.
1304
+ seedsHex: custodialSeedsHex(resp.seed),
1305
+ savedAt: new Date().toISOString(),
1306
+ };
1307
+ // The enc_key is informational here — the custodial blob is empty
1308
+ // at first login. We still don't keep it in memory.
1309
+ zeroize(resp.encKey);
1310
+ // Bootstrap the Ethos on api.aithos.be — same as signUp(zk). Without
1311
+ // this, the DID returned by signInCustodial isn't resolvable on the
1312
+ // platform (feed / profile lookups return "not found: did …"). The
1313
+ // call is idempotent server-side: a published identity replays as a
1314
+ // no-op. We do it here (rather than only on a "first login" flag)
1315
+ // because the auth Lambda doesn't know whether the api.aithos.be
1316
+ // side has been populated — the SDK is the single source of truth
1317
+ // for "the user's Ethos is bootstrapped".
1318
+ //
1319
+ // Failure aborts the sign-in: the user can retry (same behaviour as
1320
+ // signUp(zk)), and the local keystore is NOT populated half-way.
1321
+ const identity = browserIdentityFromStored({
1322
+ handle: stored.handle,
1323
+ displayName: stored.displayName,
1324
+ did: stored.did,
1325
+ seeds: stored.seedsHex,
1326
+ });
1327
+ await this.#publishIdentity(identity);
1328
+ // Hydrate in-memory owner signers from the freshly-stored material.
1329
+ if (this.#ownerSigners)
1330
+ this.#ownerSigners.destroy();
1331
+ this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
1332
+ await this.#keyStore.saveOwner(stored);
1333
+ const session = {
1334
+ session: resp.session,
1335
+ exp: resp.exp,
1336
+ did: resp.did,
1337
+ handle: resp.handle,
1338
+ blob_b64: bytesToB64Public(resp.blob),
1339
+ blob_nonce_b64: bytesToB64Public(resp.blobNonce),
1340
+ blob_version: resp.blobVersion,
1341
+ enc_key_b64: "",
1342
+ is_first_login: resp.passwordMustChange,
1343
+ };
1344
+ this.#sessionStore.set(session);
1345
+ return { session, passwordMustChange: resp.passwordMustChange };
1346
+ }
1347
+ /**
1348
+ * Trigger a password-reset email to the given address. Backend ALWAYS
1349
+ * resolves silently (no enumeration) — caller cannot tell whether the
1350
+ * email is registered or not. The mail itself, if sent, contains a
1351
+ * magic-link URL of shape `<resetBaseUrl>?token=<raw>&email=<email>`.
1352
+ *
1353
+ * Per-email rate limits apply server-side (5 mails/day, 5 min cooldown
1354
+ * between consecutive requests). Calls during cooldown silently no-op
1355
+ * the mail send while still returning success here.
1356
+ */
1357
+ async requestPasswordReset(input) {
1358
+ if (!input.email) {
1359
+ throw new AithosSDKError("auth_invalid_input", "requestPasswordReset: email is required");
1360
+ }
1361
+ await custodialResetRequest({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
1362
+ }
1363
+ /**
1364
+ * Finalise a password reset using the magic-link token sent to the
1365
+ * user's inbox by {@link requestPasswordReset}.
1366
+ *
1367
+ * Typical use site: the page mounted on the reset URL declared in
1368
+ * `aithos-auth-apps.reset_base_url`. The page reads `email` and
1369
+ * `token` from `window.location.search`, prompts the user for a new
1370
+ * password, then calls this method.
1371
+ *
1372
+ * On success, the returned {@link AithosSession} is persisted to the
1373
+ * session store but the local keystore is NOT hydrated — the backend
1374
+ * does not return the seed bundle on this endpoint. To get a fully
1375
+ * usable session (one that can sign envelopes), follow up with
1376
+ * {@link signInCustodial} using the email + new password. The two
1377
+ * round-trips can be hidden inside a single UI action: reset → auto
1378
+ * sign-in → redirect to dashboard.
1379
+ *
1380
+ * Errors map to `AithosSDKError` codes:
1381
+ * - `auth_invalid_input` (your code passed empty fields)
1382
+ * - `auth_reset_token_invalid` (400 — token forged / wrong email)
1383
+ * - `auth_reset_token_expired` (410 — token TTL elapsed)
1384
+ * - `auth_reset_token_consumed` (409 — already used)
1385
+ * - `auth_password_too_short` (400 — < 10 chars)
1386
+ * - `auth_custodial_reset_failed` (catch-all)
1387
+ */
1388
+ async applyPasswordReset(input) {
1389
+ if (!input.email || !input.token || !input.newPassword) {
1390
+ throw new AithosSDKError("auth_invalid_input", "applyPasswordReset: email, token and newPassword are required");
1391
+ }
1392
+ const resp = await custodialResetFinalize({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
1393
+ // The reset endpoint mints a JWT but doesn't ship the seed bundle —
1394
+ // the caller still has to signInCustodial() to materialise the keys
1395
+ // locally. We persist the session anyway so any code that reads
1396
+ // `getCurrentSession()` between the reset and the follow-up sign-in
1397
+ // sees the new JWT (e.g. an analytics hook).
1398
+ const session = {
1399
+ session: resp.session,
1400
+ exp: resp.exp,
1401
+ did: resp.did,
1402
+ handle: resp.handle,
1403
+ // No blob / enc_key on this path — the reset endpoint doesn't
1404
+ // re-issue the vault. Leave the blob slots empty; the follow-up
1405
+ // signInCustodial() will populate them.
1406
+ blob_b64: "",
1407
+ blob_nonce_b64: "",
1408
+ blob_version: 0,
1409
+ enc_key_b64: "",
1410
+ is_first_login: false,
1411
+ };
1412
+ this.#sessionStore.set(session);
1413
+ return { session };
1414
+ }
1415
+ /* ------------------------------------------------------------------------ */
564
1416
  /* Sign-out */
565
1417
  /* ------------------------------------------------------------------------ */
566
1418
  async signOut() {
@@ -632,6 +1484,24 @@ export class AithosAuth {
632
1484
  }
633
1485
  const json = (await res.json());
634
1486
  if (json.error) {
1487
+ // Backward-compat shim for backends without the semantic-equality
1488
+ // fix on publish-identity (alpha.33+ regression): the server may
1489
+ // reject a republish with -32022 because the client regenerates
1490
+ // `aithos.created_at` (and `proof.created`) on every
1491
+ // `signedDidDocument()` call, breaking the strict byte-equal
1492
+ // idempotence the server enforces. For an honest signer (same
1493
+ // root key, same DID) the only way to hit this code path is the
1494
+ // timestamp-drift case — which is semantically a no-op. Treat as
1495
+ // success.
1496
+ //
1497
+ // Server-side fix (publish-identity.ts switched to semantic
1498
+ // equality on cryptographic fields only) makes this branch dead
1499
+ // code on upgraded backends. Kept here as defense-in-depth for
1500
+ // SDK consumers pointing at older deployments.
1501
+ if (json.error.code === -32022 &&
1502
+ /different did\.json already published/i.test(json.error.message)) {
1503
+ return; // already published with same crypto material — no-op
1504
+ }
635
1505
  // JSON-RPC error: don't retry — these are deterministic
636
1506
  // (validation, permission, identity-already-tombstoned, …).
637
1507
  throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
@@ -690,6 +1560,52 @@ function bytesToHex(b) {
690
1560
  out += b[i].toString(16).padStart(2, "0");
691
1561
  return out;
692
1562
  }
1563
+ function hexToBytesLocal(hex) {
1564
+ const out = new Uint8Array(hex.length / 2);
1565
+ for (let i = 0; i < out.length; i++)
1566
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
1567
+ return out;
1568
+ }
1569
+ /**
1570
+ * Split a custodial seed bundle into the keystore's hex seeds, zeroizing the
1571
+ * raw bytes (the bundle and every slice) before returning.
1572
+ *
1573
+ * Two bundle shapes are accepted, in canonical sphere order:
1574
+ * - 128 bytes = root || public || circle || self (legacy, no #data)
1575
+ * - 160 bytes = root || public || circle || self || data (V2.2+, with #data)
1576
+ *
1577
+ * A 160-byte bundle yields a `data` seed so the custodial owner carries the
1578
+ * dedicated `#data` sphere — identical to a self-custody / SSO account. A
1579
+ * 128-byte bundle (an account the backend hasn't migrated yet) yields no
1580
+ * `data`; the backend upgrades it to 160 on the user's next sign-in.
1581
+ */
1582
+ function custodialSeedsHex(seed) {
1583
+ const len = seed.byteLength;
1584
+ if (len !== 128 && len !== 160) {
1585
+ zeroize(seed);
1586
+ throw new AithosSDKError("auth_custodial_seed_format", `expected a 128- or 160-byte custodial seed bundle, got ${len}`);
1587
+ }
1588
+ const root = seed.slice(0, 32);
1589
+ const pub = seed.slice(32, 64);
1590
+ const circle = seed.slice(64, 96);
1591
+ const self = seed.slice(96, 128);
1592
+ const data = len === 160 ? seed.slice(128, 160) : undefined;
1593
+ const hex = {
1594
+ root: bytesToHex(root),
1595
+ public: bytesToHex(pub),
1596
+ circle: bytesToHex(circle),
1597
+ self: bytesToHex(self),
1598
+ ...(data ? { data: bytesToHex(data) } : {}),
1599
+ };
1600
+ zeroize(root);
1601
+ zeroize(pub);
1602
+ zeroize(circle);
1603
+ zeroize(self);
1604
+ if (data)
1605
+ zeroize(data);
1606
+ zeroize(seed);
1607
+ return hex;
1608
+ }
693
1609
  /**
694
1610
  * Project a delegate as it appears in a `BlobPlaintext` (extension-kit
695
1611
  * `StoredDelegate` shape) onto the SDK's own {@link StoredDelegateKeys}.