@aithos/sdk 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +45 -0
- package/dist/src/auth-api.d.ts +50 -0
- package/dist/src/auth-api.js +102 -0
- package/dist/src/auth.d.ts +253 -0
- package/dist/src/auth.js +940 -0
- package/dist/src/compute.d.ts +370 -9
- package/dist/src/compute.js +369 -16
- package/dist/src/ethos.d.ts +117 -1
- package/dist/src/ethos.js +646 -16
- package/dist/src/index.d.ts +11 -4
- package/dist/src/index.js +31 -5
- package/dist/src/internal/delegate-bundle.d.ts +18 -0
- package/dist/src/internal/delegate-bundle.js +94 -0
- package/dist/src/internal/delegate-state.d.ts +45 -0
- package/dist/src/internal/delegate-state.js +120 -0
- package/dist/src/internal/owner-signers.d.ts +78 -0
- package/dist/src/internal/owner-signers.js +179 -0
- package/dist/src/internal/protocol-client-bridge.d.ts +8 -0
- package/dist/src/internal/protocol-client-bridge.js +20 -0
- package/dist/src/internal/recovery-file.d.ts +29 -0
- package/dist/src/internal/recovery-file.js +98 -0
- package/dist/src/internal/signer.d.ts +59 -0
- package/dist/src/internal/signer.js +86 -0
- package/dist/src/key-store.d.ts +128 -0
- package/dist/src/key-store.js +244 -0
- package/dist/src/mandates.d.ts +163 -1
- package/dist/src/mandates.js +286 -8
- package/dist/src/sdk.d.ts +36 -3
- package/dist/src/sdk.js +27 -23
- package/dist/src/session-store.d.ts +58 -0
- package/dist/src/session-store.js +158 -0
- package/dist/src/wallet.d.ts +4 -6
- package/dist/src/wallet.js +18 -8
- package/dist/test/auth-j3.test.d.ts +2 -0
- package/dist/test/auth-j3.test.js +391 -0
- package/dist/test/auth.test.d.ts +2 -0
- package/dist/test/auth.test.js +175 -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 +184 -11
- package/dist/test/ethos-first-edition.test.d.ts +2 -0
- package/dist/test/ethos-first-edition.test.js +248 -0
- package/dist/test/ethos.test.d.ts +2 -0
- package/dist/test/ethos.test.js +219 -0
- package/dist/test/key-store.test.d.ts +2 -0
- package/dist/test/key-store.test.js +161 -0
- package/dist/test/mandates-compute.test.d.ts +2 -0
- package/dist/test/mandates-compute.test.js +256 -0
- package/dist/test/mandates.test.d.ts +2 -0
- package/dist/test/mandates.test.js +93 -0
- package/dist/test/sdk.test.js +70 -30
- package/dist/test/signer.test.d.ts +2 -0
- package/dist/test/signer.test.js +117 -0
- package/dist/test/signup-bootstrap.test.d.ts +2 -0
- package/dist/test/signup-bootstrap.test.js +222 -0
- package/dist/test/wallet.test.js +20 -9
- package/package.json +4 -3
package/dist/src/auth.js
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Aithos auth — sign-up, sign-in (email+password / Google SSO /
|
|
4
|
+
// recovery file), mandate import, sign-out.
|
|
5
|
+
//
|
|
6
|
+
// One stateful object per app. Holds the active {@link AithosSession}
|
|
7
|
+
// (JWT-backed when present), the loaded owner signers in memory (when
|
|
8
|
+
// signed in as an owner), and a registry of active delegate
|
|
9
|
+
// sessions. Persists across reloads via the configured
|
|
10
|
+
// {@link AithosSessionStore} (JWT) and {@link AithosKeyStore} (signing
|
|
11
|
+
// material).
|
|
12
|
+
//
|
|
13
|
+
// Strict mode (per design discussion): the two stores must agree about
|
|
14
|
+
// who's signed in. If sessionStore has a JWT for one DID and keyStore
|
|
15
|
+
// has an owner with a DIFFERENT DID, both are wiped. If sessionStore
|
|
16
|
+
// has a JWT but keyStore has no owner at all, the JWT is wiped (a
|
|
17
|
+
// JWT alone is useless without local signing capability for everything
|
|
18
|
+
// past compute/wallet).
|
|
19
|
+
//
|
|
20
|
+
// JWT-less sessions (recovery / mandate sign-ins) are valid: the
|
|
21
|
+
// keyStore is the source of truth for "is the user signed in", the
|
|
22
|
+
// JWT is auxiliary for compute/wallet.
|
|
23
|
+
import { buildBlobPlaintext, buildSignedEnvelope, createBrowserIdentity, decryptBlob, DEFAULT_KDF, deriveAuthAndEncKeys, encryptBlob, parseBlob, randomNonce, randomSalt, serializeBlob, signedDidDocument, zeroize, } from "@aithos/protocol-client";
|
|
24
|
+
import { loginChallenge, loginVerify, putBlob, registerAccount, } from "./auth-api.js";
|
|
25
|
+
import { defaultSessionStore, } from "./session-store.js";
|
|
26
|
+
import { defaultKeyStore, } from "./key-store.js";
|
|
27
|
+
import { parseDelegateBundle, readDelegateBundleText, } from "./internal/delegate-bundle.js";
|
|
28
|
+
import { DelegateActor, DelegateRegistry, } from "./internal/delegate-state.js";
|
|
29
|
+
import { OwnerSigners } from "./internal/owner-signers.js";
|
|
30
|
+
import { parseRecoveryFile, readRecoveryFileText, serializeRecoveryFile, } from "./internal/recovery-file.js";
|
|
31
|
+
import { AithosSDKError } from "./types.js";
|
|
32
|
+
/** Default URL of the Aithos auth backend. */
|
|
33
|
+
export const DEFAULT_AUTH_BASE_URL = "https://auth.aithos.be";
|
|
34
|
+
/** Default URL of the Aithos primitives API (publish_identity, publish_ethos_edition, etc.). */
|
|
35
|
+
export const DEFAULT_API_BASE_URL = "https://api.aithos.be";
|
|
36
|
+
/* -------------------------------------------------------------------------- */
|
|
37
|
+
/* AithosAuth */
|
|
38
|
+
/* -------------------------------------------------------------------------- */
|
|
39
|
+
export class AithosAuth {
|
|
40
|
+
authBaseUrl;
|
|
41
|
+
apiBaseUrl;
|
|
42
|
+
#fetchImpl;
|
|
43
|
+
#win;
|
|
44
|
+
#sessionStore;
|
|
45
|
+
#keyStore;
|
|
46
|
+
/** In-memory owner signers — populated after sign-in or `resume`. */
|
|
47
|
+
#ownerSigners = null;
|
|
48
|
+
/** Active delegate registry. */
|
|
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;
|
|
60
|
+
constructor(config = {}) {
|
|
61
|
+
this.authBaseUrl = trimSlash(config.authBaseUrl ?? DEFAULT_AUTH_BASE_URL);
|
|
62
|
+
this.apiBaseUrl = trimSlash(config.apiBaseUrl ?? DEFAULT_API_BASE_URL);
|
|
63
|
+
this.#fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
64
|
+
this.#win =
|
|
65
|
+
config.window ??
|
|
66
|
+
(typeof window !== "undefined" ? window : undefined);
|
|
67
|
+
this.#sessionStore = config.sessionStore ?? defaultSessionStore();
|
|
68
|
+
this.#keyStore = config.keyStore ?? defaultKeyStore();
|
|
69
|
+
}
|
|
70
|
+
/* ------------------------------------------------------------------------ */
|
|
71
|
+
/* Boot-time hydration */
|
|
72
|
+
/* ------------------------------------------------------------------------ */
|
|
73
|
+
/**
|
|
74
|
+
* Reload signing material and JWT session from the configured stores.
|
|
75
|
+
* Must be called once at app boot before relying on
|
|
76
|
+
* {@link getCurrentSession} / {@link getOwnerInfo} / {@link canSignAsOwner}
|
|
77
|
+
* — until then they reflect only what's been done in-memory in the
|
|
78
|
+
* current tab.
|
|
79
|
+
*
|
|
80
|
+
* Strict consistency: if the JWT and the stored owner disagree about
|
|
81
|
+
* who's signed in, both are wiped and the user re-auths. JWT-less
|
|
82
|
+
* owner state (loaded from keyStore but no JWT) is a valid resumed
|
|
83
|
+
* state — the user signed in via recovery or imported a mandate at
|
|
84
|
+
* some earlier moment and never went through the JWT flow.
|
|
85
|
+
*/
|
|
86
|
+
async resume() {
|
|
87
|
+
// 1. Owner side.
|
|
88
|
+
const stored = await this.#keyStore.loadOwner().catch(() => null);
|
|
89
|
+
const jwt = this.#sessionStore.get();
|
|
90
|
+
if (stored) {
|
|
91
|
+
this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
|
|
92
|
+
// JWT must match the owner DID — otherwise it's stale state.
|
|
93
|
+
if (jwt && jwt.did !== stored.did) {
|
|
94
|
+
this.#sessionStore.clear();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// No owner persisted. A lingering JWT is meaningless without local
|
|
99
|
+
// signing capability — wipe it (strict mode).
|
|
100
|
+
if (jwt)
|
|
101
|
+
this.#sessionStore.clear();
|
|
102
|
+
}
|
|
103
|
+
// 2. Delegate side. Independent of owner state — a user may hold
|
|
104
|
+
// only delegate bundles and no owner identity at all.
|
|
105
|
+
const storedDelegates = await this.#keyStore
|
|
106
|
+
.listDelegates()
|
|
107
|
+
.catch(() => []);
|
|
108
|
+
for (const d of storedDelegates) {
|
|
109
|
+
try {
|
|
110
|
+
this.#delegates.add(DelegateActor.fromStored(d));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Skip corrupted entries silently — the keystore validators
|
|
114
|
+
// already filter most of these. A skip here means the keystore
|
|
115
|
+
// record passed validation but the seed couldn't be re-derived
|
|
116
|
+
// (e.g. zero-length, future migration); ignore and continue.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/* ------------------------------------------------------------------------ */
|
|
121
|
+
/* State accessors */
|
|
122
|
+
/* ------------------------------------------------------------------------ */
|
|
123
|
+
/** JWT-backed session. Null when signed in via recovery / mandate / not at all. */
|
|
124
|
+
getCurrentSession() {
|
|
125
|
+
const session = this.#sessionStore.get();
|
|
126
|
+
if (!session)
|
|
127
|
+
return null;
|
|
128
|
+
// Belt-and-braces: the session store auto-evicts on expiry, but we
|
|
129
|
+
// also re-check here in case the in-tab clock drifted post-load.
|
|
130
|
+
return session;
|
|
131
|
+
}
|
|
132
|
+
/** Loaded owner identity. Independent of JWT presence. */
|
|
133
|
+
getOwnerInfo() {
|
|
134
|
+
if (!this.#ownerSigners)
|
|
135
|
+
return null;
|
|
136
|
+
return {
|
|
137
|
+
did: this.#ownerSigners.did,
|
|
138
|
+
handle: this.#ownerSigners.handle,
|
|
139
|
+
displayName: this.#ownerSigners.displayName,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
getDelegates() {
|
|
143
|
+
return this.#delegates.list().map(actorToInfo);
|
|
144
|
+
}
|
|
145
|
+
canSignAsOwner() {
|
|
146
|
+
return this.#ownerSigners !== null && !this.#ownerSigners.destroyed;
|
|
147
|
+
}
|
|
148
|
+
canSignAsDelegateFor(did) {
|
|
149
|
+
const a = this.#delegates.findForSubject(did);
|
|
150
|
+
return a !== undefined && !a.destroyed;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Internal accessor used by sibling SDK namespaces (compute, wallet,
|
|
154
|
+
* ethos) when they need to sign on behalf of the owner. Returns null
|
|
155
|
+
* if no owner is loaded.
|
|
156
|
+
*
|
|
157
|
+
* @internal
|
|
158
|
+
*/
|
|
159
|
+
_getOwnerSigners() {
|
|
160
|
+
return this.#ownerSigners;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Internal accessor — looks up an active delegate by mandate id.
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
_getDelegateActor(mandateId) {
|
|
167
|
+
return this.#delegates.get(mandateId);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Internal accessor — finds the first active delegate whose subject
|
|
171
|
+
* matches `did`. Used by `sdk.ethos.of(did)` when the user holds a
|
|
172
|
+
* mandate for that subject.
|
|
173
|
+
* @internal
|
|
174
|
+
*/
|
|
175
|
+
_findDelegateForSubject(did) {
|
|
176
|
+
return this.#delegates.findForSubject(did);
|
|
177
|
+
}
|
|
178
|
+
/* ------------------------------------------------------------------------ */
|
|
179
|
+
/* Email + password — signIn */
|
|
180
|
+
/* ------------------------------------------------------------------------ */
|
|
181
|
+
async signIn(input) {
|
|
182
|
+
if (!input.email || !input.password) {
|
|
183
|
+
throw new AithosSDKError("auth_invalid_input", "signIn: email and password are required");
|
|
184
|
+
}
|
|
185
|
+
const challenge = await loginChallenge({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email);
|
|
186
|
+
const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, challenge.authSalt, challenge.encSalt, challenge.kdf);
|
|
187
|
+
let verify;
|
|
188
|
+
try {
|
|
189
|
+
verify = await loginVerify({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, input.email, authKey);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
// On failure, both keys must be wiped before propagating.
|
|
193
|
+
zeroize(authKey);
|
|
194
|
+
zeroize(encKey);
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
zeroize(authKey);
|
|
198
|
+
// Decrypt the vault blob → plaintext seeds + delegate bundles.
|
|
199
|
+
let plaintext;
|
|
200
|
+
try {
|
|
201
|
+
const blobBytes = decryptBlob(encKey, verify.blobNonce, verify.blob);
|
|
202
|
+
try {
|
|
203
|
+
plaintext = parseBlob(blobBytes);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
zeroize(blobBytes);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
zeroize(encKey);
|
|
211
|
+
throw new AithosSDKError("auth_blob_decrypt_failed", `Could not decrypt the vault blob: ${e.message}`);
|
|
212
|
+
}
|
|
213
|
+
zeroize(encKey);
|
|
214
|
+
// Sanity check: blob's identity must agree with what verify returned.
|
|
215
|
+
if (plaintext.identity.did !== verify.did) {
|
|
216
|
+
throw new AithosSDKError("auth_blob_identity_mismatch", "vault blob's DID does not match the verified login DID", { data: { blobDid: plaintext.identity.did, verifyDid: verify.did } });
|
|
217
|
+
}
|
|
218
|
+
// Hydrate in-memory state.
|
|
219
|
+
if (this.#ownerSigners)
|
|
220
|
+
this.#ownerSigners.destroy();
|
|
221
|
+
this.#ownerSigners = OwnerSigners.fromBlobPlaintext(plaintext);
|
|
222
|
+
// Persist to keyStore — owner first, then delegates.
|
|
223
|
+
const ownerStored = {
|
|
224
|
+
version: "0.1.0-hex",
|
|
225
|
+
did: plaintext.identity.did,
|
|
226
|
+
handle: plaintext.identity.handle,
|
|
227
|
+
displayName: plaintext.identity.displayName,
|
|
228
|
+
seedsHex: plaintext.seeds,
|
|
229
|
+
savedAt: new Date().toISOString(),
|
|
230
|
+
};
|
|
231
|
+
await this.#keyStore.saveOwner(ownerStored);
|
|
232
|
+
// Replace any prior delegate set with what the blob carries.
|
|
233
|
+
await this.#keyStore.clearAllDelegates();
|
|
234
|
+
this.#delegates.destroy();
|
|
235
|
+
for (const d of plaintext.delegates) {
|
|
236
|
+
const stored = storedDelegateFromBlob(d);
|
|
237
|
+
try {
|
|
238
|
+
await this.#keyStore.saveDelegate(stored);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Persistence failure shouldn't block the sign-in. We still load
|
|
242
|
+
// the actor in memory so the session works for the current tab.
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
this.#delegates.add(DelegateActor.fromStored(stored));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Skip silently — keep going on remaining delegates.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const session = {
|
|
252
|
+
session: verify.session,
|
|
253
|
+
exp: verify.exp,
|
|
254
|
+
did: verify.did,
|
|
255
|
+
handle: verify.handle,
|
|
256
|
+
blob_b64: bytesToB64Public(verify.blob),
|
|
257
|
+
blob_nonce_b64: bytesToB64Public(verify.blobNonce),
|
|
258
|
+
blob_version: verify.blobVersion,
|
|
259
|
+
enc_key_b64: "",
|
|
260
|
+
is_first_login: false,
|
|
261
|
+
};
|
|
262
|
+
this.#sessionStore.set(session);
|
|
263
|
+
return session;
|
|
264
|
+
}
|
|
265
|
+
/* ------------------------------------------------------------------------ */
|
|
266
|
+
/* Email + password — signUp */
|
|
267
|
+
/* ------------------------------------------------------------------------ */
|
|
268
|
+
async signUp(input) {
|
|
269
|
+
if (!input.email || !input.password) {
|
|
270
|
+
throw new AithosSDKError("auth_invalid_input", "signUp: email and password are required");
|
|
271
|
+
}
|
|
272
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(input.handle)) {
|
|
273
|
+
throw new AithosSDKError("auth_invalid_handle", "signUp: handle must be 1–63 alphanumeric chars + _ -");
|
|
274
|
+
}
|
|
275
|
+
const displayName = input.displayName ?? input.handle;
|
|
276
|
+
const identity = createBrowserIdentity(input.handle, displayName);
|
|
277
|
+
const recoverySerialized = serializeRecoveryFile(identity);
|
|
278
|
+
const recoveryFile = new Blob([recoverySerialized.text], {
|
|
279
|
+
type: "application/json",
|
|
280
|
+
});
|
|
281
|
+
// Derive password-based keys, encrypt the vault blob.
|
|
282
|
+
const authSalt = randomSalt();
|
|
283
|
+
const encSalt = randomSalt();
|
|
284
|
+
const kdf = DEFAULT_KDF;
|
|
285
|
+
const { authKey, encKey } = await deriveAuthAndEncKeys(input.password, authSalt, encSalt, kdf);
|
|
286
|
+
const plaintext = buildBlobPlaintext({
|
|
287
|
+
identity: {
|
|
288
|
+
did: identity.did,
|
|
289
|
+
handle: identity.handle,
|
|
290
|
+
displayName: identity.displayName,
|
|
291
|
+
},
|
|
292
|
+
seeds: {
|
|
293
|
+
root: identity.root.seed,
|
|
294
|
+
public: identity.public.seed,
|
|
295
|
+
circle: identity.circle.seed,
|
|
296
|
+
self: identity.self.seed,
|
|
297
|
+
},
|
|
298
|
+
delegates: [],
|
|
299
|
+
});
|
|
300
|
+
const blobBytes = serializeBlob(plaintext);
|
|
301
|
+
const blobNonce = randomNonce();
|
|
302
|
+
const blob = encryptBlob(encKey, blobNonce, blobBytes);
|
|
303
|
+
let registerResp;
|
|
304
|
+
try {
|
|
305
|
+
registerResp = await registerAccount({ fetchImpl: this.#fetchImpl, authBaseUrl: this.authBaseUrl }, {
|
|
306
|
+
email: input.email,
|
|
307
|
+
handle: identity.handle,
|
|
308
|
+
displayName: identity.displayName,
|
|
309
|
+
did: identity.did,
|
|
310
|
+
authKey,
|
|
311
|
+
authSalt,
|
|
312
|
+
encSalt,
|
|
313
|
+
kdf,
|
|
314
|
+
blob,
|
|
315
|
+
blobNonce,
|
|
316
|
+
blobVersion: 1,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
zeroize(authKey);
|
|
321
|
+
zeroize(encKey);
|
|
322
|
+
}
|
|
323
|
+
// Bootstrap the Ethos on api.aithos.be. Without this, every subsequent
|
|
324
|
+
// write (publish_ethos_edition, etc.) errors out with -32020
|
|
325
|
+
// "subject identity not published". We do this BEFORE hydrating local
|
|
326
|
+
// state so a bootstrap failure leaves the SDK in a clean
|
|
327
|
+
// "not signed in" state — the dev shows an error, the user retries.
|
|
328
|
+
// The auth account on auth.aithos.be DOES exist at this point, but
|
|
329
|
+
// without the local hydrate the user can't act on it. Self-heal on
|
|
330
|
+
// signIn (re-attempt publish_identity if missing) is planned for a
|
|
331
|
+
// follow-up release.
|
|
332
|
+
await this.#publishIdentity(identity);
|
|
333
|
+
// Hydrate in-memory state from the fresh identity.
|
|
334
|
+
if (this.#ownerSigners)
|
|
335
|
+
this.#ownerSigners.destroy();
|
|
336
|
+
this.#ownerSigners = OwnerSigners.fromBrowserIdentity(identity);
|
|
337
|
+
await this.#keyStore.saveOwner({
|
|
338
|
+
version: "0.1.0-hex",
|
|
339
|
+
did: identity.did,
|
|
340
|
+
handle: identity.handle,
|
|
341
|
+
displayName: identity.displayName,
|
|
342
|
+
seedsHex: {
|
|
343
|
+
root: bytesToHex(identity.root.seed),
|
|
344
|
+
public: bytesToHex(identity.public.seed),
|
|
345
|
+
circle: bytesToHex(identity.circle.seed),
|
|
346
|
+
self: bytesToHex(identity.self.seed),
|
|
347
|
+
},
|
|
348
|
+
savedAt: new Date().toISOString(),
|
|
349
|
+
});
|
|
350
|
+
const session = {
|
|
351
|
+
session: registerResp.session,
|
|
352
|
+
exp: registerResp.exp,
|
|
353
|
+
did: identity.did,
|
|
354
|
+
handle: identity.handle,
|
|
355
|
+
blob_b64: bytesToB64Public(blob),
|
|
356
|
+
blob_nonce_b64: bytesToB64Public(blobNonce),
|
|
357
|
+
blob_version: 1,
|
|
358
|
+
enc_key_b64: "",
|
|
359
|
+
is_first_login: false,
|
|
360
|
+
};
|
|
361
|
+
this.#sessionStore.set(session);
|
|
362
|
+
return {
|
|
363
|
+
session,
|
|
364
|
+
recoveryFile,
|
|
365
|
+
recoveryFilename: recoverySerialized.filename,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/* ------------------------------------------------------------------------ */
|
|
369
|
+
/* Recovery file */
|
|
370
|
+
/* ------------------------------------------------------------------------ */
|
|
371
|
+
/**
|
|
372
|
+
* Sign in by uploading a recovery file. Hydrates the owner signers
|
|
373
|
+
* locally — no JWT is obtained on this path because the recovery
|
|
374
|
+
* file alone doesn't authenticate against the auth backend (no
|
|
375
|
+
* password, no Google session). Apps that need compute/wallet
|
|
376
|
+
* access should follow up with an email+password sign-in or with
|
|
377
|
+
* Google SSO.
|
|
378
|
+
*
|
|
379
|
+
* The recovery file is ALWAYS the file produced by `signUp` (or the
|
|
380
|
+
* equivalent one emitted by `protocol-client`'s `runOnboarding`).
|
|
381
|
+
* Both shapes are accepted.
|
|
382
|
+
*/
|
|
383
|
+
async signInWithRecovery(input) {
|
|
384
|
+
const text = await readRecoveryFileText(input.file);
|
|
385
|
+
const parsed = parseRecoveryFile(text);
|
|
386
|
+
// Build a StoredOwnerKeys-shape on the spot, then push to keyStore +
|
|
387
|
+
// hydrate signers.
|
|
388
|
+
const stored = {
|
|
389
|
+
version: "0.1.0-hex",
|
|
390
|
+
did: parsed.did,
|
|
391
|
+
handle: parsed.handle,
|
|
392
|
+
displayName: parsed.displayName,
|
|
393
|
+
seedsHex: parsed.seedsHex,
|
|
394
|
+
savedAt: new Date().toISOString(),
|
|
395
|
+
};
|
|
396
|
+
// If a different owner is already loaded, refuse — apps must call
|
|
397
|
+
// signOut() first. Mixing two owners in one auth instance is a
|
|
398
|
+
// nonsense state we don't want to support.
|
|
399
|
+
if (this.#ownerSigners && this.#ownerSigners.did !== parsed.did) {
|
|
400
|
+
throw new AithosSDKError("auth_owner_already_loaded", "another owner is already signed in; call signOut first", { data: { current: this.#ownerSigners.did, incoming: parsed.did } });
|
|
401
|
+
}
|
|
402
|
+
if (this.#ownerSigners)
|
|
403
|
+
this.#ownerSigners.destroy();
|
|
404
|
+
this.#ownerSigners = OwnerSigners.fromStoredOwnerKeys(stored);
|
|
405
|
+
await this.#keyStore.saveOwner(stored);
|
|
406
|
+
// Recovery flow doesn't yield a JWT — wipe any stale one to keep
|
|
407
|
+
// the two stores in sync.
|
|
408
|
+
this.#sessionStore.clear();
|
|
409
|
+
return {
|
|
410
|
+
did: parsed.did,
|
|
411
|
+
handle: parsed.handle,
|
|
412
|
+
displayName: parsed.displayName,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/* ------------------------------------------------------------------------ */
|
|
416
|
+
/* Mandate import */
|
|
417
|
+
/* ------------------------------------------------------------------------ */
|
|
418
|
+
/**
|
|
419
|
+
* Import a delegate bundle (`.aithos-delegate.json`). Works in any
|
|
420
|
+
* state: with no owner loaded (delegate-only session), or alongside
|
|
421
|
+
* an existing owner (the user holds mandates for other people's
|
|
422
|
+
* ethoses while also being an owner themselves).
|
|
423
|
+
*/
|
|
424
|
+
async importMandate(input) {
|
|
425
|
+
const text = await readDelegateBundleText(input.bundle);
|
|
426
|
+
const parsed = parseDelegateBundle(text);
|
|
427
|
+
const stored = {
|
|
428
|
+
version: "0.1.0-hex",
|
|
429
|
+
subjectDid: parsed.subjectDid,
|
|
430
|
+
mandateId: parsed.mandateId,
|
|
431
|
+
mandate: parsed.mandate,
|
|
432
|
+
granteeId: parsed.granteeId,
|
|
433
|
+
granteePubkeyMultibase: parsed.granteePubkeyMultibase,
|
|
434
|
+
delegateSeedHex: parsed.delegateSeedHex,
|
|
435
|
+
importedAt: new Date().toISOString(),
|
|
436
|
+
};
|
|
437
|
+
await this.#keyStore.saveDelegate(stored);
|
|
438
|
+
this.#delegates.add(DelegateActor.fromStored(stored));
|
|
439
|
+
return {
|
|
440
|
+
mandateId: stored.mandateId,
|
|
441
|
+
subjectDid: stored.subjectDid,
|
|
442
|
+
granteeId: stored.granteeId,
|
|
443
|
+
scopes: scopesFromMandate(stored.mandate),
|
|
444
|
+
expiresAt: notAfterFromMandate(stored.mandate),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
async removeMandate(mandateId) {
|
|
448
|
+
this.#delegates.remove(mandateId);
|
|
449
|
+
await this.#keyStore.removeDelegate(mandateId);
|
|
450
|
+
}
|
|
451
|
+
/* ------------------------------------------------------------------------ */
|
|
452
|
+
/* Google SSO */
|
|
453
|
+
/* ------------------------------------------------------------------------ */
|
|
454
|
+
signInWithGoogle(opts) {
|
|
455
|
+
if (!this.#win) {
|
|
456
|
+
throw new AithosSDKError("auth_no_window", "AithosAuth.signInWithGoogle requires a browser window");
|
|
457
|
+
}
|
|
458
|
+
// appId + returnTo must come together — the backend rejects
|
|
459
|
+
// half-presence at /sso/google/start. Surface that as a clean SDK
|
|
460
|
+
// error before the network round-trip rather than letting the user
|
|
461
|
+
// bounce to Google and back for nothing.
|
|
462
|
+
if ((opts?.appId && !opts?.returnTo) || (!opts?.appId && opts?.returnTo)) {
|
|
463
|
+
throw new AithosSDKError("auth_sso_app_redirect_pair_required", "appId and returnTo must be provided together (or both omitted to use the legacy redirect)");
|
|
464
|
+
}
|
|
465
|
+
const url = new URL(`${this.authBaseUrl}/auth/sso/google/start`);
|
|
466
|
+
if (opts?.appState) {
|
|
467
|
+
if (opts.appState.length > 1024) {
|
|
468
|
+
throw new AithosSDKError("auth_app_state_too_long", "appState must be ≤ 1024 chars");
|
|
469
|
+
}
|
|
470
|
+
url.searchParams.set("app_state", opts.appState);
|
|
471
|
+
}
|
|
472
|
+
if (opts?.appId && opts?.returnTo) {
|
|
473
|
+
url.searchParams.set("app_id", opts.appId);
|
|
474
|
+
url.searchParams.set("redirect_uri", opts.returnTo);
|
|
475
|
+
}
|
|
476
|
+
this.#win.location.assign(url.toString());
|
|
477
|
+
throw new AithosSDKError("auth_redirecting", "redirecting to google");
|
|
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
|
+
*/
|
|
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() {
|
|
514
|
+
if (!this.#win)
|
|
515
|
+
return null;
|
|
516
|
+
const here = new URL(this.#win.location.href);
|
|
517
|
+
const error = here.searchParams.get("aithos_error");
|
|
518
|
+
const code = here.searchParams.get("aithos_code");
|
|
519
|
+
const appState = here.searchParams.get("app_state");
|
|
520
|
+
if (error) {
|
|
521
|
+
cleanCallbackParams(this.#win, here);
|
|
522
|
+
throw new AithosSDKError(`auth_${error}`, `Sign-in failed: ${error}`, { data: appState ? { app_state: appState } : undefined });
|
|
523
|
+
}
|
|
524
|
+
if (!code)
|
|
525
|
+
return null;
|
|
526
|
+
// Strip the aithos_code from the URL SYNCHRONOUSLY, before any
|
|
527
|
+
// await. React StrictMode (dev) invokes effects twice — without
|
|
528
|
+
// this, the first call awaits exchange (microtask, code still in
|
|
529
|
+
// the URL), the second invocation reads the same code and POSTs
|
|
530
|
+
// again, hitting `auth_code_consumed: aithos_code expired or
|
|
531
|
+
// already used`. Cleaning before the await makes the second
|
|
532
|
+
// invocation read a clean URL and return null without a network
|
|
533
|
+
// round-trip.
|
|
534
|
+
cleanCallbackParams(this.#win, here);
|
|
535
|
+
const session = await this.exchange(code);
|
|
536
|
+
// Hydrate signers if the SSO response carried an enc_key (Google flow
|
|
537
|
+
// gives us the AES-GCM key in plaintext, encrypted only in transit
|
|
538
|
+
// by TLS — see auth.aithos.be design doc).
|
|
539
|
+
if (session.enc_key_b64 &&
|
|
540
|
+
session.blob_b64 &&
|
|
541
|
+
session.blob_nonce_b64 &&
|
|
542
|
+
session.blob_version > 0) {
|
|
543
|
+
try {
|
|
544
|
+
const encKey = b64ToBytes(session.enc_key_b64);
|
|
545
|
+
const blob = b64ToBytes(session.blob_b64);
|
|
546
|
+
const nonce = b64ToBytes(session.blob_nonce_b64);
|
|
547
|
+
try {
|
|
548
|
+
const blobBytes = decryptBlob(encKey, nonce, blob);
|
|
549
|
+
try {
|
|
550
|
+
const plaintext = parseBlob(blobBytes);
|
|
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 */
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
zeroize(blobBytes);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
zeroize(encKey);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// Decryption failure is non-fatal here: the JWT still works for
|
|
602
|
+
// compute/wallet, the user will surface the issue if they try to
|
|
603
|
+
// edit their ethos.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
this.#sessionStore.set(session);
|
|
607
|
+
return session;
|
|
608
|
+
}
|
|
609
|
+
async exchange(aithosCode) {
|
|
610
|
+
const res = await this.#fetchImpl(`${this.authBaseUrl}/auth/sso/exchange`, {
|
|
611
|
+
method: "POST",
|
|
612
|
+
headers: { "content-type": "application/json" },
|
|
613
|
+
body: JSON.stringify({ aithos_code: aithosCode }),
|
|
614
|
+
});
|
|
615
|
+
if (!res.ok) {
|
|
616
|
+
let body;
|
|
617
|
+
try {
|
|
618
|
+
body = (await res.json());
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// ignore non-JSON error body
|
|
622
|
+
}
|
|
623
|
+
const code = typeof body?.["code"] === "string"
|
|
624
|
+
? `auth_${body["code"]}`
|
|
625
|
+
: "auth_exchange_failed";
|
|
626
|
+
const message = typeof body?.["error"] === "string"
|
|
627
|
+
? body["error"]
|
|
628
|
+
: `aithos_code redemption failed (${res.status})`;
|
|
629
|
+
throw new AithosSDKError(code, message, {
|
|
630
|
+
status: res.status,
|
|
631
|
+
...(body !== undefined ? { data: body } : {}),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return (await res.json());
|
|
635
|
+
}
|
|
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
|
+
/* ------------------------------------------------------------------------ */
|
|
774
|
+
/* Sign-out */
|
|
775
|
+
/* ------------------------------------------------------------------------ */
|
|
776
|
+
async signOut() {
|
|
777
|
+
if (this.#ownerSigners)
|
|
778
|
+
this.#ownerSigners.destroy();
|
|
779
|
+
this.#ownerSigners = null;
|
|
780
|
+
this.#delegates.destroy();
|
|
781
|
+
this.#sessionStore.clear();
|
|
782
|
+
await this.#keyStore.clearOwner().catch(() => { });
|
|
783
|
+
await this.#keyStore.clearAllDelegates().catch(() => { });
|
|
784
|
+
}
|
|
785
|
+
/* ------------------------------------------------------------------------ */
|
|
786
|
+
/* Internal — Ethos bootstrap */
|
|
787
|
+
/* ------------------------------------------------------------------------ */
|
|
788
|
+
/**
|
|
789
|
+
* Provision the user's Ethos on `api.aithos.be` by signing and POSTing an
|
|
790
|
+
* `aithos.publish_identity` envelope. Required after a fresh sign-up so
|
|
791
|
+
* subsequent edition publishes (`me.publish()`) don't fail with
|
|
792
|
+
* `-32020 subject identity not published`.
|
|
793
|
+
*
|
|
794
|
+
* Retries twice with exponential backoff on transient errors (network or
|
|
795
|
+
* 5xx). Throws {@link AithosSDKError} with code `ethos_bootstrap_failed`
|
|
796
|
+
* on definitive failure — the caller is expected to abort sign-up.
|
|
797
|
+
*
|
|
798
|
+
* @internal
|
|
799
|
+
*/
|
|
800
|
+
async #publishIdentity(identity) {
|
|
801
|
+
const url = `${this.apiBaseUrl}/mcp/primitives/write`;
|
|
802
|
+
const signedDoc = signedDidDocument(identity);
|
|
803
|
+
const params = {
|
|
804
|
+
did_document: signedDoc,
|
|
805
|
+
handle: identity.handle,
|
|
806
|
+
display_name: identity.displayName,
|
|
807
|
+
};
|
|
808
|
+
const envelope = buildSignedEnvelope({
|
|
809
|
+
iss: identity.did,
|
|
810
|
+
aud: url,
|
|
811
|
+
method: "aithos.publish_identity",
|
|
812
|
+
verificationMethod: `${identity.did}#root`,
|
|
813
|
+
params,
|
|
814
|
+
signer: identity.root,
|
|
815
|
+
});
|
|
816
|
+
const body = JSON.stringify({
|
|
817
|
+
jsonrpc: "2.0",
|
|
818
|
+
id: "publish_identity",
|
|
819
|
+
method: "aithos.publish_identity",
|
|
820
|
+
params: { ...params, _envelope: envelope },
|
|
821
|
+
});
|
|
822
|
+
// Two retries with backoff (300ms, 1500ms). Idempotent on the server
|
|
823
|
+
// side — replaying the same publish_identity for an existing DID is a
|
|
824
|
+
// no-op, so retries are safe even if the first attempt actually
|
|
825
|
+
// succeeded but the response was lost.
|
|
826
|
+
const delays = [0, 300, 1500];
|
|
827
|
+
let lastError;
|
|
828
|
+
for (const delay of delays) {
|
|
829
|
+
if (delay > 0)
|
|
830
|
+
await sleep(delay);
|
|
831
|
+
try {
|
|
832
|
+
const res = await this.#fetchImpl(url, {
|
|
833
|
+
method: "POST",
|
|
834
|
+
headers: { "content-type": "application/json" },
|
|
835
|
+
body,
|
|
836
|
+
});
|
|
837
|
+
// Transport errors (5xx, no body) — retry. JSON-RPC errors come
|
|
838
|
+
// back with HTTP 200 and an `error` field.
|
|
839
|
+
if (!res.ok && res.status >= 500) {
|
|
840
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const json = (await res.json());
|
|
844
|
+
if (json.error) {
|
|
845
|
+
// JSON-RPC error: don't retry — these are deterministic
|
|
846
|
+
// (validation, permission, identity-already-tombstoned, …).
|
|
847
|
+
throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity rejected: ${json.error.message}`, {
|
|
848
|
+
status: res.status,
|
|
849
|
+
data: { rpc_code: json.error.code, ...(json.error.data ?? {}) },
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
return; // success
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
if (e instanceof AithosSDKError)
|
|
856
|
+
throw e;
|
|
857
|
+
lastError = e;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
throw new AithosSDKError("ethos_bootstrap_failed", `publish_identity unreachable after ${delays.length} attempts: ${lastError?.message ?? "unknown"}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/* -------------------------------------------------------------------------- */
|
|
864
|
+
/* Helpers */
|
|
865
|
+
/* -------------------------------------------------------------------------- */
|
|
866
|
+
function trimSlash(url) {
|
|
867
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
868
|
+
}
|
|
869
|
+
function sleep(ms) {
|
|
870
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
871
|
+
}
|
|
872
|
+
function cleanCallbackParams(win, url) {
|
|
873
|
+
url.searchParams.delete("aithos_code");
|
|
874
|
+
url.searchParams.delete("aithos_error");
|
|
875
|
+
url.searchParams.delete("app_state");
|
|
876
|
+
win.history.replaceState(null, "", url.toString());
|
|
877
|
+
}
|
|
878
|
+
function bytesToB64Public(bytes) {
|
|
879
|
+
if (bytes.length === 0)
|
|
880
|
+
return "";
|
|
881
|
+
let bin = "";
|
|
882
|
+
for (let i = 0; i < bytes.length; i++)
|
|
883
|
+
bin += String.fromCharCode(bytes[i]);
|
|
884
|
+
return btoa(bin).replace(/=+$/, "");
|
|
885
|
+
}
|
|
886
|
+
function b64ToBytes(b64) {
|
|
887
|
+
if (!b64)
|
|
888
|
+
return new Uint8Array(0);
|
|
889
|
+
// standard b64 — pad to multiple of 4 if needed
|
|
890
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
|
|
891
|
+
const bin = atob(b64 + pad);
|
|
892
|
+
const out = new Uint8Array(bin.length);
|
|
893
|
+
for (let i = 0; i < bin.length; i++)
|
|
894
|
+
out[i] = bin.charCodeAt(i);
|
|
895
|
+
return out;
|
|
896
|
+
}
|
|
897
|
+
function bytesToHex(b) {
|
|
898
|
+
let out = "";
|
|
899
|
+
for (let i = 0; i < b.length; i++)
|
|
900
|
+
out += b[i].toString(16).padStart(2, "0");
|
|
901
|
+
return out;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Project a delegate as it appears in a `BlobPlaintext` (extension-kit
|
|
905
|
+
* `StoredDelegate` shape) onto the SDK's own {@link StoredDelegateKeys}.
|
|
906
|
+
*/
|
|
907
|
+
function storedDelegateFromBlob(d) {
|
|
908
|
+
return {
|
|
909
|
+
version: "0.1.0-hex",
|
|
910
|
+
subjectDid: d.subjectDid,
|
|
911
|
+
mandateId: d.mandateId,
|
|
912
|
+
mandate: d.mandate,
|
|
913
|
+
granteeId: d.granteeId,
|
|
914
|
+
granteePubkeyMultibase: d.granteePubkeyMultibase,
|
|
915
|
+
delegateSeedHex: d.delegateSeedHex,
|
|
916
|
+
importedAt: new Date().toISOString(),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
function actorToInfo(a) {
|
|
920
|
+
return {
|
|
921
|
+
mandateId: a.mandateId,
|
|
922
|
+
subjectDid: a.subjectDid,
|
|
923
|
+
granteeId: a.granteeId,
|
|
924
|
+
scopes: scopesFromMandate(a.mandate),
|
|
925
|
+
expiresAt: notAfterFromMandate(a.mandate),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
function scopesFromMandate(m) {
|
|
929
|
+
const raw = m["scopes"];
|
|
930
|
+
if (!Array.isArray(raw))
|
|
931
|
+
return [];
|
|
932
|
+
return raw.filter((s) => typeof s === "string");
|
|
933
|
+
}
|
|
934
|
+
function notAfterFromMandate(m) {
|
|
935
|
+
const raw = m["not_after"];
|
|
936
|
+
if (typeof raw !== "string")
|
|
937
|
+
return null;
|
|
938
|
+
return raw;
|
|
939
|
+
}
|
|
940
|
+
//# sourceMappingURL=auth.js.map
|