@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.
- package/dist/3-protocol/2-handles.d.ts.map +1 -1
- package/dist/3-protocol/4-graffiti.d.ts +1 -2
- package/dist/3-protocol/4-graffiti.d.ts.map +1 -1
- package/dist/3-protocol/login-dialog.html.d.ts +1 -1
- package/dist/3-protocol/login-dialog.html.d.ts.map +1 -1
- package/dist/browser/index.js +6 -6
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/{login-dialog.html-E6VRKMXH.js → login-dialog.html-WIDO3XZF.js} +18 -4
- package/dist/browser/{login-dialog.html-E6VRKMXH.js.map → login-dialog.html-WIDO3XZF.js.map} +2 -2
- package/dist/cjs/3-protocol/2-handles-tests.js +8 -0
- package/dist/cjs/3-protocol/2-handles-tests.js.map +2 -2
- package/dist/cjs/3-protocol/2-handles.js +3 -4
- package/dist/cjs/3-protocol/2-handles.js.map +2 -2
- package/dist/cjs/3-protocol/4-graffiti.js +30 -43
- package/dist/cjs/3-protocol/4-graffiti.js.map +2 -2
- package/dist/cjs/3-protocol/login-dialog.html.js +17 -3
- package/dist/cjs/3-protocol/login-dialog.html.js.map +2 -2
- package/dist/cjs/index.spec.js +89 -0
- package/dist/cjs/index.spec.js.map +3 -3
- package/dist/esm/3-protocol/2-handles-tests.js +8 -0
- package/dist/esm/3-protocol/2-handles-tests.js.map +2 -2
- package/dist/esm/3-protocol/2-handles.js +3 -4
- package/dist/esm/3-protocol/2-handles.js.map +2 -2
- package/dist/esm/3-protocol/4-graffiti.js +30 -43
- package/dist/esm/3-protocol/4-graffiti.js.map +2 -2
- package/dist/esm/3-protocol/login-dialog.html.js +17 -3
- package/dist/esm/3-protocol/login-dialog.html.js.map +2 -2
- package/dist/esm/index.spec.js +90 -1
- package/dist/esm/index.spec.js.map +2 -2
- package/package.json +1 -1
- package/src/3-protocol/2-handles-tests.ts +12 -0
- package/src/3-protocol/2-handles.ts +3 -5
- package/src/3-protocol/4-graffiti.ts +43 -73
- package/src/3-protocol/login-dialog.html.ts +17 -3
- 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?.
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
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 have a Graffiti handle? <a id="graffiti-login-new">Create 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 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);
|