@aithos/sdk 0.1.0-alpha.12 → 0.1.0-alpha.14

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.
@@ -21,6 +21,15 @@ export interface RegisterApiResponse {
21
21
  readonly exp: number;
22
22
  }
23
23
  export declare function registerAccount(http: HttpClient, input: RegisterApiInput): Promise<RegisterApiResponse>;
24
+ export interface PutBlobApiInput {
25
+ readonly jwt: string;
26
+ readonly blob: Uint8Array;
27
+ readonly blobNonce: Uint8Array;
28
+ readonly blobVersion: number;
29
+ }
30
+ export declare function putBlob(http: HttpClient, input: PutBlobApiInput): Promise<{
31
+ ok: true;
32
+ }>;
24
33
  export interface LoginChallengeResponse {
25
34
  readonly authSalt: Uint8Array;
26
35
  readonly encSalt: Uint8Array;
@@ -41,6 +41,19 @@ async function postJson(http, path, body, jwt) {
41
41
  throw await readError(res, "request_failed");
42
42
  return (await res.json());
43
43
  }
44
+ async function putJson(http, path, body, jwt) {
45
+ const res = await http.fetchImpl(`${http.authBaseUrl}${path}`, {
46
+ method: "PUT",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ authorization: `Bearer ${jwt}`,
50
+ },
51
+ body: JSON.stringify(body),
52
+ });
53
+ if (!res.ok)
54
+ throw await readError(res, "request_failed");
55
+ return (await res.json());
56
+ }
44
57
  export async function registerAccount(http, input) {
45
58
  return postJson(http, "/auth/register", {
46
59
  email: input.email,
@@ -56,6 +69,13 @@ export async function registerAccount(http, input) {
56
69
  blob_version: input.blobVersion,
57
70
  });
58
71
  }
72
+ export async function putBlob(http, input) {
73
+ return putJson(http, "/auth/blob", {
74
+ blob_b64: bytesToB64(input.blob),
75
+ blob_nonce_b64: bytesToB64(input.blobNonce),
76
+ blob_version: input.blobVersion,
77
+ }, input.jwt);
78
+ }
59
79
  export async function loginChallenge(http, email) {
60
80
  const wire = await postJson(http, "/auth/login/challenge", { email });
61
81
  return {
@@ -105,6 +105,28 @@ export interface SignUpResult {
105
105
  readonly recoveryFile: Blob;
106
106
  readonly recoveryFilename: string;
107
107
  }
108
+ /**
109
+ * Input to {@link AithosAuth.completeSsoFirstLogin}. The handle is
110
+ * required (the auth backend pre-generated one from the user's email
111
+ * local-part, available on the session payload — we re-confirm it
112
+ * here so the user can edit before commit).
113
+ */
114
+ export interface CompleteSsoFirstLoginInput {
115
+ readonly handle: string;
116
+ readonly displayName?: string;
117
+ }
118
+ /**
119
+ * Result of {@link AithosAuth.completeSsoFirstLogin}. Returns a recovery
120
+ * file just like signUp — even though the user authenticated via Google,
121
+ * the freshly-generated Ed25519 seeds are the only material that can
122
+ * sign Aithos artifacts; without the recovery file, losing access to
123
+ * the Google account means losing the ethos forever.
124
+ */
125
+ export interface CompleteSsoFirstLoginResult {
126
+ readonly session: AithosSession;
127
+ readonly recoveryFile: Blob;
128
+ readonly recoveryFilename: string;
129
+ }
108
130
  export interface SignInWithRecoveryInput {
109
131
  /** Recovery file as a Blob (browser File input) or already-decoded JSON string. */
110
132
  readonly file: Blob | string;
@@ -183,8 +205,49 @@ export declare class AithosAuth {
183
205
  importMandate(input: ImportMandateInput): Promise<DelegateInfo>;
184
206
  removeMandate(mandateId: string): Promise<void>;
185
207
  signInWithGoogle(opts?: SignInWithGoogleOptions): never;
208
+ /**
209
+ * Public entrypoint — dedupes concurrent calls (React StrictMode).
210
+ * The first call kicks off the actual exchange; subsequent calls
211
+ * before that promise resolves return the SAME promise so they all
212
+ * receive the same `AithosSession | null`. Otherwise StrictMode's
213
+ * second invocation would race against the URL clean done by the
214
+ * first call and resolve to `null`, robbing the AuthCallback page
215
+ * of the session it actually obtained.
216
+ */
186
217
  handleCallback(): Promise<AithosSession | null>;
187
218
  exchange(aithosCode: string): Promise<AithosSession>;
219
+ /**
220
+ * Finish the first-time Google SSO bootstrap. After
221
+ * `signInWithGoogle()` + `handleCallback()`, a brand-new SSO user has
222
+ * a session JWT and an `enc_key` released by the auth backend, but
223
+ * NO Aithos identity yet (no Ed25519 seeds, no published did.json,
224
+ * no blob in the auth vault). This method closes that gap:
225
+ *
226
+ * 1. Generates a fresh {@link BrowserIdentity} client-side (4
227
+ * Ed25519 keypairs, derived DID).
228
+ * 2. Calls `aithos.publish_identity` on api.aithos.be so reads
229
+ * and writes against the Aithos primitives have an ethos to
230
+ * anchor to.
231
+ * 3. AES-GCM-encrypts the seeds with the session's `enc_key`,
232
+ * PUTs the result to `/auth/blob`. From now on, every Google
233
+ * sign-in for this user will receive the encrypted blob and
234
+ * hydrate locally.
235
+ * 4. Hydrates `ownerSigners` + `keyStore` so `canSignAsOwner()`
236
+ * flips to true.
237
+ * 5. Returns a recovery-file Blob — the only material that can
238
+ * restore this ethos if Google access is lost.
239
+ *
240
+ * Preconditions:
241
+ * - `getCurrentSession()` returns a non-null session (caller went
242
+ * through `handleCallback()` already).
243
+ * - The session's `blob_version` is 0 (i.e. no blob yet).
244
+ * - The session's `enc_key_b64` is non-empty.
245
+ *
246
+ * Throws `AithosSDKError("auth_sso_no_pending_first_login", …)` if
247
+ * preconditions don't hold (e.g. blob_version > 0 means the user has
248
+ * already completed setup; nothing to do).
249
+ */
250
+ completeSsoFirstLogin(input: CompleteSsoFirstLoginInput): Promise<CompleteSsoFirstLoginResult>;
188
251
  signOut(): Promise<void>;
189
252
  }
190
253
  //# sourceMappingURL=auth.d.ts.map
package/dist/src/auth.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // keyStore is the source of truth for "is the user signed in", the
22
22
  // JWT is auxiliary for compute/wallet.
23
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";
24
+ import { 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";
@@ -47,6 +47,16 @@ export class AithosAuth {
47
47
  #ownerSigners = null;
48
48
  /** Active delegate registry. */
49
49
  #delegates = new DelegateRegistry();
50
+ /**
51
+ * In-flight (or just-resolved) `handleCallback()` result. React
52
+ * StrictMode (dev) double-invokes the mount effect — the URL clean
53
+ * inside the first call makes the second invocation see a clean URL
54
+ * and resolve to `null`, with the session it just consumed locked
55
+ * inside the first promise. Caching the result here lets both
56
+ * invocations resolve to the same value. Cleared on next mount via
57
+ * the wrapper's once-per-instance dedup.
58
+ */
59
+ #handleCallbackPromise = null;
50
60
  constructor(config = {}) {
51
61
  this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
52
62
  this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
@@ -466,7 +476,41 @@ export class AithosAuth {
466
476
  this.#win.location.assign(url.toString());
467
477
  throw new AithosSDKError("auth_redirecting", "redirecting to google");
468
478
  }
479
+ /**
480
+ * Public entrypoint — dedupes concurrent calls (React StrictMode).
481
+ * The first call kicks off the actual exchange; subsequent calls
482
+ * before that promise resolves return the SAME promise so they all
483
+ * receive the same `AithosSession | null`. Otherwise StrictMode's
484
+ * second invocation would race against the URL clean done by the
485
+ * first call and resolve to `null`, robbing the AuthCallback page
486
+ * of the session it actually obtained.
487
+ */
469
488
  async handleCallback() {
489
+ if (!this.#win)
490
+ return null;
491
+ if (this.#handleCallbackPromise)
492
+ return this.#handleCallbackPromise;
493
+ const p = this.#doHandleCallback();
494
+ this.#handleCallbackPromise = p;
495
+ // Clear the cache once the promise settles so a subsequent
496
+ // signInWithGoogle round-trip on the same AithosAuth instance can
497
+ // process its own callback. We use `then(cleanup, cleanup)`
498
+ // rather than `finally(...)` because `finally` re-throws — without
499
+ // a downstream `.catch` the resulting promise becomes an
500
+ // unhandledrejection when `p` itself rejects (the caller already
501
+ // surfaces that rejection via the returned `p`). `then(success,
502
+ // error)` converts a rejection into a clean resolution on this
503
+ // side-effect chain so node:test doesn't flag the orphan as a
504
+ // failure.
505
+ const clear = () => {
506
+ if (this.#handleCallbackPromise === p) {
507
+ this.#handleCallbackPromise = null;
508
+ }
509
+ };
510
+ p.then(clear, clear);
511
+ return p;
512
+ }
513
+ async #doHandleCallback() {
470
514
  if (!this.#win)
471
515
  return null;
472
516
  const here = new URL(this.#win.location.href);
@@ -504,34 +548,44 @@ export class AithosAuth {
504
548
  const blobBytes = decryptBlob(encKey, nonce, blob);
505
549
  try {
506
550
  const plaintext = parseBlob(blobBytes);
507
- if (plaintext.identity.did === session.did) {
508
- if (this.#ownerSigners)
509
- this.#ownerSigners.destroy();
510
- this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
511
- await this.#keyStore.saveOwner({
512
- version: "0.1.0-hex",
513
- did: plaintext.identity.did,
514
- handle: plaintext.identity.handle,
515
- displayName: plaintext.identity.displayName,
516
- seedsHex: plaintext.seeds,
517
- savedAt: new Date().toISOString(),
518
- });
519
- await this.#keyStore.clearAllDelegates();
520
- this.#delegates.destroy();
521
- for (const d of plaintext.delegates) {
522
- const stored = storedDelegateFromBlob(d);
523
- try {
524
- await this.#keyStore.saveDelegate(stored);
525
- }
526
- catch {
527
- /* keep going */
528
- }
529
- try {
530
- this.#delegates.add(DelegateActor.fromStored(stored));
531
- }
532
- catch {
533
- /* keep going */
534
- }
551
+ // Earlier versions of the SDK gated hydration on
552
+ // `plaintext.identity.did === session.did` as a defense
553
+ // against tampered sessionStores. The check breaks SSO
554
+ // flows: the auth backend assigns a placeholder random
555
+ // DID at user-record creation time (no client keypair on
556
+ // hand), but the BLOB is built around a real
557
+ // BrowserIdentity whose DID is derived from its root
558
+ // pubkey. The two intentionally differ — the blob is the
559
+ // truth source for everything downstream (signing, DID
560
+ // resolution against api.aithos.be), the session.did is
561
+ // just auth-side bookkeeping. Drop the check and trust
562
+ // the blob.
563
+ if (this.#ownerSigners)
564
+ this.#ownerSigners.destroy();
565
+ this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
566
+ await this.#keyStore.saveOwner({
567
+ version: "0.1.0-hex",
568
+ did: plaintext.identity.did,
569
+ handle: plaintext.identity.handle,
570
+ displayName: plaintext.identity.displayName,
571
+ seedsHex: plaintext.seeds,
572
+ savedAt: new Date().toISOString(),
573
+ });
574
+ await this.#keyStore.clearAllDelegates();
575
+ this.#delegates.destroy();
576
+ for (const d of plaintext.delegates) {
577
+ const stored = storedDelegateFromBlob(d);
578
+ try {
579
+ await this.#keyStore.saveDelegate(stored);
580
+ }
581
+ catch {
582
+ /* keep going */
583
+ }
584
+ try {
585
+ this.#delegates.add(DelegateActor.fromStored(stored));
586
+ }
587
+ catch {
588
+ /* keep going */
535
589
  }
536
590
  }
537
591
  }
@@ -580,6 +634,143 @@ export class AithosAuth {
580
634
  return (await res.json());
581
635
  }
582
636
  /* ------------------------------------------------------------------------ */
637
+ /* Complete SSO first login */
638
+ /* ------------------------------------------------------------------------ */
639
+ /**
640
+ * Finish the first-time Google SSO bootstrap. After
641
+ * `signInWithGoogle()` + `handleCallback()`, a brand-new SSO user has
642
+ * a session JWT and an `enc_key` released by the auth backend, but
643
+ * NO Aithos identity yet (no Ed25519 seeds, no published did.json,
644
+ * no blob in the auth vault). This method closes that gap:
645
+ *
646
+ * 1. Generates a fresh {@link BrowserIdentity} client-side (4
647
+ * Ed25519 keypairs, derived DID).
648
+ * 2. Calls `aithos.publish_identity` on api.aithos.be so reads
649
+ * and writes against the Aithos primitives have an ethos to
650
+ * anchor to.
651
+ * 3. AES-GCM-encrypts the seeds with the session's `enc_key`,
652
+ * PUTs the result to `/auth/blob`. From now on, every Google
653
+ * sign-in for this user will receive the encrypted blob and
654
+ * hydrate locally.
655
+ * 4. Hydrates `ownerSigners` + `keyStore` so `canSignAsOwner()`
656
+ * flips to true.
657
+ * 5. Returns a recovery-file Blob — the only material that can
658
+ * restore this ethos if Google access is lost.
659
+ *
660
+ * Preconditions:
661
+ * - `getCurrentSession()` returns a non-null session (caller went
662
+ * through `handleCallback()` already).
663
+ * - The session's `blob_version` is 0 (i.e. no blob yet).
664
+ * - The session's `enc_key_b64` is non-empty.
665
+ *
666
+ * Throws `AithosSDKError("auth_sso_no_pending_first_login", …)` if
667
+ * preconditions don't hold (e.g. blob_version > 0 means the user has
668
+ * already completed setup; nothing to do).
669
+ */
670
+ async completeSsoFirstLogin(input) {
671
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
672
+ throw new AithosSDKError("auth_invalid_handle", "handle must be 1–63 alphanumeric chars + _ -");
673
+ }
674
+ const displayName = input.displayName ?? input.handle;
675
+ const session = this.#sessionStore.get();
676
+ if (!session) {
677
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "no active session — sign in via Google first");
678
+ }
679
+ if (!session.enc_key_b64) {
680
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "session does not carry an enc_key (not an SSO-flow session?)");
681
+ }
682
+ if (session.blob_version > 0) {
683
+ throw new AithosSDKError("auth_sso_no_pending_first_login", "this session already has a published blob — nothing to bootstrap");
684
+ }
685
+ // 1. Fresh identity client-side. The DID derived here is the
686
+ // truth source from now on — the placeholder DID stamped in
687
+ // the user record by the auth Lambda is left as-is (auth-side
688
+ // bookkeeping; never used for signing).
689
+ const identity = createBrowserIdentity(input.handle, displayName);
690
+ const recoverySerialized = serializeRecoveryFile(identity);
691
+ const recoveryFile = new Blob([recoverySerialized.text], {
692
+ type: "application/json",
693
+ });
694
+ // 2. publish_identity on api.aithos.be — reuses the alpha.6
695
+ // helper. Must succeed before we persist anything locally:
696
+ // a half-completed bootstrap (blob uploaded but identity not
697
+ // published) would leave the user with seeds they can't use.
698
+ await this.#publishIdentity(identity);
699
+ // 3. Encrypt the seeds with the SSO-released enc_key and PUT
700
+ // /auth/blob. The auth Lambda accepts the new blob_version=1
701
+ // and stores the bytes verbatim.
702
+ const encKey = b64ToBytes(session.enc_key_b64);
703
+ let blob;
704
+ let blobNonce;
705
+ let plaintext;
706
+ try {
707
+ plaintext = buildBlobPlaintext({
708
+ identity: {
709
+ did: identity.did,
710
+ handle: identity.handle,
711
+ displayName: identity.displayName,
712
+ },
713
+ seeds: {
714
+ root: identity.root.seed,
715
+ public: identity.public.seed,
716
+ circle: identity.circle.seed,
717
+ self: identity.self.seed,
718
+ },
719
+ delegates: [],
720
+ });
721
+ const blobBytes = serializeBlob(plaintext);
722
+ blobNonce = randomNonce();
723
+ blob = encryptBlob(encKey, blobNonce, blobBytes);
724
+ }
725
+ finally {
726
+ zeroize(encKey);
727
+ }
728
+ const newBlobVersion = 1;
729
+ try {
730
+ await putBlob({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
731
+ jwt: session.session,
732
+ blob,
733
+ blobNonce,
734
+ blobVersion: newBlobVersion,
735
+ });
736
+ }
737
+ catch (e) {
738
+ throw new AithosSDKError("auth_sso_blob_upload_failed", `couldn't store the encrypted vault on auth.aithos.be: ${e.message ?? "unknown"}`);
739
+ }
740
+ // 4. Hydrate in-memory state from the fresh identity.
741
+ if (this.#ownerSigners)
742
+ this.#ownerSigners.destroy();
743
+ this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
744
+ await this.#keyStore.saveOwner({
745
+ version: "0.1.0-hex",
746
+ did: identity.did,
747
+ handle: identity.handle,
748
+ displayName: identity.displayName,
749
+ seedsHex: {
750
+ root: bytesToHex(identity.root.seed),
751
+ public: bytesToHex(identity.public.seed),
752
+ circle: bytesToHex(identity.circle.seed),
753
+ self: bytesToHex(identity.self.seed),
754
+ },
755
+ savedAt: new Date().toISOString(),
756
+ });
757
+ // 5. Persist the updated session — same JWT, but now carrying
758
+ // the freshly-built blob bytes so a subsequent `resume()` can
759
+ // rehydrate without another /auth/blob round-trip.
760
+ const refreshed = {
761
+ ...session,
762
+ blob_b64: bytesToB64Public(blob),
763
+ blob_nonce_b64: bytesToB64Public(blobNonce),
764
+ blob_version: newBlobVersion,
765
+ };
766
+ this.#sessionStore.set(refreshed);
767
+ return {
768
+ session: refreshed,
769
+ recoveryFile,
770
+ recoveryFilename: recoverySerialized.filename,
771
+ };
772
+ }
773
+ /* ------------------------------------------------------------------------ */
583
774
  /* Sign-out */
584
775
  /* ------------------------------------------------------------------------ */
585
776
  async signOut() {
@@ -10,7 +10,7 @@ export { ComputeNamespace } from "./compute.js";
10
10
  export type { CreditPackId, CreateTopupSessionArgs, CreateTopupSessionResult, GetBalanceArgs, GetBalanceResult, } from "./wallet.js";
11
11
  export { WalletNamespace } from "./wallet.js";
12
12
  export { AithosAuth, DEFAULT_API_BASE_URL, DEFAULT_AUTH_BASE_URL, } from "./auth.js";
13
- export type { AithosAuthConfig, AithosSession, DelegateInfo, ImportMandateInput, OwnerInfo, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, } from "./auth.js";
13
+ export type { AithosAuthConfig, AithosSession, CompleteSsoFirstLoginInput, CompleteSsoFirstLoginResult, DelegateInfo, ImportMandateInput, OwnerInfo, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, } from "./auth.js";
14
14
  export { DEFAULT_SESSION_STORAGE_KEY, defaultSessionStore, localStorageStore, noopStore, sessionStorageStore, type AithosSessionStore, } from "./session-store.js";
15
15
  export { DEFAULT_KEYSTORE_DB_NAME, defaultKeyStore, indexedDbKeyStore, memoryKeyStore, type AithosKeyStore, type StoredDelegateKeys, type StoredOwnerKeys, } from "./key-store.js";
16
16
  export { EthosClient, EthosNamespace, EthosZone, ZONE_NAMES, } from "./ethos.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aithos/sdk",
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "description": "Aithos SDK — high-level TypeScript developer kit for building agentic apps on the Aithos protocol. Wraps @aithos/protocol-client and exposes the Aithos compute proxy and wallet (Stripe top-up) endpoints.",
5
5
  "keywords": [
6
6
  "aithos",