astro-atmosphere-comments 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +36 -27
  3. package/components/AtmosphereComments.astro +378 -117
  4. package/components/AtmosphereReactions.astro +145 -98
  5. package/components/Composer.astro +5 -2
  6. package/components/SignInForm.astro +65 -7
  7. package/components/atmosphere.css +267 -2
  8. package/index.d.ts +11 -0
  9. package/index.mjs +53 -32
  10. package/lib/client/account.ts +661 -0
  11. package/lib/client/composer.ts +23 -10
  12. package/lib/client/subscribe.ts +106 -0
  13. package/lib/http.ts +28 -0
  14. package/lib/permissions.ts +132 -0
  15. package/lib/public-settings.ts +20 -0
  16. package/lib/quoted.ts +4 -0
  17. package/lib/settings.ts +2 -0
  18. package/lib/thread.ts +36 -6
  19. package/lib/uris.ts +39 -0
  20. package/locales/de.json +20 -4
  21. package/locales/en.json +48 -13
  22. package/locales/es.json +20 -4
  23. package/locales/fr.json +20 -4
  24. package/locales/it.json +20 -4
  25. package/locales/ja.json +20 -4
  26. package/locales/nl.json +20 -4
  27. package/locales/pt.json +20 -4
  28. package/package.json +2 -7
  29. package/routes/callback.astro +42 -0
  30. package/routes/client-metadata.json.ts +3 -2
  31. package/routes/following.ts +28 -0
  32. package/routes/identity.ts +69 -0
  33. package/routes/media.ts +7 -10
  34. package/routes/post.ts +2 -1
  35. package/routes/profile.ts +20 -0
  36. package/routes/richtext.ts +30 -0
  37. package/routes/thread.ts +23 -3
  38. package/routes/typeahead.ts +7 -23
  39. package/virtual.d.ts +5 -0
  40. package/bin/cli.mjs +0 -31
  41. package/lib/oauth.ts +0 -110
  42. package/lib/reader.ts +0 -130
  43. package/lib/store.ts +0 -46
  44. package/lib/visitor.ts +0 -42
  45. package/routes/callback.ts +0 -30
  46. package/routes/comment.ts +0 -97
  47. package/routes/delete.ts +0 -41
  48. package/routes/follow.ts +0 -71
  49. package/routes/jwks.json.ts +0 -13
  50. package/routes/login.ts +0 -58
  51. package/routes/logout.ts +0 -20
  52. package/routes/me.ts +0 -12
  53. package/routes/react.ts +0 -48
  54. package/routes/state.ts +0 -24
