@idos-network/kwil-infra 1.5.0 → 2.2.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +46 -1
  3. package/dist/actions/index.d.mts +2 -2
  4. package/dist/actions/index.mjs +1 -1370
  5. package/dist/actions/index.mjs.map +1 -1
  6. package/dist/facesign/index.d.mts +1 -1
  7. package/dist/facesign/index.mjs +1 -2
  8. package/dist/{facesign-signer-BC24j1eN.d.mts → facesign-signer-5I8juA7p.d.mts} +2 -1
  9. package/dist/facesign-signer-5I8juA7p.d.mts.map +1 -0
  10. package/dist/facesign-signer-z5T312uQ.mjs +2 -0
  11. package/dist/facesign-signer-z5T312uQ.mjs.map +1 -0
  12. package/dist/{index-iqkJfgWf.d.mts → index-Bqz6trCk.d.mts} +223 -136
  13. package/dist/index-Bqz6trCk.d.mts.map +1 -0
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +1 -429
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/signature-verification/index.d.mts +3 -4
  18. package/dist/signature-verification/index.d.mts.map +1 -1
  19. package/dist/signature-verification/index.mjs +1 -134
  20. package/dist/signature-verification/index.mjs.map +1 -1
  21. package/dist/xrp/utils.d.mts +130 -20
  22. package/dist/xrp/utils.d.mts.map +1 -1
  23. package/dist/xrp/utils.mjs +1 -104
  24. package/dist/xrp/utils.mjs.map +1 -1
  25. package/dist/xrpl-credentials/index.d.mts +6 -7
  26. package/dist/xrpl-credentials/index.d.mts.map +1 -1
  27. package/dist/xrpl-credentials/index.mjs +1 -223
  28. package/dist/xrpl-credentials/index.mjs.map +1 -1
  29. package/package.json +7 -7
  30. package/src/actions/actions.test.ts +189 -0
  31. package/src/actions/actions.ts +368 -467
  32. package/src/actions/index.ts +38 -0
  33. package/src/create-kgw-authenticated-fetch.test.ts +377 -0
  34. package/src/create-kgw-authenticated-fetch.ts +321 -0
  35. package/src/create-kwil-client.ts +33 -10
  36. package/src/create-kwil-signer.test.ts +80 -0
  37. package/src/create-kwil-signer.ts +31 -5
  38. package/src/facesign/facesign-signer.test.ts +77 -0
  39. package/src/facesign/facesign-signer.ts +50 -2
  40. package/src/index.ts +2 -0
  41. package/src/mm-token/create-mm-token-kwil-signer.test.ts +148 -0
  42. package/src/mm-token/create-mm-token-kwil-signer.ts +166 -0
  43. package/src/near/create-near-wallet-kwil-signer.ts +1 -1
  44. package/src/signature-verification/index.test.ts +18 -0
  45. package/src/signature-verification/index.ts +7 -1
  46. package/src/wait-for-kwil-tx.test.ts +92 -0
  47. package/src/wait-for-kwil-tx.ts +111 -0
  48. package/tsdown.config.ts +1 -0
  49. package/dist/facesign-signer-BC24j1eN.d.mts.map +0 -1
  50. package/dist/facesign-signer-qzCcwunk.mjs +0 -244
  51. package/dist/facesign-signer-qzCcwunk.mjs.map +0 -1
  52. package/dist/index-iqkJfgWf.d.mts.map +0 -1
@@ -26,6 +26,44 @@ export type idOSUserAttribute = GetAttributesOutput;
26
26
  export type idOSWallet = UpsertWalletAsInserterInput;
27
27
  export type idOSDelegatedWriteGrant = DwgMessageInput;
28
28
 
