@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/3-protocol/login-dialog.html.ts"],
4
- "sourcesContent": ["export const template = `<template id=\"graffiti-login-welcome\">\n <h1>\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti Log&nbsp;In</a>\n </h1>\n\n <ul>\n <li><a type=\"button\" id=\"graffiti-login-new\">Create&nbsp;new Graffiti&nbsp;identity</a></li>\n <li><button class=\"secondary\" id=\"graffiti-login-existing\">Use&nbsp;existing Graffiti&nbsp;identity</button></li>\n </ul>\n\n <aside>\n This application is built with\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti</a>.\n </aside>\n</template>\n\n<template id=\"graffiti-login-handle\">\n<h1>\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti Log&nbsp;In</a>\n</h1>\n\n <form id=\"graffiti-login-handle-form\">\n <label for=\"username\">Graffiti handle:</label>\n <input\n type=\"text\"\n name=\"username\"\n id=\"username\"\n autocomplete=\"username\"\n autocapitalize=\"none\"\n spellcheck=\"false\"\n inputmode=\"url\"\n placeholder=\"you.graffiti.actor\"\n required\n >\n <button id=\"graffiti-login-handle-submit\" type=\"submit\">\n Log In\n </button>\n </form>\n\n <p>\n Don't&nbsp;have&nbsp;a Graffiti&nbsp;handle? <a id=\"graffiti-login-new\">Create&nbsp;one</a>.\n </p>\n</template>`;\n"],
5
- "mappings": "AAAO,MAAM,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;",
4
+ "sourcesContent": ["export const template = `<template id=\"graffiti-login-welcome\">\n <h1>\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti Log&nbsp;In</a>\n </h1>\n\n <ul>\n <li><a type=\"button\" id=\"graffiti-login-new\">Create&nbsp;new Graffiti&nbsp;identity</a></li>\n <li><button class=\"secondary\" id=\"graffiti-login-existing\">Use&nbsp;existing Graffiti&nbsp;identity</button></li>\n </ul>\n\n <aside>\n This application is built with\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti</a>.\n </aside>\n</template>\n\n<template id=\"graffiti-login-handle\">\n<h1>\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti Log&nbsp;In</a>\n</h1>\n\n <form id=\"graffiti-login-handle-form\">\n <label for=\"username\">Graffiti handle:</label>\n <input\n type=\"text\"\n name=\"username\"\n id=\"username\"\n autocomplete=\"username\"\n autocapitalize=\"none\"\n spellcheck=\"false\"\n inputmode=\"url\"\n placeholder=\"you.graffiti.actor\"\n required\n >\n <button id=\"graffiti-login-handle-submit\" type=\"submit\">\n Log In\n </button>\n </form>\n\n <div style=\"display: grid; gap: 0.5em\">\n <p><a href=\"#\" id=\"graffiti-login-forgot\">Forgot your handle?</a></p>\n <p>Don't&nbsp;have&nbsp;a Graffiti&nbsp;handle? <a id=\"graffiti-login-new\">Create&nbsp;one</a>.</p>\n </div>\n</template>\n\n<template id=\"graffiti-login-provider\">\n <h1>\n <a target=\"_blank\" href=\"https://graffiti.garden\">Graffiti Log&nbsp;In</a>\n </h1>\n\n <form id=\"graffiti-login-provider-form\">\n <label for=\"graffiti-provider\">Where did you create your Graffiti account?</label>\n <input id=\"graffiti-provider\" type=\"text\" autocapitalize=\"none\" spellcheck=\"false\" required>\n <button type=\"submit\">Continue to provider</button>\n </form>\n <p><a href=\"#\" id=\"graffiti-login-provider-back\">\u2190 Back</a></p>\n</template>`;\n"],
5
+ "mappings": "AAAO,MAAM,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;",
6
6
  "names": []
7
7
  }
@@ -8,7 +8,8 @@ import { Authorization } from "./1-services/1-authorization";
8
8
  import { StorageBuckets } from "./1-services/3-storage-buckets";