@@ -0,0 +1,661 @@
1
+ // The reader's sign-in, kept only in their own browser.
2
+ //
3
+ // Readers sign in with AT Protocol OAuth as a browser ("public") client: the
4
+ // tokens, and the key they only work with (which the browser makes and won't
5
+ // let even this page's own code read out), stay in this browser's storage
6
+ // (IndexedDB). Comments, likes, reposts and follows go from the browser
7
+ // straight to the reader's own account host. The site's server never sees
8
+ // a reader's sign-in, so it has no way to act for them, and signing out
9
+ // cancels the sign-in at their host and deletes it here.
10
+ //
11
+ // What the routes used to check, this checks before anything is sent: only
12
+ // the site's announcements and the comments under them can be replied to,
13
+ // liked, reposted or quoted; only a reader's own replies under them can be
14
+ // deleted; only the site's accounts can be followed; and only the
15
+ // announcement's own author can hide comments under it.
16
+ //
17
+ // The OAuth library is loaded only when someone signs in, or comes back
18
+ // already signed in (remembered by their DID in localStorage), so other
19
+ // readers download none of it.
20
+ //
21
+ // send(path, body) answers like the old server routes did (a Response with
22
+ // JSON, the same error codes), so the components treat it the same way.
23
+ import type { BrowserOAuthClient, OAuthSession } from "@atproto/oauth-client-browser";
24
+ import { safeReturnPath, withStatus } from "../http";
25
+ import {
26
+ allScopes,
27
+ callbackPath,
28
+ clientMetadata,
29
+ DEFAULT_PERMISSIONS,
30
+ grantedPermissions,
31
+ hasBase,
32
+ type Permission,
33
+ scopeFor,
34
+ scopesAllow,
35
+ } from "../permissions";
36
+ import settings from "../public-settings";
37
+ import { authorOf, inSiteThread, isPostUri, isSiteDocument, isSitePost, rkeyOf, type PostView } from "../uris";
38
+
39
+ type IdentityResolver = NonNullable<ConstructorParameters<typeof BrowserOAuthClient>[0]["identityResolver"]>;
40
+
41
+ const { base } = settings;
42
+ const REMEMBERED = "atmosphere:did";
43
+ // That the remembered sign-in asked for Standard.site's permission too.
44
+ const ASKED_SOCIAL = "atmosphere:social";
45
+ const RECOMMEND = "site.standard.graph.recommend";
46
+ const SUBSCRIPTION = "site.standard.graph.subscription";
47
+ const APPVIEW_PROXY = "did:web:api.bsky.app#bsky_appview";
48
+ const MAX_GRAPHEMES = 300; // Bluesky's limit for a post
49
+ const graphemes = new Intl.Segmenter();
50
+
51
+ // localStorage can be missing or refuse (private windows, blocked storage):
52
+ // then a sign-in lasts only until the page is left.
53
+ const remembered = () => {
54
+ try {
55
+ return localStorage.getItem(REMEMBERED) ?? "";
56
+ } catch {
57
+ return "";
58
+ }
59
+ };
60
+ const remember = (did: string) => {
61
+ try {
62
+ if (did) {
63
+ localStorage.setItem(REMEMBERED, did);
64
+ if (settings.standardSite) localStorage.setItem(ASKED_SOCIAL, "1");
65
+ } else {
66
+ localStorage.removeItem(REMEMBERED);
67
+ localStorage.removeItem(ASKED_SOCIAL);
68
+ }
69
+ } catch {
70
+ // See above.
71
+ }
72
+ };
73
+
74
+ const askedSocial = () => {
75
+ try {
76
+ return !!localStorage.getItem(ASKED_SOCIAL);
77
+ } catch {
78
+ return false;
79
+ }
80
+ };
81
+
82
+ /** Whether this browser may have a sign-in (so the library is worth loading). */
83
+ export const maybeSignedIn = () => !!remembered();
84
+
85
+ // Locally (`astro dev`) the protocol's development client is used: no
86
+ // metadata to publish, but the site must be opened at http://127.0.0.1.
87
+ const callbackUrl = () => `${import.meta.env.DEV ? location.origin : settings.site}${callbackPath(base)}`;
88
+
89
+ let clientPromise: Promise<BrowserOAuthClient> | undefined;
90
+ function client(): Promise<BrowserOAuthClient> {
91
+ clientPromise ??= (async () => {
92
+ const { atprotoLoopbackClientMetadata, buildAtprotoLoopbackClientId, BrowserOAuthClient } = await import(
93
+ "@atproto/oauth-client-browser"
94
+ );
95
+ const oauth = new BrowserOAuthClient({
96
+ clientMetadata: import.meta.env.DEV
97
+ ? atprotoLoopbackClientMetadata(buildAtprotoLoopbackClientId({ scope: allScopes(settings), redirect_uris: [callbackUrl()] }))
98
+ : clientMetadata(settings),
99
+ // Accounts are looked up through the site (routes/identity.ts), so the
100
+ // browser contacts only the reader's own account host.
101
+ identityResolver: {
102
+ async resolve(id, options) {
103
+ const response = await fetch(`${base}/identity?id=${encodeURIComponent(id)}`, { signal: options?.signal });
104
+ if (!response.ok) throw new Error(`identity ${response.status}`);
105
+ return (await response.json()) as Awaited<ReturnType<IdentityResolver["resolve"]>>;
106
+ },
107
+ },
108
+ // Signed out elsewhere (another tab), revoked, or expired.
109
+ onSessionDeleted: () => {
110
+ remember("");
111
+ sessionPromise = Promise.resolve(null);
112
+ },
113
+ });
114
+ return oauth;
115
+ })();
116
+ return clientPromise;
117
+ }
118
+
119
+ let sessionPromise: Promise<OAuthSession | null> | undefined;
120
+ function session(): Promise<OAuthSession | null> {
121
+ sessionPromise ??= (async () => {
122
+ const did = remembered();
123
+ if (!did) return null;
124
+ // A sign-in from before the site asked for Standard.site's permission:
125
+ // cancelled, so the reader signs in once and approves everything together
126
+ // instead of making a second trip to their account host later. (This looks
127
+ // at what was asked for, not what was granted, so a host that grants less
128
+ // can't send a reader round in circles.)
129
+ if (settings.standardSite && !askedSocial()) {
130
+ try {
131
+ await (await (await client()).restore(did)).signOut();
132
+ } catch {
133
+ // Already gone.
134
+ }
135
+ remember("");
136
+ return null;
137
+ }
138
+ try {
139
+ return await (await client()).restore(did);
140
+ } catch {
141
+ remember("");
142
+ return null;
143
+ }
144
+ })();
145
+ return sessionPromise;
146
+ }
147
+
148
+ type Reader = { did: string; session: OAuthSession; scopes: string[] };
149
+
150
+ /** A JSON answer, as the routes gave. */
151
+ const answer = (body: unknown, status = 200) =>
152
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
153
+
154
+ const signedOut = () => answer({ error: "signed-out" }, 401);
155
+
156
+ async function reader(): Promise<Reader | Response> {
157
+ const current = await session();
158
+ if (!current) return signedOut();
159
+ try {
160
+ const scopes = (await current.getTokenInfo(false)).scope.split(" ");
161
+ if (!hasBase(scopes)) return signedOut();
162
+ return { did: current.did, session: current, scopes };
163
+ } catch {
164
+ remember("");
165
+ return signedOut();
166
+ }
167
+ }
168
+
169
+ const allows = (reader: Reader, collection: string, action: string) => scopesAllow(reader.scopes, collection, action);
170
+ const needs = (permission: Permission) => answer({ error: "needs-permission", permission }, 403);
171
+
172
+ /** An XRPC call to the reader's own account host, as them. */
173
+ async function call<T>(reader: Reader, method: string, init?: { query?: string; body?: unknown; proxy?: boolean }): Promise<T> {
174
+ const response = await reader.session.fetchHandler(`/xrpc/${method}${init?.query ? `?${init.query}` : ""}`, {
175
+ method: init?.body === undefined ? "GET" : "POST",
176
+ headers: {
177
+ ...(init?.body !== undefined && { "content-type": "application/json" }),
178
+ ...(init?.proxy && { "atproto-proxy": APPVIEW_PROXY }),
179
+ },
180
+ body: init?.body === undefined ? undefined : JSON.stringify(init.body),
181
+ });
182
+ const data = (await response.json().catch(() => ({}))) as T & { error?: string };
183
+ if (!response.ok) throw Object.assign(new Error(`${method} ${response.status}`), { status: response.status, code: data.error });
184
+ return data;
185
+ }
186
+
187
+ /** Posts as the reader sees them (including whether they've liked or reposted them). */
188
+ async function postsAsReader(reader: Reader, uris: string[]): Promise<PostView[]> {
189
+ const views: PostView[] = [];
190
+ // Bluesky answers for at most 25 posts at a time.
191
+ for (let start = 0; start < uris.length; start += 25) {
192
+ const query = uris
193
+ .slice(start, start + 25)
194
+ .map((uri) => `uris=${encodeURIComponent(uri)}`)
195
+ .join("&");
196
+ views.push(...((await call<{ posts?: PostView[] }>(reader, "app.bsky.feed.getPosts", { query, proxy: true })).posts ?? []));
197
+ }
198
+ return views;
199
+ }
200
+
201
+ const createRecord = (reader: Reader, collection: string, record: Record<string, unknown>) =>
202
+ call<{ uri: string; cid: string }>(reader, "com.atproto.repo.createRecord", { body: { repo: reader.did, collection, record } });
203
+
204
+ /** Deletes one of the reader's own records, given its AT URI. */
205
+ async function deleteRecord(reader: Reader, uri: string) {
206
+ const [, repo, collection, rkey] = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/) ?? [];
207
+ if (repo !== reader.did || !collection || !rkey) throw new Error("not the reader's record");
208
+ await call(reader, "com.atproto.repo.deleteRecord", { body: { repo: reader.did, collection, rkey } });
209
+ }
210
+
211
+ /**
212
+ * The AT URIs of the reader's own records of `collection` whose `field` is
213
+ * `value`. Read from their account host, so nothing else learns who's asking.
214
+ */
215
+ async function ownRecords(reader: Reader, collection: string, field: string, value: string): Promise<string[]> {
216
+ const found: string[] = [];
217
+ let cursor = "";
218
+ for (let page = 0; page < 10; page++) {
219
+ const query = new URLSearchParams({ repo: reader.did, collection, limit: "100", ...(cursor && { cursor }) }).toString();
220
+ const data = await call<{ cursor?: string; records?: { uri: string; value?: Record<string, unknown> }[] }>(reader, "com.atproto.repo.listRecords", { query });
221
+ found.push(...(data.records ?? []).filter((record) => record.value?.[field] === value).map((record) => record.uri));
222
+ if (!data.cursor) break;
223
+ cursor = data.cursor;
224
+ }
225
+ return found;
226
+ }
227
+
228
+ /** Makes the reader's records say `on`: creates one if there's none, or deletes them all. */
229
+ async function setRecord(reader: Reader, collection: string, field: string, value: string, on: boolean) {
230
+ const existing = await ownRecords(reader, collection, field, value);
231
+ if (on && !existing.length) {
232
+ await createRecord(reader, collection, { $type: collection, [field]: value, createdAt: new Date().toISOString() });
233
+ } else if (!on) {
234
+ for (const uri of existing) await deleteRecord(reader, uri);
235
+ }
236
+ }
237
+
238
+ const post = (path: string, body: unknown) =>
239
+ fetch(`${base}/${path}`, {
240
+ method: "POST",
241
+ credentials: "same-origin",
242
+ headers: { "content-type": "application/json" },
243
+ body: JSON.stringify(body),
244
+ });
245
+
246
+ /** So a change shows when the page is next loaded (the site keeps threads for two minutes). */
247
+ const refreshThread = (root: string | undefined) => {
248
+ if (isSitePost(root)) void post("thread", { root }).catch(() => undefined);
249
+ };
250
+
251
+ /** Failed because the sign-in is gone (revoked, or expired): signed out. */
252
+ const failed = (error: unknown, what: string) => {
253
+ if ((error as { status?: number }).status === 401 || /^Token/.test((error as Error).name ?? "")) {
254
+ remember("");
255
+ sessionPromise = Promise.resolve(null);
256
+ return signedOut();
257
+ }
258
+ console.warn(`[atmosphere] ${what} failed:`, error instanceof Error ? error.message : error);
259
+ return answer({ error: "post-failed" }, 502);
260
+ };
261
+
262
+ type Profile = { did: string; handle: string; displayName?: string; avatar?: string };
263
+ let profilePromise: Promise<Profile> | undefined;
264
+ function profile(did: string): Promise<Profile> {
265
+ profilePromise ??= fetch(`${base}/profile?did=${encodeURIComponent(did)}`)
266
+ .then((response) => (response.ok ? (response.json() as Promise<Profile>) : { did, handle: did }))
267
+ .catch(() => ({ did, handle: did }));
268
+ return profilePromise;
269
+ }
270
+
271
+ export type Me = { signedIn: boolean; did?: string; handle?: string; displayName?: string; avatar?: string; can?: Permission[] };
272
+
273
+ /** Who is signed in on this browser, and which permissions they've given (`can`). */
274
+ export async function me(): Promise<Me> {
275
+ if (!maybeSignedIn()) return { signedIn: false };
276
+ const signedIn = await reader();
277
+ if (signedIn instanceof Response) return { signedIn: false };
278
+ return { signedIn: true, ...(await profile(signedIn.did)), can: grantedPermissions(signedIn.scopes) };
279
+ }
280
+
281
+ // A comment: a reply to the announcement (`root`), or to a comment under it
282
+ // (`parent`); or a quote post of the announcement (`quote: true`).
283
+ async function comment(body: { text?: unknown; root?: unknown; parent?: unknown; quote?: unknown }) {
284
+ const signedIn = await reader();
285
+ if (signedIn instanceof Response) return signedIn;
286
+ const text = typeof body.text === "string" ? body.text.trim() : "";
287
+ const length = [...graphemes.segment(text)].length;
288
+ if (!length) return answer({ error: "empty" }, 400);
289
+ if (length > MAX_GRAPHEMES) return answer({ error: "too-long" }, 400);
290
+ const root = body.root;
291
+ if (!isSitePost(root)) return answer({ error: "not-allowed" }, 400);
292
+ const quote = body.quote === true;
293
+ const parent = !quote && body.parent !== undefined && body.parent !== root ? body.parent : undefined;
294
+ if (parent !== undefined && !isPostUri(parent)) return answer({ error: "not-allowed" }, 400);
295
+ // Readers who signed in only to like and repost haven't allowed posting yet.
296
+ if (!allows(signedIn, "app.bsky.feed.post", "create")) return needs("post");
297
+
298
+ try {
299
+ const posts = await postsAsReader(signedIn, parent ? [root, parent] : [root]);
300
+ const rootPost = posts.find((view) => view.uri === root);
301
+ const parentPost = parent ? posts.find((view) => view.uri === parent) : rootPost;
302
+ if (!rootPost || !parentPost) return answer({ error: "not-found" }, 404);
303
+ // A comment being replied to must belong to this announcement's thread.
304
+ if (parent && parentPost.record?.reply?.root?.uri !== root) return answer({ error: "not-allowed" }, 400);
305
+
306
+ // Links, @mentions and #tags become proper links, as in the Bluesky app.
307
+ let facets: unknown[] = [];
308
+ try {
309
+ const found = await post("richtext", { text });
310
+ if (found.ok) facets = ((await found.json()) as { facets?: unknown[] }).facets ?? [];
311
+ } catch {
312
+ // Posted as plain text.
313
+ }
314
+ const record = {
315
+ $type: "app.bsky.feed.post",
316
+ text,
317
+ ...(facets.length && { facets }),
318
+ ...(quote
319
+ ? { embed: { $type: "app.bsky.embed.record", record: { uri: rootPost.uri, cid: rootPost.cid } } }
320
+ : {
321
+ reply: {
322
+ root: { uri: rootPost.uri, cid: rootPost.cid },
323
+ parent: { uri: parentPost.uri, cid: parentPost.cid },
324
+ },
325
+ }),
326
+ createdAt: new Date().toISOString(),
327
+ };
328
+ const created = await createRecord(signedIn, "app.bsky.feed.post", record);
329
+ refreshThread(root);
330
+ const author = await profile(signedIn.did);
331
+ return answer({
332
+ uri: created.uri,
333
+ url: `https://bsky.app/profile/${author.handle}/post/${created.uri.split("/").pop()}`,
334
+ text,
335
+ author,
336
+ createdAt: record.createdAt,
337
+ quote,
338
+ parent: parent ?? null,
339
+ });
340
+ } catch (error) {
341
+ return failed(error, "post");
342
+ }
343
+ }
344
+
345
+ const COLLECTIONS = { like: "app.bsky.feed.like", repost: "app.bsky.feed.repost" } as const;
346
+
347
+ // Likes or reposts a post (an announcement, or a comment under one), or takes
348
+ // that back. A like of the post a page is about also recommends that page's
349
+ // Standard.site record (`document`), and un-liking removes the recommend.
350
+ async function react(body: { post?: unknown; kind?: unknown; on?: unknown; document?: unknown }) {
351
+ const signedIn = await reader();
352
+ if (signedIn instanceof Response) return signedIn;
353
+ const { post: uri, kind, on } = body;
354
+ if (!isPostUri(uri) || (kind !== "like" && kind !== "repost") || typeof on !== "boolean") return answer({ error: "bad-request" }, 400);
355
+ if (!allows(signedIn, COLLECTIONS[kind], on ? "create" : "delete")) return needs("react");
356
+ try {
357
+ const [view] = await postsAsReader(signedIn, [uri]);
358
+ if (!view) return answer({ error: "not-found" }, 404);
359
+ if (!inSiteThread(view)) return answer({ error: "not-allowed" }, 400);
360
+ const existing = kind === "like" ? view.viewer?.like : view.viewer?.repost;
361
+ if (on && !existing) {
362
+ await createRecord(signedIn, COLLECTIONS[kind], {
363
+ $type: COLLECTIONS[kind],
364
+ subject: { uri: view.uri, cid: view.cid },
365
+ createdAt: new Date().toISOString(),
366
+ });
367
+ } else if (!on && existing) {
368
+ await deleteRecord(signedIn, existing);
369
+ }
370
+ if (kind === "like" && isSitePost(uri) && isSiteDocument(body.document) && allows(signedIn, RECOMMEND, on ? "create" : "delete")) {
371
+ // The like stands even if this fails: it's what the reader sees here.
372
+ await setRecord(signedIn, RECOMMEND, "document", body.document, on).catch((error) =>
373
+ console.warn("[atmosphere] recommend failed:", error instanceof Error ? error.message : error),
374
+ );
375
+ }
376
+ refreshThread(isSitePost(view.uri) ? view.uri : view.record?.reply?.root?.uri);
377
+ return answer({ kind, on });
378
+ } catch (error) {
379
+ return failed(error, kind);
380
+ }
381
+ }
382
+
383
+ // Deletes one of the reader's own comments: a reply of theirs somewhere under
384
+ // one of the site's announcements. Nothing else, even though the permission
385
+ // covers all of their posts.
386
+ async function remove(body: { post?: unknown }) {
387
+ const signedIn = await reader();
388
+ if (signedIn instanceof Response) return signedIn;
389
+ const uri = body.post;
390
+ if (!isPostUri(uri) || !uri.startsWith(`at://${signedIn.did}/`)) return answer({ error: "not-allowed" }, 400);
391
+ if (!allows(signedIn, "app.bsky.feed.post", "delete")) return needs("delete");
392
+ try {
393
+ const [view] = await postsAsReader(signedIn, [uri]);
394
+ if (!view) return answer({ error: "not-found" }, 404);
395
+ const root = view.record?.reply?.root?.uri;
396
+ if (!isSitePost(root)) return answer({ error: "not-allowed" }, 400);
397
+ await deleteRecord(signedIn, view.uri);
398
+ refreshThread(root);
399
+ return answer({ deleted: view.uri });
400
+ } catch (error) {
401
+ return failed(error, "delete");
402
+ }
403
+ }
404
+
405
+ // Hides a comment under one of the reader's own announcements, here and on
406
+ // Bluesky (its threadgate's hiddenReplies, as the Bluesky app does). Only
407
+ // the announcement's author can; showing it again is done in the Bluesky app.
408
+ async function hide(body: { post?: unknown; root?: unknown }) {
409
+ const signedIn = await reader();
410
+ if (signedIn instanceof Response) return signedIn;
411
+ const { post: uri, root } = body;
412
+ if (!isPostUri(uri) || !isSitePost(root) || authorOf(root) !== signedIn.did || authorOf(uri) === signedIn.did) {
413
+ return answer({ error: "not-allowed" }, 400);
414
+ }
415
+ if (!allows(signedIn, "app.bsky.feed.threadgate", "create") || !allows(signedIn, "app.bsky.feed.threadgate", "update")) {
416
+ return needs("moderate");
417
+ }
418
+ try {
419
+ const [view] = await postsAsReader(signedIn, [uri]);
420
+ if (view?.record?.reply?.root?.uri !== root) return answer({ error: "not-allowed" }, 400);
421
+ const rkey = rkeyOf(root);
422
+ let existing: { cid?: string; value?: Record<string, unknown> } = {};
423
+ try {
424
+ existing = await call(signedIn, "com.atproto.repo.getRecord", {
425
+ query: new URLSearchParams({ repo: signedIn.did, collection: "app.bsky.feed.threadgate", rkey }).toString(),
426
+ });
427
+ } catch (error) {
428
+ if ((error as { code?: string }).code !== "RecordNotFound") throw error;
429
+ }
430
+ const value = existing.value ?? {};
431
+ const hidden = new Set([...((value.hiddenReplies as string[] | undefined) ?? []), uri]);
432
+ await call(signedIn, "com.atproto.repo.putRecord", {
433
+ body: {
434
+ repo: signedIn.did,
435
+ collection: "app.bsky.feed.threadgate",
436
+ rkey,
437
+ // Who may reply (`allow`) stays as it was.
438
+ record: {
439
+ ...value,
440
+ $type: "app.bsky.feed.threadgate",
441
+ post: root,
442
+ hiddenReplies: [...hidden],
443
+ createdAt: (value.createdAt as string | undefined) ?? new Date().toISOString(),
444
+ },
445
+ ...(existing.cid && { swapRecord: existing.cid }),
446
+ },
447
+ });
448
+ refreshThread(root);
449
+ return answer({ hidden: uri });
450
+ } catch (error) {
451
+ return failed(error, "hide");
452
+ }
453
+ }
454
+
455
+ /** Whether the reader follows `subject` (one of the site's accounts): the follow record's AT URI, or "". */
456
+ async function followRecord(did: string, subject: string): Promise<string> {
457
+ const response = await fetch(`${base}/following?${new URLSearchParams({ actor: did, subject })}`);
458
+ if (!response.ok) throw new Error(`following ${response.status}`);
459
+ return ((await response.json()) as { following?: string }).following ?? "";
460
+ }
461
+
462
+ /** Whether the signed-in reader follows `subject`; null if that can't be told. */
463
+ export async function following(subject: string): Promise<boolean | null> {
464
+ const current = await session();
465
+ if (!current) return null;
466
+ try {
467
+ return !!(await followRecord(current.did, subject));
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ // Follows (or unfollows) one of the site's own accounts. Never yourself.
474
+ async function follow(body: { subject?: unknown; on?: unknown }) {
475
+ const signedIn = await reader();
476
+ if (signedIn instanceof Response) return signedIn;
477
+ const { subject, on } = body;
478
+ if (typeof subject !== "string" || !settings.allowedAuthors.includes(subject) || subject === signedIn.did || typeof on !== "boolean") {
479
+ return answer({ error: "not-allowed" }, 400);
480
+ }
481
+ if (!allows(signedIn, "app.bsky.graph.follow", on ? "create" : "delete")) return needs("follow");
482
+ try {
483
+ const existing = await followRecord(signedIn.did, subject);
484
+ if (on && !existing) {
485
+ await createRecord(signedIn, "app.bsky.graph.follow", { $type: "app.bsky.graph.follow", subject, createdAt: new Date().toISOString() });
486
+ } else if (!on && existing) {
487
+ await deleteRecord(signedIn, existing);
488
+ }
489
+ return answer({ following: on });
490
+ } catch (error) {
491
+ return failed(error, "follow");
492
+ }
493
+ }
494
+
495
+ /** Whether the site has a Standard.site publication to subscribe to. */
496
+ export const canSubscribe = () => !!settings.standardSite;
497
+
498
+ /** Whether the signed-in reader subscribes to the site's publication; null if that can't be told. */
499
+ export async function subscribed(): Promise<boolean | null> {
500
+ const publication = settings.standardSite?.publication;
501
+ const signedIn = await reader();
502
+ if (!publication || signedIn instanceof Response) return null;
503
+ try {
504
+ return !!(await ownRecords(signedIn, SUBSCRIPTION, "publication", publication)).length;
505
+ } catch {
506
+ return null;
507
+ }
508
+ }
509
+
510
+ // Subscribes to (or unsubscribes from) the site's own publication, and no other.
511
+ async function subscribe(body: { on?: unknown }) {
512
+ const signedIn = await reader();
513
+ if (signedIn instanceof Response) return signedIn;
514
+ const publication = settings.standardSite?.publication;
515
+ if (!publication || typeof body.on !== "boolean") return answer({ error: "not-allowed" }, 400);
516
+ if (!allows(signedIn, SUBSCRIPTION, body.on ? "create" : "delete")) return needs("social");
517
+ try {
518
+ await setRecord(signedIn, SUBSCRIPTION, "publication", publication, body.on);
519
+ return answer({ subscribed: body.on });
520
+ } catch (error) {
521
+ return failed(error, "subscribe");
522
+ }
523
+ }
524
+
525
+ /** Whether the reader has liked or reposted these posts (those in the site's threads). */
526
+ export async function state(uris: string[]) {
527
+ const signedIn = await reader();
528
+ if (signedIn instanceof Response) return signedIn;
529
+ try {
530
+ const views = await postsAsReader(signedIn, [...new Set(uris)].filter(isPostUri).slice(0, 100));
531
+ const posts: Record<string, { liked: boolean; reposted: boolean }> = {};
532
+ for (const view of views) {
533
+ if (inSiteThread(view)) posts[view.uri] = { liked: !!view.viewer?.like, reposted: !!view.viewer?.repost };
534
+ }
535
+ return answer({ posts });
536
+ } catch (error) {
537
+ return failed(error, "state");
538
+ }
539
+ }
540
+
541
+ /** Signs out: cancels the sign-in at the reader's account host, and deletes it from this browser. */
542
+ export async function signOut() {
543
+ const current = await session().catch(() => null);
544
+ remember("");
545
+ sessionPromise = Promise.resolve(null);
546
+ profilePromise = undefined;
547
+ try {
548
+ await current?.signOut();
549
+ } catch (error) {
550
+ console.warn("[atmosphere] sign-out:", error instanceof Error ? error.message : error);
551
+ }
552
+ return answer({ signedIn: false });
553
+ }
554
+
555
+ /** What the comments and reactions send, answered as the old routes did. */
556
+ export function send(path: string, body: unknown): Promise<Response> {
557
+ const actions: Record<string, (body: never) => Promise<Response>> = { comment, react, delete: remove, hide, follow, subscribe };
558
+ const action = actions[path];
559
+ return action ? action(body as never) : Promise.resolve(answer({ error: "bad-request" }, 400));
560
+ }
561
+
562
+ const HANDLE = /^(did:[a-z0-9:._%-]+|[a-z0-9.-]+\.[a-z0-9-]+)$/;
563
+
564
+ /**
565
+ * Sends the reader to their account host to approve everything they can do
566
+ * here (DEFAULT_PERMISSIONS, plus "social" with the `standardSite` option and
567
+ * "moderate" for the site's own accounts)
568
+ * and any `permissions` named, coming back to `returnTo`. A new approval
569
+ * replaces the old one, so whatever the same account already allowed is
570
+ * asked for again too. If the account can't be found, the reader is sent
571
+ * to `returnTo` with ?atmosphere=<problem>, as the comments expect.
572
+ */
573
+ export async function signIn(input: string, options: { permissions?: Permission[]; returnTo?: string; lang?: string } = {}) {
574
+ const returnTo = safeReturnPath(options.returnTo ?? `${location.pathname}${location.search}${location.hash}`);
575
+ const handle = input.trim().replace(/^@/, "").toLowerCase();
576
+ if (!handle || handle.length > 253 || !HANDLE.test(handle)) return location.assign(withStatus(returnTo, "invalid-handle"));
577
+ const permissions = new Set<Permission>([...DEFAULT_PERMISSIONS, ...(options.permissions ?? [])]);
578
+ if (settings.standardSite) permissions.add("social");
579
+ // The site's own accounts can also hide comments under their announcements.
580
+ try {
581
+ const did = handle.startsWith("did:")
582
+ ? handle
583
+ : (((await (await fetch(`${base}/identity?id=${encodeURIComponent(handle)}`)).json()) as { did?: string }).did ?? "");
584
+ if (settings.allowedAuthors.includes(did)) permissions.add("moderate");
585
+ } catch {
586
+ // Found again below, or reported as not found.
587
+ }
588
+ try {
589
+ const current = await session();
590
+ if (current && current.did === handle) {
591
+ for (const name of grantedPermissions((await current.getTokenInfo(false)).scope.split(" "))) permissions.add(name);
592
+ }
593
+ } catch {
594
+ // Not signed in any more: just what's asked for.
595
+ }
596
+ try {
597
+ const url = await (await client()).authorize(handle, {
598
+ scope: scopeFor(permissions),
599
+ state: JSON.stringify({ returnTo }),
600
+ ...(options.lang && { ui_locales: options.lang }),
601
+ });
602
+ location.assign(url.href);
603
+ } catch (error) {
604
+ console.warn("[atmosphere] sign-in could not start:", error instanceof Error ? error.message : error);
605
+ location.assign(withStatus(returnTo, "account-not-found"));
606
+ }
607
+ }
608
+
609
+ /** Signs in with a sign-in form (SignInForm.astro): its handle box, return address and any permission fields. */
610
+ export function handleSignInForm(form: HTMLFormElement, lang?: string) {
611
+ const button = form.querySelector<HTMLButtonElement>('button[type="submit"]');
612
+ let safetyNet: number | undefined;
613
+ // Disabled with a spinner from the moment signing in starts (a press, Enter
614
+ // or picking a suggestion), until the browser leaves for the account host.
615
+ const setBusy = (busy: boolean) => {
616
+ window.clearTimeout(safetyNet);
617
+ form.toggleAttribute("data-signing-in", busy);
618
+ if (button) button.disabled = busy;
619
+ };
620
+ form.addEventListener("submit", (event) => {
621
+ event.preventDefault();
622
+ if (form.hasAttribute("data-signing-in")) return;
623
+ const data = new FormData(form);
624
+ setBusy(true);
625
+ // Every way signIn() ends leaves the page. If that somehow doesn't happen,
626
+ // the reader can try again rather than face a spinner for ever.
627
+ safetyNet = window.setTimeout(() => setBusy(false), 30000);
628
+ void signIn(String(data.get("handle") ?? ""), {
629
+ permissions: data.getAll("permission").map(String) as Permission[],
630
+ returnTo: String(data.get("returnTo") ?? ""),
631
+ lang,
632
+ });
633
+ });
634
+ // Back from the account host with the browser's back button: the page can
635
+ // come back from its cache just as it was left.
636
+ window.addEventListener("pageshow", () => setBusy(false));
637
+ }
638
+
639
+ /** On the callback page: finishes signing in, then back to the page the reader was on. */
640
+ export async function finishSignIn() {
641
+ let returnTo = "/";
642
+ const back = (value: unknown) => {
643
+ try {
644
+ returnTo = safeReturnPath((JSON.parse(String(value)) as { returnTo?: unknown }).returnTo);
645
+ } catch {
646
+ // Back to the home page.
647
+ }
648
+ };
649
+ try {
650
+ const { session: signedIn, state: value } = await (await client()).initCallback(undefined, callbackUrl() as `https://${string}`);
651
+ remember(signedIn.did);
652
+ back(value);
653
+ location.replace(withStatus(returnTo, "signed-in"));
654
+ } catch (error) {
655
+ // The reader declined, took too long, or something went wrong. The
656
+ // library's error still carries our state, so they can go back.
657
+ back((error as { state?: unknown }).state);
658
+ console.warn("[atmosphere] sign-in failed:", error instanceof Error ? error.message : error);
659
+ location.replace(withStatus(returnTo, "sign-in-failed"));
660
+ }
661
+ }