@graffiti-garden/implementation-decentralized 0.1.2 → 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 (35) hide show
  1. package/dist/3-protocol/2-handles.d.ts.map +1 -1
  2. package/dist/3-protocol/4-graffiti.d.ts +1 -2
  3. package/dist/3-protocol/4-graffiti.d.ts.map +1 -1
  4. package/dist/3-protocol/login-dialog.html.d.ts +1 -1
  5. package/dist/3-protocol/login-dialog.html.d.ts.map +1 -1
  6. package/dist/browser/index.js +6 -6
  7. package/dist/browser/index.js.map +3 -3
  8. package/dist/browser/{login-dialog.html-E6VRKMXH.js → login-dialog.html-WIDO3XZF.js} +18 -4
  9. package/dist/browser/{login-dialog.html-E6VRKMXH.js.map → login-dialog.html-WIDO3XZF.js.map} +2 -2
  10. package/dist/cjs/3-protocol/2-handles-tests.js +8 -0
  11. package/dist/cjs/3-protocol/2-handles-tests.js.map +2 -2
  12. package/dist/cjs/3-protocol/2-handles.js +3 -4
  13. package/dist/cjs/3-protocol/2-handles.js.map +2 -2
  14. package/dist/cjs/3-protocol/4-graffiti.js +30 -43
  15. package/dist/cjs/3-protocol/4-graffiti.js.map +2 -2
  16. package/dist/cjs/3-protocol/login-dialog.html.js +17 -3
  17. package/dist/cjs/3-protocol/login-dialog.html.js.map +2 -2
  18. package/dist/cjs/index.spec.js +89 -0
  19. package/dist/cjs/index.spec.js.map +3 -3
  20. package/dist/esm/3-protocol/2-handles-tests.js +8 -0
  21. package/dist/esm/3-protocol/2-handles-tests.js.map +2 -2
  22. package/dist/esm/3-protocol/2-handles.js +3 -4
  23. package/dist/esm/3-protocol/2-handles.js.map +2 -2
  24. package/dist/esm/3-protocol/4-graffiti.js +30 -43
  25. package/dist/esm/3-protocol/4-graffiti.js.map +2 -2
  26. package/dist/esm/3-protocol/login-dialog.html.js +17 -3
  27. package/dist/esm/3-protocol/login-dialog.html.js.map +2 -2
  28. package/dist/esm/index.spec.js +90 -1
  29. package/dist/esm/index.spec.js.map +2 -2
  30. package/package.json +1 -1
  31. package/src/3-protocol/2-handles-tests.ts +12 -0
  32. package/src/3-protocol/2-handles.ts +3 -5
  33. package/src/3-protocol/4-graffiti.ts +43 -73
  34. package/src/3-protocol/login-dialog.html.ts +17 -3
  35. package/src/index.spec.ts +103 -1
@@ -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);
@@ -220,7 +220,7 @@ export class GraffitiDecentralized implements Graffiti {
220
220
  }
221
221
 
222
222
  if (handle) {
223
- await this.rememberHandleInPasswordManager(handle, actor);
223
+ this.rememberHandleInBrowserStorage(handle);
224
224
  }
225
225
  });
226
226
 
@@ -282,8 +282,7 @@ export class GraffitiDecentralized implements Graffiti {
282
282
  "#username",
283
283
  ) as HTMLInputElement | null;
284
284
  const rememberedHandle =
285
- proposedHandle ||
286
- (await this.getRememberedHandleFromPasswordManager());
285
+ proposedHandle || this.getRememberedHandleFromBrowserStorage();
287
286
  if (input) input.value = rememberedHandle ?? "";
288
287
  input?.addEventListener("focus", () => input?.select());
289
288
  setTimeout(() => input?.focus(), 0);
@@ -328,6 +327,12 @@ export class GraffitiDecentralized implements Graffiti {
328
327
  this.login_(handle);
329
328
  }
330
329
  });
