@aithos/sdk 0.1.0-alpha.5 → 0.1.0-alpha.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -7
- package/dist/src/apps.d.ts +224 -0
- package/dist/src/apps.js +432 -0
- package/dist/src/assets.d.ts +209 -0
- package/dist/src/assets.js +534 -0
- package/dist/src/auth-api.d.ts +219 -0
- package/dist/src/auth-api.js +248 -0
- package/dist/src/auth.d.ts +543 -0
- package/dist/src/auth.js +937 -31
- package/dist/src/compute.d.ts +464 -6
- package/dist/src/compute.js +746 -20
- package/dist/src/data-schema-contacts-v1.d.ts +14 -0
- package/dist/src/data-schema-contacts-v1.js +28 -0
- package/dist/src/data.d.ts +342 -0
- package/dist/src/data.js +1002 -0
- package/dist/src/endpoints.d.ts +25 -0
- package/dist/src/endpoints.js +7 -0
- package/dist/src/ethos.d.ts +85 -0
- package/dist/src/ethos.js +463 -7
- package/dist/src/index.d.ts +17 -6
- package/dist/src/index.js +25 -3
- package/dist/src/internal/delegate-bundle.js +7 -2
- package/dist/src/internal/envelope.d.ts +93 -0
- package/dist/src/internal/envelope.js +59 -0
- package/dist/src/mandates.d.ts +111 -2
- package/dist/src/mandates.js +150 -7
- package/dist/src/react/AithosAsset.d.ts +66 -0
- package/dist/src/react/AithosAsset.js +67 -0
- package/dist/src/react/context.d.ts +29 -0
- package/dist/src/react/context.js +31 -0
- package/dist/src/react/index.d.ts +29 -0
- package/dist/src/react/index.js +31 -0
- package/dist/src/react/use-aithos-asset.d.ts +39 -0
- package/dist/src/react/use-aithos-asset.js +118 -0
- package/dist/src/react/use-transcribe-pending.d.ts +21 -0
- package/dist/src/react/use-transcribe-pending.js +47 -0
- package/dist/src/sdk.d.ts +10 -0
- package/dist/src/sdk.js +22 -0
- package/dist/src/transcribe-resilience.d.ts +57 -0
- package/dist/src/transcribe-resilience.js +203 -0
- package/dist/src/web.d.ts +279 -0
- package/dist/src/web.js +186 -0
- package/dist/test/auth-j3.test.js +32 -1
- package/dist/test/canonical-conformance.test.d.ts +2 -0
- package/dist/test/canonical-conformance.test.js +86 -0
- package/dist/test/compute-delegate-path.test.d.ts +2 -0
- package/dist/test/compute-delegate-path.test.js +183 -0
- package/dist/test/compute.test.js +4 -0
- package/dist/test/endpoints.test.js +30 -1
- package/dist/test/envelope-core-conformance.test.d.ts +2 -0
- package/dist/test/envelope-core-conformance.test.js +75 -0
- package/dist/test/envelope.test.d.ts +2 -0
- package/dist/test/envelope.test.js +318 -0
- package/dist/test/ethos-first-edition.test.d.ts +2 -0
- package/dist/test/ethos-first-edition.test.js +371 -0
- package/dist/test/mandates-compute.test.d.ts +2 -0
- package/dist/test/mandates-compute.test.js +256 -0
- package/dist/test/sdk.test.js +11 -2
- package/dist/test/signup-bootstrap.test.d.ts +2 -0
- package/dist/test/signup-bootstrap.test.js +311 -0
- package/dist/test/transcribe-invoke.test.d.ts +2 -0
- package/dist/test/transcribe-invoke.test.js +204 -0
- package/dist/test/transcribe.test.d.ts +2 -0
- package/dist/test/transcribe.test.js +186 -0
- package/dist/test/web.test.d.ts +2 -0
- package/dist/test/web.test.js +270 -0
- package/package.json +20 -3
package/dist/src/auth.js
CHANGED
|
@@ -20,38 +20,55 @@
|
|
|
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, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, 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";
|
|
30
31
|
import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from "./internal/recovery-file.js";
|
|
31
32
|
import { AithosSDKError } from "./types.js";
|
|
32
33
|
/** Default URL of the Aithos auth backend. */
|
|
33
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";
|
|
34
37
|
/* -------------------------------------------------------------------------- */
|
|
35
38
|
/* AithosAuth */
|
|
36
39
|
/* -------------------------------------------------------------------------- */
|
|
37
40
|
export class AithosAuth {
|
|
38
41
|
authBaseUrl;
|
|
42
|
+
apiBaseUrl;
|
|
39
43
|
#fetchImpl;
|
|
40
44
|
#win;
|
|
41
45
|
#sessionStore;
|
|
42
46
|
#keyStore;
|
|
47
|
+
#publicKey;
|
|
43
48
|
/** In-memory owner signers — populated after sign-in or `resume`. */
|
|
44
49
|
#ownerSigners = null;
|
|
45
50
|
/** Active delegate registry. */
|
|
46
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;
|
|
47
62
|
constructor(config = {}) {
|
|
48
63
|
this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
|
|
64
|
+
this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
|
|
49
65
|
this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
50
66
|
this.#win =
|
|
51
67
|
config.window ??
|
|
52
68
|
(typeof window !== "undefined" ? window : undefined);
|
|
53
69
|
this.#sessionStore = config.sessionStore ?? defaultSessionStore();
|
|
54
70
|
this.#keyStore = config.keyStore ?? defaultKeyStore();
|
|
71
|
+
this.#publicKey = config.publicKey;
|
|
55
72
|
}
|
|
56
73
|
/* ------------------------------------------------------------------------ */
|
|
57
74
|
/* Boot-time hydration */
|
|
@@ -131,6 +148,64 @@ export class AithosAuth {
|
|
|
131
148
|
canSignAsOwner() {
|
|
132
149
|
return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
|
|
133
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
|
+
}
|
|
134
209
|
canSignAsDelegateFor(did) {
|
|
135
210
|
const a = this.#delegates.findForSubject(did);
|
|
136
211
|
return a !== undefined && !a.destroyed;
|
|
@@ -162,6 +237,63 @@ export class AithosAuth {
|
|
|
162
237
|
return this.#delegates.findForSubject(did);
|
|
163
238
|
}
|
|
164
239
|
/* ------------------------------------------------------------------------ */
|
|
240
|
+
/* Unified email + password — signInAuto (zk / custodial dispatch) */
|
|
241
|
+
/* ------------------------------------------------------------------------ */
|
|
242
|
+
/**
|
|
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).
|
|
252
|
+
*
|
|
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).
|
|
258
|
+
*
|
|
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)
|
|
266
|
+
*
|
|
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.
|
|
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
|
+
/* ------------------------------------------------------------------------ */
|
|
165
297
|
/* Email + password — signIn */
|
|
166
298
|
/* ------------------------------------------------------------------------ */
|
|
167
299
|
async signIn(input) {
|
|
@@ -306,6 +438,16 @@ export class AithosAuth {
|
|
|
306
438
|
zeroize(authKey);
|
|
307
439
|
zeroize(encKey);
|
|
308
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);
|
|
309
451
|
// Hydrate in-memory state from the fresh identity.
|
|
310
452
|
if (this.#ownerSigners)
|
|
311
453
|
this.#ownerSigners.destroy();
|
|
@@ -431,6 +573,13 @@ export class AithosAuth {
|
|
|
431
573
|
if (!this.#win) {
|
|
432
574
|
throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
|
|
433
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
|
+
}
|
|
434
583
|
const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
|
|
435
584
|
if (opts?.appState) {
|
|
436
585
|
if (opts.appState.length > 1024) {
|
|
@@ -438,10 +587,48 @@ export class AithosAuth {
|
|
|
438
587
|
}
|
|
439
588
|
url.searchParams.set("app_state", opts.appState);
|
|
440
589
|
}
|
|
590
|
+
if (opts?.appId && opts?.returnTo) {
|
|
591
|
+
url.searchParams.set("app_id", opts.appId);
|
|
592
|
+
url.searchParams.set("redirect_uri", opts.returnTo);
|
|
593
|
+
}
|
|
441
594
|
this.#win.location.assign(url.toString());
|
|
442
595
|
throw new AithosSDKError("auth_redirecting", "redirecting to google");
|
|
443
596
|
}
|
|
597
|
+
/**
|
|
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.
|
|
605
|
+
*/
|
|
444
606
|
async handleCallback() {
|
|
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() {
|
|
445
632
|
if (!this.#win)
|
|
446
633
|
return null;
|
|
447
634
|
const here = new URL(this.#win.location.href);
|
|
@@ -454,8 +641,16 @@ export class AithosAuth {
|
|
|
454
641
|
}
|
|
455
642
|
if (!code)
|
|
456
643
|
return null;
|
|
457
|
-
|
|
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.
|
|
458
652
|
cleanCallbackParams(this.#win, here);
|
|
653
|
+
const session = await this.exchange(code);
|
|
459
654
|
// Hydrate signers if the SSO response carried an enc_key (Google flow
|
|
460
655
|
// gives us the AES-GCM key in plaintext, encrypted only in transit
|
|
461
656
|
// by TLS — see auth.aithos.be design doc).
|
|
@@ -471,34 +666,44 @@ export class AithosAuth {
|
|
|
471
666
|
const blobBytes = decryptBlob(encKey, nonce, blob);
|
|
472
667
|
try {
|
|
473
668
|
const plaintext = parseBlob(blobBytes);
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
this.#
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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 */
|
|
502
707
|
}
|
|
503
708
|
}
|
|
504
709
|
}
|
|
@@ -547,6 +752,609 @@ export class AithosAuth {
|
|
|
547
752
|
return (await res.json());
|
|
548
753
|
}
|
|
549
754
|
/* ------------------------------------------------------------------------ */
|
|
755
|
+
/* Complete SSO first login */
|
|
756
|
+
/* ------------------------------------------------------------------------ */
|
|
757
|
+
/**
|
|
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).
|
|
787
|
+
*/
|
|
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
|
+
* Send an invitation magic link carrying a mandate. The issuer (owner)
|
|
1034
|
+
* mints any mandate via {@link AithosSDK.mandates} (read/write/append/…),
|
|
1035
|
+
* then calls this with the bundle: the auth backend stores it bound to a
|
|
1036
|
+
* single-use token and emails the magic link. The mandate (and its delegate
|
|
1037
|
+
* seed) never ride the email URL. The invitee redeems it via
|
|
1038
|
+
* {@link acceptInvite}.
|
|
1039
|
+
*
|
|
1040
|
+
* Generic — knows nothing about the mandate's scope. Authenticate with
|
|
1041
|
+
* `apiKey` (server) or `publicKey` (browser, Origin-gated).
|
|
1042
|
+
*/
|
|
1043
|
+
async inviteCustodial(input) {
|
|
1044
|
+
if (!input.email) {
|
|
1045
|
+
throw new AithosSDKError("auth_invalid_input", "inviteCustodial: email is required");
|
|
1046
|
+
}
|
|
1047
|
+
if (input.mandateBundle === undefined || input.mandateBundle === null) {
|
|
1048
|
+
throw new AithosSDKError("auth_invalid_input", "inviteCustodial: mandateBundle is required");
|
|
1049
|
+
}
|
|
1050
|
+
const apiKey = input.apiKey;
|
|
1051
|
+
const publicKey = input.publicKey ?? this.#publicKey;
|
|
1052
|
+
if (!apiKey && !publicKey) {
|
|
1053
|
+
throw new AithosSDKError("auth_missing_api_key", "inviteCustodial: pass apiKey, or publicKey, or set publicKey on the AithosAuth constructor");
|
|
1054
|
+
}
|
|
1055
|
+
// Normalize the bundle to a JSON string (the opaque invite payload).
|
|
1056
|
+
let invitePayload;
|
|
1057
|
+
if (typeof input.mandateBundle === "string") {
|
|
1058
|
+
invitePayload = input.mandateBundle;
|
|
1059
|
+
}
|
|
1060
|
+
else if (input.mandateBundle instanceof Blob) {
|
|
1061
|
+
invitePayload = await input.mandateBundle.text();
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
invitePayload = JSON.stringify(input.mandateBundle);
|
|
1065
|
+
}
|
|
1066
|
+
const result = await custodialInvite({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
1067
|
+
email: input.email,
|
|
1068
|
+
invitePayload,
|
|
1069
|
+
...(apiKey ? { apiKey } : publicKey ? { publicKey } : {}),
|
|
1070
|
+
...(input.ttlSeconds !== undefined ? { ttlSeconds: input.ttlSeconds } : {}),
|
|
1071
|
+
...(input.displayName ? { displayName: input.displayName } : {}),
|
|
1072
|
+
});
|
|
1073
|
+
return {
|
|
1074
|
+
status: "invited",
|
|
1075
|
+
email: result.email,
|
|
1076
|
+
mailSent: result.mailSent,
|
|
1077
|
+
...(result.mailMessageId !== undefined ? { mailMessageId: result.mailMessageId } : {}),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Redeem an invitation from the magic link: consume the token, sign in
|
|
1082
|
+
* (create the account with `password`, or authenticate an existing one),
|
|
1083
|
+
* and AUTO-IMPORT the mandate the inviter attached. Returns the session and
|
|
1084
|
+
* the imported {@link DelegateInfo}.
|
|
1085
|
+
*
|
|
1086
|
+
* Mount this on the page declared as the invitation's verify/redirect URL;
|
|
1087
|
+
* read `email` + `token` from `window.location.search`, collect the
|
|
1088
|
+
* `password`, call this.
|
|
1089
|
+
*
|
|
1090
|
+
* Throws `auth_token_invalid_or_expired` (bad/consumed/expired token) or an
|
|
1091
|
+
* auth error if an existing account's password is wrong / a new one is weak.
|
|
1092
|
+
*/
|
|
1093
|
+
async acceptInvite(input) {
|
|
1094
|
+
if (!input.email || !input.token) {
|
|
1095
|
+
throw new AithosSDKError("auth_invalid_input", "acceptInvite: email and token are required");
|
|
1096
|
+
}
|
|
1097
|
+
const resp = await custodialAccept({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
1098
|
+
email: input.email,
|
|
1099
|
+
token: input.token,
|
|
1100
|
+
...(input.password ? { password: input.password } : {}),
|
|
1101
|
+
});
|
|
1102
|
+
// Materialise the 4 sphere seeds + session — same shape as the verifyEmail
|
|
1103
|
+
// magic-link path. Kept inline (additive; verifyEmail is left untouched).
|
|
1104
|
+
if (resp.seed.byteLength !== 128) {
|
|
1105
|
+
zeroize(resp.seed);
|
|
1106
|
+
zeroize(resp.encKey);
|
|
1107
|
+
throw new AithosSDKError("auth_custodial_seed_format", `acceptInvite: expected 128-byte seed bundle, got ${resp.seed.byteLength}`);
|
|
1108
|
+
}
|
|
1109
|
+
const seedRoot = resp.seed.slice(0, 32);
|
|
1110
|
+
const seedPublic = resp.seed.slice(32, 64);
|
|
1111
|
+
const seedCircle = resp.seed.slice(64, 96);
|
|
1112
|
+
const seedSelf = resp.seed.slice(96, 128);
|
|
1113
|
+
const stored = {
|
|
1114
|
+
version: "0.1.0-hex",
|
|
1115
|
+
did: resp.did,
|
|
1116
|
+
handle: resp.handle,
|
|
1117
|
+
displayName: resp.displayName,
|
|
1118
|
+
seedsHex: {
|
|
1119
|
+
root: bytesToHex(seedRoot),
|
|
1120
|
+
public: bytesToHex(seedPublic),
|
|
1121
|
+
circle: bytesToHex(seedCircle),
|
|
1122
|
+
self: bytesToHex(seedSelf),
|
|
1123
|
+
},
|
|
1124
|
+
savedAt: new Date().toISOString(),
|
|
1125
|
+
};
|
|
1126
|
+
zeroize(resp.seed);
|
|
1127
|
+
zeroize(seedRoot);
|
|
1128
|
+
zeroize(seedPublic);
|
|
1129
|
+
zeroize(seedCircle);
|
|
1130
|
+
zeroize(seedSelf);
|
|
1131
|
+
zeroize(resp.encKey);
|
|
1132
|
+
const identity = browserIdentityFromStored({
|
|
1133
|
+
handle: stored.handle,
|
|
1134
|
+
displayName: stored.displayName,
|
|
1135
|
+
did: stored.did,
|
|
1136
|
+
seeds: stored.seedsHex,
|
|
1137
|
+
});
|
|
1138
|
+
await this.#publishIdentity(identity);
|
|
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: false,
|
|
1153
|
+
};
|
|
1154
|
+
this.#sessionStore.set(session);
|
|
1155
|
+
// Import the invited mandate into the keystore (generic — any scope).
|
|
1156
|
+
const delegate = await this.importMandate({ bundle: resp.invitePayload });
|
|
1157
|
+
return {
|
|
1158
|
+
status: "signed_in",
|
|
1159
|
+
session,
|
|
1160
|
+
delegate,
|
|
1161
|
+
accountCreated: resp.accountCreated,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Re-send the verification mail for a pending account. Use when the
|
|
1166
|
+
* user reports never having received the welcome mail, or when their
|
|
1167
|
+
* verification token expired (24h TTL).
|
|
1168
|
+
*
|
|
1169
|
+
* The backend is anti-enumeration (always 200) and rate-limited
|
|
1170
|
+
* 1/h/account, so it's safe to call even when the state of `email`
|
|
1171
|
+
* is unknown. Accepts the same credential families as
|
|
1172
|
+
* {@link signUpCustodial}; falls back to the constructor's
|
|
1173
|
+
* `publicKey` when neither override is set.
|
|
1174
|
+
*/
|
|
1175
|
+
async resendVerificationEmail(input) {
|
|
1176
|
+
if (!input.email) {
|
|
1177
|
+
throw new AithosSDKError("auth_invalid_input", "resendVerificationEmail: email is required");
|
|
1178
|
+
}
|
|
1179
|
+
const apiKey = input.apiKey;
|
|
1180
|
+
const publicKey = input.publicKey ?? this.#publicKey;
|
|
1181
|
+
if (!apiKey && !publicKey) {
|
|
1182
|
+
throw new AithosSDKError("auth_missing_api_key", "resendVerificationEmail: pass apiKey, publicKey, or set publicKey on the AithosAuth constructor");
|
|
1183
|
+
}
|
|
1184
|
+
await custodialResendVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
1185
|
+
email: input.email,
|
|
1186
|
+
...(apiKey ? { apiKey } : {}),
|
|
1187
|
+
...(apiKey ? {} : publicKey ? { publicKey } : {}),
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Authenticate a custodial-mode user with email + password. Single
|
|
1192
|
+
* round-trip: returns a fresh JWT session AND hydrates the local
|
|
1193
|
+
* KeyStore with the user's 4 Ed25519 seeds (KMS-unwrapped server-side
|
|
1194
|
+
* after Argon2id verify).
|
|
1195
|
+
*
|
|
1196
|
+
* After this returns, the SDK is ready to publish ethos editions,
|
|
1197
|
+
* invoke compute, mint mandates, etc. — exactly as if the user had
|
|
1198
|
+
* signed in via {@link signIn} (zk) or {@link handleCallback} (SSO).
|
|
1199
|
+
*
|
|
1200
|
+
* Errors map to `AithosSDKError` codes:
|
|
1201
|
+
* - `auth_invalid_input` (your code passed empty fields)
|
|
1202
|
+
* - `auth_invalid_credentials` (401 — wrong email / wrong password)
|
|
1203
|
+
* - `auth_wrong_auth_mode` (403 — user exists in another flow)
|
|
1204
|
+
*/
|
|
1205
|
+
async signInCustodial(input) {
|
|
1206
|
+
if (!input.email || !input.password) {
|
|
1207
|
+
throw new AithosSDKError("auth_invalid_input", "signInCustodial: email and password are required");
|
|
1208
|
+
}
|
|
1209
|
+
const resp = await custodialSignIn({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
|
|
1210
|
+
// Split the 128-byte seed bundle into the four sphere seeds. The
|
|
1211
|
+
// backend lays them out in the canonical order
|
|
1212
|
+
// [root || public || circle || self] (cf. seed-wrapper.ts).
|
|
1213
|
+
if (resp.seed.byteLength !== 128) {
|
|
1214
|
+
// Legacy 32-byte rows shouldn't happen in production (we wiped the
|
|
1215
|
+
// single test row before redeploying with the 4-seed bundle), but
|
|
1216
|
+
// we surface a clear error rather than silently corrupting the
|
|
1217
|
+
// identity.
|
|
1218
|
+
zeroize(resp.seed);
|
|
1219
|
+
zeroize(resp.encKey);
|
|
1220
|
+
throw new AithosSDKError("auth_custodial_seed_format", `signInCustodial: expected 128-byte seed bundle, got ${resp.seed.byteLength}`);
|
|
1221
|
+
}
|
|
1222
|
+
const seedRoot = resp.seed.slice(0, 32);
|
|
1223
|
+
const seedPublic = resp.seed.slice(32, 64);
|
|
1224
|
+
const seedCircle = resp.seed.slice(64, 96);
|
|
1225
|
+
const seedSelf = resp.seed.slice(96, 128);
|
|
1226
|
+
// Stored shape uses hex strings; round-trip through bytesToHex
|
|
1227
|
+
// so the keyStore record is identical to what signUp(zk) writes.
|
|
1228
|
+
const stored = {
|
|
1229
|
+
version: "0.1.0-hex",
|
|
1230
|
+
did: resp.did,
|
|
1231
|
+
handle: resp.handle,
|
|
1232
|
+
displayName: resp.displayName,
|
|
1233
|
+
seedsHex: {
|
|
1234
|
+
root: bytesToHex(seedRoot),
|
|
1235
|
+
public: bytesToHex(seedPublic),
|
|
1236
|
+
circle: bytesToHex(seedCircle),
|
|
1237
|
+
self: bytesToHex(seedSelf),
|
|
1238
|
+
},
|
|
1239
|
+
savedAt: new Date().toISOString(),
|
|
1240
|
+
};
|
|
1241
|
+
// Zeroize the raw bundle + the split copies now that they've been
|
|
1242
|
+
// serialised into the keyStore record (hex strings live in the
|
|
1243
|
+
// record; the original bytes can go).
|
|
1244
|
+
zeroize(resp.seed);
|
|
1245
|
+
zeroize(seedRoot);
|
|
1246
|
+
zeroize(seedPublic);
|
|
1247
|
+
zeroize(seedCircle);
|
|
1248
|
+
zeroize(seedSelf);
|
|
1249
|
+
// The enc_key is informational here — the custodial blob is empty
|
|
1250
|
+
// at first login. We still don't keep it in memory.
|
|
1251
|
+
zeroize(resp.encKey);
|
|
1252
|
+
// Bootstrap the Ethos on api.aithos.be — same as signUp(zk). Without
|
|
1253
|
+
// this, the DID returned by signInCustodial isn't resolvable on the
|
|
1254
|
+
// platform (feed / profile lookups return "not found: did …"). The
|
|
1255
|
+
// call is idempotent server-side: a published identity replays as a
|
|
1256
|
+
// no-op. We do it here (rather than only on a "first login" flag)
|
|
1257
|
+
// because the auth Lambda doesn't know whether the api.aithos.be
|
|
1258
|
+
// side has been populated — the SDK is the single source of truth
|
|
1259
|
+
// for "the user's Ethos is bootstrapped".
|
|
1260
|
+
//
|
|
1261
|
+
// Failure aborts the sign-in: the user can retry (same behaviour as
|
|
1262
|
+
// signUp(zk)), and the local keystore is NOT populated half-way.
|
|
1263
|
+
const identity = browserIdentityFromStored({
|
|
1264
|
+
handle: stored.handle,
|
|
1265
|
+
displayName: stored.displayName,
|
|
1266
|
+
did: stored.did,
|
|
1267
|
+
seeds: stored.seedsHex,
|
|
1268
|
+
});
|
|
1269
|
+
await this.#publishIdentity(identity);
|
|
1270
|
+
// Hydrate in-memory owner signers from the freshly-stored material.
|
|
1271
|
+
if (this.#ownerSigners)
|
|
1272
|
+
this.#ownerSigners.destroy();
|
|
1273
|
+
this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
|
|
1274
|
+
await this.#keyStore.saveOwner(stored);
|
|
1275
|
+
const session = {
|
|
1276
|
+
session: resp.session,
|
|
1277
|
+
exp: resp.exp,
|
|
1278
|
+
did: resp.did,
|
|
1279
|
+
handle: resp.handle,
|
|
1280
|
+
blob_b64: bytesToB64Public(resp.blob),
|
|
1281
|
+
blob_nonce_b64: bytesToB64Public(resp.blobNonce),
|
|
1282
|
+
blob_version: resp.blobVersion,
|
|
1283
|
+
enc_key_b64: "",
|
|
1284
|
+
is_first_login: resp.passwordMustChange,
|
|
1285
|
+
};
|
|
1286
|
+
this.#sessionStore.set(session);
|
|
1287
|
+
return { session, passwordMustChange: resp.passwordMustChange };
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Trigger a password-reset email to the given address. Backend ALWAYS
|
|
1291
|
+
* resolves silently (no enumeration) — caller cannot tell whether the
|
|
1292
|
+
* email is registered or not. The mail itself, if sent, contains a
|
|
1293
|
+
* magic-link URL of shape `<resetBaseUrl>?token=<raw>&email=<email>`.
|
|
1294
|
+
*
|
|
1295
|
+
* Per-email rate limits apply server-side (5 mails/day, 5 min cooldown
|
|
1296
|
+
* between consecutive requests). Calls during cooldown silently no-op
|
|
1297
|
+
* the mail send while still returning success here.
|
|
1298
|
+
*/
|
|
1299
|
+
async requestPasswordReset(input) {
|
|
1300
|
+
if (!input.email) {
|
|
1301
|
+
throw new AithosSDKError("auth_invalid_input", "requestPasswordReset: email is required");
|
|
1302
|
+
}
|
|
1303
|
+
await custodialResetRequest({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Finalise a password reset using the magic-link token sent to the
|
|
1307
|
+
* user's inbox by {@link requestPasswordReset}.
|
|
1308
|
+
*
|
|
1309
|
+
* Typical use site: the page mounted on the reset URL declared in
|
|
1310
|
+
* `aithos-auth-apps.reset_base_url`. The page reads `email` and
|
|
1311
|
+
* `token` from `window.location.search`, prompts the user for a new
|
|
1312
|
+
* password, then calls this method.
|
|
1313
|
+
*
|
|
1314
|
+
* On success, the returned {@link AithosSession} is persisted to the
|
|
1315
|
+
* session store but the local keystore is NOT hydrated — the backend
|
|
1316
|
+
* does not return the seed bundle on this endpoint. To get a fully
|
|
1317
|
+
* usable session (one that can sign envelopes), follow up with
|
|
1318
|
+
* {@link signInCustodial} using the email + new password. The two
|
|
1319
|
+
* round-trips can be hidden inside a single UI action: reset → auto
|
|
1320
|
+
* sign-in → redirect to dashboard.
|
|
1321
|
+
*
|
|
1322
|
+
* Errors map to `AithosSDKError` codes:
|
|
1323
|
+
* - `auth_invalid_input` (your code passed empty fields)
|
|
1324
|
+
* - `auth_reset_token_invalid` (400 — token forged / wrong email)
|
|
1325
|
+
* - `auth_reset_token_expired` (410 — token TTL elapsed)
|
|
1326
|
+
* - `auth_reset_token_consumed` (409 — already used)
|
|
1327
|
+
* - `auth_password_too_short` (400 — < 10 chars)
|
|
1328
|
+
* - `auth_custodial_reset_failed` (catch-all)
|
|
1329
|
+
*/
|
|
1330
|
+
async applyPasswordReset(input) {
|
|
1331
|
+
if (!input.email || !input.token || !input.newPassword) {
|
|
1332
|
+
throw new AithosSDKError("auth_invalid_input", "applyPasswordReset: email, token and newPassword are required");
|
|
1333
|
+
}
|
|
1334
|
+
const resp = await custodialResetFinalize({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input);
|
|
1335
|
+
// The reset endpoint mints a JWT but doesn't ship the seed bundle —
|
|
1336
|
+
// the caller still has to signInCustodial() to materialise the keys
|
|
1337
|
+
// locally. We persist the session anyway so any code that reads
|
|
1338
|
+
// `getCurrentSession()` between the reset and the follow-up sign-in
|
|
1339
|
+
// sees the new JWT (e.g. an analytics hook).
|
|
1340
|
+
const session = {
|
|
1341
|
+
session: resp.session,
|
|
1342
|
+
exp: resp.exp,
|
|
1343
|
+
did: resp.did,
|
|
1344
|
+
handle: resp.handle,
|
|
1345
|
+
// No blob / enc_key on this path — the reset endpoint doesn't
|
|
1346
|
+
// re-issue the vault. Leave the blob slots empty; the follow-up
|
|
1347
|
+
// signInCustodial() will populate them.
|
|
1348
|
+
blob_b64: "",
|
|
1349
|
+
blob_nonce_b64: "",
|
|
1350
|
+
blob_version: 0,
|
|
1351
|
+
enc_key_b64: "",
|
|
1352
|
+
is_first_login: false,
|
|
1353
|
+
};
|
|
1354
|
+
this.#sessionStore.set(session);
|
|
1355
|
+
return { session };
|
|
1356
|
+
}
|
|
1357
|
+
/* ------------------------------------------------------------------------ */
|
|
550
1358
|
/* Sign-out */
|
|
551
1359
|
/* ------------------------------------------------------------------------ */
|
|
552
1360
|
async signOut() {
|
|
@@ -558,6 +1366,101 @@ export class AithosAuth {
|
|
|
558
1366
|
await this.#keyStore.clearOwner().catch(() => { });
|
|
559
1367
|
await this.#keyStore.clearAllDelegates().catch(() => { });
|
|
560
1368
|
}
|
|
1369
|
+
/* ------------------------------------------------------------------------ */
|
|
1370
|
+
/* Internal — Ethos bootstrap */
|
|
1371
|
+
/* ------------------------------------------------------------------------ */
|
|
1372
|
+
/**
|
|
1373
|
+
* Provision the user's Ethos on `api.aithos.be` by signing and POSTing an
|
|
1374
|
+
* `aithos.publish_identity` envelope. Required after a fresh sign-up so
|
|
1375
|
+
* subsequent edition publishes (`me.publish()`) don't fail with
|
|
1376
|
+
* `-32020 subject identity not published`.
|
|
1377
|
+
*
|
|
1378
|
+
* Retries twice with exponential backoff on transient errors (network or
|
|
1379
|
+
* 5xx). Throws {@link AithosSDKError} with code `ethos_bootstrap_failed`
|
|
1380
|
+
* on definitive failure — the caller is expected to abort sign-up.
|
|
1381
|
+
*
|
|
1382
|
+
* @internal
|
|
1383
|
+
*/
|
|
1384
|
+
async #publishIdentity(identity) {
|
|
1385
|
+
const url = `${this.apiBaseUrl}/mcp/primitives/write`;
|
|
1386
|
+
const signedDoc = signedDidDocument(identity);
|
|
1387
|
+
const params = {
|
|
1388
|
+
did_document: signedDoc,
|
|
1389
|
+
handle: identity.handle,
|
|
1390
|
+
display_name: identity.displayName,
|
|
1391
|
+
};
|
|
1392
|
+
const envelope = buildSignedEnvelope({
|
|
1393
|
+
iss: identity.did,
|
|
1394
|
+
aud: url,
|
|
1395
|
+
method: "aithos.publish_identity",
|
|
1396
|
+
verificationMethod: `${identity.did}#root`,
|
|
1397
|
+
params,
|
|
1398
|
+
signer: identity.root,
|
|
1399
|
+
});
|
|
1400
|
+
const body = JSON.stringify({
|
|
1401
|
+
jsonrpc: "2.0",
|
|
1402
|
+
id: "publish_identity",
|
|
1403
|
+
method: "aithos.publish_identity",
|
|
1404
|
+
params: { ...params, _envelope: envelope },
|
|
1405
|
+
});
|
|
1406
|
+
// Two retries with backoff (300ms, 1500ms). Idempotent on the server
|
|
1407
|
+
// side — replaying the same publish_identity for an existing DID is a
|
|
1408
|
+
// no-op, so retries are safe even if the first attempt actually
|
|
1409
|
+
// succeeded but the response was lost.
|
|
1410
|
+
const delays = [0, 300, 1500];
|
|
1411
|
+
let lastError;
|
|
1412
|
+
for (const delay of delays) {
|
|
1413
|
+
if (delay > 0)
|
|
1414
|
+
await sleep(delay);
|
|
1415
|
+
try {
|
|
1416
|
+
const res = await this.#fetchImpl(url, {
|
|
1417
|
+
method: "POST",
|
|
1418
|
+
headers: { "content-type": "application/json" },
|
|
1419
|
+
body,
|
|
1420
|
+
});
|
|
1421
|
+
// Transport errors (5xx, no body) — retry. JSON-RPC errors come
|
|
1422
|
+
// back with HTTP 200 and an `error` field.
|
|
1423
|
+
if (!res.ok && res.status >= 500) {
|
|
1424
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
const json = (await res.json());
|
|
1428
|
+
if (json.error) {
|
|
1429
|
+
// Backward-compat shim for backends without the semantic-equality
|
|
1430
|
+
// fix on publish-identity (alpha.33+ regression): the server may
|
|
1431
|
+
// reject a republish with -32022 because the client regenerates
|
|
1432
|
+
// `aithos.created_at` (and `proof.created`) on every
|
|
1433
|
+
// `signedDidDocument()` call, breaking the strict byte-equal
|
|
1434
|
+
// idempotence the server enforces. For an honest signer (same
|
|
1435
|
+
// root key, same DID) the only way to hit this code path is the
|
|
1436
|
+
// timestamp-drift case — which is semantically a no-op. Treat as
|
|
1437
|
+
// success.
|
|
1438
|
+
//
|
|
1439
|
+
// Server-side fix (publish-identity.ts switched to semantic
|
|
1440
|
+
// equality on cryptographic fields only) makes this branch dead
|
|
1441
|
+
// code on upgraded backends. Kept here as defense-in-depth for
|
|
1442
|
+
// SDK consumers pointing at older deployments.
|
|
1443
|
+
if (json.error.code === -32022 &&
|
|
1444
|
+
/different did\.json already published/i.test(json.error.message)) {
|
|
1445
|
+
return; // already published with same crypto material — no-op
|
|
1446
|
+
}
|
|
1447
|
+
// JSON-RPC error: don't retry — these are deterministic
|
|
1448
|
+
// (validation, permission, identity-already-tombstoned, …).
|
|
1449
|
+
throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
|
|
1450
|
+
status: res.status,
|
|
1451
|
+
data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
return; // success
|
|
1455
|
+
}
|
|
1456
|
+
catch (e) {
|
|
1457
|
+
if (e instanceof AithosSDKError)
|
|
1458
|
+
throw e;
|
|
1459
|
+
lastError = e;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity unreachable after ${delays.length} attempts: ${lastError?.message ?? "unknown"}`);
|
|
1463
|
+
}
|
|
561
1464
|
}
|
|
562
1465
|
/* -------------------------------------------------------------------------- */
|
|
563
1466
|
/* Helpers */
|
|
@@ -565,6 +1468,9 @@ export class AithosAuth {
|
|
|
565
1468
|
function trimSlash(url) {
|
|
566
1469
|
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
567
1470
|
}
|
|
1471
|
+
function sleep(ms) {
|
|
1472
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1473
|
+
}
|
|
568
1474
|
function cleanCallbackParams(win, url) {
|
|
569
1475
|
url.searchParams.delete("aithos_code");
|
|
570
1476
|
url.searchParams.delete("aithos_error");
|