@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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/* -------------------------------------------------------------------------- */
|
|
4
|
+
/* Storage key & expiration */
|
|
5
|
+
/* -------------------------------------------------------------------------- */
|
|
6
|
+
/**
|
|
7
|
+
* Storage key used by the bundled stores. Apps that want to coexist with
|
|
8
|
+
* other Aithos-aware libs (or that want to scope sessions per-tenant) can
|
|
9
|
+
* pass a custom key via {@link sessionStorageStore} or
|
|
10
|
+
* {@link localStorageStore}.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_SESSION_STORAGE_KEY = "aithos.session.v1";
|
|
13
|
+
/** Conservative buffer — drop the session 30 s before its `exp` so we
|
|
14
|
+
* don't hand out a token the server is about to reject. */
|
|
15
|
+
const SESSION_EXPIRY_BUFFER_S = 30;
|
|
16
|
+
function isExpired(session, nowSec) {
|
|
17
|
+
return session.exp <= nowSec + SESSION_EXPIRY_BUFFER_S;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validate at runtime that an opaque object looks like an `AithosSession`.
|
|
21
|
+
* Storage values come from JSON.parse over user-controlled data — we can't
|
|
22
|
+
* trust them blind. This isn't a security check (the server validates the
|
|
23
|
+
* JWT ; persistence layers don't authenticate themselves) ; it just
|
|
24
|
+
* prevents weird crashes when the storage was tampered with.
|
|
25
|
+
*/
|
|
26
|
+
function isSessionShaped(v) {
|
|
27
|
+
if (typeof v !== "object" || v === null)
|
|
28
|
+
return false;
|
|
29
|
+
const o = v;
|
|
30
|
+
return (typeof o["session"] === "string" &&
|
|
31
|
+
typeof o["exp"] === "number" &&
|
|
32
|
+
typeof o["did"] === "string" &&
|
|
33
|
+
typeof o["handle"] === "string");
|
|
34
|
+
}
|
|
35
|
+
function browserStorageStore(storageRef, opts = {}) {
|
|
36
|
+
const key = opts.key ?? DEFAULT_SESSION_STORAGE_KEY;
|
|
37
|
+
return {
|
|
38
|
+
get() {
|
|
39
|
+
const s = storageRef();
|
|
40
|
+
if (!s)
|
|
41
|
+
return null;
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = s.getItem(key);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!raw)
|
|
50
|
+
return null;
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Corrupted entry — wipe to recover.
|
|
57
|
+
try {
|
|
58
|
+
s.removeItem(key);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!isSessionShaped(parsed))
|
|
66
|
+
return null;
|
|
67
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
68
|
+
if (isExpired(parsed, nowSec)) {
|
|
69
|
+
// Auto-evict — let the caller see "no session" and re-auth.
|
|
70
|
+
try {
|
|
71
|
+
s.removeItem(key);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
},
|
|
80
|
+
set(session) {
|
|
81
|
+
const s = storageRef();
|
|
82
|
+
if (!s)
|
|
83
|
+
return;
|
|
84
|
+
try {
|
|
85
|
+
s.setItem(key, JSON.stringify(session));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// Quota exceeded, private mode, etc. — log but don't throw : the
|
|
89
|
+
// sign-in returned successfully, the in-memory session is still
|
|
90
|
+
// usable for this tab.
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn("[AithosAuth] failed to persist session:", e.message);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
clear() {
|
|
96
|
+
const s = storageRef();
|
|
97
|
+
if (!s)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
s.removeItem(key);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function safeStorage(getter) {
|
|
109
|
+
return () => {
|
|
110
|
+
try {
|
|
111
|
+
const s = getter();
|
|
112
|
+
return s ?? null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Some restricted contexts (sandboxed iframes, file:// URLs) throw
|
|
116
|
+
// on access. Treat them as "no storage available".
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Default web store : `sessionStorage`. The session lives until the tab
|
|
123
|
+
* is closed. Cleared on `signOut()`. Use this when reauthenticating each
|
|
124
|
+
* day is acceptable and reduces blast radius after an XSS.
|
|
125
|
+
*/
|
|
126
|
+
export function sessionStorageStore(opts) {
|
|
127
|
+
return browserStorageStore(safeStorage(() => (typeof sessionStorage !== "undefined" ? sessionStorage : undefined)), opts);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `localStorage` store. The session persists until the JWT expires or the
|
|
131
|
+
* user explicitly signs out. Higher convenience, larger XSS blast radius.
|
|
132
|
+
*/
|
|
133
|
+
export function localStorageStore(opts) {
|
|
134
|
+
return browserStorageStore(safeStorage(() => (typeof localStorage !== "undefined" ? localStorage : undefined)), opts);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* No-op store. `set` and `clear` discard their input ; `get` always
|
|
138
|
+
* returns null. The default in non-browser contexts (Node, edge runtimes)
|
|
139
|
+
* — apps running there should pass their own store explicitly.
|
|
140
|
+
*/
|
|
141
|
+
export function noopStore() {
|
|
142
|
+
return {
|
|
143
|
+
get: () => null,
|
|
144
|
+
set: () => { },
|
|
145
|
+
clear: () => { },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Pick a sensible default : `sessionStorage` if the browser environment
|
|
150
|
+
* is available, {@link noopStore} otherwise.
|
|
151
|
+
*/
|
|
152
|
+
export function defaultSessionStore() {
|
|
153
|
+
if (typeof sessionStorage !== "undefined") {
|
|
154
|
+
return sessionStorageStore();
|
|
155
|
+
}
|
|
156
|
+
return noopStore();
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=session-store.js.map
|
package/dist/src/wallet.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AithosAuth } from "./auth.js";
|
|
2
2
|
import { type AithosSdkEndpoints } from "./endpoints.js";
|
|
3
3
|
/**
|
|
4
4
|
* Canonical credit-pack identifiers. Pricing and microcredit amounts are
|
|
@@ -44,12 +44,10 @@ export interface GetBalanceResult {
|
|
|
44
44
|
readonly exists: boolean;
|
|
45
45
|
}
|
|
46
46
|
export interface WalletNamespaceDeps {
|
|
47
|
-
/**
|
|
48
|
-
readonly
|
|
47
|
+
/** Auth instance — the wallet reads the active owner DID + signing key from here. */
|
|
48
|
+
readonly auth: AithosAuth;
|
|
49
49
|
/** App DID — sent as audit attribution alongside the balance request. */
|
|
50
50
|
readonly appDid: string;
|
|
51
|
-
/** Pre-resolved DID convenience accessor (mirrors identity.did). */
|
|
52
|
-
readonly userDid: string;
|
|
53
51
|
readonly endpoints: AithosSdkEndpoints;
|
|
54
52
|
readonly fetch: typeof fetch;
|
|
55
53
|
}
|
|
@@ -64,7 +62,7 @@ export declare class WalletNamespace {
|
|
|
64
62
|
* hosted URL — the caller is responsible for redirecting the user (e.g.
|
|
65
63
|
* `window.location.href = result.checkoutUrl`).
|
|
66
64
|
*
|
|
67
|
-
* On success, the Stripe webhook will credit
|
|
65
|
+
* On success, the Stripe webhook will credit the user's wallet once the
|
|
68
66
|
* payment clears. Wallet balances are shared across all Aithos apps that
|
|
69
67
|
* use the same DID.
|
|
70
68
|
*/
|
package/dist/src/wallet.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// compute proxy at `${compute}/v1/invoke`. Read-only DDB GetItem on
|
|
14
14
|
// the wallet table, gated by the same signed envelope verification
|
|
15
15
|
// as compute_invoke. Returns the current balance + daily spent.
|
|
16
|
-
import { buildSignedEnvelope
|
|
16
|
+
import { buildSignedEnvelope } from "@aithos/protocol-client";
|
|
17
17
|
import { computeInvokeUrl, walletTopupCheckoutUrl, } from "./endpoints.js";
|
|
18
|
+
import { ownerKeyPair } from "./internal/protocol-client-bridge.js";
|
|
18
19
|
import { AithosSDKError } from "./types.js";
|
|
19
20
|
/**
|
|
20
21
|
* `sdk.wallet` namespace.
|
|
@@ -29,12 +30,16 @@ export class WalletNamespace {
|
|
|
29
30
|
* hosted URL — the caller is responsible for redirecting the user (e.g.
|
|
30
31
|
* `window.location.href = result.checkoutUrl`).
|
|
31
32
|
*
|
|
32
|
-
* On success, the Stripe webhook will credit
|
|
33
|
+
* On success, the Stripe webhook will credit the user's wallet once the
|
|
33
34
|
* payment clears. Wallet balances are shared across all Aithos apps that
|
|
34
35
|
* use the same DID.
|
|
35
36
|
*/
|
|
36
37
|
async createTopupSession(args) {
|
|
37
|
-
const {
|
|
38
|
+
const { auth, endpoints, fetch: fetchImpl } = this.#deps;
|
|
39
|
+
const owner = auth._getOwnerSigners();
|
|
40
|
+
if (!owner || owner.destroyed) {
|
|
41
|
+
throw new AithosSDKError("sdk_no_owner", "no owner signed in; sign in first");
|
|
42
|
+
}
|
|
38
43
|
const url = walletTopupCheckoutUrl(endpoints);
|
|
39
44
|
let res;
|
|
40
45
|
try {
|
|
@@ -42,7 +47,7 @@ export class WalletNamespace {
|
|
|
42
47
|
method: "POST",
|
|
43
48
|
headers: { "content-type": "application/json" },
|
|
44
49
|
body: JSON.stringify({
|
|
45
|
-
user_did:
|
|
50
|
+
user_did: owner.did,
|
|
46
51
|
pack_id: args.packId,
|
|
47
52
|
success_url: args.successUrl,
|
|
48
53
|
cancel_url: args.cancelUrl,
|
|
@@ -88,16 +93,21 @@ export class WalletNamespace {
|
|
|
88
93
|
* envelope-verify failures from the proxy).
|
|
89
94
|
*/
|
|
90
95
|
async getBalance(args = {}) {
|
|
91
|
-
const {
|
|
96
|
+
const { auth, appDid, endpoints, fetch: fetchImpl } = this.#deps;
|
|
97
|
+
const owner = auth._getOwnerSigners();
|
|
98
|
+
if (!owner || owner.destroyed) {
|
|
99
|
+
throw new AithosSDKError("sdk_no_owner", "no owner signed in; sign in first");
|
|
100
|
+
}
|
|
101
|
+
const publicKp = ownerKeyPair(owner, "public");
|
|
92
102
|
const url = computeInvokeUrl(endpoints);
|
|
93
103
|
const params = { app_did: appDid };
|
|
94
104
|
const envelope = buildSignedEnvelope({
|
|
95
|
-
iss:
|
|
105
|
+
iss: owner.did,
|
|
96
106
|
aud: url,
|
|
97
107
|
method: "aithos.wallet_get_balance",
|
|
98
|
-
verificationMethod: `${
|
|
108
|
+
verificationMethod: `${owner.did}#public`,
|
|
99
109
|
params,
|
|
100
|
-
signer:
|
|
110
|
+
signer: publicKp,
|
|
101
111
|
});
|
|
102
112
|
let res;
|
|
103
113
|
try {
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for J3 — KeyStore integration, signInWithRecovery,
|
|
4
|
+
// importMandate, resume(), signOut(), state accessors.
|
|
5
|
+
import { strict as assert } from "node:assert";
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
8
|
+
import { AithosAuth, AithosSDKError, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
9
|
+
import { parseDelegateBundle, readDelegateBundleText, } from "../src/internal/delegate-bundle.js";
|
|
10
|
+
import { parseRecoveryFile, serializeRecoveryFile, } from "../src/internal/recovery-file.js";
|
|
11
|
+
/* -------------------------------------------------------------------------- */
|
|
12
|
+
/* Test helpers */
|
|
13
|
+
/* -------------------------------------------------------------------------- */
|
|
14
|
+
function makeAuth(opts = {}) {
|
|
15
|
+
return new AithosAuth({
|
|
16
|
+
authBaseUrl: "https://auth.test",
|
|
17
|
+
fetch: (() => {
|
|
18
|
+
throw new Error("network not expected in this test");
|
|
19
|
+
}),
|
|
20
|
+
sessionStore: opts.sessionStore ?? noopStore(),
|
|
21
|
+
keyStore: opts.keyStore ?? memoryKeyStore(),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Build a recovery-file JSON string from a fresh BrowserIdentity. */
|
|
25
|
+
function recoveryTextFor(handle, displayName) {
|
|
26
|
+
const id = createBrowserIdentity(handle, displayName);
|
|
27
|
+
const { text } = serializeRecoveryFile(id);
|
|
28
|
+
return { text, did: id.did };
|
|
29
|
+
}
|
|
30
|
+
function delegateBundleText(args) {
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
aithos_delegate_version: "0.1.0",
|
|
33
|
+
mandate: {
|
|
34
|
+
id: args.mandateId,
|
|
35
|
+
subject_did: args.subjectDid,
|
|
36
|
+
grantee: {
|
|
37
|
+
id: args.granteeId,
|
|
38
|
+
pubkey: args.granteePubkeyMultibase ?? "z6MkqGenericPubKey",
|
|
39
|
+
},
|
|
40
|
+
scopes: args.scopes ?? ["ethos.read.public"],
|
|
41
|
+
},
|
|
42
|
+
delegate_seed_hex: "11".repeat(32),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/* -------------------------------------------------------------------------- */
|
|
46
|
+
/* parseRecoveryFile / serializeRecoveryFile */
|
|
47
|
+
/* -------------------------------------------------------------------------- */
|
|
48
|
+
describe("recovery file: parse + serialize", () => {
|
|
49
|
+
it("round-trips a fresh identity", () => {
|
|
50
|
+
const id = createBrowserIdentity("alice", "Alice");
|
|
51
|
+
const { text } = serializeRecoveryFile(id);
|
|
52
|
+
const parsed = parseRecoveryFile(text);
|
|
53
|
+
assert.equal(parsed.did, id.did);
|
|
54
|
+
assert.equal(parsed.handle, id.handle);
|
|
55
|
+
assert.equal(parsed.displayName, id.displayName);
|
|
56
|
+
assert.equal(parsed.seedsHex.root.length, 64);
|
|
57
|
+
});
|
|
58
|
+
it("accepts the runOnboarding shape (warning + created_at)", () => {
|
|
59
|
+
const id = createBrowserIdentity("bob", "Bob");
|
|
60
|
+
const text = JSON.stringify({
|
|
61
|
+
aithos_recovery_version: "0.1.0-plaintext",
|
|
62
|
+
warning: "this is plaintext, store offline",
|
|
63
|
+
handle: id.handle,
|
|
64
|
+
display_name: id.displayName,
|
|
65
|
+
did: id.did,
|
|
66
|
+
created_at: new Date().toISOString(),
|
|
67
|
+
seeds_hex: {
|
|
68
|
+
root: bytesToHex(id.root.seed),
|
|
69
|
+
public: bytesToHex(id.public.seed),
|
|
70
|
+
circle: bytesToHex(id.circle.seed),
|
|
71
|
+
self: bytesToHex(id.self.seed),
|
|
72
|
+
},
|
|
73
|
+
public_keys_multibase: {},
|
|
74
|
+
});
|
|
75
|
+
const parsed = parseRecoveryFile(text);
|
|
76
|
+
assert.equal(parsed.did, id.did);
|
|
77
|
+
});
|
|
78
|
+
it("rejects unsupported versions", () => {
|
|
79
|
+
assert.throws(() => parseRecoveryFile(JSON.stringify({ aithos_recovery_version: "9.9.9" })), (e) => e instanceof AithosSDKError && e.code === "auth_invalid_recovery_file");
|
|
80
|
+
});
|
|
81
|
+
it("rejects malformed seeds", () => {
|
|
82
|
+
const id = createBrowserIdentity("alice", "Alice");
|
|
83
|
+
const { text } = serializeRecoveryFile(id);
|
|
84
|
+
const obj = JSON.parse(text);
|
|
85
|
+
obj.seeds_hex.root = "not hex";
|
|
86
|
+
assert.throws(() => parseRecoveryFile(JSON.stringify(obj)), AithosSDKError);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
/* -------------------------------------------------------------------------- */
|
|
90
|
+
/* parseDelegateBundle */
|
|
91
|
+
/* -------------------------------------------------------------------------- */
|
|
92
|
+
describe("delegate bundle: parse", () => {
|
|
93
|
+
it("parses a well-formed bundle (legacy subject_did field)", () => {
|
|
94
|
+
const text = delegateBundleText({
|
|
95
|
+
mandateId: "mandate:01H8XYZ",
|
|
96
|
+
subjectDid: "did:aithos:zCarol",
|
|
97
|
+
granteeId: "urn:aithos:agent:bob1",
|
|
98
|
+
scopes: ["ethos.read.public", "ethos.write.public"],
|
|
99
|
+
});
|
|
100
|
+
const parsed = parseDelegateBundle(text);
|
|
101
|
+
assert.equal(parsed.mandateId, "mandate:01H8XYZ");
|
|
102
|
+
assert.equal(parsed.subjectDid, "did:aithos:zCarol");
|
|
103
|
+
assert.equal(parsed.granteeId, "urn:aithos:agent:bob1");
|
|
104
|
+
assert.equal(parsed.delegateSeedHex.length, 64);
|
|
105
|
+
});
|
|
106
|
+
it("parses a bundle minted by mintDelegateBundle (issuer field)", () => {
|
|
107
|
+
// Real wire shape emitted by `mintDelegateBundle` in protocol-client:
|
|
108
|
+
// SignedMandate carries the subject's DID under `issuer`, NOT
|
|
109
|
+
// `subject_did`. Regression test for the import flow that broke
|
|
110
|
+
// every freshly-minted mandate before this fix.
|
|
111
|
+
const text = JSON.stringify({
|
|
112
|
+
aithos_delegate_version: "0.1.0",
|
|
113
|
+
mandate: {
|
|
114
|
+
"aithos-mandate": "0.1",
|
|
115
|
+
id: "mandate:01H8ISSUER",
|
|
116
|
+
issuer: "did:aithos:zCarol",
|
|
117
|
+
issued_by_key: "did:aithos:zCarol#root",
|
|
118
|
+
grantee: {
|
|
119
|
+
id: "urn:aithos:agent:bob1",
|
|
120
|
+
pubkey: "z6MkqGenericPubKey",
|
|
121
|
+
},
|
|
122
|
+
actor_sphere: "self",
|
|
123
|
+
scopes: ["ethos.read.public", "ethos.write.public"],
|
|
124
|
+
not_before: "2026-05-10T00:00:00Z",
|
|
125
|
+
not_after: "2026-05-11T00:00:00Z",
|
|
126
|
+
issued_at: "2026-05-10T00:00:00Z",
|
|
127
|
+
nonce: "abc",
|
|
128
|
+
signature: { alg: "ed25519", key: "...", value: "..." },
|
|
129
|
+
},
|
|
130
|
+
delegate_seed_hex: "11".repeat(32),
|
|
131
|
+
});
|
|
132
|
+
const parsed = parseDelegateBundle(text);
|
|
133
|
+
assert.equal(parsed.subjectDid, "did:aithos:zCarol");
|
|
134
|
+
assert.equal(parsed.mandateId, "mandate:01H8ISSUER");
|
|
135
|
+
assert.equal(parsed.granteeId, "urn:aithos:agent:bob1");
|
|
136
|
+
});
|
|
137
|
+
it("readDelegateBundleText accepts string passthrough", async () => {
|
|
138
|
+
const text = delegateBundleText({
|
|
139
|
+
mandateId: "m",
|
|
140
|
+
subjectDid: "did:aithos:z",
|
|
141
|
+
granteeId: "urn:x",
|
|
142
|
+
});
|
|
143
|
+
assert.equal(await readDelegateBundleText(text), text);
|
|
144
|
+
});
|
|
145
|
+
it("rejects missing mandate", () => {
|
|
146
|
+
assert.throws(() => parseDelegateBundle(JSON.stringify({
|
|
147
|
+
aithos_delegate_version: "0.1.0",
|
|
148
|
+
delegate_seed_hex: "11".repeat(32),
|
|
149
|
+
})), AithosSDKError);
|
|
150
|
+
});
|
|
151
|
+
it("rejects malformed delegate seed", () => {
|
|
152
|
+
assert.throws(() => parseDelegateBundle(JSON.stringify({
|
|
153
|
+
aithos_delegate_version: "0.1.0",
|
|
154
|
+
mandate: {
|
|
155
|
+
id: "m",
|
|
156
|
+
subject_did: "did:aithos:z",
|
|
157
|
+
grantee: { id: "u", pubkey: "z6MkXYZ" },
|
|
158
|
+
},
|
|
159
|
+
delegate_seed_hex: "not hex",
|
|
160
|
+
})), AithosSDKError);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
/* -------------------------------------------------------------------------- */
|
|
164
|
+
/* AithosAuth — recovery sign-in */
|
|
165
|
+
/* -------------------------------------------------------------------------- */
|
|
166
|
+
describe("AithosAuth.signInWithRecovery", () => {
|
|
167
|
+
it("hydrates owner signers + persists to keyStore (no JWT)", async () => {
|
|
168
|
+
const keyStore = memoryKeyStore();
|
|
169
|
+
const sessionStore = noopStore();
|
|
170
|
+
const auth = makeAuth({ sessionStore, keyStore });
|
|
171
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
172
|
+
assert.equal(auth.getOwnerInfo(), null);
|
|
173
|
+
const { text } = recoveryTextFor("alice", "Alice");
|
|
174
|
+
const info = await auth.signInWithRecovery({ file: text });
|
|
175
|
+
assert.equal(info.handle, "alice");
|
|
176
|
+
assert.equal(auth.canSignAsOwner(), true);
|
|
177
|
+
assert.equal(auth.getOwnerInfo()?.did, info.did);
|
|
178
|
+
assert.equal(auth.getCurrentSession(), null, "no JWT for recovery flow");
|
|
179
|
+
const persisted = await keyStore.loadOwner();
|
|
180
|
+
assert.equal(persisted?.did, info.did);
|
|
181
|
+
});
|
|
182
|
+
it("rejects loading a different owner without signOut first", async () => {
|
|
183
|
+
const auth = makeAuth();
|
|
184
|
+
const a = recoveryTextFor("alice", "Alice");
|
|
185
|
+
await auth.signInWithRecovery({ file: a.text });
|
|
186
|
+
const b = recoveryTextFor("bob", "Bob");
|
|
187
|
+
await assert.rejects(() => auth.signInWithRecovery({ file: b.text }), (e) => e instanceof AithosSDKError && e.code === "auth_owner_already_loaded");
|
|
188
|
+
});
|
|
189
|
+
it("clears any stale JWT to keep stores in sync", async () => {
|
|
190
|
+
const keyStore = memoryKeyStore();
|
|
191
|
+
let stored = {
|
|
192
|
+
session: "stale-jwt",
|
|
193
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
194
|
+
did: "did:aithos:zStale",
|
|
195
|
+
handle: "stale",
|
|
196
|
+
blob_b64: "",
|
|
197
|
+
blob_nonce_b64: "",
|
|
198
|
+
blob_version: 0,
|
|
199
|
+
enc_key_b64: "",
|
|
200
|
+
is_first_login: false,
|
|
201
|
+
};
|
|
202
|
+
const sessionStore = {
|
|
203
|
+
get: () => stored,
|
|
204
|
+
set: (s) => {
|
|
205
|
+
stored = s;
|
|
206
|
+
},
|
|
207
|
+
clear: () => {
|
|
208
|
+
stored = null;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const auth = makeAuth({ sessionStore, keyStore });
|
|
212
|
+
const { text } = recoveryTextFor("alice", "Alice");
|
|
213
|
+
await auth.signInWithRecovery({ file: text });
|
|
214
|
+
assert.equal(stored, null, "stale JWT must be wiped");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
/* -------------------------------------------------------------------------- */
|
|
218
|
+
/* AithosAuth — importMandate */
|
|
219
|
+
/* -------------------------------------------------------------------------- */
|
|
220
|
+
describe("AithosAuth.importMandate", () => {
|
|
221
|
+
it("registers a delegate, lists it, removes it", async () => {
|
|
222
|
+
const keyStore = memoryKeyStore();
|
|
223
|
+
const auth = makeAuth({ keyStore });
|
|
224
|
+
const text = delegateBundleText({
|
|
225
|
+
mandateId: "mandate:A",
|
|
226
|
+
subjectDid: "did:aithos:zCarol",
|
|
227
|
+
granteeId: "urn:aithos:agent:bob1",
|
|
228
|
+
scopes: ["ethos.read.circle"],
|
|
229
|
+
});
|
|
230
|
+
const info = await auth.importMandate({ bundle: text });
|
|
231
|
+
assert.equal(info.mandateId, "mandate:A");
|
|
232
|
+
assert.equal(info.subjectDid, "did:aithos:zCarol");
|
|
233
|
+
assert.deepEqual(info.scopes, ["ethos.read.circle"]);
|
|
234
|
+
const list = auth.getDelegates();
|
|
235
|
+
assert.equal(list.length, 1);
|
|
236
|
+
assert.equal(list[0]?.mandateId, "mandate:A");
|
|
237
|
+
assert.equal(auth.canSignAsDelegateFor("did:aithos:zCarol"), true);
|
|
238
|
+
assert.equal(auth.canSignAsDelegateFor("did:aithos:zNobody"), false);
|
|
239
|
+
await auth.removeMandate("mandate:A");
|
|
240
|
+
assert.equal(auth.getDelegates().length, 0);
|
|
241
|
+
assert.equal((await keyStore.listDelegates()).length, 0);
|
|
242
|
+
});
|
|
243
|
+
it("works without an owner loaded (delegate-only session)", async () => {
|
|
244
|
+
const auth = makeAuth();
|
|
245
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
246
|
+
const text = delegateBundleText({
|
|
247
|
+
mandateId: "mandate:Solo",
|
|
248
|
+
subjectDid: "did:aithos:zCarol",
|
|
249
|
+
granteeId: "urn:aithos:agent:solo1",
|
|
250
|
+
});
|
|
251
|
+
await auth.importMandate({ bundle: text });
|
|
252
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
253
|
+
assert.equal(auth.canSignAsDelegateFor("did:aithos:zCarol"), true);
|
|
254
|
+
});
|
|
255
|
+
it("re-importing the same mandate replaces the prior actor", async () => {
|
|
256
|
+
const auth = makeAuth();
|
|
257
|
+
const text = delegateBundleText({
|
|
258
|
+
mandateId: "mandate:R",
|
|
259
|
+
subjectDid: "did:aithos:zCarol",
|
|
260
|
+
granteeId: "urn:aithos:agent:r",
|
|
261
|
+
});
|
|
262
|
+
await auth.importMandate({ bundle: text });
|
|
263
|
+
await auth.importMandate({ bundle: text });
|
|
264
|
+
assert.equal(auth.getDelegates().length, 1);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
/* -------------------------------------------------------------------------- */
|
|
268
|
+
/* AithosAuth — resume() */
|
|
269
|
+
/* -------------------------------------------------------------------------- */
|
|
270
|
+
describe("AithosAuth.resume", () => {
|
|
271
|
+
it("rehydrates owner + delegates from keyStore on a fresh instance", async () => {
|
|
272
|
+
const keyStore = memoryKeyStore();
|
|
273
|
+
const sessionStore = noopStore();
|
|
274
|
+
// Seed the stores via a first auth instance.
|
|
275
|
+
{
|
|
276
|
+
const auth1 = makeAuth({ keyStore, sessionStore });
|
|
277
|
+
const { text: rt } = recoveryTextFor("alice", "Alice");
|
|
278
|
+
await auth1.signInWithRecovery({ file: rt });
|
|
279
|
+
const dt = delegateBundleText({
|
|
280
|
+
mandateId: "mandate:M1",
|
|
281
|
+
subjectDid: "did:aithos:zCarol",
|
|
282
|
+
granteeId: "urn:x",
|
|
283
|
+
});
|
|
284
|
+
await auth1.importMandate({ bundle: dt });
|
|
285
|
+
}
|
|
286
|
+
// Fresh instance, same stores → resume() must reload everything.
|
|
287
|
+
const auth2 = makeAuth({ keyStore, sessionStore });
|
|
288
|
+
assert.equal(auth2.canSignAsOwner(), false, "before resume, in-memory only");
|
|
289
|
+
await auth2.resume();
|
|
290
|
+
assert.equal(auth2.canSignAsOwner(), true);
|
|
291
|
+
assert.equal(auth2.getOwnerInfo()?.handle, "alice");
|
|
292
|
+
assert.equal(auth2.getDelegates().length, 1);
|
|
293
|
+
assert.equal(auth2.canSignAsDelegateFor("did:aithos:zCarol"), true);
|
|
294
|
+
});
|
|
295
|
+
it("strict mode: JWT in sessionStore but no owner in keyStore → wipes JWT", async () => {
|
|
296
|
+
let jwt = {
|
|
297
|
+
session: "j",
|
|
298
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
299
|
+
did: "did:aithos:zGhost",
|
|
300
|
+
handle: "ghost",
|
|
301
|
+
blob_b64: "",
|
|
302
|
+
blob_nonce_b64: "",
|
|
303
|
+
blob_version: 0,
|
|
304
|
+
enc_key_b64: "",
|
|
305
|
+
is_first_login: false,
|
|
306
|
+
};
|
|
307
|
+
const sessionStore = {
|
|
308
|
+
get: () => jwt,
|
|
309
|
+
set: (s) => {
|
|
310
|
+
jwt = s;
|
|
311
|
+
},
|
|
312
|
+
clear: () => {
|
|
313
|
+
jwt = null;
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
const keyStore = memoryKeyStore();
|
|
317
|
+
const auth = makeAuth({ keyStore, sessionStore });
|
|
318
|
+
await auth.resume();
|
|
319
|
+
assert.equal(jwt, null, "out-of-sync JWT must be cleared");
|
|
320
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
321
|
+
});
|
|
322
|
+
it("strict mode: JWT and owner disagree on DID → wipes JWT only", async () => {
|
|
323
|
+
const keyStore = memoryKeyStore();
|
|
324
|
+
const { text } = recoveryTextFor("alice", "Alice");
|
|
325
|
+
// Seed the keystore with alice via one instance.
|
|
326
|
+
{
|
|
327
|
+
const auth1 = makeAuth({ keyStore });
|
|
328
|
+
await auth1.signInWithRecovery({ file: text });
|
|
329
|
+
}
|
|
330
|
+
// Now plant a JWT for a DIFFERENT DID in the session store.
|
|
331
|
+
let jwt = {
|
|
332
|
+
session: "j",
|
|
333
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
334
|
+
did: "did:aithos:zSomeoneElse",
|
|
335
|
+
handle: "someone",
|
|
336
|
+
blob_b64: "",
|
|
337
|
+
blob_nonce_b64: "",
|
|
338
|
+
blob_version: 0,
|
|
339
|
+
enc_key_b64: "",
|
|
340
|
+
is_first_login: false,
|
|
341
|
+
};
|
|
342
|
+
const sessionStore = {
|
|
343
|
+
get: () => jwt,
|
|
344
|
+
set: (s) => {
|
|
345
|
+
jwt = s;
|
|
346
|
+
},
|
|
347
|
+
clear: () => {
|
|
348
|
+
jwt = null;
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
const auth2 = makeAuth({ keyStore, sessionStore });
|
|
352
|
+
await auth2.resume();
|
|
353
|
+
assert.equal(jwt, null, "mismatched JWT must be wiped");
|
|
354
|
+
// Owner is preserved (it's the source of truth in strict mode).
|
|
355
|
+
assert.equal(auth2.canSignAsOwner(), true);
|
|
356
|
+
assert.equal(auth2.getOwnerInfo()?.handle, "alice");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
/* -------------------------------------------------------------------------- */
|
|
360
|
+
/* AithosAuth — signOut */
|
|
361
|
+
/* -------------------------------------------------------------------------- */
|
|
362
|
+
describe("AithosAuth.signOut", () => {
|
|
363
|
+
it("wipes both stores and in-memory state", async () => {
|
|
364
|
+
const keyStore = memoryKeyStore();
|
|
365
|
+
const auth = makeAuth({ keyStore });
|
|
366
|
+
const { text } = recoveryTextFor("alice", "Alice");
|
|
367
|
+
await auth.signInWithRecovery({ file: text });
|
|
368
|
+
const dt = delegateBundleText({
|
|
369
|
+
mandateId: "mandate:X",
|
|
370
|
+
subjectDid: "did:aithos:zCarol",
|
|
371
|
+
granteeId: "urn:x",
|
|
372
|
+
});
|
|
373
|
+
await auth.importMandate({ bundle: dt });
|
|
374
|
+
await auth.signOut();
|
|
375
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
376
|
+
assert.equal(auth.getOwnerInfo(), null);
|
|
377
|
+
assert.equal(auth.getDelegates().length, 0);
|
|
378
|
+
assert.equal(await keyStore.loadOwner(), null);
|
|
379
|
+
assert.equal((await keyStore.listDelegates()).length, 0);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
/* -------------------------------------------------------------------------- */
|
|
383
|
+
/* Helpers */
|
|
384
|
+
/* -------------------------------------------------------------------------- */
|
|
385
|
+
function bytesToHex(b) {
|
|
386
|
+
let out = "";
|
|
387
|
+
for (let i = 0; i < b.length; i++)
|
|
388
|
+
out += b[i].toString(16).padStart(2, "0");
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=auth-j3.test.js.map
|