@graffiti-garden/implementation-decentralized 0.1.1 → 0.1.3

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/dist/1-services/3-storage-buckets.d.ts +5 -0
  2. package/dist/1-services/3-storage-buckets.d.ts.map +1 -1
  3. package/dist/3-protocol/2-handles.d.ts.map +1 -1
  4. package/dist/3-protocol/4-graffiti.d.ts +1 -2
  5. package/dist/3-protocol/4-graffiti.d.ts.map +1 -1
  6. package/dist/3-protocol/login-dialog.html.d.ts +1 -1
  7. package/dist/3-protocol/login-dialog.html.d.ts.map +1 -1
  8. package/dist/browser/index.js +7 -7
  9. package/dist/browser/index.js.map +3 -3
  10. package/dist/browser/{login-dialog.html-E6VRKMXH.js → login-dialog.html-WIDO3XZF.js} +18 -4
  11. package/dist/browser/{login-dialog.html-E6VRKMXH.js.map → login-dialog.html-WIDO3XZF.js.map} +2 -2
  12. package/dist/cjs/1-services/3-storage-buckets-tests.js +179 -0
  13. package/dist/cjs/1-services/3-storage-buckets-tests.js.map +3 -3
  14. package/dist/cjs/1-services/3-storage-buckets.js +91 -0
  15. package/dist/cjs/1-services/3-storage-buckets.js.map +3 -3
  16. package/dist/cjs/1-services/4-inboxes-tests.js +20 -0
  17. package/dist/cjs/1-services/4-inboxes-tests.js.map +3 -3
  18. package/dist/cjs/3-protocol/2-handles-tests.js +8 -0
  19. package/dist/cjs/3-protocol/2-handles-tests.js.map +2 -2
  20. package/dist/cjs/3-protocol/2-handles.js +3 -4
  21. package/dist/cjs/3-protocol/2-handles.js.map +2 -2
  22. package/dist/cjs/3-protocol/4-graffiti.js +30 -43
  23. package/dist/cjs/3-protocol/4-graffiti.js.map +2 -2
  24. package/dist/cjs/3-protocol/login-dialog.html.js +17 -3
  25. package/dist/cjs/3-protocol/login-dialog.html.js.map +2 -2
  26. package/dist/cjs/index.spec.js +89 -0
  27. package/dist/cjs/index.spec.js.map +3 -3
  28. package/dist/esm/1-services/3-storage-buckets-tests.js +180 -1
  29. package/dist/esm/1-services/3-storage-buckets-tests.js.map +2 -2
  30. package/dist/esm/1-services/3-storage-buckets.js +92 -1
  31. package/dist/esm/1-services/3-storage-buckets.js.map +2 -2
  32. package/dist/esm/1-services/4-inboxes-tests.js +20 -0
  33. package/dist/esm/1-services/4-inboxes-tests.js.map +2 -2
  34. package/dist/esm/3-protocol/2-handles-tests.js +8 -0
  35. package/dist/esm/3-protocol/2-handles-tests.js.map +2 -2
  36. package/dist/esm/3-protocol/2-handles.js +3 -4
  37. package/dist/esm/3-protocol/2-handles.js.map +2 -2
  38. package/dist/esm/3-protocol/4-graffiti.js +30 -43
  39. package/dist/esm/3-protocol/4-graffiti.js.map +2 -2
  40. package/dist/esm/3-protocol/login-dialog.html.js +17 -3
  41. package/dist/esm/3-protocol/login-dialog.html.js.map +2 -2
  42. package/dist/esm/index.spec.js +90 -1
  43. package/dist/esm/index.spec.js.map +2 -2
  44. package/package.json +1 -1
  45. package/src/1-services/3-storage-buckets-tests.ts +199 -1
  46. package/src/1-services/3-storage-buckets.ts +129 -1
  47. package/src/1-services/4-inboxes-tests.ts +25 -0
  48. package/src/3-protocol/2-handles-tests.ts +12 -0
  49. package/src/3-protocol/2-handles.ts +3 -5
  50. package/src/3-protocol/4-graffiti.ts +43 -73
  51. package/src/3-protocol/login-dialog.html.ts +17 -3
  52. package/src/index.spec.ts +103 -1
@@ -6,6 +6,7 @@ var import_storage_buckets = require("./1-services/3-storage-buckets");
6
6
  var import_inboxes = require("./1-services/4-inboxes");
7
7
  var import_sessions = require("./3-protocol/1-sessions");
8
8
  var import_vitest = require("vitest");
9
+ var import_dag_cbor = require("@ipld/dag-cbor");
9
10
  var import_handles = require("./3-protocol/2-handles");
10
11
  var import_dids_tests = require("./1-services/2-dids-tests");
11
12
  var import_storage_buckets_tests = require("./1-services/3-storage-buckets-tests");
@@ -80,6 +81,94 @@ var import_environment = require("../test/environment");
80
81
  () => sessions[0],
81
82
  () => sessions[1]
82
83
  );