9
9
  import { Inboxes } from "./1-services/4-inboxes";
10
10
  import { Sessions } from "./3-protocol/1-sessions";
11
- import { afterAll, beforeAll, describe } from "vitest";
11
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
12
+ import { decode as dagCborDecode, encode as dagCborEncode } from "@ipld/dag-cbor";
12
13
  import { Handles } from "./3-protocol/2-handles";
13
14
  import { didTests } from "./1-services/2-dids-tests";
14
15
  import { storageBucketTests } from "./1-services/3-storage-buckets-tests";
@@ -87,6 +88,94 @@ describe("GraffitiDecentralized Tests", () => {
87
88
  () => sessions[0],
88
89
  () => sessions[1]
89
90
  );
91
+ test("OAuth codes require approval from the selected account", async () => {
92
+ const account = decentralizedTestUsers[0];
93
+ const redirectUri = "https://client.example/callback";
94
+ const endpoint = "https://localhost:5173/app/oauth/authorize";
95
+ const cookie = `account_${account.accountId}=${account.token}`;
96
+ const headers = {
97
+ Cookie: cookie,
98
+ "X-Graffiti-Account": String(account.accountId),
99
+ "Content-Type": "application/json",
100
+ Origin: "https://localhost:5173"
101
+ };
102
+ const request = (scope, requestHeaders = headers) => fetch(endpoint, {
103
+ method: "POST",
104
+ headers: requestHeaders,
105
+ body: JSON.stringify({ redirect_uri: redirectUri, state: "test-state", scope })
106
+ });
107
+ const navigation = await fetch(
108
+ `${endpoint}?account=${account.accountId}&redirect_uri=${encodeURIComponent(redirectUri)}`,
109
+ { headers: { Cookie: cookie } }
110
+ );
111
+ expect(navigation.status).toBe(404);
112
+ expect((await request([account.bucketEndpoint], { Cookie: cookie })).status).toBe(401);
113
+ expect((await request([account.bucketEndpoint], {
114
+ ...headers,
115
+ Origin: "https://attacker.example"
116
+ })).status).toBe(403);
117
+ expect((await request([decentralizedTestUsers[1].bucketEndpoint])).status).toBe(403);
118
+ const approval = await request([account.bucketEndpoint]);
119
+ expect(approval.status).toBe(200);
120
+ const { redirectUri: callback } = await approval.json();
121
+ const code = new URL(callback).searchParams.get("code");
122
+ expect(code).toBeTruthy();
123
+ expect(new URL(callback).searchParams.get("state")).toBe("test-state");
124
+ const exchange = await fetch("https://localhost:5173/app/oauth/token", {
125
+ method: "POST",
126
+ body: new URLSearchParams({ code, redirect_uri: redirectUri })
127
+ });
128
+ expect(exchange.status).toBe(200);
129
+ expect((await exchange.json()).access_token).toBeTypeOf("string");
130
+ });
131
+ test("deleting a bucket removes its values and rejects requests to its old URL", async () => {
132
+ const account = decentralizedTestUsers[0];
133
+ const smallKey = crypto.randomUUID();
134
+ const largeKey = crypto.randomUUID();
135
+ const largeValue = new Uint8Array(32 * 1024 + 1).fill(7);
136
+ const authorization = { Authorization: `Bearer ${account.token}` };
137
+ for (const [key, value] of [
138
+ [smallKey, new Uint8Array([1, 2, 3])],
139
+ [largeKey, largeValue]
140
+ ]) {
141
+ const response = await fetch(`${account.bucketEndpoint}/value/${key}`, {
142
+ method: "PUT",
143
+ headers: authorization,
144
+ body: value
145
+ });
146
+ expect(response.status).toBe(201);
147
+ }
148
+ const r2Key = `${account.bucketId}/${largeKey}`;
149
+ expect(await environment.storedR2ObjectExists(r2Key)).toBe(true);
150
+ const deletion = await fetch(
151
+ `https://localhost:5173/app/service-instances/bucket/service/${account.bucketId}`,
152
+ {
153
+ method: "DELETE",
154
+ headers: {
155
+ Cookie: `account_${account.accountId}=${account.token}`,
156
+ "X-Graffiti-Account": String(account.accountId)
157
+ }
158
+ }
159
+ );
160
+ expect(deletion.status).toBe(200);
161
+ expect(await environment.storedR2ObjectExists(r2Key)).toBe(false);
162
+ for (const key of [smallKey, largeKey]) {
163
+ const response = await fetch(`${account.bucketEndpoint}/value/${key}`);
164
+ expect(response.status).toBe(404);
165
+ }
166
+ const batch = await fetch(`${account.bucketEndpoint}/values`, {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/cbor" },
169
+ body: dagCborEncode({ keys: [smallKey, largeKey], maxValueBytes: largeValue.length }).slice()
170
+ });
171
+ expect(dagCborDecode(await batch.arrayBuffer()).results).toEqual([{ status: 404 }, { status: 404 }]);
172
+ const putAfterDeletion = await fetch(`${account.bucketEndpoint}/value/${smallKey}`, {
173
+ method: "PUT",
174
+ headers: authorization,
175
+ body: new Uint8Array([4])
176
+ });
177
+ expect(putAfterDeletion.status).toBe(404);
178
+ });
90
179
  async function login(handle) {
91
180
  const actor = await handleMethods.handleToActor(handle);
92
181
  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;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gCAAgC;AACzC,SAAS,qBAAqB;AAC9B,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AACxB,SAAS,gBAAgB;AACzB,SAAS,UAAU,WAAW,gBAAgB;AAC9C,SAAS,eAAe;AACxB,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;AACnC,SAAS,kBAAkB;AAC3B,SAAS,2BAA2B;AACpC,SAAS,6BAA6B;AACtC,SAAS,+BAA+B;AACxC,SAAS,+BAA+B;AACxC,SAAS,mBAAmB;AAC5B,SAAS,2BAA2B;AACpC,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,+BAA+B,MAAM;AAC5C,QAAM,cAAc,IAAI,6BAA6B;AACrD,QAAM,UAAU,uBAAuB,IAAI,CAAC,SAAS,KAAK,MAAM;AAChE,MAAI,WAA8B,CAAC;AACnC,MAAI;AACJ,MAAI;AAEJ,YAAU,YAAY;AACpB,UAAM,YAAY,MAAM;AAExB,UAAM,OAAO,IAAI,yBAAyB;AAC1C,qBAAiB,IAAI,SAAS;AAAA,MAC5B;AAAA,MACA,eAAe,IAAI,cAAc;AAAA,MACjC,SAAS,IAAI,QAAQ;AAAA,MACrB,gBAAgB,IAAI,eAAe;AAAA,IACrC,CAAC;AACD,oBAAgB,IAAI,QAAQ,EAAE,KAAK,CAAC;AAEpC,eAAW,UAAU,SAAS;AAC5B,eAAS,KAAK,MAAM,MAAM,MAAM,CAAC;AAAA,IACnC;AAAA,EACF,CAAC;AAED,WAAS,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,WAAS;AACT;AAAA,IACE,uBAAuB,CAAC,EAAE;AAAA,IAC1B,uBAAuB,CAAC,EAAE;AAAA,EAC5B;AACA;AAAA,IACE,uBAAuB,CAAC,EAAE;AAAA,IAC1B,uBAAuB,CAAC,EAAE;AAAA,EAC5B;AAGA,sBAAoB;AACpB,wBAAsB;AACtB,0BAAwB;AACxB,0BAAwB;AAGxB,cAAY,QAAQ,CAAC,CAAC;AACtB,sBAAoB;AAEpB,QAAM,cAAc,MAClB,IAAI,sBAAsB;AAAA,IACxB,uBAAuB,CAAC,wBAAwB;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;",
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
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graffiti-garden/implementation-decentralized",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A client for a decentralized implementation of the Graffiti API",
5
5
  "types": "./dist/index.d.ts",
6
6
  "module": "./dist/esm/index.js",
@@ -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
 
6
7
  export function storageBucketTests(
7
8
  storageBucketEndpoint: string,
@@ -62,6 +63,203 @@ export function storageBucketTests(
62
63
  ).rejects.toThrow();
63
64
  });
64
65
 
66
+ test("overwrites across the small and large storage cutoff", async () => {
67
+ const key = crypto.randomUUID();
68
+ const small = new Uint8Array([1, 2, 3]);
69
+ const large = new Uint8Array(32 * 1024 + 1).fill(42);
70
+ const otherLarge = new Uint8Array(32 * 1024 + 1).fill(43);
71
+
72
+ await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);
73
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);
74
+
75
+ await storageBuckets.put(storageBucketEndpoint, key, otherLarge, storageBucketToken);
76
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(otherLarge);
77
+
78
+ await storageBuckets.put(storageBucketEndpoint, key, small, storageBucketToken);
79
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(small);
80
+
81
+ await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);
82
+ expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);
83
+
84
+ await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
85
+ await expect(storageBuckets.get(storageBucketEndpoint, key)).rejects.toThrow(
86
+ GraffitiErrorNotFound,
87
+ );
88
+ });
89
+
90
+ test("batch reads arbitrary keys from one bucket", async () => {
91
+ const first = crypto.randomUUID();
92
+ const second = crypto.randomUUID();
93
+ const missing = crypto.randomUUID();
94
+ const small = new Uint8Array([1, 2, 3]);
95
+ const large = new Uint8Array(32 * 1024 + 1).fill(7);
96
+ await storageBuckets.put(storageBucketEndpoint, first, small, storageBucketToken);
97
+ await storageBuckets.put(storageBucketEndpoint, second, large, storageBucketToken);
98
+ try {
99
+ const response = await fetch(`${storageBucketEndpoint}/values`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/cbor" },
102
+ body: dagCborEncode({
103
+ keys: [first, missing, second, first],
104
+ maxValueBytes: small.length,
105
+ }).slice(),
106
+ });
107
+ expect(response.status).toBe(200);
108
+ const decoded = dagCborDecode(await response.arrayBuffer()) as {
109
+ results: Array<{ status: number; value?: Uint8Array; etag?: string }>;
110
+ };
111
+ expect(decoded.results.map(({ status }) => status)).toEqual([200, 404, 413, 200]);
112
+ expect(decoded.results[0].value).toEqual(small);
113
+ expect(decoded.results[0].etag).toBeTypeOf("string");
114
+ expect(decoded.results[3].value).toEqual(small);
115
+ const largeResponse = await fetch(`${storageBucketEndpoint}/values`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/cbor" },
118
+ body: dagCborEncode({
119
+ keys: [second],
120
+ maxValueBytes: large.length,
121
+ }).slice(),
122
+ });
123
+ expect(largeResponse.status).toBe(200);
124
+ const largeDecoded = dagCborDecode(await largeResponse.arrayBuffer()) as {
125
+ results: Array<{ status: number; value?: Uint8Array }>;
126
+ };
127
+ expect(largeDecoded.results[0].value).toEqual(large);
128
+
129
+ const oversizedResponse = await fetch(`${storageBucketEndpoint}/values`, {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/cbor" },
132
+ body: dagCborEncode({
133
+ keys: [first, second],
134
+ maxValueBytes: 1024 * 1024,
135
+ }).slice(),
136
+ });
137
+ expect(oversizedResponse.status).toBe(400);
138
+
139
+ const settled = await Promise.allSettled([
140
+ storageBuckets.get(storageBucketEndpoint, first, small.length),
141
+ storageBuckets.get(storageBucketEndpoint, missing, small.length),
142
+ storageBuckets.get(storageBucketEndpoint, second, small.length),
143
+ ]);
144
+ expect(settled[0]).toEqual({ status: "fulfilled", value: small });
145
+ expect(settled[1].status).toBe("rejected");
146
+ expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(GraffitiErrorNotFound);
147
+ expect(settled[2].status).toBe("rejected");
148
+ expect((settled[2] as PromiseRejectedResult).reason).toBeInstanceOf(GraffitiErrorTooLarge);
149
+ expect(await storageBuckets.get(storageBucketEndpoint, second)).toEqual(large);
150
+ } finally {
151
+ await storageBuckets.delete(storageBucketEndpoint, first, storageBucketToken);
152
+ await storageBuckets.delete(storageBucketEndpoint, second, storageBucketToken);
153
+ }
154
+ });
155
+
156
+ test("accepts a full batch of maximum-length Unicode keys", async () => {
157
+ const key = "界".repeat(255);
158
+ const body = dagCborEncode({
159
+ keys: Array(32).fill(key),
160
+ maxValueBytes: 0,
161
+ }).slice();
162
+ expect(body.byteLength).toBeGreaterThan(16 * 1024);
163
+ const response = await fetch(`${storageBucketEndpoint}/values`, {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/cbor" },
166
+ body,
167
+ });
168
+ expect(response.status).toBe(200);
169
+ const decoded = dagCborDecode(await response.arrayBuffer()) as {
170
+ results: Array<{ status: number }>;
171
+ };
172
+ expect(decoded.results.map(({ status }) => status)).toEqual(Array(32).fill(404));
173
+ });
174
+
175
+ test("concurrent bounded gets share one batch", async () => {
176
+ const keys = Array.from({ length: 3 }, () => crypto.randomUUID());
177
+ const values = keys.map((_, i) => new Uint8Array([i + 1]));
178
+ for (let i = 0; i < keys.length; i++) {
179
+ await storageBuckets.put(storageBucketEndpoint, keys[i], values[i], storageBucketToken);
180
+ }
181
+ const originalFetch = globalThis.fetch;
182
+ const calls: string[] = [];
183
+ globalThis.fetch = (input, init) => {
184
+ const request = new Request(input, init);
185
+ calls.push(`${request.method} ${request.url}`);
186
+ return originalFetch(input, init);
187
+ };
188
+ try {
189
+ expect(await Promise.all(keys.map((key) =>
190
+ storageBuckets.get(storageBucketEndpoint, key, 1),
191
+ ))).toEqual(values);
192
+ expect(calls.filter((call) => call === `POST ${storageBucketEndpoint}/values`)).toHaveLength(1);
193
+ expect(calls.filter((call) => call.includes("/value/"))).toHaveLength(0);
194
+ } finally {
195
+ globalThis.fetch = originalFetch;
196
+ for (const key of keys) {
197
+ await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
198
+ }
199
+ }
200
+ });
201
+
202
+ test("falls back to GET when a bucket server does not support batches", async () => {
203
+ const keys = Array.from({ length: 2 }, () => crypto.randomUUID());
204
+ const value = new Uint8Array([42]);
205
+ for (const key of keys) {
206
+ await storageBuckets.put(storageBucketEndpoint, key, value, storageBucketToken);
207
+ }
208
+ const originalFetch = globalThis.fetch;
209
+ let batchAttempts = 0;
210
+ globalThis.fetch = (input, init) => {
211
+ const request = new Request(input, init);
212
+ if (request.method === "POST" && request.url === `${storageBucketEndpoint}/values`) {
213
+ batchAttempts++;
214
+ return Promise.resolve(new Response("Not found", { status: 404 }));
215
+ }
216
+ return originalFetch(input, init);
217
+ };
218
+ try {
219
+ const legacyBuckets = new StorageBuckets();
220
+ expect(await Promise.all(keys.map((key) =>
221
+ legacyBuckets.get(storageBucketEndpoint, key, 1),
222
+ ))).toEqual([value, value]);
223
+ expect(await Promise.all(keys.map((key) =>
224
+ legacyBuckets.get(storageBucketEndpoint, key, 1),
225
+ ))).toEqual([value, value]);
226
+ expect(batchAttempts).toBe(1);
227
+ } finally {
228
+ globalThis.fetch = originalFetch;
229
+ for (const key of keys) {
230
+ await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
231
+ }
232
+ }
233
+ });
234
+
235
+ test("a batch error does not disable batching or fall back to GET", async () => {
236
+ const originalFetch = globalThis.fetch;
237
+ let batchAttempts = 0;
238
+ let getAttempts = 0;
239
+ globalThis.fetch = (input, init) => {
240
+ const request = new Request(input, init);
241
+ if (request.method === "POST") {
242
+ batchAttempts++;
243
+ return Promise.resolve(new Response("Batch limit exceeded", { status: 413 }));
244
+ }
245
+ getAttempts++;
246
+ return originalFetch(input, init);
247
+ };
248
+ try {
249
+ const buckets = new StorageBuckets();
250
+ const readPair = () => Promise.all([
251
+ buckets.get(storageBucketEndpoint, "a", 1),
252
+ buckets.get(storageBucketEndpoint, "b", 1),
253
+ ]);
254
+ await expect(readPair()).rejects.toThrow("Batch limit exceeded");
255
+ await expect(readPair()).rejects.toThrow("Batch limit exceeded");
256
+ expect(batchAttempts).toBe(2);
257
+ expect(getAttempts).toBe(0);
258
+ } finally {
259
+ globalThis.fetch = originalFetch;
260
+ }
261
+ });
262
+
65
263
  test("unauthorized", async () => {
66
264
  const key = Math.random().toString(36).substring(2, 15);
67
265
  const input = "Hello world";
@@ -4,10 +4,33 @@ 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
+
10
+ // All batch-capable bucket servers accept these transport limits.
11
+ const BATCH_KEYS = 32; // total number of keys in a batch request
12
+ const BATCH_TOTAL_VALUE_BYTES = 1024 * 1024; // total response limit
13
+
14
+ // This is a heuristic for how long to wait to fill a batch request
15
+ // before sending it. Just enough waiting to capture a few more requests,
16
+ // but not so long that it adds noticeable latency.
17
+ const BATCH_WAIT_MS = 2;
18
+
19
+ function batchWidth(maxBytes: number) {
20
+ return Math.min(BATCH_KEYS, Math.floor(BATCH_TOTAL_VALUE_BYTES / maxBytes));
21
+ }
22
+
23
+ type PendingGet = {
24
+ key: string;
25
+ resolve: (value: Uint8Array) => void;
26
+ reject: (reason: unknown) => void;
27
+ };
28
+ type BatchQueue = { requests: PendingGet[]; timer?: ReturnType<typeof setTimeout> };
8
29
 
9
30
  export class StorageBuckets {
10
31
  getAuthorizationEndpoint = getAuthorizationEndpoint;
32
+ private readonly queues = new Map<string, Map<number, BatchQueue>>();
33
+ private readonly unsupportedBatchEndpoints = new Set<string>();
11
34
 
12
35
  async put(
13
36
  storageBucketEndpoint: string,
@@ -50,6 +73,111 @@ export class StorageBuckets {
50
73
  maxBytes?: number,
51
74
  ): Promise<Uint8Array> {
52
75
  verifyHTTPSEndpoint(storageBucketEndpoint);
76
+ if (
77
+ maxBytes !== undefined &&
78
+ Number.isSafeInteger(maxBytes) &&
79
+ maxBytes > 0 &&
80
+ batchWidth(maxBytes) >= 2 &&
81
+ !this.unsupportedBatchEndpoints.has(storageBucketEndpoint)
82
+ ) {
83
+ return new Promise<Uint8Array>((resolve, reject) => {
84
+ let queuesByMaxBytes = this.queues.get(storageBucketEndpoint);
85
+ if (!queuesByMaxBytes) {
86
+ queuesByMaxBytes = new Map();
87
+ this.queues.set(storageBucketEndpoint, queuesByMaxBytes);
88
+ }
89
+ let queue = queuesByMaxBytes.get(maxBytes);
90
+ if (!queue) {
91
+ queue = { requests: [] };
92
+ queuesByMaxBytes.set(maxBytes, queue);
93
+ }
94
+ queue.requests.push({ key, resolve, reject });
95
+ if (queue.requests.length === batchWidth(maxBytes)) {
96
+ this.flush(storageBucketEndpoint, maxBytes, queue);
97
+ } else if (queue.timer === undefined) {
98
+ queue.timer = setTimeout(
99
+ () => this.flush(storageBucketEndpoint, maxBytes, queue),
100
+ BATCH_WAIT_MS,
101
+ );
102
+ }
103
+ });
104
+ }
105
+ return this.getOne(storageBucketEndpoint, key, maxBytes);
106
+ }
107
+
108
+ private flush(endpoint: string, maxBytes: number, queue: BatchQueue) {
109
+ if (queue.timer !== undefined) clearTimeout(queue.timer);
110
+ const requests = queue.requests;
111
+ const queuesByMaxBytes = this.queues.get(endpoint)!;
112
+ queuesByMaxBytes.delete(maxBytes);
113
+ if (queuesByMaxBytes.size === 0) this.queues.delete(endpoint);
114
+ if (requests.length === 1 || this.unsupportedBatchEndpoints.has(endpoint)) {
115
+ requests.forEach(({ key, resolve, reject }) => {
116
+ void this.getOne(endpoint, key, maxBytes).then(resolve, reject);
117
+ });
118
+ } else {
119
+ void this.getBatch(endpoint, maxBytes, requests);
120
+ }
121
+ }
122
+
123
+ private async getBatch(endpoint: string, maxBytes: number, requests: PendingGet[]) {
124
+ try {
125
+ const response = await fetch(`${endpoint}/values`, {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/cbor", Accept: "application/cbor" },
128
+ body: dagCborEncode({
129
+ keys: requests.map(({ key }) => key),
130
+ maxValueBytes: maxBytes,
131
+ }).slice(),
132
+ });
133
+ if (response.status === 404) {
134
+ // An older bucket server has no /values route but still serves GETs.
135
+ this.unsupportedBatchEndpoints.add(endpoint);
136
+ await Promise.all(requests.map(async ({ key, resolve, reject }) => {
137
+ try {
138
+ resolve(await this.getOne(endpoint, key, maxBytes));
139
+ } catch (error) {
140
+ reject(error);
141
+ }
142
+ }));
143
+ return;
144
+ }
145
+ if (!response.ok) throw new Error(await response.text());
146
+ const decoded = dagCborDecode(await response.arrayBuffer());
147
+ if (
148
+ !decoded || typeof decoded !== "object" ||
149
+ !("results" in decoded) || !Array.isArray(decoded.results) ||
150
+ decoded.results.length !== requests.length
151
+ ) throw new Error("Invalid storage batch response");
152
+
153
+ decoded.results.forEach((result: unknown, index: number) => {
154
+ const { resolve, reject } = requests[index];
155
+ if (!result || typeof result !== "object" || !("status" in result)) {
156
+ reject(new Error("Invalid storage batch result"));
157
+ } else if (
158
+ result.status === 200 && "value" in result &&
159
+ result.value instanceof Uint8Array &&
160
+ result.value.byteLength <= maxBytes
161
+ ) {
162
+ resolve(result.value);
163
+ } else if (result.status === 404) {
164
+ reject(new GraffitiErrorNotFound("Value not found"));
165
+ } else if (result.status === 413) {
166
+ reject(new GraffitiErrorTooLarge("Value exceeds maximum byte limit"));
167
+ } else {
168
+ reject(new Error("Invalid storage batch result"));
169
+ }
170
+ });
171
+ } catch (error) {
172
+ requests.forEach(({ reject }) => reject(error));
173
+ }
174
+ }
175
+
176
+ private async getOne(
177
+ storageBucketEndpoint: string,
178
+ key: string,
179
+ maxBytes?: number,
180
+ ): Promise<Uint8Array> {
53
181
  const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;
54
182
 
55
183
  const response = await fetchWithErrorHandling(url);
@@ -3,6 +3,7 @@ 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
5
  import type { GraffitiObjectBase } from "@graffiti-garden/api";
6
+ import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
6
7
 
7
8
  export function inboxTests(inboxEndpoint: string, inboxToken: string) {
8
9
  describe("Inboxes", async () => {
@@ -138,6 +139,30 @@ export function inboxTests(inboxEndpoint: string, inboxToken: string) {
138
139
  });
139
140
  }
140
141
 
142
+ let cursor: string | undefined;
143
+ for (const [page, expectedCount] of [10, 100, 101].entries()) {
144
+ const response = await fetch(
145
+ `${inboxEndpoint}/query${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
146
+ {
147
+ method: "POST",
148
+ headers: {
149
+ "Content-Type": "application/cbor",
150
+ Authorization: `Bearer ${inboxToken}`,
151
+ },
152
+ body: cursor ? undefined : dagCborEncode({ tags: [tags[1]], schema: {} }).slice(),
153
+ },
154
+ );
155
+ expect(response.status).toBe(200);
156
+ const pageResult = dagCborDecode(new Uint8Array(await response.arrayBuffer())) as {
157
+ results: unknown[];
158
+ hasMore: boolean;
159
+ cursor: string;
160
+ };
161
+ expect(pageResult.results).toHaveLength(expectedCount);
162
+ expect(pageResult.hasMore).toBe(page < 2);
163
+ cursor = pageResult.cursor;
164
+ }
165
+
141
166
  const iterator = inboxes.query(
142
167
  inboxEndpoint,
143
168
  [randomBytes(), tags[1], randomBytes()],
@@ -13,5 +13,17 @@ export function handleTests(handle: string) {
13
13
  const resolvedHandle = await handles.actorToHandle(actor);
14
14
  expect(resolvedHandle).toBe(handle);
15
15
  });
16
+
17
+ test("actorToHandle uses the first did:web alias", async () => {
18
+ const actor = "did:plc:example";
19
+ const handleDid = "did:web:alice.example";
20
+ const dids = {
21
+ resolve: async (did: string) => did === actor
22
+ ? { alsoKnownAs: ["at://alice.example", handleDid, "did:web:other.example"] }
23
+ : { alsoKnownAs: [actor] },
24
+ } as DecentralizedIdentifiers;
25
+ expect(await new Handles({ dids }).actorToHandle(actor)).toBe("alice.example");
26
+ });
27
+
16
28
  });
17
29
  }
@@ -13,16 +13,14 @@ export class Handles {
13
13
  actorToHandle: Graffiti["actorToHandle"] = async (actor) => {
14
14
  const actorDocument = await this.services.dids.resolve(actor);
15
15
 
16
- const handleDid = actorDocument.alsoKnownAs?.at(0);
16
+ const handleDid = actorDocument.alsoKnownAs?.find((alias) =>
17
+ alias.startsWith(HANDLE_DID_PREFIX),
18
+ );
17
19
  if (!handleDid) {
18
20
  throw new GraffitiErrorNotFound(
19
21
  `Handle for actor DID ${actor} not found`,
20
22
  );
21
23
  }
22
- if (!handleDid.startsWith(HANDLE_DID_PREFIX)) {
23
- throw new Error(`Handle DID ${handleDid} is not a valid handle`);
24
- }
25
-
26
24
  const handle = handleDid.slice(HANDLE_DID_PREFIX.length);
27
25
 
28
26
  const handleDocument = await this.services.dids.resolve(handleDid);