29
+ export type DelegatedWriteGrantBaseParams = {
30
+ id: string;
31
+ ownerWalletIdentifier: string;
32
+ consumerWalletIdentifier: string;
33
+ issuerPublicKey: string;
34
+ accessGrantTimelock: string;
35
+ notUsableBefore: string;
36
+ notUsableAfter: string;
37
+ };
38
+
39
+ export function toDelegatedWriteGrantBaseParams(
40
+ params: idOSDelegatedWriteGrant,
41
+ ): DelegatedWriteGrantBaseParams {
42
+ return {
43
+ id: params.id,
44
+ ownerWalletIdentifier: params.owner_wallet_identifier,
45
+ consumerWalletIdentifier: params.grantee_wallet_identifier,
46
+ issuerPublicKey: params.issuer_public_key,
47
+ accessGrantTimelock: params.access_grant_timelock,
48
+ notUsableBefore: params.not_usable_before,
49
+ notUsableAfter: params.not_usable_after,
50
+ };
51
+ }
52
+
53
+ /** @deprecated Prefer the `*Preliminary*` names matching the Kwil actions. */
54
+ export type {
55
+ SharePreliminaryCredentialInput as ShareCredentialInput,
56
+ CreatePreliminaryCredentialsByDwgInput as CreateCredentialsByDwgInput,
57
+ } from "./actions";
58
+
59
+ /** @deprecated Prefer the `*Preliminary*` names matching the Kwil actions. */
60
+ export {
61
+ SharePreliminaryCredentialInputSchema as ShareCredentialInputSchema,
62
+ sharePreliminaryCredential as shareCredential,
63
+ CreatePreliminaryCredentialsByDwgInputSchema as CreateCredentialsByDwgInputSchema,
64
+ createPreliminaryCredentialsByDwg as createCredentialsByDwg,
65
+ } from "./actions";
66
+
29
67
  export async function addWallets(
30
68
  kwilClient: KwilActionClient,
31
69
  wallets: AddWalletInput[],
@@ -0,0 +1,377 @@
1
+ // @vitest-environment node
2
+
3
+ import { type KwilSigner, NodeKwil } from "@idos-network/kwil-js";
4
+ import { base64UrlEncode, utf8Encode } from "@idos-network/utils/codecs";
5
+ import { describe, expect, it, vi } from "vitest";
6
+
7
+ import {
8
+ createKgwAuthenticatedBlobGateway,
9
+ createKgwAuthenticatedFetch,
10
+ } from "./create-kgw-authenticated-fetch";
11
+ import { KwilActionClient } from "./create-kwil-client";
12
+ import { createMmTokenAuth } from "./mm-token/create-mm-token-kwil-signer";
13
+
14
+ const mmEnvelopeToken = base64UrlEncode(
15
+ utf8Encode(
16
+ JSON.stringify({
17
+ payload: {
18
+ signing_public_key: base64UrlEncode(new Uint8Array(32).fill(7)),
19
+ storage_id: "storage-abc",
20
+ },
21
+ signature: base64UrlEncode(new Uint8Array(64).fill(9)),
22
+ }),
23
+ ),
24
+ );
25
+
26
+ function createTestClient(
27
+ initialCookie?: string,
28
+ params?: { isAuthFailure?: (response: Response) => Promise<boolean> | boolean },
29
+ ): {
30
+ authFetch: typeof fetch;
31
+ fetchFn: ReturnType<typeof vi.fn<typeof fetch>>;
32
+ refresh: ReturnType<typeof vi.fn<() => Promise<string>>>;
33
+ } {
34
+ let cookie = initialCookie;
35
+ const nodeKwil = new NodeKwil({
36
+ kwilProvider: "https://nodes.example",
37
+ chainId: "test-chain",
38
+ }) as NodeKwil & {
39
+ authenticateKGWAndSetCookie: ReturnType<typeof vi.fn<() => Promise<string>>>;
40
+ getKgwCookie: ReturnType<typeof vi.fn<() => string | undefined>>;
41
+ };
42
+ const kwilClient = new KwilActionClient(nodeKwil);
43
+ const refresh = vi.fn(async () => {
44
+ cookie = "kgw_session=fresh; Path=/";
45
+ return cookie;
46
+ });
47
+ const fetchFn = vi.fn<typeof fetch>(async () => new Response("ok"));
48
+
49
+ nodeKwil.getKgwCookie = vi.fn(() => cookie);
50
+ nodeKwil.authenticateKGWAndSetCookie = refresh;
51
+
52
+ const authFetchParams: Parameters<typeof createKgwAuthenticatedFetch>[0] = {
53
+ kwilClient,
54
+ signer: {} as KwilSigner,
55
+ fetchFn,
56
+ };
57
+ if (params?.isAuthFailure) {
58
+ authFetchParams.isAuthFailure = params.isAuthFailure;
59
+ }
60
+
61
+ return {
62
+ authFetch: createKgwAuthenticatedFetch(authFetchParams),
63
+ fetchFn,
64
+ refresh,
65
+ };
66
+ }
67
+
68
+ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
69
+ let timeout: ReturnType<typeof setTimeout> | undefined;
70
+ try {
71
+ return await Promise.race([
72
+ promise,
73
+ new Promise<never>((_, reject) => {
74
+ timeout = setTimeout(() => reject(new Error(message)), ms);
75
+ }),
76
+ ]);
77
+ } finally {
78
+ if (timeout) {
79
+ clearTimeout(timeout);
80
+ }
81
+ }
82
+ }
83
+
84
+ function cookieHeader(call: Parameters<typeof fetch>): string | null {
85
+ const init = call[1];
86
+ return new Headers(init?.headers).get("cookie");
87
+ }
88
+
89
+ async function readFormDataValue(value: FormDataEntryValue | null): Promise<string> {
90
+ if (value == null) {
91
+ return "";
92
+ }
93
+
94
+ if (typeof value === "string") {
95
+ return value;
96
+ }
97
+
98
+ return new TextDecoder().decode(await new Response(value).arrayBuffer());
99
+ }
100
+
101
+ describe("createKgwAuthenticatedFetch", () => {
102
+ it("authenticates before the first request when there is no cookie", async () => {
103
+ const { authFetch, fetchFn, refresh } = createTestClient();
104
+
105
+ await authFetch("https://blob.example/upload", { method: "POST" });
106
+
107
+ expect(refresh).toHaveBeenCalledTimes(1);
108
+ expect(fetchFn).toHaveBeenCalledTimes(1);
109
+ expect(cookieHeader(fetchFn.mock.calls[0])).toBe("kgw_session=fresh; Path=/");
110
+ });
111
+
112
+ it("attaches an existing cookie without refreshing", async () => {
113
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=existing; Path=/");
114
+
115
+ await authFetch("https://blob.example/upload", {
116
+ headers: { "x-request-id": "request-1" },
117
+ });
118
+
119
+ const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers);
120
+ expect(refresh).not.toHaveBeenCalled();
121
+ expect(headers.get("cookie")).toBe("kgw_session=existing; Path=/");
122
+ expect(headers.get("x-request-id")).toBe("request-1");
123
+ });
124
+
125
+ it("retries blob uploads with the same FormData body after an auth failure", async () => {
126
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/");
127
+ const receivedBodies: FormData[] = [];
128
+
129
+ fetchFn.mockImplementation(async (_input, init) => {
130
+ const body = init?.body as FormData;
131
+ receivedBodies.push(body);
132
+
133
+ // Simulate fetch consuming multipart bodies on the first attempt.
134
+ await readFormDataValue(body.get("original"));
135
+
136
+ if (fetchFn.mock.calls.length === 1) {
137
+ return new Response("unauthorized", { status: 401 });
138
+ }
139
+
140
+ return new Response("ok");
141
+ });
142
+
143
+ const formData = new FormData();
144
+ formData.append(
145
+ "original",
146
+ new Blob(["blob-bytes"], { type: "application/octet-stream" }),
147
+ "original.blob",
148
+ );
149
+ formData.append("duplicate", new Blob(["first"]), "first.blob");
150
+ formData.append("duplicate", new Blob(["second"]), "second.blob");
151
+
152
+ const response = await authFetch("https://blob.example/upload", {
153
+ method: "POST",
154
+ body: formData,
155
+ });
156
+
157
+ expect(response.ok).toBe(true);
158
+ expect(refresh).toHaveBeenCalledTimes(1);
159
+ expect(fetchFn).toHaveBeenCalledTimes(2);
160
+ expect(receivedBodies).toHaveLength(2);
161
+
162
+ const firstEntry = receivedBodies[0]?.get("original");
163
+ const secondEntry = receivedBodies[1]?.get("original");
164
+
165
+ expect(firstEntry).toBeTruthy();
166
+ expect(secondEntry).toBeTruthy();
167
+ expect(await readFormDataValue(firstEntry)).toBe("blob-bytes");
168
+ expect(await readFormDataValue(secondEntry)).toBe("blob-bytes");
169
+ await expect(
170
+ Promise.all(receivedBodies[1]?.getAll("duplicate").map(readFormDataValue) ?? []),
171
+ ).resolves.toEqual(["first", "second"]);
172
+ });
173
+
174
+ it("retries Request-input bodies after an auth failure without locking the original", async () => {
175
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/");
176
+ const receivedBodies: string[] = [];
177
+
178
+ fetchFn.mockImplementation(async (_input, init) => {
179
+ const body = init?.body;
180
+ receivedBodies.push(
181
+ body instanceof ArrayBuffer ? new TextDecoder().decode(body) : String(body),
182
+ );
183
+
184
+ if (fetchFn.mock.calls.length === 1) {
185
+ return new Response("unauthorized", { status: 401 });
186
+ }
187
+
188
+ return new Response("ok");
189
+ });
190
+
191
+ const request = new Request("https://blob.example/upload", {
192
+ method: "POST",
193
+ body: "request-body",
194
+ });
195
+ const response = await authFetch(request);
196
+
197
+ expect(response.ok).toBe(true);
198
+ expect(request.bodyUsed).toBe(false);
199
+ expect(refresh).toHaveBeenCalledTimes(1);
200
+ expect(fetchFn).toHaveBeenCalledTimes(2);
201
+ expect(receivedBodies).toEqual(["request-body", "request-body"]);
202
+ });
203
+
204
+ it("refreshes and retries once after an auth failure", async () => {
205
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/");
206
+ fetchFn
207
+ .mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
208
+ .mockResolvedValueOnce(new Response("ok"));
209
+
210
+ const response = await authFetch("https://blob.example/upload");
211
+
212
+ expect(response.ok).toBe(true);
213
+ expect(refresh).toHaveBeenCalledTimes(1);
214
+ expect(fetchFn).toHaveBeenCalledTimes(2);
215
+ expect(cookieHeader(fetchFn.mock.calls[0])).toBe("kgw_session=expired; Path=/");
216
+ expect(cookieHeader(fetchFn.mock.calls[1])).toBe("kgw_session=fresh; Path=/");
217
+ });
218
+
219
+ // Regression: JSON 401 used to clone for the auth probe, then cancel the
220
+ // original while the clone tee stayed unread — undici hangs on that cancel.
221
+ it("retries after a JSON 401 without hanging on body cancel", async () => {
222
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/");
223
+ fetchFn
224
+ .mockResolvedValueOnce(Response.json({ error: "unauthorized" }, { status: 401 }))
225
+ .mockResolvedValueOnce(new Response("ok"));
226
+
227
+ const response = await withTimeout(
228
+ authFetch("https://blob.example/upload"),
229
+ 1000,
230
+ "hung discarding 401 body",
231
+ );
232
+
233
+ expect(response.ok).toBe(true);
234
+ expect(refresh).toHaveBeenCalledTimes(1);
235
+ expect(fetchFn).toHaveBeenCalledTimes(2);
236
+ });
237
+
238
+ it("discards both tee branches when a custom predicate flags unread JSON", async () => {
239
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/", {
240
+ isAuthFailure: (response) => response.status === 419,
241
+ });
242
+ fetchFn
243
+ .mockResolvedValueOnce(Response.json({ error: "session expired" }, { status: 419 }))
244
+ .mockResolvedValueOnce(new Response("ok"));
245
+
246
+ const response = await withTimeout(
247
+ authFetch("https://blob.example/upload"),
248
+ 1000,
249
+ "hung discarding cloned JSON body",
250
+ );
251
+
252
+ expect(response.ok).toBe(true);
253
+ expect(refresh).toHaveBeenCalledTimes(1);
254
+ expect(fetchFn).toHaveBeenCalledTimes(2);
255
+ });
256
+
257
+ it("returns the retry response when authentication keeps failing", async () => {
258
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/");
259
+ fetchFn
260
+ .mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
261
+ .mockResolvedValueOnce(new Response("still unauthorized", { status: 401 }));
262
+
263
+ const response = await authFetch("https://blob.example/upload");
264
+
265
+ expect(response.status).toBe(401);
266
+ expect(refresh).toHaveBeenCalledTimes(1);
267
+ expect(fetchFn).toHaveBeenCalledTimes(2);
268
+ });
269
+
270
+ it("does not reauthenticate on forbidden responses", async () => {
271
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=valid; Path=/");
272
+ fetchFn.mockResolvedValueOnce(new Response("forbidden", { status: 403 }));
273
+
274
+ const response = await authFetch("https://blob.example/upload");
275
+
276
+ expect(response.status).toBe(403);
277
+ expect(refresh).not.toHaveBeenCalled();
278
+ expect(fetchFn).toHaveBeenCalledTimes(1);
279
+ expect(cookieHeader(fetchFn.mock.calls[0])).toBe("kgw_session=valid; Path=/");
280
+ });
281
+
282
+ it("shares one refresh across parallel auth failures", async () => {
283
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=expired; Path=/");
284
+ fetchFn
285
+ .mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
286
+ .mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
287
+ .mockResolvedValueOnce(new Response("ok"))
288
+ .mockResolvedValueOnce(new Response("ok"));
289
+
290
+ const [first, second] = await Promise.all([
291
+ authFetch("https://blob.example/upload-a"),
292
+ authFetch("https://blob.example/upload-b"),
293
+ ]);
294
+
295
+ expect(first.ok).toBe(true);
296
+ expect(second.ok).toBe(true);
297
+ expect(refresh).toHaveBeenCalledTimes(1);
298
+ expect(fetchFn).toHaveBeenCalledTimes(4);
299
+ expect(cookieHeader(fetchFn.mock.calls[2])).toBe("kgw_session=fresh; Path=/");
300
+ expect(cookieHeader(fetchFn.mock.calls[3])).toBe("kgw_session=fresh; Path=/");
301
+ });
302
+
303
+ it("detects KGW-shaped JSON auth errors", async () => {
304
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=wrong; Path=/");
305
+ fetchFn
306
+ .mockResolvedValueOnce(
307
+ Response.json({ error: { code: -901, message: "auth required" } }, { status: 200 }),
308
+ )
309
+ .mockResolvedValueOnce(new Response("ok"));
310
+
311
+ await authFetch("https://blob.example/upload");
312
+
313
+ expect(refresh).toHaveBeenCalledTimes(1);
314
+ expect(fetchFn).toHaveBeenCalledTimes(2);
315
+ });
316
+
317
+ it("leaves a non-JSON success body readable after the auth probe", async () => {
318
+ const { authFetch, fetchFn, refresh } = createTestClient("kgw_session=valid; Path=/");
319
+ const payload = new Uint8Array(1024 * 64).map((_, i) => i % 256);
320
+ fetchFn.mockResolvedValueOnce(
321
+ new Response(payload, {
322
+ status: 200,
323
+ headers: { "content-type": "application/octet-stream" },
324
+ }),
325
+ );
326
+
327
+ const response = await authFetch("https://blob.example/download");
328
+ const body = new Uint8Array(await response.arrayBuffer());
329
+
330
+ expect(refresh).not.toHaveBeenCalled();
331
+ expect(body).toEqual(payload);
332
+ });
333
+ });
334
+
335
+ describe("createKgwAuthenticatedBlobGateway", () => {
336
+ function kgwSessionClient(): KwilActionClient {
337
+ const nodeKwil = new NodeKwil({
338
+ kwilProvider: "https://nodes.example",
339
+ chainId: "test-chain",
340
+ }) as NodeKwil & {
341
+ authenticateKGWAndSetCookie: () => Promise<string>;
342
+ getKgwCookie: () => string | undefined;
343
+ };
344
+ nodeKwil.getKgwCookie = () => "kgw_session=valid; Path=/";
345
+ nodeKwil.authenticateKGWAndSetCookie = async () => "kgw_session=valid; Path=/";
346
+
347
+ return new KwilActionClient(nodeKwil);
348
+ }
349
+
350
+ it("infers UKYC authorization from an MM signer", () => {
351
+ const gateway = createKgwAuthenticatedBlobGateway({
352
+ url: "https://blob.example",
353
+ kwilClient: kgwSessionClient(),
354
+ signer: createMmTokenAuth(mmEnvelopeToken),
355
+ });
356
+
357
+ expect(gateway.hasAccessToken).toBe(true);
358
+ });
359
+
360
+ it("accepts an explicit MM authority for a non-MM signer, and none otherwise", () => {
361
+ const kwilClient = kgwSessionClient();
362
+ const signer = {} as KwilSigner;
363
+
364
+ expect(
365
+ createKgwAuthenticatedBlobGateway({
366
+ url: "https://blob.example",
367
+ kwilClient,
368
+ signer,
369
+ mmAuth: createMmTokenAuth(mmEnvelopeToken),
370
+ }).hasAccessToken,
371
+ ).toBe(true);
372
+ expect(
373
+ createKgwAuthenticatedBlobGateway({ url: "https://blob.example", kwilClient, signer })
374
+ .hasAccessToken,
375
+ ).toBe(false);
376
+ });
377
+ });