@aithos/sdk 0.1.0-alpha.4 → 0.1.0-alpha.6
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.d.ts +103 -134
- package/dist/src/auth.js +532 -157
- package/dist/src/compute.d.ts +8 -6
- package/dist/src/compute.js +19 -11
- package/dist/src/ethos.d.ts +117 -1
- package/dist/src/ethos.js +417 -16
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +18 -6
- package/dist/src/internal/delegate-bundle.d.ts +18 -0
- package/dist/src/internal/delegate-bundle.js +89 -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 +151 -1
- package/dist/src/mandates.js +285 -8
- package/dist/src/sdk.d.ts +36 -3
- package/dist/src/sdk.js +27 -23
- 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 +360 -0
- package/dist/test/compute.test.js +22 -11
- 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 +64 -30
- package/dist/test/signer.test.d.ts +2 -0
- package/dist/test/signer.test.js +117 -0
- package/dist/test/wallet.test.js +20 -9
- package/package.json +2 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for J4 — sdk.ethos namespace: actor resolution, staging
|
|
4
|
+
// semantics, anonymous-mode guard rails. Tests requiring real
|
|
5
|
+
// protocol-client network calls (sections() / publish()) are not
|
|
6
|
+
// covered here — they ride on an integration suite against a real
|
|
7
|
+
// backend.
|
|
8
|
+
import { strict as assert } from "node:assert";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
11
|
+
import { AithosAuth, AithosSDKError, EthosNamespace, ZONE_NAMES, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
12
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
13
|
+
import { DEFAULT_SDK_ENDPOINTS } from "../src/endpoints.js";
|
|
14
|
+
/* -------------------------------------------------------------------------- */
|
|
15
|
+
/* Fixtures */
|
|
16
|
+
/* -------------------------------------------------------------------------- */
|
|
17
|
+
function makeAuth(opts = {}) {
|
|
18
|
+
return new AithosAuth({
|
|
19
|
+
authBaseUrl: "https://auth.test",
|
|
20
|
+
fetch: (() => {
|
|
21
|
+
throw new Error("network not expected in unit tests");
|
|
22
|
+
}),
|
|
23
|
+
sessionStore: opts.sessionStore ?? noopStore(),
|
|
24
|
+
keyStore: opts.keyStore ?? memoryKeyStore(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function makeNamespace(auth) {
|
|
28
|
+
return new EthosNamespace({
|
|
29
|
+
auth,
|
|
30
|
+
endpoints: DEFAULT_SDK_ENDPOINTS,
|
|
31
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function signInAsAlice(auth) {
|
|
35
|
+
const id = createBrowserIdentity("alice", "Alice");
|
|
36
|
+
const { text } = serializeRecoveryFile(id);
|
|
37
|
+
const info = await auth.signInWithRecovery({ file: text });
|
|
38
|
+
return { did: info.did, handle: info.handle };
|
|
39
|
+
}
|
|
40
|
+
function delegateBundleText(args) {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
aithos_delegate_version: "0.1.0",
|
|
43
|
+
mandate: {
|
|
44
|
+
id: args.mandateId,
|
|
45
|
+
subject_did: args.subjectDid,
|
|
46
|
+
grantee: {
|
|
47
|
+
id: args.granteeId,
|
|
48
|
+
pubkey: "z6MkqGenericPubKey",
|
|
49
|
+
},
|
|
50
|
+
scopes: args.scopes ?? ["ethos.read.public", "ethos.write.public"],
|
|
51
|
+
},
|
|
52
|
+
delegate_seed_hex: "11".repeat(32),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/* -------------------------------------------------------------------------- */
|
|
56
|
+
/* EthosNamespace — actor resolution */
|
|
57
|
+
/* -------------------------------------------------------------------------- */
|
|
58
|
+
describe("EthosNamespace.me()", () => {
|
|
59
|
+
it("returns an owner-mode EthosClient when signed in", async () => {
|
|
60
|
+
const auth = makeAuth();
|
|
61
|
+
const alice = await signInAsAlice(auth);
|
|
62
|
+
const ns = makeNamespace(auth);
|
|
63
|
+
const me = ns.me();
|
|
64
|
+
assert.equal(me.mode, "owner");
|
|
65
|
+
assert.equal(me.subjectDid, alice.did);
|
|
66
|
+
// Three zones, all return Zone proxies.
|
|
67
|
+
for (const z of ZONE_NAMES) {
|
|
68
|
+
const zone = me.zone(z);
|
|
69
|
+
assert.equal(zone.name, z);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it("throws ethos_no_owner when no owner is signed in", () => {
|
|
73
|
+
const auth = makeAuth();
|
|
74
|
+
const ns = makeNamespace(auth);
|
|
75
|
+
assert.throws(() => ns.me(), (e) => e instanceof AithosSDKError && e.code === "ethos_no_owner");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe("EthosNamespace.of(did)", () => {
|
|
79
|
+
it("returns owner-mode for the user's own DID", async () => {
|
|
80
|
+
const auth = makeAuth();
|
|
81
|
+
const alice = await signInAsAlice(auth);
|
|
82
|
+
const ns = makeNamespace(auth);
|
|
83
|
+
const me = await ns.of(alice.did);
|
|
84
|
+
assert.equal(me.mode, "owner");
|
|
85
|
+
});
|
|
86
|
+
it("returns delegate-mode when a mandate covers the subject", async () => {
|
|
87
|
+
const auth = makeAuth();
|
|
88
|
+
await auth.importMandate({
|
|
89
|
+
bundle: delegateBundleText({
|
|
90
|
+
mandateId: "mandate:A",
|
|
91
|
+
subjectDid: "did:aithos:zCarol",
|
|
92
|
+
granteeId: "urn:aithos:agent:bob1",
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
const ns = makeNamespace(auth);
|
|
96
|
+
const carol = await ns.of("did:aithos:zCarol");
|
|
97
|
+
assert.equal(carol.mode, "delegate");
|
|
98
|
+
assert.equal(carol.subjectDid, "did:aithos:zCarol");
|
|
99
|
+
});
|
|
100
|
+
it("returns anonymous-mode when no actor matches", async () => {
|
|
101
|
+
const auth = makeAuth();
|
|
102
|
+
const ns = makeNamespace(auth);
|
|
103
|
+
const stranger = await ns.of("did:aithos:zStranger");
|
|
104
|
+
assert.equal(stranger.mode, "anonymous");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
/* -------------------------------------------------------------------------- */
|
|
108
|
+
/* Mutation staging (no network) */
|
|
109
|
+
/* -------------------------------------------------------------------------- */
|
|
110
|
+
describe("EthosClient mutation staging", () => {
|
|
111
|
+
it("addSection / updateSection / deleteSection accumulate", async () => {
|
|
112
|
+
const auth = makeAuth();
|
|
113
|
+
await signInAsAlice(auth);
|
|
114
|
+
const ns = makeNamespace(auth);
|
|
115
|
+
const me = ns.me();
|
|
116
|
+
assert.equal(me.hasPendingChanges(), false);
|
|
117
|
+
me.zone("public").addSection({ title: "Hello", body: "world" });
|
|
118
|
+
me.zone("public").addSection({ title: "Two", body: "sections" });
|
|
119
|
+
me.zone("circle").deleteSection("sec_old");
|
|
120
|
+
me.zone("self").updateSection("sec_x", { body: "new" });
|
|
121
|
+
assert.equal(me.hasPendingChanges(), true);
|
|
122
|
+
const pending = me.pendingChanges();
|
|
123
|
+
assert.equal(pending.length, 4);
|
|
124
|
+
const kinds = pending.map((c) => `${c.zone}:${c.kind}`);
|
|
125
|
+
assert.deepEqual(kinds, [
|
|
126
|
+
"public:add",
|
|
127
|
+
"public:add",
|
|
128
|
+
"circle:delete",
|
|
129
|
+
"self:update",
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
it("each addSection generates a fresh sec_-prefixed id", async () => {
|
|
133
|
+
const auth = makeAuth();
|
|
134
|
+
await signInAsAlice(auth);
|
|
135
|
+
const ns = makeNamespace(auth);
|
|
136
|
+
const me = ns.me();
|
|
137
|
+
me.zone("public").addSection({ title: "a", body: "1" });
|
|
138
|
+
me.zone("public").addSection({ title: "b", body: "2" });
|
|
139
|
+
const ids = me
|
|
140
|
+
.pendingChanges()
|
|
141
|
+
.filter((c) => c.kind === "add")
|
|
142
|
+
.map((c) => c.section.id);
|
|
143
|
+
assert.equal(ids.length, 2);
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
assert.match(id, /^sec_[0-9a-f]{12}$/);
|
|
146
|
+
}
|
|
147
|
+
assert.notEqual(ids[0], ids[1]);
|
|
148
|
+
});
|
|
149
|
+
it("discard() clears pending changes", async () => {
|
|
150
|
+
const auth = makeAuth();
|
|
151
|
+
await signInAsAlice(auth);
|
|
152
|
+
const ns = makeNamespace(auth);
|
|
153
|
+
const me = ns.me();
|
|
154
|
+
me.zone("public").addSection({ title: "h", body: "w" });
|
|
155
|
+
assert.equal(me.hasPendingChanges(), true);
|
|
156
|
+
me.discard();
|
|
157
|
+
assert.equal(me.hasPendingChanges(), false);
|
|
158
|
+
assert.equal(me.pendingChanges().length, 0);
|
|
159
|
+
});
|
|
160
|
+
it("publish() rejects when nothing is staged", async () => {
|
|
161
|
+
const auth = makeAuth();
|
|
162
|
+
await signInAsAlice(auth);
|
|
163
|
+
const ns = makeNamespace(auth);
|
|
164
|
+
const me = ns.me();
|
|
165
|
+
await assert.rejects(() => me.publish(), (e) => e instanceof AithosSDKError && e.code === "ethos_nothing_to_publish");
|
|
166
|
+
});
|
|
167
|
+
it("zone(invalid) throws", async () => {
|
|
168
|
+
const auth = makeAuth();
|
|
169
|
+
await signInAsAlice(auth);
|
|
170
|
+
const ns = makeNamespace(auth);
|
|
171
|
+
const me = ns.me();
|
|
172
|
+
assert.throws(() => me.zone("nope"), AithosSDKError);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
/* Anonymous mode guard rails */
|
|
177
|
+
/* -------------------------------------------------------------------------- */
|
|
178
|
+
describe("EthosClient anonymous mode", () => {
|
|
179
|
+
it("rejects every write op", async () => {
|
|
180
|
+
const auth = makeAuth();
|
|
181
|
+
const ns = makeNamespace(auth);
|
|
182
|
+
const stranger = await ns.of("did:aithos:zStranger");
|
|
183
|
+
assert.equal(stranger.mode, "anonymous");
|
|
184
|
+
assert.throws(() => stranger
|
|
185
|
+
.zone("public")
|
|
186
|
+
.addSection({ title: "x", body: "y" }), AithosSDKError);
|
|
187
|
+
assert.throws(() => stranger.zone("public").updateSection("sec_x", { body: "y" }), AithosSDKError);
|
|
188
|
+
assert.throws(() => stranger.zone("public").deleteSection("sec_x"), AithosSDKError);
|
|
189
|
+
});
|
|
190
|
+
it("rejects publish()", async () => {
|
|
191
|
+
const auth = makeAuth();
|
|
192
|
+
const ns = makeNamespace(auth);
|
|
193
|
+
const stranger = await ns.of("did:aithos:zStranger");
|
|
194
|
+
await assert.rejects(() => stranger.publish(), (e) => e instanceof AithosSDKError && e.code === "ethos_anonymous_cannot_publish");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
/* -------------------------------------------------------------------------- */
|
|
198
|
+
/* Delegate mode scope checks */
|
|
199
|
+
/* -------------------------------------------------------------------------- */
|
|
200
|
+
describe("EthosClient delegate mode", () => {
|
|
201
|
+
it("publish rejects writes outside the mandate scopes", async () => {
|
|
202
|
+
const auth = makeAuth();
|
|
203
|
+
// Mandate only grants ethos.read.public — no write scope at all.
|
|
204
|
+
await auth.importMandate({
|
|
205
|
+
bundle: delegateBundleText({
|
|
206
|
+
mandateId: "mandate:RO",
|
|
207
|
+
subjectDid: "did:aithos:zCarol",
|
|
208
|
+
granteeId: "urn:x",
|
|
209
|
+
scopes: ["ethos.read.public"],
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
const ns = makeNamespace(auth);
|
|
213
|
+
const carol = await ns.of("did:aithos:zCarol");
|
|
214
|
+
carol.zone("public").addSection({ title: "x", body: "y" });
|
|
215
|
+
await assert.rejects(() => carol.publish(), (e) => e instanceof AithosSDKError &&
|
|
216
|
+
e.code === "ethos_delegate_scope_missing");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
//# sourceMappingURL=ethos.test.js.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Unit tests for AithosKeyStore — both built-in implementations.
|
|
4
|
+
//
|
|
5
|
+
// memoryKeyStore is exercised directly. indexedDbKeyStore is driven by
|
|
6
|
+
// `fake-indexeddb` (a W3C-compatible in-memory IDBFactory), with a
|
|
7
|
+
// fresh factory + DB name per test so cases don't bleed into each
|
|
8
|
+
// other.
|
|
9
|
+
import { strict as assert } from "node:assert";
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import { IDBFactory } from "fake-indexeddb";
|
|
12
|
+
import { indexedDbKeyStore, memoryKeyStore, } from "../src/key-store.js";
|
|
13
|
+
/* -------------------------------------------------------------------------- */
|
|
14
|
+
/* Fixtures */
|
|
15
|
+
/* -------------------------------------------------------------------------- */
|
|
16
|
+
const SEED_HEX = "00".repeat(32);
|
|
17
|
+
function ownerFixture(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
version: "0.1.0-hex",
|
|
20
|
+
did: "did:aithos:zAlice",
|
|
21
|
+
handle: "alice",
|
|
22
|
+
displayName: "Alice",
|
|
23
|
+
seedsHex: {
|
|
24
|
+
root: SEED_HEX,
|
|
25
|
+
public: SEED_HEX,
|
|
26
|
+
circle: SEED_HEX,
|
|
27
|
+
self: SEED_HEX,
|
|
28
|
+
},
|
|
29
|
+
savedAt: "2026-05-08T00:00:00.000Z",
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function delegateFixture(overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
version: "0.1.0-hex",
|
|
36
|
+
subjectDid: "did:aithos:zCarol",
|
|
37
|
+
mandateId: "mandate:01H8XYZ",
|
|
38
|
+
mandate: { id: "mandate:01H8XYZ", scopes: ["ethos.read.public"] },
|
|
39
|
+
granteeId: "urn:aithos:agent:bob1",
|
|
40
|
+
granteePubkeyMultibase: "z6MkqBobPubKey",
|
|
41
|
+
delegateSeedHex: "11".repeat(32),
|
|
42
|
+
importedAt: "2026-05-08T01:00:00.000Z",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/* -------------------------------------------------------------------------- */
|
|
47
|
+
/* Behaviour suite — runs against both implementations */
|
|
48
|
+
/* -------------------------------------------------------------------------- */
|
|
49
|
+
const implementations = [
|
|
50
|
+
{ name: "memoryKeyStore", make: () => memoryKeyStore() },
|
|
51
|
+
{
|
|
52
|
+
name: "indexedDbKeyStore",
|
|
53
|
+
make: () => {
|
|
54
|
+
// Fresh in-memory IDBFactory + unique db name per call so cases stay
|
|
55
|
+
// isolated even across test runs.
|
|
56
|
+
const factory = new IDBFactory();
|
|
57
|
+
return indexedDbKeyStore({
|
|
58
|
+
dbName: `test-${Math.random().toString(36).slice(2)}`,
|
|
59
|
+
factory,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
for (const impl of implementations) {
|
|
65
|
+
describe(`AithosKeyStore: ${impl.name}`, () => {
|
|
66
|
+
it("loadOwner returns null on a fresh store", async () => {
|
|
67
|
+
const store = impl.make();
|
|
68
|
+
assert.equal(await store.loadOwner(), null);
|
|
69
|
+
});
|
|
70
|
+
it("save/load/clear owner round-trips", async () => {
|
|
71
|
+
const store = impl.make();
|
|
72
|
+
const o = ownerFixture();
|
|
73
|
+
await store.saveOwner(o);
|
|
74
|
+
const loaded = await store.loadOwner();
|
|
75
|
+
assert.deepEqual(loaded, o);
|
|
76
|
+
await store.clearOwner();
|
|
77
|
+
assert.equal(await store.loadOwner(), null);
|
|
78
|
+
});
|
|
79
|
+
it("saveOwner overwrites the prior owner", async () => {
|
|
80
|
+
const store = impl.make();
|
|
81
|
+
await store.saveOwner(ownerFixture({ handle: "alice" }));
|
|
82
|
+
await store.saveOwner(ownerFixture({ handle: "alice2" }));
|
|
83
|
+
const loaded = await store.loadOwner();
|
|
84
|
+
assert.equal(loaded?.handle, "alice2");
|
|
85
|
+
});
|
|
86
|
+
it("listDelegates is empty on a fresh store", async () => {
|
|
87
|
+
const store = impl.make();
|
|
88
|
+
assert.deepEqual(await store.listDelegates(), []);
|
|
89
|
+
});
|
|
90
|
+
it("save/load/list/remove delegates", async () => {
|
|
91
|
+
const store = impl.make();
|
|
92
|
+
const a = delegateFixture({ mandateId: "mandate:A" });
|
|
93
|
+
const b = delegateFixture({ mandateId: "mandate:B", subjectDid: "did:aithos:zDave" });
|
|
94
|
+
await store.saveDelegate(a);
|
|
95
|
+
await store.saveDelegate(b);
|
|
96
|
+
assert.deepEqual(await store.loadDelegate("mandate:A"), a);
|
|
97
|
+
assert.deepEqual(await store.loadDelegate("mandate:B"), b);
|
|
98
|
+
assert.equal(await store.loadDelegate("mandate:missing"), null);
|
|
99
|
+
const list = await store.listDelegates();
|
|
100
|
+
assert.equal(list.length, 2);
|
|
101
|
+
const ids = list.map((d) => d.mandateId).sort();
|
|
102
|
+
assert.deepEqual(ids, ["mandate:A", "mandate:B"]);
|
|
103
|
+
await store.removeDelegate("mandate:A");
|
|
104
|
+
const after = await store.listDelegates();
|
|
105
|
+
assert.equal(after.length, 1);
|
|
106
|
+
assert.equal(after[0]?.mandateId, "mandate:B");
|
|
107
|
+
});
|
|
108
|
+
it("clearAllDelegates wipes them all but leaves the owner intact", async () => {
|
|
109
|
+
const store = impl.make();
|
|
110
|
+
await store.saveOwner(ownerFixture());
|
|
111
|
+
await store.saveDelegate(delegateFixture({ mandateId: "mandate:A" }));
|
|
112
|
+
await store.saveDelegate(delegateFixture({ mandateId: "mandate:B" }));
|
|
113
|
+
await store.clearAllDelegates();
|
|
114
|
+
assert.deepEqual(await store.listDelegates(), []);
|
|
115
|
+
assert.notEqual(await store.loadOwner(), null);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/* -------------------------------------------------------------------------- */
|
|
120
|
+
/* Defensive validators — only relevant for IndexedDB which can hold */
|
|
121
|
+
/* arbitrary tampered records */
|
|
122
|
+
/* -------------------------------------------------------------------------- */
|
|
123
|
+
describe("AithosKeyStore: validators (indexedDbKeyStore)", () => {
|
|
124
|
+
it("treats a corrupted owner record as null", async () => {
|
|
125
|
+
const factory = new IDBFactory();
|
|
126
|
+
const dbName = `test-corrupt-${Math.random().toString(36).slice(2)}`;
|
|
127
|
+
const store = indexedDbKeyStore({ dbName, factory });
|
|
128
|
+
// Bypass the typed API to inject garbage at the OWNER_KEY slot.
|
|
129
|
+
const db = await new Promise((resolve, reject) => {
|
|
130
|
+
const req = factory.open(dbName, 1);
|
|
131
|
+
req.onupgradeneeded = () => {
|
|
132
|
+
const d = req.result;
|
|
133
|
+
if (!d.objectStoreNames.contains("owner"))
|
|
134
|
+
d.createObjectStore("owner");
|
|
135
|
+
if (!d.objectStoreNames.contains("delegates"))
|
|
136
|
+
d.createObjectStore("delegates", { keyPath: "mandateId" });
|
|
137
|
+
};
|
|
138
|
+
req.onsuccess = () => resolve(req.result);
|
|
139
|
+
req.onerror = () => reject(req.error);
|
|
140
|
+
});
|
|
141
|
+
await new Promise((resolve, reject) => {
|
|
142
|
+
const t = db.transaction(["owner"], "readwrite");
|
|
143
|
+
t.objectStore("owner").put({ not: "an owner" }, "self");
|
|
144
|
+
t.oncomplete = () => resolve();
|
|
145
|
+
t.onerror = () => reject(t.error);
|
|
146
|
+
});
|
|
147
|
+
db.close();
|
|
148
|
+
assert.equal(await store.loadOwner(), null);
|
|
149
|
+
});
|
|
150
|
+
it("treats a wrong-version owner record as null", async () => {
|
|
151
|
+
const factory = new IDBFactory();
|
|
152
|
+
const dbName = `test-version-${Math.random().toString(36).slice(2)}`;
|
|
153
|
+
const store = indexedDbKeyStore({ dbName, factory });
|
|
154
|
+
// Owner record with a future schema version → must not be returned.
|
|
155
|
+
const futureRecord = ownerFixture();
|
|
156
|
+
futureRecord.version = "0.2.0-cryptokey";
|
|
157
|
+
await store.saveOwner(futureRecord);
|
|
158
|
+
assert.equal(await store.loadOwner(), null);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
//# sourceMappingURL=key-store.test.js.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the `compute` namespace on `MandatesNamespace.create`.
|
|
5
|
+
*
|
|
6
|
+
* Invariants under test (mirror of the protocol-core v0.4.0 invariants,
|
|
7
|
+
* enforced at the SDK boundary so callers fail fast with a precise
|
|
8
|
+
* error rather than only blowing up server-side):
|
|
9
|
+
*
|
|
10
|
+
* 1. Passing `compute.invoke` directly in `scopes[]` is rejected.
|
|
11
|
+
* The opt-in must go through the typed `compute` namespace, which
|
|
12
|
+
* is what a consent UI can review.
|
|
13
|
+
* 2. The `compute` namespace requires at least one of
|
|
14
|
+
* `dailyCapMicrocredits` or `totalCapMicrocredits` — an unbounded
|
|
15
|
+
* compute mandate is the bearer-token footgun this whole namespace
|
|
16
|
+
* exists to prevent.
|
|
17
|
+
* 3. Numeric caps must be positive integers.
|
|
18
|
+
* 4. `allowedModels`, when present, must be an array of non-empty
|
|
19
|
+
* strings.
|
|
20
|
+
* 5. A compute-only mandate (`scopes: []` + `compute: {…}`) is
|
|
21
|
+
* legitimate and accepted — the grantee gets no ethos data
|
|
22
|
+
* access, only bounded compute spending. Empty scopes WITHOUT
|
|
23
|
+
* compute is still rejected.
|
|
24
|
+
*
|
|
25
|
+
* Network-dependent paths (real mint, list, revoke) ride on the same
|
|
26
|
+
* integration suite as the rest of the namespace — see the note in
|
|
27
|
+
* `mandates.test.ts`. We intentionally don't go through `mintDelegateBundle`
|
|
28
|
+
* here; failures fire BEFORE that boundary.
|
|
29
|
+
*/
|
|
30
|
+
import { strict as assert } from "node:assert";
|
|
31
|
+
import { describe, it } from "node:test";
|
|
32
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
33
|
+
import { AithosAuth, AithosSDKError, COMPUTE_INVOKE_SCOPE, MandatesNamespace, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
34
|
+
import { DEFAULT_SDK_ENDPOINTS } from "../src/endpoints.js";
|
|
35
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
36
|
+
function makeAuth() {
|
|
37
|
+
return new AithosAuth({
|
|
38
|
+
authBaseUrl: "https://auth.test",
|
|
39
|
+
fetch: (() => {
|
|
40
|
+
throw new Error("network not expected");
|
|
41
|
+
}),
|
|
42
|
+
sessionStore: noopStore(),
|
|
43
|
+
keyStore: memoryKeyStore(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function makeMandates(auth) {
|
|
47
|
+
return new MandatesNamespace({
|
|
48
|
+
auth,
|
|
49
|
+
endpoints: DEFAULT_SDK_ENDPOINTS,
|
|
50
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async function signInAsAlice(auth) {
|
|
54
|
+
const id = createBrowserIdentity("alice", "Alice");
|
|
55
|
+
const { text } = serializeRecoveryFile(id);
|
|
56
|
+
const info = await auth.signInWithRecovery({ file: text });
|
|
57
|
+
return info.did;
|
|
58
|
+
}
|
|
59
|
+
/* -------------------------------------------------------------------------- */
|
|
60
|
+
/* Constant export sanity */
|
|
61
|
+
/* -------------------------------------------------------------------------- */
|
|
62
|
+
describe("COMPUTE_INVOKE_SCOPE constant", () => {
|
|
63
|
+
it("is the canonical 'compute.invoke' string", () => {
|
|
64
|
+
assert.equal(COMPUTE_INVOKE_SCOPE, "compute.invoke");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
/* -------------------------------------------------------------------------- */
|
|
68
|
+
/* Smuggling guard: compute.invoke in scopes[] is rejected */
|
|
69
|
+
/* -------------------------------------------------------------------------- */
|
|
70
|
+
describe("MandatesNamespace.create — compute.invoke smuggling guard", () => {
|
|
71
|
+
it("rejects a mandate that smuggles compute.invoke into scopes[]", async () => {
|
|
72
|
+
const auth = makeAuth();
|
|
73
|
+
await signInAsAlice(auth);
|
|
74
|
+
const m = makeMandates(auth);
|
|
75
|
+
await assert.rejects(() => m.create({
|
|
76
|
+
granteeId: "urn:agent:bob",
|
|
77
|
+
// Up-cast through string[] — this is exactly the path the
|
|
78
|
+
// runtime check has to catch. Type-checking can't (the union
|
|
79
|
+
// doesn't include "compute.invoke", but cast slips through).
|
|
80
|
+
scopes: ["ethos.read.public", "compute.invoke"],
|
|
81
|
+
ttlSeconds: 3600,
|
|
82
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
83
|
+
e.code === "mandates_invalid_scopes" &&
|
|
84
|
+
/compute' namespace/.test(e.message));
|
|
85
|
+
});
|
|
86
|
+
it("rejects even when 'compute.invoke' is the ONLY scope passed", async () => {
|
|
87
|
+
const auth = makeAuth();
|
|
88
|
+
await signInAsAlice(auth);
|
|
89
|
+
const m = makeMandates(auth);
|
|
90
|
+
await assert.rejects(() => m.create({
|
|
91
|
+
granteeId: "urn:agent:bob",
|
|
92
|
+
scopes: ["compute.invoke"],
|
|
93
|
+
ttlSeconds: 3600,
|
|
94
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
95
|
+
e.code === "mandates_invalid_scopes");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
/* -------------------------------------------------------------------------- */
|
|
99
|
+
/* Compute namespace — input validation */
|
|
100
|
+
/* -------------------------------------------------------------------------- */
|
|
101
|
+
describe("MandatesNamespace.create — compute namespace validation", () => {
|
|
102
|
+
it("rejects compute namespace with no caps at all", async () => {
|
|
103
|
+
const auth = makeAuth();
|
|
104
|
+
await signInAsAlice(auth);
|
|
105
|
+
const m = makeMandates(auth);
|
|
106
|
+
await assert.rejects(() => m.create({
|
|
107
|
+
granteeId: "urn:agent:bob",
|
|
108
|
+
scopes: ["ethos.read.public"],
|
|
109
|
+
ttlSeconds: 3600,
|
|
110
|
+
compute: {}, // empty — neither daily nor total cap
|
|
111
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
112
|
+
e.code === "mandates_invalid_compute" &&
|
|
113
|
+
/at least one of dailyCapMicrocredits or totalCapMicrocredits/.test(e.message));
|
|
114
|
+
});
|
|
115
|
+
it("rejects compute namespace with only allowedModels (no cap)", async () => {
|
|
116
|
+
const auth = makeAuth();
|
|
117
|
+
await signInAsAlice(auth);
|
|
118
|
+
const m = makeMandates(auth);
|
|
119
|
+
await assert.rejects(() => m.create({
|
|
120
|
+
granteeId: "urn:agent:bob",
|
|
121
|
+
scopes: ["ethos.read.public"],
|
|
122
|
+
ttlSeconds: 3600,
|
|
123
|
+
compute: { allowedModels: ["claude-haiku-4-5"] },
|
|
124
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
125
|
+
e.code === "mandates_invalid_compute");
|
|
126
|
+
});
|
|
127
|
+
it("rejects non-positive caps", async () => {
|
|
128
|
+
const auth = makeAuth();
|
|
129
|
+
await signInAsAlice(auth);
|
|
130
|
+
const m = makeMandates(auth);
|
|
131
|
+
for (const bad of [0, -1, 3.14]) {
|
|
132
|
+
await assert.rejects(() => m.create({
|
|
133
|
+
granteeId: "urn:agent:bob",
|
|
134
|
+
scopes: ["ethos.read.public"],
|
|
135
|
+
ttlSeconds: 3600,
|
|
136
|
+
compute: { dailyCapMicrocredits: bad },
|
|
137
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
138
|
+
e.code === "mandates_invalid_compute" &&
|
|
139
|
+
/must be a positive integer/.test(e.message), `expected rejection for dailyCapMicrocredits=${bad}`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
it("rejects malformed allowedModels (empty string entry)", async () => {
|
|
143
|
+
const auth = makeAuth();
|
|
144
|
+
await signInAsAlice(auth);
|
|
145
|
+
const m = makeMandates(auth);
|
|
146
|
+
await assert.rejects(() => m.create({
|
|
147
|
+
granteeId: "urn:agent:bob",
|
|
148
|
+
scopes: ["ethos.read.public"],
|
|
149
|
+
ttlSeconds: 3600,
|
|
150
|
+
compute: {
|
|
151
|
+
dailyCapMicrocredits: 5_000,
|
|
152
|
+
allowedModels: ["claude-haiku-4-5", ""],
|
|
153
|
+
},
|
|
154
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
155
|
+
e.code === "mandates_invalid_compute" &&
|
|
156
|
+
/allowedModels entries must be non-empty strings/.test(e.message));
|
|
157
|
+
});
|
|
158
|
+
it("rejects allowedModels that is not an array", async () => {
|
|
159
|
+
const auth = makeAuth();
|
|
160
|
+
await signInAsAlice(auth);
|
|
161
|
+
const m = makeMandates(auth);
|
|
162
|
+
await assert.rejects(() => m.create({
|
|
163
|
+
granteeId: "urn:agent:bob",
|
|
164
|
+
scopes: ["ethos.read.public"],
|
|
165
|
+
ttlSeconds: 3600,
|
|
166
|
+
compute: {
|
|
167
|
+
dailyCapMicrocredits: 5_000,
|
|
168
|
+
// @ts-expect-error — runtime check guards against bad casts
|
|
169
|
+
allowedModels: "claude-haiku-4-5",
|
|
170
|
+
},
|
|
171
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
172
|
+
e.code === "mandates_invalid_compute");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
/* -------------------------------------------------------------------------- */
|
|
176
|
+
/* Read-only mandate is unaffected */
|
|
177
|
+
/* -------------------------------------------------------------------------- */
|
|
178
|
+
describe("MandatesNamespace.create — read-only mandates unaffected", () => {
|
|
179
|
+
it("does NOT reject a clean read-only mandate (no compute, no smuggling)", async () => {
|
|
180
|
+
// We can't run the full mint here because the installed
|
|
181
|
+
// @aithos/protocol-core (0.5.1, pre-0.4.0-mandate) doesn't yet
|
|
182
|
+
// accept compute.invoke on the public sphere whitelist — the real
|
|
183
|
+
// mint would still succeed for a read-only mandate, but it goes
|
|
184
|
+
// out to S3 etc. Instead we check the synchronous validation
|
|
185
|
+
// surface: passing a clean read-only input must not throw before
|
|
186
|
+
// the mint call.
|
|
187
|
+
//
|
|
188
|
+
// (The async `m.create(...)` would still error later on the
|
|
189
|
+
// network path — that's the integration suite's job.)
|
|
190
|
+
//
|
|
191
|
+
// What we ARE asserting here: no SDK-side validation rejection.
|
|
192
|
+
const auth = makeAuth();
|
|
193
|
+
await signInAsAlice(auth);
|
|
194
|
+
const m = makeMandates(auth);
|
|
195
|
+
// The mint will fail with a network error (fetch throws), but
|
|
196
|
+
// crucially NOT with a validation error — that proves our compute
|
|
197
|
+
// guard is precisely scoped to compute-related inputs.
|
|
198
|
+
await assert.rejects(() => m.create({
|
|
199
|
+
granteeId: "urn:viewer:bob",
|
|
200
|
+
scopes: ["ethos.read.public"],
|
|
201
|
+
ttlSeconds: 3600,
|
|
202
|
+
}), (e) => {
|
|
203
|
+
if (!(e instanceof AithosSDKError))
|
|
204
|
+
return true; // any non-validation error fine
|
|
205
|
+
const code = e.code;
|
|
206
|
+
// Anything except a validation rejection is fine — the network
|
|
207
|
+
// is not wired up in this fake auth.
|
|
208
|
+
return (code !== "mandates_invalid_scopes" &&
|
|
209
|
+
code !== "mandates_invalid_compute");
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
/* -------------------------------------------------------------------------- */
|
|
214
|
+
/* Compute-only mandate (scopes: [] + compute namespace) */
|
|
215
|
+
/* -------------------------------------------------------------------------- */
|
|
216
|
+
describe("MandatesNamespace.create — compute-only mandate", () => {
|
|
217
|
+
it("accepts an empty scopes[] when compute is provided (no ethos data access, just bounded spending)", async () => {
|
|
218
|
+
// Same caveat as the "read-only unaffected" suite: the installed
|
|
219
|
+
// @aithos/protocol-core does not yet whitelist compute.invoke on
|
|
220
|
+
// the public sphere, so the real `mintDelegateBundle` call would
|
|
221
|
+
// still reject downstream. What this test proves is that the
|
|
222
|
+
// SDK validation layer (a) does NOT throw `mandates_invalid_scopes`
|
|
223
|
+
// for the empty list when compute is set, and (b) does NOT throw
|
|
224
|
+
// `mandates_invalid_compute` for a properly-capped budget. The
|
|
225
|
+
// request is allowed to proceed past validation.
|
|
226
|
+
const auth = makeAuth();
|
|
227
|
+
await signInAsAlice(auth);
|
|
228
|
+
const m = makeMandates(auth);
|
|
229
|
+
await assert.rejects(() => m.create({
|
|
230
|
+
granteeId: "urn:agent:creative",
|
|
231
|
+
scopes: [], // no ethos data access
|
|
232
|
+
ttlSeconds: 3600,
|
|
233
|
+
compute: { dailyCapMicrocredits: 1_000 },
|
|
234
|
+
}), (e) => {
|
|
235
|
+
if (!(e instanceof AithosSDKError))
|
|
236
|
+
return true;
|
|
237
|
+
const code = e.code;
|
|
238
|
+
// Validation must NOT have rejected this input.
|
|
239
|
+
return (code !== "mandates_invalid_scopes" &&
|
|
240
|
+
code !== "mandates_invalid_compute");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
it("STILL rejects empty scopes[] when compute is NOT provided", async () => {
|
|
244
|
+
const auth = makeAuth();
|
|
245
|
+
await signInAsAlice(auth);
|
|
246
|
+
const m = makeMandates(auth);
|
|
247
|
+
await assert.rejects(() => m.create({
|
|
248
|
+
granteeId: "urn:agent:bob",
|
|
249
|
+
scopes: [],
|
|
250
|
+
ttlSeconds: 3600,
|
|
251
|
+
}), (e) => e instanceof AithosSDKError &&
|
|
252
|
+
e.code === "mandates_invalid_scopes" &&
|
|
253
|
+
/compute-only mandate/.test(e.message));
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
//# sourceMappingURL=mandates-compute.test.js.map
|