330
+ template
331
+ ?.querySelector("#graffiti-login-forgot")
332
+ ?.addEventListener("click", (e) => {
333
+ e.preventDefault();
334
+ void this.loginWithoutHandle();
335
+ });
331
336
  } else {
332
337
  template = await this.modal?.displayTemplate("graffiti-login-welcome");
333
338
  template
@@ -396,79 +401,44 @@ export class GraffitiDecentralized implements Graffiti {
396
401
  }
397
402
  }
398
403
 
399
- protected async rememberHandleInPasswordManager(
400
- handle: string,
401
- actor: string,
402
- ) {
403
- if (typeof window === "undefined") return;
404
-
405
- const credentials = window.navigator.credentials;
406
-
407
- const PasswordCredentialConstructor = (
408
- window as unknown as {
409
- PasswordCredential?: new (data: {
410
- id: string;
411
- password: string;
412
- name?: string;
413
- }) => Credential;
414
- }
415
- ).PasswordCredential;
416
-
417
- if (!credentials?.store || !PasswordCredentialConstructor) {
418
- this.rememberHandleInBrowserStorage(handle);
419
- return;
420
- }
421
-
422
- try {
423
- const credential = new PasswordCredentialConstructor({
424
- id: handle,
425
- // Graffiti logins use external authorization; this stable placeholder
426
- // allows browsers/password managers to persist the handle.
427
- password: "no-password",
428
- name: handle,
404
+ protected async loginWithoutHandle() {
405
+ const template = await this.modal?.displayTemplate("graffiti-login-provider");
406
+ const defaultProvider = new URL("/", this.identityCreatorEndpoint);
407
+ const provider = template?.querySelector("#graffiti-provider") as HTMLInputElement | null;
408
+ if (provider) provider.value = defaultProvider.host;
409
+ provider?.addEventListener("focus", () => provider.select());
410
+ setTimeout(() => provider?.focus(), 0);
411
+
412
+ template
413
+ ?.querySelector("#graffiti-login-provider-form")
414
+ ?.addEventListener("submit", (event) => {
415
+ event.preventDefault();
416
+ try {
417
+ const domain = provider?.value.trim() ?? "";
418
+ const origin = domain === defaultProvider.host
419
+ ? defaultProvider
420
+ : new URL(`https://${domain}`);
421
+ if (
422
+ !origin.hostname || origin.username || origin.password ||
423
+ origin.pathname !== "/" || origin.search || origin.hash
424
+ ) throw new Error("Invalid provider domain");
425
+
426
+ const url = new URL("/forgot-handle", origin);
427
+ url.searchParams.set("redirect_uri", window.location.toString());
428
+ window.location.assign(url.toString());
429
+ } catch {
430
+ alert("Enter a valid provider domain.");
431
+ }
429
432
  });
430
- await credentials.store(credential);
431
- } catch (error) {
432
- console.error("Failed to remember handle in password manager:", error);
433
- this.rememberHandleInBrowserStorage(handle);
434
- }
435
- }
436
433
 
437
- protected async getRememberedHandleFromPasswordManager() {
438
- if (typeof window === "undefined") return undefined;
439
-
440
- const credentials = window.navigator.credentials;
441
- const PasswordCredentialConstructor = (
442
- window as unknown as {
443
- PasswordCredential?: new (data: {
444
- id: string;
445
- password: string;
446
- name?: string;
447
- }) => Credential;
448
- }
449
- ).PasswordCredential;
450
- if (!credentials?.get || !PasswordCredentialConstructor) {
451
- return this.getRememberedHandleFromBrowserStorage();
452
- }
453
-
454
- try {
455
- const credential = await credentials.get({
456
- password: true,
457
- mediation: "optional",
458
- } as any);
459
- if (
460
- credential &&
461
- "id" in credential &&
462
- typeof credential.id === "string"
463
- ) {
464
- return credential.id;
465
- }
466
- } catch (error) {
467
- console.error("Failed to load handle from password manager:", error);
468
- return this.getRememberedHandleFromBrowserStorage();
469
- }
434
+ template
435
+ ?.querySelector("#graffiti-login-provider-back")
436
+ ?.addEventListener("click", (event) => {
437
+ event.preventDefault();
438
+ void this.login_("");
439
+ });
470
440
 
471
- return undefined;
441
+ await this.modal?.open();
472
442
  }
473
443
 
474
444
  protected rememberHandleInBrowserStorage(handle: string) {
@@ -37,7 +37,21 @@ export const template = `<template id="graffiti-login-welcome">
37
37
  </button>
38
38
  </form>
39
39
 
40
- <p>
41
- Don't&nbsp;have&nbsp;a Graffiti&nbsp;handle? <a id="graffiti-login-new">Create&nbsp;one</a>.
42
- </p>
40
+ <div style="display: grid; gap: 0.5em">
41
+ <p><a href="#" id="graffiti-login-forgot">Forgot your handle?</a></p>
42
+ <p>Don't&nbsp;have&nbsp;a Graffiti&nbsp;handle? <a id="graffiti-login-new">Create&nbsp;one</a>.</p>
43
+ </div>
44
+ </template>
45
+
46
+ <template id="graffiti-login-provider">
47
+ <h1>
48
+ <a target="_blank" href="https://graffiti.garden">Graffiti Log&nbsp;In</a>
49
+ </h1>
50
+
51
+ <form id="graffiti-login-provider-form">
52
+ <label for="graffiti-provider">Where did you create your Graffiti account?</label>
53
+ <input id="graffiti-provider" type="text" autocapitalize="none" spellcheck="false" required>
54
+ <button type="submit">Continue to provider</button>
55
+ </form>
56
+ <p><a href="#" id="graffiti-login-provider-back">← Back</a></p>
43
57
  </template>`;
package/src/index.spec.ts CHANGED
@@ -13,7 +13,8 @@ import { Authorization } from "./1-services/1-authorization";
13
13
  import { StorageBuckets } from "./1-services/3-storage-buckets";
14
14
  import { Inboxes } from "./1-services/4-inboxes";
15
15
  import { Sessions } from "./3-protocol/1-sessions";
16
- import { afterAll, beforeAll, describe } from "vitest";
16
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
17
+ import { decode as dagCborDecode, encode as dagCborEncode } from "@ipld/dag-cbor";
17
18
  import { Handles } from "./3-protocol/2-handles";
18
19
  import { didTests } from "./1-services/2-dids-tests";
19
20
  import { storageBucketTests } from "./1-services/3-storage-buckets-tests";
@@ -106,6 +107,107 @@ describe("GraffitiDecentralized Tests", () => {
106
107
  () => sessions[1],
107
108
  );
108
109
 
110
+ test("OAuth codes require approval from the selected account", async () => {
111
+ const account = decentralizedTestUsers[0];
112
+ const redirectUri = "https://client.example/callback";
113
+ const endpoint = "https://localhost:5173/app/oauth/authorize";
114
+ const cookie = `account_${account.accountId}=${account.token}`;
115
+ const headers = {
116
+ Cookie: cookie,
117
+ "X-Graffiti-Account": String(account.accountId),
118
+ "Content-Type": "application/json",
119
+ Origin: "https://localhost:5173",
120
+ };
121
+ const request = (scope: string[], requestHeaders: HeadersInit = headers) =>
122
+ fetch(endpoint, {
123
+ method: "POST",
124
+ headers: requestHeaders,
125
+ body: JSON.stringify({ redirect_uri: redirectUri, state: "test-state", scope }),
126
+ });
127
+
128
+ const navigation = await fetch(
129
+ `${endpoint}?account=${account.accountId}&redirect_uri=${encodeURIComponent(redirectUri)}`,
130
+ { headers: { Cookie: cookie } },
131
+ );
132
+ expect(navigation.status).toBe(404);
133
+
134
+ expect((await request([account.bucketEndpoint], { Cookie: cookie })).status).toBe(401);
135
+ expect((await request([account.bucketEndpoint], {
136
+ ...headers,
137
+ Origin: "https://attacker.example",
138
+ })).status).toBe(403);
139
+ expect((await request([decentralizedTestUsers[1].bucketEndpoint])).status).toBe(403);
140
+
141
+ const approval = await request([account.bucketEndpoint]);
142
+ expect(approval.status).toBe(200);
143
+ const { redirectUri: callback } = await approval.json() as { redirectUri: string };
144
+ const code = new URL(callback).searchParams.get("code");
145
+ expect(code).toBeTruthy();
146
+ expect(new URL(callback).searchParams.get("state")).toBe("test-state");
147
+
148
+ const exchange = await fetch("https://localhost:5173/app/oauth/token", {
149
+ method: "POST",
150
+ body: new URLSearchParams({ code: code!, redirect_uri: redirectUri }),
151
+ });
152
+ expect(exchange.status).toBe(200);
153
+ expect((await exchange.json()).access_token).toBeTypeOf("string");
154
+ });
155
+
156
+ test("deleting a bucket removes its values and rejects requests to its old URL", async () => {
157
+ const account = decentralizedTestUsers[0];
158
+ const smallKey = crypto.randomUUID();
159
+ const largeKey = crypto.randomUUID();
160
+ const largeValue = new Uint8Array(32 * 1024 + 1).fill(7);
161
+ const authorization = { Authorization: `Bearer ${account.token}` };
162
+
163
+ for (const [key, value] of [
164
+ [smallKey, new Uint8Array([1, 2, 3])],
165
+ [largeKey, largeValue],
166
+ ] as const) {
167
+ const response = await fetch(`${account.bucketEndpoint}/value/${key}`, {
168
+ method: "PUT",
169
+ headers: authorization,
170
+ body: value,
171
+ });
172
+ expect(response.status).toBe(201);
173
+ }
174
+
175
+ const r2Key = `${account.bucketId}/${largeKey}`;
176
+ expect(await environment.storedR2ObjectExists(r2Key)).toBe(true);
177
+
178
+ const deletion = await fetch(
179
+ `https://localhost:5173/app/service-instances/bucket/service/${account.bucketId}`,
180
+ {
181
+ method: "DELETE",
182
+ headers: {
183
+ Cookie: `account_${account.accountId}=${account.token}`,
184
+ "X-Graffiti-Account": String(account.accountId),
185
+ },
186
+ },
187
+ );
188
+ expect(deletion.status).toBe(200);
189
+
190
+ expect(await environment.storedR2ObjectExists(r2Key)).toBe(false);
191
+ for (const key of [smallKey, largeKey]) {
192
+ const response = await fetch(`${account.bucketEndpoint}/value/${key}`);
193
+ expect(response.status).toBe(404);
194
+ }
195
+ const batch = await fetch(`${account.bucketEndpoint}/values`, {
196
+ method: "POST",
197
+ headers: { "Content-Type": "application/cbor" },
198
+ body: dagCborEncode({ keys: [smallKey, largeKey], maxValueBytes: largeValue.length }).slice(),
199
+ });
200
+ expect((dagCborDecode(await batch.arrayBuffer()) as { results: { status: number }[] }).results)
201
+ .toEqual([{ status: 404 }, { status: 404 }]);
202
+
203
+ const putAfterDeletion = await fetch(`${account.bucketEndpoint}/value/${smallKey}`, {
204
+ method: "PUT",
205
+ headers: authorization,
206
+ body: new Uint8Array([4]),
207
+ });
208
+ expect(putAfterDeletion.status).toBe(404);
209
+ });
210
+
109
211
  // How to log in/out vvv
110
212
  async function login(handle: string) {
111
213
  const actor = await handleMethods.handleToActor(handle);