@aithos/sdk 0.1.0-alpha.4 → 0.1.0-alpha.41
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 +211 -7
- package/dist/src/apps.d.ts +155 -0
- package/dist/src/apps.js +288 -0
- package/dist/src/assets.d.ts +207 -0
- package/dist/src/assets.js +533 -0
- package/dist/src/auth-api.d.ts +138 -0
- package/dist/src/auth-api.js +168 -0
- package/dist/src/auth.d.ts +536 -119
- package/dist/src/auth.js +1207 -152
- package/dist/src/compute.d.ts +251 -9
- package/dist/src/compute.js +293 -16
- package/dist/src/data-schema-contacts-v1.d.ts +14 -0
- package/dist/src/data-schema-contacts-v1.js +28 -0
- package/dist/src/data.d.ts +153 -0
- package/dist/src/data.js +670 -0
- package/dist/src/endpoints.d.ts +9 -0
- package/dist/src/endpoints.js +5 -0
- package/dist/src/ethos.d.ts +202 -1
- package/dist/src/ethos.js +821 -16
- package/dist/src/index.d.ts +18 -6
- package/dist/src/index.js +39 -6
- 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/envelope.d.ts +77 -0
- package/dist/src/internal/envelope.js +154 -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/react/AithosAsset.d.ts +66 -0
- package/dist/src/react/AithosAsset.js +67 -0
- package/dist/src/react/context.d.ts +29 -0
- package/dist/src/react/context.js +31 -0
- package/dist/src/react/index.d.ts +28 -0
- package/dist/src/react/index.js +30 -0
- package/dist/src/react/use-aithos-asset.d.ts +39 -0
- package/dist/src/react/use-aithos-asset.js +118 -0
- package/dist/src/sdk.d.ts +46 -3
- package/dist/src/sdk.js +49 -23
- package/dist/src/wallet.d.ts +4 -6
- package/dist/src/wallet.js +18 -8
- package/dist/src/web.d.ts +279 -0
- package/dist/src/web.js +186 -0
- package/dist/test/auth-j3.test.d.ts +2 -0
- package/dist/test/auth-j3.test.js +391 -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 +26 -11
- package/dist/test/endpoints.test.js +20 -1
- package/dist/test/envelope.test.d.ts +2 -0
- package/dist/test/envelope.test.js +318 -0
- package/dist/test/ethos-first-edition.test.d.ts +2 -0
- package/dist/test/ethos-first-edition.test.js +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 +311 -0
- package/dist/test/wallet.test.js +20 -9
- package/dist/test/web.test.d.ts +2 -0
- package/dist/test/web.test.js +270 -0
- package/package.json +18 -3
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for the Ethos bootstrap step inside AithosAuth.signUp().
|
|
4
|
+
//
|
|
5
|
+
// The contract:
|
|
6
|
+
// 1. POST /auth/register → creates the auth user (auth.aithos.be).
|
|
7
|
+
// 2. POST /mcp/primitives/write with method=aithos.publish_identity →
|
|
8
|
+
// provisions the user's Ethos on api.aithos.be.
|
|
9
|
+
// 3. Hydrate local state ONLY after both steps succeed.
|
|
10
|
+
//
|
|
11
|
+
// Failure modes:
|
|
12
|
+
// - register fails → throw, no publish_identity attempted, no hydrate.
|
|
13
|
+
// - publish_identity rejected (JSON-RPC error) → throw immediately,
|
|
14
|
+
// no retry, no hydrate.
|
|
15
|
+
// - publish_identity 5xx / network error → 2 retries with backoff,
|
|
16
|
+
// then throw `ethos_bootstrap_failed` if all fail.
|
|
17
|
+
//
|
|
18
|
+
// We mock fetch end-to-end so the tests run offline. The protocol-client
|
|
19
|
+
// crypto runs for real — we want to assert that the envelope shape coming
|
|
20
|
+
// out of the SDK matches what api.aithos.be will accept.
|
|
21
|
+
import { strict as assert } from "node:assert";
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import { AithosAuth, AithosSDKError, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
24
|
+
function makeMockFetch(handlers) {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const fetchImpl = (async (input, init) => {
|
|
27
|
+
const url = String(input);
|
|
28
|
+
const method = init?.method ?? "GET";
|
|
29
|
+
const bodyText = typeof init?.body === "string"
|
|
30
|
+
? init.body
|
|
31
|
+
: init?.body == null
|
|
32
|
+
? null
|
|
33
|
+
: String(init.body);
|
|
34
|
+
const body = bodyText ? JSON.parse(bodyText) : null;
|
|
35
|
+
const call = { url, method, body };
|
|
36
|
+
calls.push(call);
|
|
37
|
+
for (const h of handlers) {
|
|
38
|
+
if (!url.includes(h.url))
|
|
39
|
+
continue;
|
|
40
|
+
if (h.method && h.method !== method)
|
|
41
|
+
continue;
|
|
42
|
+
if (h.remaining !== undefined && h.remaining <= 0)
|
|
43
|
+
continue;
|
|
44
|
+
if (h.remaining !== undefined)
|
|
45
|
+
h.remaining--;
|
|
46
|
+
const out = await h.respond(call);
|
|
47
|
+
const status = out.status ?? 200;
|
|
48
|
+
return new Response(JSON.stringify(out.json), {
|
|
49
|
+
status,
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`unhandled fetch: ${method} ${url}`);
|
|
54
|
+
});
|
|
55
|
+
return { fetch: fetchImpl, calls };
|
|
56
|
+
}
|
|
57
|
+
function fakeRegisterOk() {
|
|
58
|
+
return {
|
|
59
|
+
json: {
|
|
60
|
+
session: "jwt-token-here",
|
|
61
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function fakePublishIdentityOk() {
|
|
66
|
+
return {
|
|
67
|
+
json: {
|
|
68
|
+
jsonrpc: "2.0",
|
|
69
|
+
id: "publish_identity",
|
|
70
|
+
result: { ok: true, did_document_url: "https://cdn.aithos.be/ethos/zABC/did.json" },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function makeAuth(fetchImpl) {
|
|
75
|
+
return new AithosAuth({
|
|
76
|
+
authBaseUrl: "https://auth.test",
|
|
77
|
+
apiBaseUrl: "https://api.test",
|
|
78
|
+
fetch: fetchImpl,
|
|
79
|
+
sessionStore: noopStore(),
|
|
80
|
+
keyStore: memoryKeyStore(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const validInput = {
|
|
84
|
+
email: "alice@test.example",
|
|
85
|
+
password: "correct horse battery staple",
|
|
86
|
+
handle: "alice",
|
|
87
|
+
};
|
|
88
|
+
/* -------------------------------------------------------------------------- */
|
|
89
|
+
/* Happy path */
|
|
90
|
+
/* -------------------------------------------------------------------------- */
|
|
91
|
+
describe("AithosAuth.signUp — Ethos bootstrap", () => {
|
|
92
|
+
it("calls /auth/register THEN /mcp/primitives/write with publish_identity", async () => {
|
|
93
|
+
const { fetch: f, calls } = makeMockFetch([
|
|
94
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
95
|
+
{
|
|
96
|
+
url: "/mcp/primitives/write",
|
|
97
|
+
method: "POST",
|
|
98
|
+
respond: fakePublishIdentityOk,
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const auth = makeAuth(f);
|
|
102
|
+
const r = await auth.signUp(validInput);
|
|
103
|
+
assert.equal(calls.length, 2, "should make exactly 2 calls");
|
|
104
|
+
assert.match(calls[0].url, /\/auth\/register$/);
|
|
105
|
+
assert.match(calls[1].url, /\/mcp\/primitives\/write$/);
|
|
106
|
+
assert.ok(r.session.session === "jwt-token-here");
|
|
107
|
+
assert.ok(auth.canSignAsOwner(), "must be hydrated as owner");
|
|
108
|
+
});
|
|
109
|
+
it("envelope is JSON-RPC publish_identity, signed by #root, with valid params", async () => {
|
|
110
|
+
let publishBody = null;
|
|
111
|
+
const { fetch: f } = makeMockFetch([
|
|
112
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
113
|
+
{
|
|
114
|
+
url: "/mcp/primitives/write",
|
|
115
|
+
method: "POST",
|
|
116
|
+
respond: (call) => {
|
|
117
|
+
publishBody = call.body;
|
|
118
|
+
return fakePublishIdentityOk();
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
await makeAuth(f).signUp(validInput);
|
|
123
|
+
// JSON-RPC envelope shape
|
|
124
|
+
assert.equal(publishBody.jsonrpc, "2.0");
|
|
125
|
+
assert.equal(publishBody.method, "aithos.publish_identity");
|
|
126
|
+
// params include the inline _envelope and the publish_identity payload
|
|
127
|
+
const params = publishBody.params;
|
|
128
|
+
assert.equal(typeof params.handle, "string");
|
|
129
|
+
assert.equal(params.handle, "alice");
|
|
130
|
+
assert.equal(typeof params.display_name, "string");
|
|
131
|
+
assert.ok(params.did_document, "did_document must be present");
|
|
132
|
+
assert.ok(params._envelope, "_envelope must be present");
|
|
133
|
+
// envelope is signed by #root
|
|
134
|
+
const env = params._envelope;
|
|
135
|
+
assert.equal(env["aithos-envelope"], "0.1.0");
|
|
136
|
+
assert.match(env.iss, /^did:aithos:/);
|
|
137
|
+
assert.equal(env.method, "aithos.publish_identity");
|
|
138
|
+
assert.equal(env.aud, "https://api.test/mcp/primitives/write");
|
|
139
|
+
assert.equal(env.proof.type, "Ed25519Signature2020");
|
|
140
|
+
assert.match(env.proof.verificationMethod, /#root$/);
|
|
141
|
+
assert.equal(typeof env.proof.proofValue, "string");
|
|
142
|
+
assert.ok(env.proof.proofValue.length > 0);
|
|
143
|
+
});
|
|
144
|
+
it("does NOT hydrate state when /auth/register fails", async () => {
|
|
145
|
+
const { fetch: f, calls } = makeMockFetch([
|
|
146
|
+
{
|
|
147
|
+
url: "/auth/register",
|
|
148
|
+
method: "POST",
|
|
149
|
+
respond: () => ({
|
|
150
|
+
status: 409,
|
|
151
|
+
json: { error: "email_taken" },
|
|
152
|
+
}),
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
const auth = makeAuth(f);
|
|
156
|
+
await assert.rejects(() => auth.signUp(validInput), AithosSDKError);
|
|
157
|
+
assert.equal(calls.length, 1, "publish_identity must NOT be called");
|
|
158
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
159
|
+
});
|
|
160
|
+
it("does NOT hydrate state when publish_identity returns a JSON-RPC error", async () => {
|
|
161
|
+
const { fetch: f, calls } = makeMockFetch([
|
|
162
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
163
|
+
{
|
|
164
|
+
url: "/mcp/primitives/write",
|
|
165
|
+
method: "POST",
|
|
166
|
+
respond: () => ({
|
|
167
|
+
json: {
|
|
168
|
+
jsonrpc: "2.0",
|
|
169
|
+
id: "publish_identity",
|
|
170
|
+
error: { code: -32600, message: "invalid envelope signature" },
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
const auth = makeAuth(f);
|
|
176
|
+
await assert.rejects(() => auth.signUp(validInput), (e) => e instanceof AithosSDKError && e.code === "ethos_bootstrap_failed");
|
|
177
|
+
// No retry on JSON-RPC error: 1 register + 1 publish.
|
|
178
|
+
assert.equal(calls.length, 2);
|
|
179
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
180
|
+
});
|
|
181
|
+
it("retries publish_identity on 5xx, then succeeds", async () => {
|
|
182
|
+
let publishCalls = 0;
|
|
183
|
+
const { fetch: f } = makeMockFetch([
|
|
184
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
185
|
+
{
|
|
186
|
+
url: "/mcp/primitives/write",
|
|
187
|
+
method: "POST",
|
|
188
|
+
respond: () => {
|
|
189
|
+
publishCalls++;
|
|
190
|
+
if (publishCalls < 2) {
|
|
191
|
+
return { status: 503, json: { error: "transient" } };
|
|
192
|
+
}
|
|
193
|
+
return fakePublishIdentityOk();
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
const auth = makeAuth(f);
|
|
198
|
+
await auth.signUp(validInput);
|
|
199
|
+
assert.equal(publishCalls, 2);
|
|
200
|
+
assert.ok(auth.canSignAsOwner());
|
|
201
|
+
});
|
|
202
|
+
it("throws ethos_bootstrap_failed after all retries fail with 5xx", async () => {
|
|
203
|
+
let publishCalls = 0;
|
|
204
|
+
const { fetch: f } = makeMockFetch([
|
|
205
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
206
|
+
{
|
|
207
|
+
url: "/mcp/primitives/write",
|
|
208
|
+
method: "POST",
|
|
209
|
+
respond: () => {
|
|
210
|
+
publishCalls++;
|
|
211
|
+
return { status: 503, json: { error: "transient" } };
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
const auth = makeAuth(f);
|
|
216
|
+
await assert.rejects(() => auth.signUp(validInput), (e) => e instanceof AithosSDKError && e.code === "ethos_bootstrap_failed");
|
|
217
|
+
// 3 attempts total (initial + 2 retries).
|
|
218
|
+
assert.equal(publishCalls, 3);
|
|
219
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
220
|
+
});
|
|
221
|
+
/* ------------------------------------------------------------------------ */
|
|
222
|
+
/* alpha.36 — defense-in-depth for legacy backends without semantic-equal */
|
|
223
|
+
/* ------------------------------------------------------------------------ */
|
|
224
|
+
it("treats -32022 'different did.json already published' as a no-op success", async () => {
|
|
225
|
+
// This is the regression introduced in alpha.33: republishing the same
|
|
226
|
+
// identity returns -32022 because `signedDidDocument()` regenerates
|
|
227
|
+
// `aithos.created_at` on every call. For an honest signer, this is
|
|
228
|
+
// semantically a no-op — the Ethos is published, crypto material matches.
|
|
229
|
+
// The SDK swallows this specific case so chatty publish_identity callers
|
|
230
|
+
// (signInCustodial, verifyEmail) don't break on every subsequent sign-in.
|
|
231
|
+
const { fetch: f, calls } = makeMockFetch([
|
|
232
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
233
|
+
{
|
|
234
|
+
url: "/mcp/primitives/write",
|
|
235
|
+
method: "POST",
|
|
236
|
+
respond: () => ({
|
|
237
|
+
json: {
|
|
238
|
+
jsonrpc: "2.0",
|
|
239
|
+
id: "publish_identity",
|
|
240
|
+
error: {
|
|
241
|
+
code: -32022,
|
|
242
|
+
message: "different did.json already published for this DID",
|
|
243
|
+
data: { existing_doc_url: "https://cdn.aithos.be/did.json" },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
]);
|
|
249
|
+
const auth = makeAuth(f);
|
|
250
|
+
// Must succeed, not throw — the republish-conflict is masked.
|
|
251
|
+
await auth.signUp(validInput);
|
|
252
|
+
// Hydrate worked: owner is loaded.
|
|
253
|
+
assert.equal(auth.canSignAsOwner(), true);
|
|
254
|
+
// No retry on -32022 (deterministic) — exactly 1 publish call.
|
|
255
|
+
assert.equal(calls.filter((c) => c.url.includes("/mcp/primitives/write")).length, 1, "publish_identity must not retry on -32022");
|
|
256
|
+
});
|
|
257
|
+
it("still throws ethos_bootstrap_failed on -32022 with a DIFFERENT message", async () => {
|
|
258
|
+
// The shim is narrow: it ONLY swallows the specific
|
|
259
|
+
// "different did.json already published" message. Other -32022 cases
|
|
260
|
+
// (server might use the same code for different semantics) must still
|
|
261
|
+
// bubble up as ethos_bootstrap_failed.
|
|
262
|
+
const { fetch: f } = makeMockFetch([
|
|
263
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
264
|
+
{
|
|
265
|
+
url: "/mcp/primitives/write",
|
|
266
|
+
method: "POST",
|
|
267
|
+
respond: () => ({
|
|
268
|
+
json: {
|
|
269
|
+
jsonrpc: "2.0",
|
|
270
|
+
id: "publish_identity",
|
|
271
|
+
error: {
|
|
272
|
+
code: -32022,
|
|
273
|
+
message: "subject identity is tombstoned",
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
},
|
|
278
|
+
]);
|
|
279
|
+
const auth = makeAuth(f);
|
|
280
|
+
await assert.rejects(() => auth.signUp(validInput), (e) => e instanceof AithosSDKError &&
|
|
281
|
+
e.code === "ethos_bootstrap_failed" &&
|
|
282
|
+
/tombstoned/i.test(e.message));
|
|
283
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
284
|
+
});
|
|
285
|
+
it("still throws ethos_bootstrap_failed on the conflict message under a different code", async () => {
|
|
286
|
+
// Conversely, an error matching the message but with a code OTHER than
|
|
287
|
+
// -32022 is not swallowed — we anchor on both (code, message) to keep
|
|
288
|
+
// the shim narrow.
|
|
289
|
+
const { fetch: f } = makeMockFetch([
|
|
290
|
+
{ url: "/auth/register", method: "POST", respond: fakeRegisterOk },
|
|
291
|
+
{
|
|
292
|
+
url: "/mcp/primitives/write",
|
|
293
|
+
method: "POST",
|
|
294
|
+
respond: () => ({
|
|
295
|
+
json: {
|
|
296
|
+
jsonrpc: "2.0",
|
|
297
|
+
id: "publish_identity",
|
|
298
|
+
error: {
|
|
299
|
+
code: -32000,
|
|
300
|
+
message: "different did.json already published for this DID",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
},
|
|
305
|
+
]);
|
|
306
|
+
const auth = makeAuth(f);
|
|
307
|
+
await assert.rejects(() => auth.signUp(validInput), (e) => e instanceof AithosSDKError && e.code === "ethos_bootstrap_failed");
|
|
308
|
+
assert.equal(auth.canSignAsOwner(), false);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
//# sourceMappingURL=signup-bootstrap.test.js.map
|
package/dist/test/wallet.test.js
CHANGED
|
@@ -4,12 +4,23 @@
|
|
|
4
4
|
import { strict as assert } from "node:assert";
|
|
5
5
|
import { describe, it } from "node:test";
|
|
6
6
|
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
7
|
-
import { AithosSDK, AithosSDKError } from "../src/index.js";
|
|
7
|
+
import { AithosAuth, AithosSDK, AithosSDKError, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
8
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
8
9
|
const APP_DID = "did:aithos:app:test";
|
|
9
|
-
function makeSdk(fetchImpl) {
|
|
10
|
-
const
|
|
10
|
+
async function makeSdk(fetchImpl) {
|
|
11
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
12
|
+
const auth = new AithosAuth({
|
|
13
|
+
authBaseUrl: "https://auth.test",
|
|
14
|
+
fetch: (() => {
|
|
15
|
+
throw new Error("auth not used in wallet tests");
|
|
16
|
+
}),
|
|
17
|
+
sessionStore: noopStore(),
|
|
18
|
+
keyStore: memoryKeyStore(),
|
|
19
|
+
});
|
|
20
|
+
const { text } = serializeRecoveryFile(id);
|
|
21
|
+
await auth.signInWithRecovery({ file: text });
|
|
11
22
|
return new AithosSDK({
|
|
12
|
-
|
|
23
|
+
auth,
|
|
13
24
|
appDid: APP_DID,
|
|
14
25
|
endpoints: { wallet: "https://wallet.example.test" },
|
|
15
26
|
fetch: fetchImpl,
|
|
@@ -27,7 +38,7 @@ describe("wallet.createTopupSession — happy path", () => {
|
|
|
27
38
|
session_id: "cs_test_xyz",
|
|
28
39
|
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
29
40
|
};
|
|
30
|
-
const sdk = makeSdk(fakeFetch);
|
|
41
|
+
const sdk = await makeSdk(fakeFetch);
|
|
31
42
|
const out = await sdk.wallet.createTopupSession({
|
|
32
43
|
packId: "credits-1m",
|
|
33
44
|
successUrl: "https://app.example.com/?topup=success",
|
|
@@ -47,7 +58,7 @@ describe("wallet.createTopupSession — errors", () => {
|
|
|
47
58
|
const fakeFetch = async () => {
|
|
48
59
|
throw new TypeError("Failed to fetch");
|
|
49
60
|
};
|
|
50
|
-
const sdk = makeSdk(fakeFetch);
|
|
61
|
+
const sdk = await makeSdk(fakeFetch);
|
|
51
62
|
await assert.rejects(sdk.wallet.createTopupSession({
|
|
52
63
|
packId: "credits-100k",
|
|
53
64
|
successUrl: "https://app.example.com/?ok",
|
|
@@ -60,7 +71,7 @@ describe("wallet.createTopupSession — errors", () => {
|
|
|
60
71
|
});
|
|
61
72
|
it("surfaces the proxy's structured error (error/detail) on a 4xx", async () => {
|
|
62
73
|
const fakeFetch = async () => new Response(JSON.stringify({ error: "unknown_pack", pack_id: "credits-9999" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
63
|
-
const sdk = makeSdk(fakeFetch);
|
|
74
|
+
const sdk = await makeSdk(fakeFetch);
|
|
64
75
|
await assert.rejects(sdk.wallet.createTopupSession({
|
|
65
76
|
// @ts-expect-error: deliberately invalid pack id
|
|
66
77
|
packId: "credits-9999",
|
|
@@ -78,7 +89,7 @@ describe("wallet.createTopupSession — errors", () => {
|
|
|
78
89
|
status: 500,
|
|
79
90
|
headers: { "content-type": "text/html" },
|
|
80
91
|
});
|
|
81
|
-
const sdk = makeSdk(fakeFetch);
|
|
92
|
+
const sdk = await makeSdk(fakeFetch);
|
|
82
93
|
await assert.rejects(sdk.wallet.createTopupSession({
|
|
83
94
|
packId: "credits-100k",
|
|
84
95
|
successUrl: "https://app.example.com/?ok",
|
|
@@ -95,7 +106,7 @@ describe("wallet.createTopupSession — errors", () => {
|
|
|
95
106
|
status: 200,
|
|
96
107
|
headers: { "content-type": "application/json" },
|
|
97
108
|
});
|
|
98
|
-
const sdk = makeSdk(fakeFetch);
|
|
109
|
+
const sdk = await makeSdk(fakeFetch);
|
|
99
110
|
await assert.rejects(sdk.wallet.createTopupSession({
|
|
100
111
|
packId: "credits-100k",
|
|
101
112
|
successUrl: "https://app.example.com/?ok",
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Unit tests for sdk.web.extract with a mock fetch.
|
|
4
|
+
//
|
|
5
|
+
// Mirrors test/compute.test.ts: real BrowserIdentity (synchronous Ed25519)
|
|
6
|
+
// so the envelope-signing path runs for real and any
|
|
7
|
+
// signature/canonicalization regression surfaces here.
|
|
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, AithosSDK, AithosSDKError, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
12
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
13
|
+
const APP_DID = "did:aithos:app:test";
|
|
14
|
+
async function makeSdk(fetchImpl) {
|
|
15
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
16
|
+
const auth = new AithosAuth({
|
|
17
|
+
authBaseUrl: "https://auth.test",
|
|
18
|
+
fetch: (() => {
|
|
19
|
+
throw new Error("auth not used in web tests");
|
|
20
|
+
}),
|
|
21
|
+
sessionStore: noopStore(),
|
|
22
|
+
keyStore: memoryKeyStore(),
|
|
23
|
+
});
|
|
24
|
+
const { text } = serializeRecoveryFile(id);
|
|
25
|
+
await auth.signInWithRecovery({ file: text });
|
|
26
|
+
return new AithosSDK({
|
|
27
|
+
auth,
|
|
28
|
+
appDid: APP_DID,
|
|
29
|
+
endpoints: { web: "https://extract.example.test" },
|
|
30
|
+
fetch: fetchImpl,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const HAPPY_RESULT = {
|
|
34
|
+
data: {
|
|
35
|
+
url: "https://example.com",
|
|
36
|
+
final_url: "https://example.com/",
|
|
37
|
+
fetched_at: "2026-05-13T05:30:00.000Z",
|
|
38
|
+
render_ms: 1234,
|
|
39
|
+
meta: {
|
|
40
|
+
title: "Example",
|
|
41
|
+
description: null,
|
|
42
|
+
lang: "en",
|
|
43
|
+
charset: "UTF-8",
|
|
44
|
+
viewport: null,
|
|
45
|
+
canonical: null,
|
|
46
|
+
og: {},
|
|
47
|
+
},
|
|
48
|
+
structure: { headings: [], sections: [], nav_links: [], forms: [] },
|
|
49
|
+
content: {
|
|
50
|
+
main_html: "<main>hi</main>",
|
|
51
|
+
main_text: "hi",
|
|
52
|
+
images: [],
|
|
53
|
+
links: { internal: [], external: [] },
|
|
54
|
+
},
|
|
55
|
+
styles: { css: "", inline_styles_count: 0 },
|
|
56
|
+
visual_signature: {
|
|
57
|
+
colors: {
|
|
58
|
+
palette: [],
|
|
59
|
+
background: "#ffffff",
|
|
60
|
+
text: "#000000",
|
|
61
|
+
primary: null,
|
|
62
|
+
link: null,
|
|
63
|
+
},
|
|
64
|
+
typography: {
|
|
65
|
+
heading_font: null,
|
|
66
|
+
body_font: "sans-serif",
|
|
67
|
+
size_scale: [],
|
|
68
|
+
base_size_px: 16,
|
|
69
|
+
base_line_height: 1.5,
|
|
70
|
+
},
|
|
71
|
+
radii: { button: null, input: null, card: null },
|
|
72
|
+
spacing: { base_unit_px: null, common_gaps_px: [] },
|
|
73
|
+
layout: { max_content_width_px: null, mode: "block" },
|
|
74
|
+
components: { buttons: [], inputs: [], cards: [] },
|
|
75
|
+
},
|
|
76
|
+
logo: null,
|
|
77
|
+
},
|
|
78
|
+
creditsCharged: 1,
|
|
79
|
+
walletBalance: 99_999,
|
|
80
|
+
auditId: "audit-web-1",
|
|
81
|
+
};
|
|
82
|
+
describe("web.extract — happy path", () => {
|
|
83
|
+
it("posts to ${web}/v1/invoke with a JSON-RPC envelope and parses the result", async () => {
|
|
84
|
+
let capturedUrl;
|
|
85
|
+
let capturedInit;
|
|
86
|
+
const fakeFetch = async (input, init) => {
|
|
87
|
+
capturedUrl = typeof input === "string" ? input : input.toString();
|
|
88
|
+
capturedInit = init;
|
|
89
|
+
return new Response(JSON.stringify({ result: HAPPY_RESULT }), {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: { "content-type": "application/json" },
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
const sdk = await makeSdk(fakeFetch);
|
|
95
|
+
const out = await sdk.web.extract({ url: "https://example.com" });
|
|
96
|
+
assert.deepEqual(out, HAPPY_RESULT);
|
|
97
|
+
assert.equal(capturedUrl, "https://extract.example.test/v1/invoke");
|
|
98
|
+
assert.equal(capturedInit?.method, "POST");
|
|
99
|
+
const headers = capturedInit?.headers;
|
|
100
|
+
assert.equal(headers["content-type"], "application/json");
|
|
101
|
+
const body = JSON.parse(capturedInit?.body);
|
|
102
|
+
assert.equal(body.jsonrpc, "2.0");
|
|
103
|
+
assert.equal(body.method, "aithos.web_extract");
|
|
104
|
+
assert.equal(body.params.url, "https://example.com");
|
|
105
|
+
assert.equal(body.params.app_did, APP_DID);
|
|
106
|
+
assert.ok(typeof body.params.mandate_id === "string");
|
|
107
|
+
assert.ok(body.params.mandate_id.length > 0);
|
|
108
|
+
assert.ok(body.params._envelope, "request must carry a signed envelope");
|
|
109
|
+
});
|
|
110
|
+
it("forwards waitUntil and timeoutMs as snake_case-free params", async () => {
|
|
111
|
+
let capturedBody;
|
|
112
|
+
const fakeFetch = async (_input, init) => {
|
|
113
|
+
capturedBody = JSON.parse(init.body);
|
|
114
|
+
return new Response(JSON.stringify({ result: HAPPY_RESULT }), {
|
|
115
|
+
status: 200,
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const sdk = await makeSdk(fakeFetch);
|
|
120
|
+
await sdk.web.extract({
|
|
121
|
+
url: "https://example.com",
|
|
122
|
+
waitUntil: "domcontentloaded",
|
|
123
|
+
timeoutMs: 5000,
|
|
124
|
+
});
|
|
125
|
+
assert.equal(capturedBody?.params.waitUntil, "domcontentloaded");
|
|
126
|
+
assert.equal(capturedBody?.params.timeoutMs, 5000);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe("web.extract — error mapping", () => {
|
|
130
|
+
it("propagates -32071 insufficient_balance as AithosSDKError with data", async () => {
|
|
131
|
+
const fakeFetch = async () => new Response(JSON.stringify({
|
|
132
|
+
error: {
|
|
133
|
+
code: -32071,
|
|
134
|
+
message: "Insufficient balance",
|
|
135
|
+
data: { required: 1, available: 0 },
|
|
136
|
+
},
|
|
137
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
138
|
+
const sdk = await makeSdk(fakeFetch);
|
|
139
|
+
await assert.rejects(() => sdk.web.extract({ url: "https://example.com" }), (err) => {
|
|
140
|
+
assert.ok(err instanceof AithosSDKError);
|
|
141
|
+
assert.equal(err.code, "-32071");
|
|
142
|
+
const data = err.data;
|
|
143
|
+
assert.equal(data?.required, 1);
|
|
144
|
+
assert.equal(data?.available, 0);
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it("propagates -32042 scope mismatch as AithosSDKError", async () => {
|
|
149
|
+
const fakeFetch = async () => new Response(JSON.stringify({
|
|
150
|
+
error: {
|
|
151
|
+
code: -32042,
|
|
152
|
+
message: "mandate does not carry the web.extract scope",
|
|
153
|
+
data: {
|
|
154
|
+
mandate_id: "mandate:abc",
|
|
155
|
+
required_scope: "web.extract",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
159
|
+
const sdk = await makeSdk(fakeFetch);
|
|
160
|
+
await assert.rejects(() => sdk.web.extract({ url: "https://example.com" }), (err) => {
|
|
161
|
+
assert.ok(err instanceof AithosSDKError);
|
|
162
|
+
assert.equal(err.code, "-32042");
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
it("rejects HTTP transport errors as AithosSDKError code=http", async () => {
|
|
167
|
+
const fakeFetch = async () => new Response("upstream is down", { status: 502 });
|
|
168
|
+
const sdk = await makeSdk(fakeFetch);
|
|
169
|
+
await assert.rejects(() => sdk.web.extract({ url: "https://example.com" }), (err) => {
|
|
170
|
+
assert.ok(err instanceof AithosSDKError);
|
|
171
|
+
assert.equal(err.code, "http");
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe("web.extract — endpoint default", () => {
|
|
177
|
+
it("defaults to https://extract.aithos.be when no override is given", async () => {
|
|
178
|
+
let capturedUrl;
|
|
179
|
+
const fakeFetch = async (input) => {
|
|
180
|
+
capturedUrl = typeof input === "string" ? input : input.toString();
|
|
181
|
+
return new Response(JSON.stringify({ result: HAPPY_RESULT }), {
|
|
182
|
+
status: 200,
|
|
183
|
+
headers: { "content-type": "application/json" },
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
187
|
+
const auth = new AithosAuth({
|
|
188
|
+
authBaseUrl: "https://auth.test",
|
|
189
|
+
fetch: (() => {
|
|
190
|
+
throw new Error("auth not used");
|
|
191
|
+
}),
|
|
192
|
+
sessionStore: noopStore(),
|
|
193
|
+
keyStore: memoryKeyStore(),
|
|
194
|
+
});
|
|
195
|
+
const { text } = serializeRecoveryFile(id);
|
|
196
|
+
await auth.signInWithRecovery({ file: text });
|
|
197
|
+
const sdk = new AithosSDK({
|
|
198
|
+
auth,
|
|
199
|
+
appDid: APP_DID,
|
|
200
|
+
fetch: fakeFetch,
|
|
201
|
+
});
|
|
202
|
+
await sdk.web.extract({ url: "https://example.com" });
|
|
203
|
+
assert.equal(capturedUrl, "https://extract.aithos.be/v1/invoke");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe("web.fetchAsset — happy path", () => {
|
|
207
|
+
it("posts to ${web}/v1/invoke with method aithos.web_fetch_asset", async () => {
|
|
208
|
+
let capturedUrl;
|
|
209
|
+
let capturedInit;
|
|
210
|
+
const fakeResult = {
|
|
211
|
+
data: {
|
|
212
|
+
url: "https://example.com/logo.png",
|
|
213
|
+
final_url: "https://example.com/logo.png",
|
|
214
|
+
content_type: "image/png",
|
|
215
|
+
size_bytes: 4096,
|
|
216
|
+
base64: "aGVsbG8K",
|
|
217
|
+
},
|
|
218
|
+
creditsCharged: 1,
|
|
219
|
+
walletBalance: 99_998,
|
|
220
|
+
auditId: "audit-asset-1",
|
|
221
|
+
};
|
|
222
|
+
const fakeFetch = async (input, init) => {
|
|
223
|
+
capturedUrl = typeof input === "string" ? input : input.toString();
|
|
224
|
+
capturedInit = init;
|
|
225
|
+
return new Response(JSON.stringify({ result: fakeResult }), {
|
|
226
|
+
status: 200,
|
|
227
|
+
headers: { "content-type": "application/json" },
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
const sdk = await makeSdk(fakeFetch);
|
|
231
|
+
const out = await sdk.web.fetchAsset({ url: "https://example.com/logo.png" });
|
|
232
|
+
assert.deepEqual(out, fakeResult);
|
|
233
|
+
assert.equal(capturedUrl, "https://extract.example.test/v1/invoke");
|
|
234
|
+
const body = JSON.parse(capturedInit?.body);
|
|
235
|
+
assert.equal(body.method, "aithos.web_fetch_asset");
|
|
236
|
+
assert.equal(body.params.url, "https://example.com/logo.png");
|
|
237
|
+
assert.equal(body.params.app_did, APP_DID);
|
|
238
|
+
assert.ok(typeof body.params.mandate_id === "string");
|
|
239
|
+
assert.ok(body.params._envelope, "request must carry a signed envelope");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe("web.fetchAsset — error propagation", () => {
|
|
243
|
+
it("propagates -32071 insufficient_balance as AithosSDKError", async () => {
|
|
244
|
+
const fakeFetch = async () => new Response(JSON.stringify({
|
|
245
|
+
error: {
|
|
246
|
+
code: -32071,
|
|
247
|
+
message: "Insufficient balance",
|
|
248
|
+
data: { required: 1, available: 0 },
|
|
249
|
+
},
|
|
250
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
251
|
+
const sdk = await makeSdk(fakeFetch);
|
|
252
|
+
await assert.rejects(() => sdk.web.fetchAsset({ url: "https://example.com/logo.png" }), (err) => {
|
|
253
|
+
assert.ok(err instanceof AithosSDKError);
|
|
254
|
+
assert.equal(err.code, "-32071");
|
|
255
|
+
return true;
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
it("propagates -32000 upstream fetch failure as AithosSDKError", async () => {
|
|
259
|
+
const fakeFetch = async () => new Response(JSON.stringify({
|
|
260
|
+
error: { code: -32000, message: "Asset fetch failed", data: { reason: "upstream HTTP 404" } },
|
|
261
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
262
|
+
const sdk = await makeSdk(fakeFetch);
|
|
263
|
+
await assert.rejects(() => sdk.web.fetchAsset({ url: "https://example.com/missing.png" }), (err) => {
|
|
264
|
+
assert.ok(err instanceof AithosSDKError);
|
|
265
|
+
assert.equal(err.code, "-32000");
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
//# sourceMappingURL=web.test.js.map
|