84
+ (0, import_vitest.test)("OAuth codes require approval from the selected account", async () => {
85
+ const account = import_environment.decentralizedTestUsers[0];
86
+ const redirectUri = "https://client.example/callback";
87
+ const endpoint = "https://localhost:5173/app/oauth/authorize";
88
+ const cookie = `account_${account.accountId}=${account.token}`;
89
+ const headers = {
90
+ Cookie: cookie,
91
+ "X-Graffiti-Account": String(account.accountId),
92
+ "Content-Type": "application/json",
93
+ Origin: "https://localhost:5173"
94
+ };
95
+ const request = (scope, requestHeaders = headers) => fetch(endpoint, {
96
+ method: "POST",
97
+ headers: requestHeaders,
98
+ body: JSON.stringify({ redirect_uri: redirectUri, state: "test-state", scope })
99
+ });
100
+ const navigation = await fetch(
101
+ `${endpoint}?account=${account.accountId}&redirect_uri=${encodeURIComponent(redirectUri)}`,
102
+ { headers: { Cookie: cookie } }
103
+ );
104
+ (0, import_vitest.expect)(navigation.status).toBe(404);
105
+ (0, import_vitest.expect)((await request([account.bucketEndpoint], { Cookie: cookie })).status).toBe(401);
106
+ (0, import_vitest.expect)((await request([account.bucketEndpoint], {
107
+ ...headers,
108
+ Origin: "https://attacker.example"
109
+ })).status).toBe(403);
110
+ (0, import_vitest.expect)((await request([import_environment.decentralizedTestUsers[1].bucketEndpoint])).status).toBe(403);
111
+ const approval = await request([account.bucketEndpoint]);
112
+ (0, import_vitest.expect)(approval.status).toBe(200);
113
+ const { redirectUri: callback } = await approval.json();
114
+ const code = new URL(callback).searchParams.get("code");
115
+ (0, import_vitest.expect)(code).toBeTruthy();
116
+ (0, import_vitest.expect)(new URL(callback).searchParams.get("state")).toBe("test-state");
117
+ const exchange = await fetch("https://localhost:5173/app/oauth/token", {
118
+ method: "POST",
119
+ body: new URLSearchParams({ code, redirect_uri: redirectUri })
120
+ });
121
+ (0, import_vitest.expect)(exchange.status).toBe(200);
122
+ (0, import_vitest.expect)((await exchange.json()).access_token).toBeTypeOf("string");
123
+ });
124
+ (0, import_vitest.test)("deleting a bucket removes its values and rejects requests to its old URL", async () => {
125
+ const account = import_environment.decentralizedTestUsers[0];
126
+ const smallKey = crypto.randomUUID();
127
+ const largeKey = crypto.randomUUID();
128
+ const largeValue = new Uint8Array(32 * 1024 + 1).fill(7);
129
+ const authorization = { Authorization: `Bearer ${account.token}` };
130
+ for (const [key, value] of [
131
+ [smallKey, new Uint8Array([1, 2, 3])],
132
+ [largeKey, largeValue]
133
+ ]) {
134
+ const response = await fetch(`${account.bucketEndpoint}/value/${key}`, {
135
+ method: "PUT",
136
+ headers: authorization,
137
+ body: value
138
+ });
139
+ (0, import_vitest.expect)(response.status).toBe(201);
140
+ }
141
+ const r2Key = `${account.bucketId}/${largeKey}`;
142
+ (0, import_vitest.expect)(await environment.storedR2ObjectExists(r2Key)).toBe(true);
143
+ const deletion = await fetch(
144
+ `https://localhost:5173/app/service-instances/bucket/service/${account.bucketId}`,
145
+ {
146
+ method: "DELETE",
147
+ headers: {
148
+ Cookie: `account_${account.accountId}=${account.token}`,
149
+ "X-Graffiti-Account": String(account.accountId)
150
+ }
151
+ }
152
+ );
153
+ (0, import_vitest.expect)(deletion.status).toBe(200);
154
+ (0, import_vitest.expect)(await environment.storedR2ObjectExists(r2Key)).toBe(false);
155
+ for (const key of [smallKey, largeKey]) {
156
+ const response = await fetch(`${account.bucketEndpoint}/value/${key}`);
157
+ (0, import_vitest.expect)(response.status).toBe(404);
158
+ }
159
+ const batch = await fetch(`${account.bucketEndpoint}/values`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/cbor" },
162
+ body: (0, import_dag_cbor.encode)({ keys: [smallKey, largeKey], maxValueBytes: largeValue.length }).slice()
163
+ });
164
+ (0, import_vitest.expect)((0, import_dag_cbor.decode)(await batch.arrayBuffer()).results).toEqual([{ status: 404 }, { status: 404 }]);
165
+ const putAfterDeletion = await fetch(`${account.bucketEndpoint}/value/${smallKey}`, {
166
+ method: "PUT",
167
+ headers: authorization,
168
+ body: new Uint8Array([4])
169
+ });
170
+ (0, import_vitest.expect)(putAfterDeletion.status).toBe(404);
171
+ });
83
172
  async function login(handle) {
84
173
  const actor = await handleMethods.handleToActor(handle);
85
174
  return await new Promise((resolve, reject) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/index.spec.ts"],
4
- "sourcesContent": ["import type {\n GraffitiLoginEvent,\n GraffitiLogoutEvent,\n GraffitiSession,\n} from \"@graffiti-garden/api\";\nimport {\n graffitiCRUDTests,\n graffitiDiscoverTests,\n graffitiMediaTests,\n} from \"@graffiti-garden/api/tests\";\nimport { DecentralizedIdentifiers } from \"./1-services/2-dids\";\nimport { Authorization } from \"./1-services/1-authorization\";\nimport { StorageBuckets } from \"./1-services/3-storage-buckets\";\nimport { Inboxes } from \"./1-services/4-inboxes\";\nimport { Sessions } from \"./3-protocol/1-sessions\";\nimport { afterAll, beforeAll, describe } from \"vitest\";\nimport { Handles } from \"./3-protocol/2-handles\";\nimport { didTests } from \"./1-services/2-dids-tests\";\nimport { storageBucketTests } from \"./1-services/3-storage-buckets-tests\";\nimport { inboxTests } from \"./1-services/4-inboxes-tests\";\nimport { stringEncodingTests } from \"./2-primitives/1-string-encoding-tests\";\nimport { contentAddressesTests } from \"./2-primitives/2-content-addresses-tests\";\nimport { channelAttestationTests } from \"./2-primitives/3-channel-attestations-tests\";\nimport { allowedAttestationTests } from \"./2-primitives/4-allowed-attestations-tests\";\nimport { handleTests } from \"./3-protocol/2-handles-tests\";\nimport { objectEncodingTests } from \"./3-protocol/3-object-encoding-tests\";\nimport { GraffitiDecentralized } from \"./3-protocol/4-graffiti\";\nimport {\n DecentralizedTestEnvironment,\n decentralizedSharedInbox,\n decentralizedTestUsers,\n} from \"../test/environment\";\n\ndescribe(\"GraffitiDecentralized Tests\", () => {\n const environment = new DecentralizedTestEnvironment();\n const handles = decentralizedTestUsers.map((user) => user.handle);\n let sessions: GraffitiSession[] = [];\n let sessionMethods: Sessions;\n let handleMethods: Handles;\n\n beforeAll(async () => {\n await environment.start();\n\n const dids = new DecentralizedIdentifiers();\n sessionMethods = new Sessions({\n dids,\n authorization: new Authorization(),\n inboxes: new Inboxes(),\n storageBuckets: new StorageBuckets(),\n });\n handleMethods = new Handles({ dids });\n\n for (const handle of handles) {\n sessions.push(await login(handle));\n }\n });\n\n afterAll(async () => {\n try {\n for (const session of sessions) {\n await logout(session.actor);\n }\n } finally {\n await environment.stop();\n }\n });\n\n // Service tests\n didTests();\n storageBucketTests(\n decentralizedTestUsers[0].bucketEndpoint,\n decentralizedTestUsers[0].token,\n );\n inboxTests(\n decentralizedTestUsers[0].inboxEndpoint,\n decentralizedTestUsers[0].token,\n );\n\n // Primitive tests\n stringEncodingTests();\n contentAddressesTests();\n channelAttestationTests();\n allowedAttestationTests();\n\n // Protocol tests\n handleTests(handles[0]);\n objectEncodingTests();\n\n const useGraffiti = () =>\n new GraffitiDecentralized({\n defaultInboxEndpoints: [decentralizedSharedInbox],\n });\n graffitiCRUDTests(\n useGraffiti,\n () => sessions[0],\n () => sessions[1],\n );\n graffitiMediaTests(\n useGraffiti,\n () => sessions[0],\n () => sessions[1],\n );\n graffitiDiscoverTests(\n useGraffiti,\n () => sessions[0],\n () => sessions[1],\n );\n\n // How to log in/out vvv\n async function login(handle: string) {\n const actor = await handleMethods.handleToActor(handle);\n\n return await new Promise<GraffitiSession>((resolve, reject) => {\n let timeout: ReturnType<typeof setTimeout>;\n const cleanup = () => {\n clearTimeout(timeout);\n sessionMethods.sessionEvents.removeEventListener(\"login\", listener);\n };\n const listener = (e: unknown) => {\n if (!(e instanceof CustomEvent)) return;\n const detail = e.detail as GraffitiLoginEvent[\"detail\"];\n if (detail.session?.actor !== actor) return;\n cleanup();\n if (detail.error) {\n reject(detail.error);\n } else {\n resolve(detail.session);\n }\n };\n sessionMethods.sessionEvents.addEventListener(\"login\", listener);\n\n timeout = setTimeout(\n () => {\n cleanup();\n reject(new Error(\"Authorization timed out\"));\n },\n 30 * 1000,\n );\n\n void sessionMethods.login(actor);\n });\n }\n\n async function logout(actor: string) {\n return await new Promise<void>((resolve, reject) => {\n let timeout: ReturnType<typeof setTimeout>;\n const cleanup = () => {\n clearTimeout(timeout);\n sessionMethods.sessionEvents.removeEventListener(\"logout\", listener);\n };\n const listener = (e: unknown) => {\n if (!(e instanceof CustomEvent)) return;\n const detail = e.detail as GraffitiLogoutEvent[\"detail\"];\n if (detail.actor !== actor) return;\n cleanup();\n\n if (detail.error) {\n reject(detail.error);\n } else {\n resolve();\n }\n };\n sessionMethods.sessionEvents.addEventListener(\"logout\", listener);\n\n timeout = setTimeout(\n () => {\n cleanup();\n reject(new Error(\"Logout timed out\"));\n },\n 30 * 1000,\n );\n\n void sessionMethods.logout(actor);\n });\n }\n});\n"],
5
- "mappings": ";AAKA,mBAIO;AACP,kBAAyC;AACzC,2BAA8B;AAC9B,6BAA+B;AAC/B,qBAAwB;AACxB,sBAAyB;AACzB,oBAA8C;AAC9C,qBAAwB;AACxB,wBAAyB;AACzB,mCAAmC;AACnC,2BAA2B;AAC3B,mCAAoC;AACpC,qCAAsC;AACtC,wCAAwC;AACxC,wCAAwC;AACxC,2BAA4B;AAC5B,mCAAoC;AACpC,sBAAsC;AACtC,yBAIO;AAAA,IAEP,wBAAS,+BAA+B,MAAM;AAC5C,QAAM,cAAc,IAAI,gDAA6B;AACrD,QAAM,UAAU,0CAAuB,IAAI,CAAC,SAAS,KAAK,MAAM;AAChE,MAAI,WAA8B,CAAC;AACnC,MAAI;AACJ,MAAI;AAEJ,+BAAU,YAAY;AACpB,UAAM,YAAY,MAAM;AAExB,UAAM,OAAO,IAAI,qCAAyB;AAC1C,qBAAiB,IAAI,yBAAS;AAAA,MAC5B;AAAA,MACA,eAAe,IAAI,mCAAc;AAAA,MACjC,SAAS,IAAI,uBAAQ;AAAA,MACrB,gBAAgB,IAAI,sCAAe;AAAA,IACrC,CAAC;AACD,oBAAgB,IAAI,uBAAQ,EAAE,KAAK,CAAC;AAEpC,eAAW,UAAU,SAAS;AAC5B,eAAS,KAAK,MAAM,MAAM,MAAM,CAAC;AAAA,IACnC;AAAA,EACF,CAAC;AAED,8BAAS,YAAY;AACnB,QAAI;AACF,iBAAW,WAAW,UAAU;AAC9B,cAAM,OAAO,QAAQ,KAAK;AAAA,MAC5B;AAAA,IACF,UAAE;AACA,YAAM,YAAY,KAAK;AAAA,IACzB;AAAA,EACF,CAAC;AAGD,kCAAS;AACT;AAAA,IACE,0CAAuB,CAAC,EAAE;AAAA,IAC1B,0CAAuB,CAAC,EAAE;AAAA,EAC5B;AACA;AAAA,IACE,0CAAuB,CAAC,EAAE;AAAA,IAC1B,0CAAuB,CAAC,EAAE;AAAA,EAC5B;AAGA,wDAAoB;AACpB,4DAAsB;AACtB,iEAAwB;AACxB,iEAAwB;AAGxB,wCAAY,QAAQ,CAAC,CAAC;AACtB,wDAAoB;AAEpB,QAAM,cAAc,MAClB,IAAI,sCAAsB;AAAA,IACxB,uBAAuB,CAAC,2CAAwB;AAAA,EAClD,CAAC;AACH;AAAA,IACE;AAAA,IACA,MAAM,SAAS,CAAC;AAAA,IAChB,MAAM,SAAS,CAAC;AAAA,EAClB;AACA;AAAA,IACE;AAAA,IACA,MAAM,SAAS,CAAC;AAAA,IAChB,MAAM,SAAS,CAAC;AAAA,EAClB;AACA;AAAA,IACE;AAAA,IACA,MAAM,SAAS,CAAC;AAAA,IAChB,MAAM,SAAS,CAAC;AAAA,EAClB;AAGA,iBAAe,MAAM,QAAgB;AACnC,UAAM,QAAQ,MAAM,cAAc,cAAc,MAAM;AAEtD,WAAO,MAAM,IAAI,QAAyB,CAAC,SAAS,WAAW;AAC7D,UAAI;AACJ,YAAM,UAAU,MAAM;AACpB,qBAAa,OAAO;AACpB,uBAAe,cAAc,oBAAoB,SAAS,QAAQ;AAAA,MACpE;AACA,YAAM,WAAW,CAAC,MAAe;AAC/B,YAAI,EAAE,aAAa,aAAc;AACjC,cAAM,SAAS,EAAE;AACjB,YAAI,OAAO,SAAS,UAAU,MAAO;AACrC,gBAAQ;AACR,YAAI,OAAO,OAAO;AAChB,iBAAO,OAAO,KAAK;AAAA,QACrB,OAAO;AACL,kBAAQ,OAAO,OAAO;AAAA,QACxB;AAAA,MACF;AACA,qBAAe,cAAc,iBAAiB,SAAS,QAAQ;AAE/D,gBAAU;AAAA,QACR,MAAM;AACJ,kBAAQ;AACR,iBAAO,IAAI,MAAM,yBAAyB,CAAC;AAAA,QAC7C;AAAA,QACA,KAAK;AAAA,MACP;AAEA,WAAK,eAAe,MAAM,KAAK;AAAA,IACjC,CAAC;AAAA,EACH;AAEA,iBAAe,OAAO,OAAe;AACnC,WAAO,MAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAClD,UAAI;AACJ,YAAM,UAAU,MAAM;AACpB,qBAAa,OAAO;AACpB,uBAAe,cAAc,oBAAoB,UAAU,QAAQ;AAAA,MACrE;AACA,YAAM,WAAW,CAAC,MAAe;AAC/B,YAAI,EAAE,aAAa,aAAc;AACjC,cAAM,SAAS,EAAE;AACjB,YAAI,OAAO,UAAU,MAAO;AAC5B,gBAAQ;AAER,YAAI,OAAO,OAAO;AAChB,iBAAO,OAAO,KAAK;AAAA,QACrB,OAAO;AACL,kBAAQ;AAAA,QACV;AAAA,MACF;AACA,qBAAe,cAAc,iBAAiB,UAAU,QAAQ;AAEhE,gBAAU;AAAA,QACR,MAAM;AACJ,kBAAQ;AACR,iBAAO,IAAI,MAAM,kBAAkB,CAAC;AAAA,QACtC;AAAA,QACA,KAAK;AAAA,MACP;AAEA,WAAK,eAAe,OAAO,KAAK;AAAA,IAClC,CAAC;AAAA,EACH;AACF,CAAC;",
6
- "names": []
4
+ "sourcesContent": ["import type {\n GraffitiLoginEvent,\n GraffitiLogoutEvent,\n GraffitiSession,\n} from \"@graffiti-garden/api\";\nimport {\n graffitiCRUDTests,\n graffitiDiscoverTests,\n graffitiMediaTests,\n} from \"@graffiti-garden/api/tests\";\nimport { DecentralizedIdentifiers } from \"./1-services/2-dids\";\nimport { Authorization } from \"./1-services/1-authorization\";\nimport { StorageBuckets } from \"./1-services/3-storage-buckets\";\nimport { Inboxes } from \"./1-services/4-inboxes\";\nimport { Sessions } from \"./3-protocol/1-sessions\";\nimport { afterAll, beforeAll, describe, expect, test } from \"vitest\";\nimport { decode as dagCborDecode, encode as dagCborEncode } from \"@ipld/dag-cbor\";\nimport { Handles } from \"./3-protocol/2-handles\";\nimport { didTests } from \"./1-services/2-dids-tests\";\nimport { storageBucketTests } from \"./1-services/3-storage-buckets-tests\";\nimport { inboxTests } from \"./1-services/4-inboxes-tests\";\nimport { stringEncodingTests } from \"./2-primitives/1-string-encoding-tests\";\nimport { contentAddressesTests } from \"./2-primitives/2-content-addresses-tests\";\nimport { channelAttestationTests } from \"./2-primitives/3-channel-attestations-tests\";\nimport { allowedAttestationTests } from \"./2-primitives/4-allowed-attestations-tests\";\nimport { handleTests } from \"./3-protocol/2-handles-tests\";\nimport { objectEncodingTests } from \"./3-protocol/3-object-encoding-tests\";\nimport { GraffitiDecentralized } from \"./3-protocol/4-graffiti\";\nimport {\n DecentralizedTestEnvironment,\n decentralizedSharedInbox,\n decentralizedTestUsers,\n} from \"../test/environment\";\n\ndescribe(\"GraffitiDecentralized Tests\", () => {\n const environment = new DecentralizedTestEnvironment();\n const handles = decentralizedTestUsers.map((user) => user.handle);\n let sessions: GraffitiSession[] = [];\n let sessionMethods: Sessions;\n let handleMethods: Handles;\n\n beforeAll(async () => {\n await environment.start();\n\n const dids = new DecentralizedIdentifiers();\n sessionMethods = new Sessions({\n dids,\n authorization: new Authorization(),\n inboxes: new Inboxes(),\n storageBuckets: new StorageBuckets(),\n });\n handleMethods = new Handles({ dids });\n\n for (const handle of handles) {\n sessions.push(await login(handle));\n }\n });\n\n afterAll(async () => {\n try {\n for (const session of sessions) {\n await logout(session.actor);\n }\n } finally {\n await environment.stop();\n }\n });\n\n // Service tests\n didTests();\n storageBucketTests(\n decentralizedTestUsers[0].bucketEndpoint,\n decentralizedTestUsers[0].token,\n );\n inboxTests(\n decentralizedTestUsers[0].inboxEndpoint,\n decentralizedTestUsers[0].token,\n );\n\n // Primitive tests\n stringEncodingTests();\n contentAddressesTests();\n channelAttestationTests();\n allowedAttestationTests();\n\n // Protocol tests\n handleTests(handles[0]);\n objectEncodingTests();\n\n const useGraffiti = () =>\n new GraffitiDecentralized({\n defaultInboxEndpoints: [decentralizedSharedInbox],\n });\n graffitiCRUDTests(\n useGraffiti,\n () => sessions[0],\n () => sessions[1],\n );\n graffitiMediaTests(\n useGraffiti,\n () => sessions[0],\n () => sessions[1],\n );\n graffitiDiscoverTests(\n useGraffiti,\n () => sessions[0],\n () => sessions[1],\n );\n\n test(\"OAuth codes require approval from the selected account\", async () => {\n const account = decentralizedTestUsers[0];\n const redirectUri = \"https://client.example/callback\";\n const endpoint = \"https://localhost:5173/app/oauth/authorize\";\n const cookie = `account_${account.accountId}=${account.token}`;\n const headers = {\n Cookie: cookie,\n \"X-Graffiti-Account\": String(account.accountId),\n \"Content-Type\": \"application/json\",\n Origin: \"https://localhost:5173\",\n };\n const request = (scope: string[], requestHeaders: HeadersInit = headers) =>\n fetch(endpoint, {\n method: \"POST\",\n headers: requestHeaders,\n body: JSON.stringify({ redirect_uri: redirectUri, state: \"test-state\", scope }),\n });\n\n const navigation = await fetch(\n `${endpoint}?account=${account.accountId}&redirect_uri=${encodeURIComponent(redirectUri)}`,\n { headers: { Cookie: cookie } },\n );\n expect(navigation.status).toBe(404);\n\n expect((await request([account.bucketEndpoint], { Cookie: cookie })).status).toBe(401);\n expect((await request([account.bucketEndpoint], {\n ...headers,\n Origin: \"https://attacker.example\",\n })).status).toBe(403);\n expect((await request([decentralizedTestUsers[1].bucketEndpoint])).status).toBe(403);\n\n const approval = await request([account.bucketEndpoint]);\n expect(approval.status).toBe(200);\n const { redirectUri: callback } = await approval.json() as { redirectUri: string };\n const code = new URL(callback).searchParams.get(\"code\");\n expect(code).toBeTruthy();\n expect(new URL(callback).searchParams.get(\"state\")).toBe(\"test-state\");\n\n const exchange = await fetch(\"https://localhost:5173/app/oauth/token\", {\n method: \"POST\",\n body: new URLSearchParams({ code: code!, redirect_uri: redirectUri }),\n });\n expect(exchange.status).toBe(200);\n expect((await exchange.json()).access_token).toBeTypeOf(\"string\");\n });\n\n test(\"deleting a bucket removes its values and rejects requests to its old URL\", async () => {\n const account = decentralizedTestUsers[0];\n const smallKey = crypto.randomUUID();\n const largeKey = crypto.randomUUID();\n const largeValue = new Uint8Array(32 * 1024 + 1).fill(7);\n const authorization = { Authorization: `Bearer ${account.token}` };\n\n for (const [key, value] of [\n [smallKey, new Uint8Array([1, 2, 3])],\n [largeKey, largeValue],\n ] as const) {\n const response = await fetch(`${account.bucketEndpoint}/value/${key}`, {\n method: \"PUT\",\n headers: authorization,\n body: value,\n });\n expect(response.status).toBe(201);\n }\n\n const r2Key = `${account.bucketId}/${largeKey}`;\n expect(await environment.storedR2ObjectExists(r2Key)).toBe(true);\n\n const deletion = await fetch(\n `https://localhost:5173/app/service-instances/bucket/service/${account.bucketId}`,\n {\n method: \"DELETE\",\n headers: {\n Cookie: `account_${account.accountId}=${account.token}`,\n \"X-Graffiti-Account\": String(account.accountId),\n },\n },\n );\n expect(deletion.status).toBe(200);\n\n expect(await environment.storedR2ObjectExists(r2Key)).toBe(false);\n for (const key of [smallKey, largeKey]) {\n const response = await fetch(`${account.bucketEndpoint}/value/${key}`);\n expect(response.status).toBe(404);\n }\n const batch = await fetch(`${account.bucketEndpoint}/values`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/cbor\" },\n body: dagCborEncode({ keys: [smallKey, largeKey], maxValueBytes: largeValue.length }).slice(),\n });\n expect((dagCborDecode(await batch.arrayBuffer()) as { results: { status: number }[] }).results)\n .toEqual([{ status: 404 }, { status: 404 }]);\n\n const putAfterDeletion = await fetch(`${account.bucketEndpoint}/value/${smallKey}`, {\n method: \"PUT\",\n headers: authorization,\n body: new Uint8Array([4]),\n });\n expect(putAfterDeletion.status).toBe(404);\n });\n\n // How to log in/out vvv\n async function login(handle: string) {\n const actor = await handleMethods.handleToActor(handle);\n\n return await new Promise<GraffitiSession>((resolve, reject) => {\n let timeout: ReturnType<typeof setTimeout>;\n const cleanup = () => {\n clearTimeout(timeout);\n sessionMethods.sessionEvents.removeEventListener(\"login\", listener);\n };\n const listener = (e: unknown) => {\n if (!(e instanceof CustomEvent)) return;\n const detail = e.detail as GraffitiLoginEvent[\"detail\"];\n if (detail.session?.actor !== actor) return;\n cleanup();\n if (detail.error) {\n reject(detail.error);\n } else {\n resolve(detail.session);\n }\n };\n sessionMethods.sessionEvents.addEventListener(\"login\", listener);\n\n timeout = setTimeout(\n () => {\n cleanup();\n reject(new Error(\"Authorization timed out\"));\n },\n 30 * 1000,\n );\n\n void sessionMethods.login(actor);\n });\n }\n\n async function logout(actor: string) {\n return await new Promise<void>((resolve, reject) => {\n let timeout: ReturnType<typeof setTimeout>;\n const cleanup = () => {\n clearTimeout(timeout);\n sessionMethods.sessionEvents.removeEventListener(\"logout\", listener);\n };\n const listener = (e: unknown) => {\n if (!(e instanceof CustomEvent)) return;\n const detail = e.detail as GraffitiLogoutEvent[\"detail\"];\n if (detail.actor !== actor) return;\n cleanup();\n\n if (detail.error) {\n reject(detail.error);\n } else {\n resolve();\n }\n };\n sessionMethods.sessionEvents.addEventListener(\"logout\", listener);\n\n timeout = setTimeout(\n () => {\n cleanup();\n reject(new Error(\"Logout timed out\"));\n },\n 30 * 1000,\n );\n\n void sessionMethods.logout(actor);\n });\n }\n});\n"],
5
+ "mappings": "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",
6
+ "names": ["dagCborEncode", "dagCborDecode"]
7
7
  }
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { StorageBuckets } from "./3-storage-buckets";
3
3
  import { GraffitiErrorUnauthorized } from "./utilities";
4
- import { GraffitiErrorNotFound } from "@graffiti-garden/api";
4
+ import { GraffitiErrorNotFound, GraffitiErrorTooLarge } from "@graffiti-garden/api";
5
+ import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
5
6
  function storageBucketTests(storageBucketEndpoint, storageBucketToken) {
6
7
  describe("Storage buckets", async () => {
7
8
  const storageBuckets = new StorageBuckets();
@@ -48,6 +49,184 @@ function storageBucketTests(storageBucketEndpoint, storageBucketToken) {
48
49
  storageBuckets.get(storageBucketEndpoint, key, bytes.length - 1)
49
50
  ).rejects.toThrow();
50
51
  });
52
+ test("overwrites across the small and large storage cutoff", async () => {
53
+ const key = crypto.randomUUID();
54
+ const small = new Uint8Array([1, 2, 3]);
55
+ const large = new Uint8Array(32 * 1024 + 1).fill(42);
56
+ const otherLarge = new Uint8Array(32 * 1024 + 1).fill(43);
57
+ await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);
58
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);
59
+ await storageBuckets.put(storageBucketEndpoint, key, otherLarge, storageBucketToken);
60
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(otherLarge);
61
+ await storageBuckets.put(storageBucketEndpoint, key, small, storageBucketToken);
62
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(small);
63
+ await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);
64
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);
65
+ await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
66
+ await expect(storageBuckets.get(storageBucketEndpoint, key)).rejects.toThrow(
67
+ GraffitiErrorNotFound
68
+ );
69
+ });
70
+ test("batch reads arbitrary keys from one bucket", async () => {
71
+ const first = crypto.randomUUID();
72
+ const second = crypto.randomUUID();
73
+ const missing = crypto.randomUUID();
74
+ const small = new Uint8Array([1, 2, 3]);
75
+ const large = new Uint8Array(32 * 1024 + 1).fill(7);
76
+ await storageBuckets.put(storageBucketEndpoint, first, small, storageBucketToken);
77
+ await storageBuckets.put(storageBucketEndpoint, second, large, storageBucketToken);
78
+ try {
79
+ const response = await fetch(`${storageBucketEndpoint}/values`, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/cbor" },
82
+ body: dagCborEncode({
83
+ keys: [first, missing, second, first],
84
+ maxValueBytes: small.length
85
+ }).slice()
86
+ });
87
+ expect(response.status).toBe(200);
88
+ const decoded = dagCborDecode(await response.arrayBuffer());
89
+ expect(decoded.results.map(({ status }) => status)).toEqual([200, 404, 413, 200]);
90
+ expect(decoded.results[0].value).toEqual(small);
91
+ expect(decoded.results[0].etag).toBeTypeOf("string");
92
+ expect(decoded.results[3].value).toEqual(small);
93
+ const largeResponse = await fetch(`${storageBucketEndpoint}/values`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/cbor" },
96
+ body: dagCborEncode({
97
+ keys: [second],
98
+ maxValueBytes: large.length
99
+ }).slice()
100
+ });
101
+ expect(largeResponse.status).toBe(200);
102
+ const largeDecoded = dagCborDecode(await largeResponse.arrayBuffer());
103
+ expect(largeDecoded.results[0].value).toEqual(large);
104
+ const oversizedResponse = await fetch(`${storageBucketEndpoint}/values`, {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/cbor" },
107
+ body: dagCborEncode({
108
+ keys: [first, second],
109
+ maxValueBytes: 1024 * 1024
110
+ }).slice()
111
+ });
112
+ expect(oversizedResponse.status).toBe(400);
113
+ const settled = await Promise.allSettled([
114
+ storageBuckets.get(storageBucketEndpoint, first, small.length),
115
+ storageBuckets.get(storageBucketEndpoint, missing, small.length),
116
+ storageBuckets.get(storageBucketEndpoint, second, small.length)
117
+ ]);
118
+ expect(settled[0]).toEqual({ status: "fulfilled", value: small });
119
+ expect(settled[1].status).toBe("rejected");
120
+ expect(settled[1].reason).toBeInstanceOf(GraffitiErrorNotFound);
121
+ expect(settled[2].status).toBe("rejected");
122
+ expect(settled[2].reason).toBeInstanceOf(GraffitiErrorTooLarge);
123
+ expect(await storageBuckets.get(storageBucketEndpoint, second)).toEqual(large);
124
+ } finally {
125
+ await storageBuckets.delete(storageBucketEndpoint, first, storageBucketToken);
126
+ await storageBuckets.delete(storageBucketEndpoint, second, storageBucketToken);
127
+ }
128
+ });
129
+ test("accepts a full batch of maximum-length Unicode keys", async () => {
130
+ const key = "\u754C".repeat(255);
131
+ const body = dagCborEncode({
132
+ keys: Array(32).fill(key),
133
+ maxValueBytes: 0
134
+ }).slice();
135
+ expect(body.byteLength).toBeGreaterThan(16 * 1024);
136
+ const response = await fetch(`${storageBucketEndpoint}/values`, {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/cbor" },
139
+ body
140
+ });
141
+ expect(response.status).toBe(200);
142
+ const decoded = dagCborDecode(await response.arrayBuffer());
143
+ expect(decoded.results.map(({ status }) => status)).toEqual(Array(32).fill(404));
144
+ });
145
+ test("concurrent bounded gets share one batch", async () => {
146
+ const keys = Array.from({ length: 3 }, () => crypto.randomUUID());
147
+ const values = keys.map((_, i) => new Uint8Array([i + 1]));
148
+ for (let i = 0; i < keys.length; i++) {
149
+ await storageBuckets.put(storageBucketEndpoint, keys[i], values[i], storageBucketToken);
150
+ }
151
+ const originalFetch = globalThis.fetch;
152
+ const calls = [];
153
+ globalThis.fetch = (input, init) => {
154
+ const request = new Request(input, init);
155
+ calls.push(`${request.method} ${request.url}`);
156
+ return originalFetch(input, init);
157
+ };
158
+ try {
159
+ expect(await Promise.all(keys.map(
160
+ (key) => storageBuckets.get(storageBucketEndpoint, key, 1)
161
+ ))).toEqual(values);
162
+ expect(calls.filter((call) => call === `POST ${storageBucketEndpoint}/values`)).toHaveLength(1);
163
+ expect(calls.filter((call) => call.includes("/value/"))).toHaveLength(0);
164
+ } finally {
165
+ globalThis.fetch = originalFetch;
166
+ for (const key of keys) {
167
+ await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
168
+ }
169
+ }
170
+ });
171
+ test("falls back to GET when a bucket server does not support batches", async () => {
172
+ const keys = Array.from({ length: 2 }, () => crypto.randomUUID());
173
+ const value = new Uint8Array([42]);
174
+ for (const key of keys) {
175
+ await storageBuckets.put(storageBucketEndpoint, key, value, storageBucketToken);
176
+ }
177
+ const originalFetch = globalThis.fetch;
178
+ let batchAttempts = 0;
179
+ globalThis.fetch = (input, init) => {
180
+ const request = new Request(input, init);
181
+ if (request.method === "POST" && request.url === `${storageBucketEndpoint}/values`) {
182
+ batchAttempts++;
183
+ return Promise.resolve(new Response("Not found", { status: 404 }));
184
+ }
185
+ return originalFetch(input, init);
186
+ };
187
+ try {
188
+ const legacyBuckets = new StorageBuckets();
189
+ expect(await Promise.all(keys.map(
190
+ (key) => legacyBuckets.get(storageBucketEndpoint, key, 1)
191
+ ))).toEqual([value, value]);
192
+ expect(await Promise.all(keys.map(
193
+ (key) => legacyBuckets.get(storageBucketEndpoint, key, 1)
194
+ ))).toEqual([value, value]);
195
+ expect(batchAttempts).toBe(1);
196
+ } finally {
197
+ globalThis.fetch = originalFetch;
198
+ for (const key of keys) {
199
+ await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
200
+ }
201
+ }
202
+ });
203
+ test("a batch error does not disable batching or fall back to GET", async () => {
204
+ const originalFetch = globalThis.fetch;
205
+ let batchAttempts = 0;
206
+ let getAttempts = 0;
207
+ globalThis.fetch = (input, init) => {
208
+ const request = new Request(input, init);
209
+ if (request.method === "POST") {
210
+ batchAttempts++;
211
+ return Promise.resolve(new Response("Batch limit exceeded", { status: 413 }));
212
+ }
213
+ getAttempts++;
214
+ return originalFetch(input, init);
215
+ };
216
+ try {
217
+ const buckets = new StorageBuckets();
218
+ const readPair = () => Promise.all([
219
+ buckets.get(storageBucketEndpoint, "a", 1),
220
+ buckets.get(storageBucketEndpoint, "b", 1)
221
+ ]);
222
+ await expect(readPair()).rejects.toThrow("Batch limit exceeded");
223
+ await expect(readPair()).rejects.toThrow("Batch limit exceeded");
224
+ expect(batchAttempts).toBe(2);
225
+ expect(getAttempts).toBe(0);
226
+ } finally {
227
+ globalThis.fetch = originalFetch;
228
+ }
229
+ });
51
230
  test("unauthorized", async () => {
52
231
  const key = Math.random().toString(36).substring(2, 15);
53
232
  const input = "Hello world";
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/1-services/3-storage-buckets-tests.ts"],
4
- "sourcesContent": ["import { describe, expect, test } from \"vitest\";\nimport { StorageBuckets } from \"./3-storage-buckets\";\nimport { GraffitiErrorUnauthorized } from \"./utilities\";\nimport { GraffitiErrorNotFound } from \"@graffiti-garden/api\";\n\nexport function storageBucketTests(\n storageBucketEndpoint: string,\n storageBucketToken: string,\n) {\n describe(\"Storage buckets\", async () => {\n const storageBuckets = new StorageBuckets();\n\n test(\"put, get, delete\", async () => {\n const key = Math.random().toString(36).substring(2, 15);\n const input = \"Hello world\";\n const bytes = new TextEncoder().encode(input);\n\n await expect(\n storageBuckets.get(storageBucketEndpoint, key),\n ).rejects.toThrow(GraffitiErrorNotFound);\n\n await storageBuckets.put(\n storageBucketEndpoint,\n key,\n bytes,\n storageBucketToken,\n );\n\n const resultBytes = await storageBuckets.get(\n storageBucketEndpoint,\n key,\n bytes.length,\n );\n const result = new TextDecoder().decode(resultBytes);\n expect(result).toEqual(input);\n\n await storageBuckets.delete(\n storageBucketEndpoint,\n key,\n storageBucketToken,\n );\n\n await expect(\n storageBuckets.get(storageBucketEndpoint, key),\n ).rejects.toThrow(GraffitiErrorNotFound);\n });\n\n test(\"get with limit less than object\", async () => {\n const key = Math.random().toString(36).substring(2, 15);\n const input = \"Hello world\";\n const bytes = new TextEncoder().encode(input);\n\n await storageBuckets.put(\n storageBucketEndpoint,\n key,\n bytes,\n storageBucketToken,\n );\n\n await expect(\n storageBuckets.get(storageBucketEndpoint, key, bytes.length - 1),\n ).rejects.toThrow();\n });\n\n test(\"unauthorized\", async () => {\n const key = Math.random().toString(36).substring(2, 15);\n const input = \"Hello world\";\n const bytes = new TextEncoder().encode(input);\n\n await expect(\n storageBuckets.put(storageBucketEndpoint, key, bytes, \"invalid-token\"),\n ).rejects.toThrow(GraffitiErrorUnauthorized);\n await expect(\n storageBuckets.delete(storageBucketEndpoint, key, \"invalid-token\"),\n ).rejects.toThrow(GraffitiErrorUnauthorized);\n await expect(\n storageBuckets.export(storageBucketEndpoint, \"invalid-token\").next(),\n ).rejects.toThrow(GraffitiErrorUnauthorized);\n });\n\n test(\"export\", async () => {\n // Put a whole bunch of stuff so the export needs to page\n const keys = new Set<string>();\n for (let i = 0; i < 256; i++) {\n const key = Math.random().toString(36).substring(2, 15);\n keys.add(key);\n\n const input = \"Hello world \" + i;\n const bytes = new TextEncoder().encode(input);\n await storageBuckets.put(\n storageBucketEndpoint,\n key,\n bytes,\n storageBucketToken,\n );\n }\n\n // Export\n const retrievedKeys = new Set<string>();\n const iterator = storageBuckets.export(\n storageBucketEndpoint,\n storageBucketToken,\n );\n for await (const result of iterator) {\n if (keys.has(result.key)) {\n retrievedKeys.add(result.key);\n }\n }\n expect(retrievedKeys.size).toEqual(keys.size);\n\n // Delete all the keys\n for (const key of keys) {\n await storageBuckets.delete(\n storageBucketEndpoint,\n key,\n storageBucketToken,\n );\n }\n }, 1000000);\n });\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { describe, expect, test } from \"vitest\";\nimport { StorageBuckets } from \"./3-storage-buckets\";\nimport { GraffitiErrorUnauthorized } from \"./utilities\";\nimport { GraffitiErrorNotFound, GraffitiErrorTooLarge } from \"@graffiti-garden/api\";\nimport { encode as dagCborEncode, decode as dagCborDecode } from \"@ipld/dag-cbor\";\n\nexport function storageBucketTests(\n storageBucketEndpoint: string,\n storageBucketToken: string,\n) {\n describe(\"Storage buckets\", async () => {\n const storageBuckets = new StorageBuckets();\n\n test(\"put, get, delete\", async () => {\n const key = Math.random().toString(36).substring(2, 15);\n const input = \"Hello world\";\n const bytes = new TextEncoder().encode(input);\n\n await expect(\n storageBuckets.get(storageBucketEndpoint, key),\n ).rejects.toThrow(GraffitiErrorNotFound);\n\n await storageBuckets.put(\n storageBucketEndpoint,\n key,\n bytes,\n storageBucketToken,\n );\n\n const resultBytes = await storageBuckets.get(\n storageBucketEndpoint,\n key,\n bytes.length,\n );\n const result = new TextDecoder().decode(resultBytes);\n expect(result).toEqual(input);\n\n await storageBuckets.delete(\n storageBucketEndpoint,\n key,\n storageBucketToken,\n );\n\n await expect(\n storageBuckets.get(storageBucketEndpoint, key),\n ).rejects.toThrow(GraffitiErrorNotFound);\n });\n\n test(\"get with limit less than object\", async () => {\n const key = Math.random().toString(36).substring(2, 15);\n const input = \"Hello world\";\n const bytes = new TextEncoder().encode(input);\n\n await storageBuckets.put(\n storageBucketEndpoint,\n key,\n bytes,\n storageBucketToken,\n );\n\n await expect(\n storageBuckets.get(storageBucketEndpoint, key, bytes.length - 1),\n ).rejects.toThrow();\n });\n\n test(\"overwrites across the small and large storage cutoff\", async () => {\n const key = crypto.randomUUID();\n const small = new Uint8Array([1, 2, 3]);\n const large = new Uint8Array(32 * 1024 + 1).fill(42);\n const otherLarge = new Uint8Array(32 * 1024 + 1).fill(43);\n\n await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);\n expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);\n\n await storageBuckets.put(storageBucketEndpoint, key, otherLarge, storageBucketToken);\n expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(otherLarge);\n\n await storageBuckets.put(storageBucketEndpoint, key, small, storageBucketToken);\n expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(small);\n\n await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);\n expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);\n\n await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);\n await expect(storageBuckets.get(storageBucketEndpoint, key)).rejects.toThrow(\n GraffitiErrorNotFound,\n );\n });\n\n test(\"batch reads arbitrary keys from one bucket\", async () => {\n const first = crypto.randomUUID();\n const second = crypto.randomUUID();\n const missing = crypto.randomUUID();\n const small = new Uint8Array([1, 2, 3]);\n const large = new Uint8Array(32 * 1024 + 1).fill(7);\n await storageBuckets.put(storageBucketEndpoint, first, small, storageBucketToken);\n await storageBuckets.put(storageBucketEndpoint, second, large, storageBucketToken);\n try {\n const response = await fetch(`${storageBucketEndpoint}/values`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/cbor\" },\n body: dagCborEncode({\n keys: [first, missing, second, first],\n maxValueBytes: small.length,\n }).slice(),\n });\n expect(response.status).toBe(200);\n const decoded = dagCborDecode(await response.arrayBuffer()) as {\n results: Array<{ status: number; value?: Uint8Array; etag?: string }>;\n };\n expect(decoded.results.map(({ status }) => status)).toEqual([200, 404, 413, 200]);\n expect(decoded.results[0].value).toEqual(small);\n expect(decoded.results[0].etag).toBeTypeOf(\"string\");\n expect(decoded.results[3].value).toEqual(small);\n const largeResponse = await fetch(`${storageBucketEndpoint}/values`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/cbor\" },\n body: dagCborEncode({\n keys: [second],\n maxValueBytes: large.length,\n }).slice(),\n });\n expect(largeResponse.status).toBe(200);\n const largeDecoded = dagCborDecode(await largeResponse.arrayBuffer()) as {\n results: Array<{ status: number; value?: Uint8Array }>;\n };\n expect(largeDecoded.results[0].value).toEqual(large);\n\n const oversizedResponse = await fetch(`${storageBucketEndpoint}/values`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/cbor\" },\n body: dagCborEncode({\n keys: [first, second],\n maxValueBytes: 1024 * 1024,\n }).slice(),\n });\n expect(oversizedResponse.status).toBe(400);\n\n const settled = await Promise.allSettled([\n storageBuckets.get(storageBucketEndpoint, first, small.length),\n storageBuckets.get(storageBucketEndpoint, missing, small.length),\n storageBuckets.get(storageBucketEndpoint, second, small.length),\n ]);\n expect(settled[0]).toEqual({ status: \"fulfilled\", value: small });\n expect(settled[1].status).toBe(\"rejected\");\n expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(GraffitiErrorNotFound);\n expect(settled[2].status).toBe(\"rejected\");\n expect((settled[2] as PromiseRejectedResult).reason).toBeInstanceOf(GraffitiErrorTooLarge);\n expect(await storageBuckets.get(storageBucketEndpoint, second)).toEqual(large);\n } finally {\n await storageBuckets.delete(storageBucketEndpoint, first, storageBucketToken);\n await storageBuckets.delete(storageBucketEndpoint, second, storageBucketToken);\n }\n });\n\n test(\"accepts a full batch of maximum-length Unicode keys\", async () => {\n const key = \"\u754C\".repeat(255);\n const body = dagCborEncode({\n keys: Array(32).fill(key),\n maxValueBytes: 0,\n }).slice();\n expect(body.byteLength).toBeGreaterThan(16 * 1024);\n const response = await fetch(`${storageBucketEndpoint}/values`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/cbor\" },\n body,\n });\n expect(response.status).toBe(200);\n const decoded = dagCborDecode(await response.arrayBuffer()) as {\n results: Array<{ status: number }>;\n };\n expect(decoded.results.map(({ status }) => status)).toEqual(Array(32).fill(404));\n });\n\n test(\"concurrent bounded gets share one batch\", async () => {\n const keys = Array.from({ length: 3 }, () => crypto.randomUUID());\n const values = keys.map((_, i) => new Uint8Array([i + 1]));\n for (let i = 0; i < keys.length; i++) {\n await storageBuckets.put(storageBucketEndpoint, keys[i], values[i], storageBucketToken);\n }\n const originalFetch = globalThis.fetch;\n const calls: string[] = [];\n globalThis.fetch = (input, init) => {\n const request = new Request(input, init);\n calls.push(`${request.method} ${request.url}`);\n return originalFetch(input, init);\n };\n try {\n expect(await Promise.all(keys.map((key) =>\n storageBuckets.get(storageBucketEndpoint, key, 1),\n ))).toEqual(values);\n expect(calls.filter((call) => call === `POST ${storageBucketEndpoint}/values`)).toHaveLength(1);\n expect(calls.filter((call) => call.includes(\"/value/\"))).toHaveLength(0);\n } finally {\n globalThis.fetch = originalFetch;\n for (const key of keys) {\n await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);\n }\n }\n });\n\n test(\"falls back to GET when a bucket server does not support batches\", async () => {\n const keys = Array.from({ length: 2 }, () => crypto.randomUUID());\n const value = new Uint8Array([42]);\n for (const key of keys) {\n await storageBuckets.put(storageBucketEndpoint, key, value, storageBucketToken);\n }\n const originalFetch = globalThis.fetch;\n let batchAttempts = 0;\n globalThis.fetch = (input, init) => {\n const request = new Request(input, init);\n if (request.method === \"POST\" && request.url === `${storageBucketEndpoint}/values`) {\n batchAttempts++;\n return Promise.resolve(new Response(\"Not found\", { status: 404 }));\n }\n return originalFetch(input, init);\n };\n try {\n const legacyBuckets = new StorageBuckets();\n expect(await Promise.all(keys.map((key) =>\n legacyBuckets.get(storageBucketEndpoint, key, 1),\n ))).toEqual([value, value]);\n expect(await Promise.all(keys.map((key) =>\n legacyBuckets.get(storageBucketEndpoint, key, 1),\n ))).toEqual([value, value]);\n expect(batchAttempts).toBe(1);\n } finally {\n globalThis.fetch = originalFetch;\n for (const key of keys) {\n await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);\n }\n }\n });\n\n test(\"a batch error does not disable batching or fall back to GET\", async () => {\n const originalFetch = globalThis.fetch;\n let batchAttempts = 0;\n let getAttempts = 0;\n globalThis.fetch = (input, init) => {\n const request = new Request(input, init);\n if (request.method === \"POST\") {\n batchAttempts++;\n return Promise.resolve(new Response(\"Batch limit exceeded\", { status: 413 }));\n }\n getAttempts++;\n return originalFetch(input, init);\n };\n try {\n const buckets = new StorageBuckets();\n const readPair = () => Promise.all([\n buckets.get(storageBucketEndpoint, \"a\", 1),\n buckets.get(storageBucketEndpoint, \"b\", 1),\n ]);\n await expect(readPair()).rejects.toThrow(\"Batch limit exceeded\");\n await expect(readPair()).rejects.toThrow(\"Batch limit exceeded\");\n expect(batchAttempts).toBe(2);\n expect(getAttempts).toBe(0);\n } finally {\n globalThis.fetch = originalFetch;\n }\n });\n\n test(\"unauthorized\", async () => {\n const key = Math.random().toString(36).substring(2, 15);\n const input = \"Hello world\";\n const bytes = new TextEncoder().encode(input);\n\n await expect(\n storageBuckets.put(storageBucketEndpoint, key, bytes, \"invalid-token\"),\n ).rejects.toThrow(GraffitiErrorUnauthorized);\n await expect(\n storageBuckets.delete(storageBucketEndpoint, key, \"invalid-token\"),\n ).rejects.toThrow(GraffitiErrorUnauthorized);\n await expect(\n storageBuckets.export(storageBucketEndpoint, \"invalid-token\").next(),\n ).rejects.toThrow(GraffitiErrorUnauthorized);\n });\n\n test(\"export\", async () => {\n // Put a whole bunch of stuff so the export needs to page\n const keys = new Set<string>();\n for (let i = 0; i < 256; i++) {\n const key = Math.random().toString(36).substring(2, 15);\n keys.add(key);\n\n const input = \"Hello world \" + i;\n const bytes = new TextEncoder().encode(input);\n await storageBuckets.put(\n storageBucketEndpoint,\n key,\n bytes,\n storageBucketToken,\n );\n }\n\n // Export\n const retrievedKeys = new Set<string>();\n const iterator = storageBuckets.export(\n storageBucketEndpoint,\n storageBucketToken,\n );\n for await (const result of iterator) {\n if (keys.has(result.key)) {\n retrievedKeys.add(result.key);\n }\n }\n expect(retrievedKeys.size).toEqual(keys.size);\n\n // Delete all the keys\n for (const key of keys) {\n await storageBuckets.delete(\n storageBucketEndpoint,\n key,\n storageBucketToken,\n );\n }\n }, 1000000);\n });\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,9 +4,18 @@ import {
4
4
  verifyHTTPSEndpoint
5
5
  } from "./utilities";
6
6
  import { string, array, object, optional, nullable } from "zod/mini";
7
- import { decode as dagCborDecode } from "@ipld/dag-cbor";
7
+ import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
8
+ import { GraffitiErrorNotFound, GraffitiErrorTooLarge } from "@graffiti-garden/api";
9
+ const BATCH_KEYS = 32;
10
+ const BATCH_TOTAL_VALUE_BYTES = 1024 * 1024;
11
+ const BATCH_WAIT_MS = 2;
12
+ function batchWidth(maxBytes) {
13
+ return Math.min(BATCH_KEYS, Math.floor(BATCH_TOTAL_VALUE_BYTES / maxBytes));
14
+ }
8
15
  class StorageBuckets {
9
16
  getAuthorizationEndpoint = getAuthorizationEndpoint;
17
+ queues = /* @__PURE__ */ new Map();
18
+ unsupportedBatchEndpoints = /* @__PURE__ */ new Set();
10
19
  async put(storageBucketEndpoint, key, value, authorizationToken) {
11
20
  verifyHTTPSEndpoint(storageBucketEndpoint);
12
21
  const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;
@@ -31,6 +40,88 @@ class StorageBuckets {
31
40
  }
32
41
  async get(storageBucketEndpoint, key, maxBytes) {
33
42
  verifyHTTPSEndpoint(storageBucketEndpoint);
43
+ if (maxBytes !== void 0 && Number.isSafeInteger(maxBytes) && maxBytes > 0 && batchWidth(maxBytes) >= 2 && !this.unsupportedBatchEndpoints.has(storageBucketEndpoint)) {
44
+ return new Promise((resolve, reject) => {
45
+ let queuesByMaxBytes = this.queues.get(storageBucketEndpoint);
46
+ if (!queuesByMaxBytes) {
47
+ queuesByMaxBytes = /* @__PURE__ */ new Map();
48
+ this.queues.set(storageBucketEndpoint, queuesByMaxBytes);
49
+ }
50
+ let queue = queuesByMaxBytes.get(maxBytes);
51
+ if (!queue) {
52
+ queue = { requests: [] };
53
+ queuesByMaxBytes.set(maxBytes, queue);
54
+ }
55
+ queue.requests.push({ key, resolve, reject });
56
+ if (queue.requests.length === batchWidth(maxBytes)) {
57
+ this.flush(storageBucketEndpoint, maxBytes, queue);
58
+ } else if (queue.timer === void 0) {
59
+ queue.timer = setTimeout(
60
+ () => this.flush(storageBucketEndpoint, maxBytes, queue),
61
+ BATCH_WAIT_MS
62
+ );
63
+ }
64
+ });
65
+ }
66
+ return this.getOne(storageBucketEndpoint, key, maxBytes);
67
+ }
68
+ flush(endpoint, maxBytes, queue) {
69
+ if (queue.timer !== void 0) clearTimeout(queue.timer);
70
+ const requests = queue.requests;
71
+ const queuesByMaxBytes = this.queues.get(endpoint);
72
+ queuesByMaxBytes.delete(maxBytes);
73
+ if (queuesByMaxBytes.size === 0) this.queues.delete(endpoint);
74
+ if (requests.length === 1 || this.unsupportedBatchEndpoints.has(endpoint)) {
75
+ requests.forEach(({ key, resolve, reject }) => {
76
+ void this.getOne(endpoint, key, maxBytes).then(resolve, reject);
77
+ });
78
+ } else {
79
+ void this.getBatch(endpoint, maxBytes, requests);
80
+ }
81
+ }
82
+ async getBatch(endpoint, maxBytes, requests) {
83
+ try {
84
+ const response = await fetch(`${endpoint}/values`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/cbor", Accept: "application/cbor" },
87
+ body: dagCborEncode({
88
+ keys: requests.map(({ key }) => key),
89
+ maxValueBytes: maxBytes
90
+ }).slice()
91
+ });
92
+ if (response.status === 404) {
93
+ this.unsupportedBatchEndpoints.add(endpoint);
94
+ await Promise.all(requests.map(async ({ key, resolve, reject }) => {
95
+ try {
96
+ resolve(await this.getOne(endpoint, key, maxBytes));
97
+ } catch (error) {
98
+ reject(error);
99
+ }
100
+ }));
101
+ return;
102
+ }
103
+ if (!response.ok) throw new Error(await response.text());
104
+ const decoded = dagCborDecode(await response.arrayBuffer());
105
+ if (!decoded || typeof decoded !== "object" || !("results" in decoded) || !Array.isArray(decoded.results) || decoded.results.length !== requests.length) throw new Error("Invalid storage batch response");
106
+ decoded.results.forEach((result, index) => {
107
+ const { resolve, reject } = requests[index];
108
+ if (!result || typeof result !== "object" || !("status" in result)) {
109
+ reject(new Error("Invalid storage batch result"));
110
+ } else if (result.status === 200 && "value" in result && result.value instanceof Uint8Array && result.value.byteLength <= maxBytes) {
111
+ resolve(result.value);
112
+ } else if (result.status === 404) {
113
+ reject(new GraffitiErrorNotFound("Value not found"));
114
+ } else if (result.status === 413) {
115
+ reject(new GraffitiErrorTooLarge("Value exceeds maximum byte limit"));
116
+ } else {
117
+ reject(new Error("Invalid storage batch result"));
118
+ }
119
+ });
120
+ } catch (error) {
121
+ requests.forEach(({ reject }) => reject(error));
122
+ }
123
+ }
124
+ async getOne(storageBucketEndpoint, key, maxBytes) {
34
125
  const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;
35
126
  const response = await fetchWithErrorHandling(url);
36
127
  const reader = response.body?.getReader();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/1-services/3-storage-buckets.ts"],
4
- "sourcesContent": ["import {\n fetchWithErrorHandling,\n getAuthorizationEndpoint,\n verifyHTTPSEndpoint,\n} from \"./utilities\";\nimport { string, array, object, optional, nullable } from \"zod/mini\";\nimport { decode as dagCborDecode } from \"@ipld/dag-cbor\";\n\nexport class StorageBuckets {\n getAuthorizationEndpoint = getAuthorizationEndpoint;\n\n async put(\n storageBucketEndpoint: string,\n key: string,\n value: Uint8Array,\n authorizationToken: string,\n ): Promise<void> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;\n\n await fetchWithErrorHandling(url, {\n method: \"PUT\",\n headers: {\n \"Content-Type\": \"application/octet-stream\",\n Authorization: `Bearer ${authorizationToken}`,\n },\n body: value.slice(),\n });\n }\n\n async delete(\n storageBucketEndpoint: string,\n key: string,\n authorizationToken: string,\n ): Promise<void> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;\n\n await fetchWithErrorHandling(url, {\n method: \"DELETE\",\n headers: {\n Authorization: `Bearer ${authorizationToken}`,\n },\n });\n }\n\n async get(\n storageBucketEndpoint: string,\n key: string,\n maxBytes?: number,\n ): Promise<Uint8Array> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;\n\n const response = await fetchWithErrorHandling(url);\n\n const reader = response.body?.getReader();\n if (!reader) {\n throw new Error(\"Failed to read value from storage bucket\");\n }\n\n const contentLengthHeader = response.headers.get(\"Content-Length\");\n const parsedContentLength = contentLengthHeader\n ? Number(contentLengthHeader)\n : undefined;\n\n const hasValidContentLength =\n !!parsedContentLength &&\n !!Number.isFinite(parsedContentLength) &&\n parsedContentLength >= 0;\n\n // Fast path: Content-Length exists and is valid\n if (hasValidContentLength) {\n const contentLength = parsedContentLength!;\n if (maxBytes !== undefined && contentLength > maxBytes) {\n throw new Error(\"Value exceeds maximum byte limit\");\n }\n\n const out = new Uint8Array(contentLength);\n let offset = 0;\n let completed = false;\n\n try {\n while (offset <= out.length) {\n const { done, value } = await reader.read();\n\n if (done) {\n completed = true;\n break;\n }\n if (!value || value.length === 0) continue;\n\n const nextOffset = offset + value.length;\n if (nextOffset > out.length) {\n throw new Error(\"Received more data than expected\");\n }\n\n out.set(value, offset);\n offset = nextOffset;\n }\n } finally {\n reader.releaseLock();\n }\n\n if (!completed) {\n throw new Error(\"Failed to read complete value from storage bucket\");\n }\n\n return offset === contentLength ? out : out.slice(0, offset);\n }\n\n // Fallback path: no (usable) Content-Length\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n\n if (done) break;\n if (!value || value.length === 0) continue;\n\n total += value.length;\n if (maxBytes !== undefined && total > maxBytes) {\n throw new Error(\"Value exceeds maximum byte limit\");\n }\n\n // Copy because some implementations reuse the underlying buffer\n chunks.push(value.slice());\n }\n } finally {\n reader.releaseLock();\n }\n\n // Concatenate chunks into one Uint8Array\n const out = new Uint8Array(total);\n let offset = 0;\n for (const c of chunks) {\n out.set(c, offset);\n offset += c.length;\n }\n return out;\n }\n\n async *export(\n storageBucketEndpoint: string,\n authorizationToken: string,\n ): AsyncGenerator<{ key: string }> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/export`;\n\n let cursor: string | undefined = undefined;\n while (true) {\n const response = await fetchWithErrorHandling(\n cursor ? `${url}?cursor=${encodeURIComponent(cursor)}` : url,\n {\n headers: {\n Authorization: `Bearer ${authorizationToken}`,\n },\n },\n );\n\n const blob = await response.blob();\n const cbor = dagCborDecode(await blob.arrayBuffer());\n const data = ExportSchema.parse(cbor);\n\n for (const key of data.keys) {\n yield { key };\n }\n\n if (data.cursor) {\n cursor = data.cursor;\n } else {\n break;\n }\n }\n }\n}\n\nconst ExportSchema = object({\n keys: array(string()),\n cursor: optional(nullable(string())),\n});\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import {\n fetchWithErrorHandling,\n getAuthorizationEndpoint,\n verifyHTTPSEndpoint,\n} from \"./utilities\";\nimport { string, array, object, optional, nullable } from \"zod/mini\";\nimport { encode as dagCborEncode, decode as dagCborDecode } from \"@ipld/dag-cbor\";\nimport { GraffitiErrorNotFound, GraffitiErrorTooLarge } from \"@graffiti-garden/api\";\n\n// All batch-capable bucket servers accept these transport limits.\nconst BATCH_KEYS = 32; // total number of keys in a batch request\nconst BATCH_TOTAL_VALUE_BYTES = 1024 * 1024; // total response limit\n\n// This is a heuristic for how long to wait to fill a batch request\n// before sending it. Just enough waiting to capture a few more requests,\n// but not so long that it adds noticeable latency.\nconst BATCH_WAIT_MS = 2;\n\nfunction batchWidth(maxBytes: number) {\n return Math.min(BATCH_KEYS, Math.floor(BATCH_TOTAL_VALUE_BYTES / maxBytes));\n}\n\ntype PendingGet = {\n key: string;\n resolve: (value: Uint8Array) => void;\n reject: (reason: unknown) => void;\n};\ntype BatchQueue = { requests: PendingGet[]; timer?: ReturnType<typeof setTimeout> };\n\nexport class StorageBuckets {\n getAuthorizationEndpoint = getAuthorizationEndpoint;\n private readonly queues = new Map<string, Map<number, BatchQueue>>();\n private readonly unsupportedBatchEndpoints = new Set<string>();\n\n async put(\n storageBucketEndpoint: string,\n key: string,\n value: Uint8Array,\n authorizationToken: string,\n ): Promise<void> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;\n\n await fetchWithErrorHandling(url, {\n method: \"PUT\",\n headers: {\n \"Content-Type\": \"application/octet-stream\",\n Authorization: `Bearer ${authorizationToken}`,\n },\n body: value.slice(),\n });\n }\n\n async delete(\n storageBucketEndpoint: string,\n key: string,\n authorizationToken: string,\n ): Promise<void> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;\n\n await fetchWithErrorHandling(url, {\n method: \"DELETE\",\n headers: {\n Authorization: `Bearer ${authorizationToken}`,\n },\n });\n }\n\n async get(\n storageBucketEndpoint: string,\n key: string,\n maxBytes?: number,\n ): Promise<Uint8Array> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n if (\n maxBytes !== undefined &&\n Number.isSafeInteger(maxBytes) &&\n maxBytes > 0 &&\n batchWidth(maxBytes) >= 2 &&\n !this.unsupportedBatchEndpoints.has(storageBucketEndpoint)\n ) {\n return new Promise<Uint8Array>((resolve, reject) => {\n let queuesByMaxBytes = this.queues.get(storageBucketEndpoint);\n if (!queuesByMaxBytes) {\n queuesByMaxBytes = new Map();\n this.queues.set(storageBucketEndpoint, queuesByMaxBytes);\n }\n let queue = queuesByMaxBytes.get(maxBytes);\n if (!queue) {\n queue = { requests: [] };\n queuesByMaxBytes.set(maxBytes, queue);\n }\n queue.requests.push({ key, resolve, reject });\n if (queue.requests.length === batchWidth(maxBytes)) {\n this.flush(storageBucketEndpoint, maxBytes, queue);\n } else if (queue.timer === undefined) {\n queue.timer = setTimeout(\n () => this.flush(storageBucketEndpoint, maxBytes, queue),\n BATCH_WAIT_MS,\n );\n }\n });\n }\n return this.getOne(storageBucketEndpoint, key, maxBytes);\n }\n\n private flush(endpoint: string, maxBytes: number, queue: BatchQueue) {\n if (queue.timer !== undefined) clearTimeout(queue.timer);\n const requests = queue.requests;\n const queuesByMaxBytes = this.queues.get(endpoint)!;\n queuesByMaxBytes.delete(maxBytes);\n if (queuesByMaxBytes.size === 0) this.queues.delete(endpoint);\n if (requests.length === 1 || this.unsupportedBatchEndpoints.has(endpoint)) {\n requests.forEach(({ key, resolve, reject }) => {\n void this.getOne(endpoint, key, maxBytes).then(resolve, reject);\n });\n } else {\n void this.getBatch(endpoint, maxBytes, requests);\n }\n }\n\n private async getBatch(endpoint: string, maxBytes: number, requests: PendingGet[]) {\n try {\n const response = await fetch(`${endpoint}/values`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/cbor\", Accept: \"application/cbor\" },\n body: dagCborEncode({\n keys: requests.map(({ key }) => key),\n maxValueBytes: maxBytes,\n }).slice(),\n });\n if (response.status === 404) {\n // An older bucket server has no /values route but still serves GETs.\n this.unsupportedBatchEndpoints.add(endpoint);\n await Promise.all(requests.map(async ({ key, resolve, reject }) => {\n try {\n resolve(await this.getOne(endpoint, key, maxBytes));\n } catch (error) {\n reject(error);\n }\n }));\n return;\n }\n if (!response.ok) throw new Error(await response.text());\n const decoded = dagCborDecode(await response.arrayBuffer());\n if (\n !decoded || typeof decoded !== \"object\" ||\n !(\"results\" in decoded) || !Array.isArray(decoded.results) ||\n decoded.results.length !== requests.length\n ) throw new Error(\"Invalid storage batch response\");\n\n decoded.results.forEach((result: unknown, index: number) => {\n const { resolve, reject } = requests[index];\n if (!result || typeof result !== \"object\" || !(\"status\" in result)) {\n reject(new Error(\"Invalid storage batch result\"));\n } else if (\n result.status === 200 && \"value\" in result &&\n result.value instanceof Uint8Array &&\n result.value.byteLength <= maxBytes\n ) {\n resolve(result.value);\n } else if (result.status === 404) {\n reject(new GraffitiErrorNotFound(\"Value not found\"));\n } else if (result.status === 413) {\n reject(new GraffitiErrorTooLarge(\"Value exceeds maximum byte limit\"));\n } else {\n reject(new Error(\"Invalid storage batch result\"));\n }\n });\n } catch (error) {\n requests.forEach(({ reject }) => reject(error));\n }\n }\n\n private async getOne(\n storageBucketEndpoint: string,\n key: string,\n maxBytes?: number,\n ): Promise<Uint8Array> {\n const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;\n\n const response = await fetchWithErrorHandling(url);\n\n const reader = response.body?.getReader();\n if (!reader) {\n throw new Error(\"Failed to read value from storage bucket\");\n }\n\n const contentLengthHeader = response.headers.get(\"Content-Length\");\n const parsedContentLength = contentLengthHeader\n ? Number(contentLengthHeader)\n : undefined;\n\n const hasValidContentLength =\n !!parsedContentLength &&\n !!Number.isFinite(parsedContentLength) &&\n parsedContentLength >= 0;\n\n // Fast path: Content-Length exists and is valid\n if (hasValidContentLength) {\n const contentLength = parsedContentLength!;\n if (maxBytes !== undefined && contentLength > maxBytes) {\n throw new Error(\"Value exceeds maximum byte limit\");\n }\n\n const out = new Uint8Array(contentLength);\n let offset = 0;\n let completed = false;\n\n try {\n while (offset <= out.length) {\n const { done, value } = await reader.read();\n\n if (done) {\n completed = true;\n break;\n }\n if (!value || value.length === 0) continue;\n\n const nextOffset = offset + value.length;\n if (nextOffset > out.length) {\n throw new Error(\"Received more data than expected\");\n }\n\n out.set(value, offset);\n offset = nextOffset;\n }\n } finally {\n reader.releaseLock();\n }\n\n if (!completed) {\n throw new Error(\"Failed to read complete value from storage bucket\");\n }\n\n return offset === contentLength ? out : out.slice(0, offset);\n }\n\n // Fallback path: no (usable) Content-Length\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n\n if (done) break;\n if (!value || value.length === 0) continue;\n\n total += value.length;\n if (maxBytes !== undefined && total > maxBytes) {\n throw new Error(\"Value exceeds maximum byte limit\");\n }\n\n // Copy because some implementations reuse the underlying buffer\n chunks.push(value.slice());\n }\n } finally {\n reader.releaseLock();\n }\n\n // Concatenate chunks into one Uint8Array\n const out = new Uint8Array(total);\n let offset = 0;\n for (const c of chunks) {\n out.set(c, offset);\n offset += c.length;\n }\n return out;\n }\n\n async *export(\n storageBucketEndpoint: string,\n authorizationToken: string,\n ): AsyncGenerator<{ key: string }> {\n verifyHTTPSEndpoint(storageBucketEndpoint);\n const url = `${storageBucketEndpoint}/export`;\n\n let cursor: string | undefined = undefined;\n while (true) {\n const response = await fetchWithErrorHandling(\n cursor ? `${url}?cursor=${encodeURIComponent(cursor)}` : url,\n {\n headers: {\n Authorization: `Bearer ${authorizationToken}`,\n },\n },\n );\n\n const blob = await response.blob();\n const cbor = dagCborDecode(await blob.arrayBuffer());\n const data = ExportSchema.parse(cbor);\n\n for (const key of data.keys) {\n yield { key };\n }\n\n if (data.cursor) {\n cursor = data.cursor;\n } else {\n break;\n }\n }\n }\n}\n\nconst ExportSchema = object({\n keys: array(string()),\n cursor: optional(nullable(string())),\n});\n"],
5
+ "mappings": "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",
6
6
  "names": ["out", "offset"]
7
7
  }
@@ -2,6 +2,7 @@ import { assert, describe, expect, test } from "vitest";
2
2
  import { Inboxes, LABELED_MESSAGE_LABEL_KEY } from "./4-inboxes";
3
3
  import { GraffitiErrorUnauthorized } from "./utilities";
4
4
  import { randomBytes } from "@noble/hashes/utils.js";
5
+ import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
5
6
  function inboxTests(inboxEndpoint, inboxToken) {
6
7
  describe("Inboxes", async () => {
7
8
  const inboxes = new Inboxes();
@@ -107,6 +108,25 @@ function inboxTests(inboxEndpoint, inboxToken) {
107
108
  }
108
109
  });
109
110
  }
111
+ let cursor;
112
+ for (const [page, expectedCount] of [10, 100, 101].entries()) {
113
+ const response = await fetch(
114
+ `${inboxEndpoint}/query${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
115
+ {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/cbor",
119
+ Authorization: `Bearer ${inboxToken}`
120
+ },
121
+ body: cursor ? void 0 : dagCborEncode({ tags: [tags[1]], schema: {} }).slice()
122
+ }
123
+ );
124
+ expect(response.status).toBe(200);
125
+ const pageResult = dagCborDecode(new Uint8Array(await response.arrayBuffer()));
126
+ expect(pageResult.results).toHaveLength(expectedCount);
127
+ expect(pageResult.hasMore).toBe(page < 2);
128
+ cursor = pageResult.cursor;
129
+ }
110
130
  const iterator = inboxes.query(
111
131
  inboxEndpoint,
112
132
  [randomBytes(), tags[1], randomBytes()],