astro-atmosphere-comments 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (4 November 2026)
4
+
5
+ - The sign-in form is shown straight away under each post: a handle box and a **Sign in with Atmosphere** button, both with the Atmosphere logo. The "Respond with your Atmosphere Account" button that opened it is gone.
6
+ - `label` is now the form's heading (default "Sign in to leave a comment, like or repost"). If you set it for the old button's small text, change or remove it.
7
+ - As readers type in the handle box, **matching accounts are suggested** with their avatar, name and handle, so they can see they've found the right one; picking one signs in with it. Enter still signs in with exactly what was typed.
8
+ - Under the sign-in form, **"Rather not sign in here? Reply on Bluesky instead →"** links to the announcement, for readers who'd rather reply in the Bluesky app (`blueskyLink={false}` hides it). If your page already says this in the `after-box` slot, remove one.
9
+ - Readers can **delete their own comments**: a Delete button under each of them, with a confirmation. Signing in no longer asks for permission to delete posts; the first delete asks the reader's account host for it (one more approval), then finishes. The server only ever deletes the reader's own replies under your announcements. Fires `atmosphere:deleted` (`detail`: `uri`).
10
+ - The site's OAuth client metadata moved to `/oauth-client-metadata.json` at the site's root (was `/atmosphere/client-metadata.json`), so Bluesky's approval page names the site by its address ("saltedlolly.com") instead of showing a file address. **Readers already signed in will need to sign in once more**, because their sign-in belonged to the old address. If your site already serves a file at that address, the two will clash.
11
+ - The handle box keeps `name="handle"` (the name most atproto apps use, so browsers can suggest handles typed elsewhere) and asks password managers to leave it alone: it isn't a login for your site, so they offered the wrong one. This follows the community's emerging convention and may change (see AGENTS.md).
12
+ - `--atmo-danger` sets the colour of the confirming Delete button.
13
+ - Pressing Reply, Like or Repost on a comment while signed out moves to the form and puts the cursor in the handle box.
14
+
15
+ ## 0.1.1 (3 November 2026)
16
+
17
+ - The credit link now goes to a permanent address, saltedlolly.com/atmosphere-comments-for-astro/.
18
+
3
19
  ## 0.1.0 (3 November 2026)
4
20
 
5
21
  First release, as used on [saltedlolly.com](https://saltedlolly.com).
package/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  Bluesky comments for your Astro site, without the reader leaving the page. (On npm: [`astro-atmosphere-comments`](https://www.npmjs.com/package/astro-atmosphere-comments).)
4
4
 
5
- Readers press **Respond with your Atmosphere Account**, sign in with their Bluesky handle (or any other [Atmosphere account](https://atmosphereaccount.com)), and can then:
5
+ Under each post, readers type their Bluesky handle (or that of any other [Atmosphere account](https://atmosphereaccount.com)) into the **Sign in with Atmosphere** form, which suggests matching accounts with their avatars as they type, and can then:
6
6
 
7
7
  - **comment** on your post, and **reply** to other comments;
8
8
  - **like** and **repost** your post, or any comment under it;
9
9
  - **quote** your post on their own profile, with a Bluesky-style preview;
10
- - **@mention** people, with suggestions as they type.
10
+ - **@mention** people, with suggestions as they type;
11
+ - **delete** their own comments.
11
12
 
12
13
  Everything they post is written to **their own account**, as ordinary Bluesky posts, likes and reposts: comments are replies to your post's announcement on Bluesky. They can delete them in any Bluesky app, and people who prefer the app can reply there instead; it all shows up on your page the same way.
13
14
 
@@ -18,11 +19,13 @@ See it on [saltedlolly.com](https://saltedlolly.com/2005/07/13/summer-jam-party-
18
19
  ## How it works
19
20
 
20
21
  1. You announce each post on Bluesky (a post linking to it). That post's thread is the comment section.
21
- 2. A reader signs in with **AT Protocol OAuth, straight to their own account host** (Bluesky's, for most people). There is no third-party login service in between, and your site never sees a password.
22
- 3. Your site asks only for what commenting needs: to create and delete posts, likes and reposts, and to see which posts the reader has already liked or reposted. Readers can revoke it at any time in their Bluesky settings.
22
+ 2. A reader signs in with **AT Protocol OAuth, straight to their own account host** (Bluesky's, for most people), which asks them to approve your site, named by its address (example.com). There is no third-party login service in between, and your site never sees a password.
23
+ 3. Your site asks only for what commenting needs: to create posts, to create and delete likes and reposts, and to see which posts the reader has already liked or reposted. Permission to delete posts is asked for separately, only the first time a reader deletes one of their comments on your site, and the site only ever uses it on their own comments under your posts. Readers can revoke it all at any time in their Bluesky settings.
23
24
  4. Your server keeps each reader's sign-in (a random cookie in their browser; their tokens on your server), and posts on their behalf when they press a button.
24
25
 
25
- Images (avatars, link-card pictures) and @mention searches go through your server, so readers' browsers never contact Bluesky directly.
26
+ Readers who'd rather not sign in can use the **Reply on Bluesky instead** link under the form.
27
+
28
+ Images (avatars, link-card pictures) and account searches (the suggestions under the sign-in box and for @mentions) go through your server, so readers' browsers never contact Bluesky directly.
26
29
 
27
30
  ## Requirements
28
31
 
@@ -67,7 +70,7 @@ To find an account's DID: `https://bsky.social/xrpc/com.atproto.identity.resolve
67
70
  npx astro-atmosphere-comments keygen
68
71
  ```
69
72
 
70
- This adds `ATMOSPHERE_PRIVATE_JWK` to your `.env` without printing it. Your site signs its requests to readers' account hosts with it; the public half is published at `/atmosphere/jwks.json`. Set the same variable on your server, keep `.env` out of Git, and keep a backup: **a new key signs every reader out.**
73
+ This adds `ATMOSPHERE_PRIVATE_JWK` to your `.env` without printing it. Your site signs its requests to readers' account hosts with it; the public half is published at `/atmosphere/jwks.json`, and the site's details for account hosts (its name, logo and permissions) at `/oauth-client-metadata.json`. That second file has to be at your site's root: it's the conventional address, and only then does Bluesky's approval page name your site by its address alone. Set the same variable on your server, keep `.env` out of Git, and keep a backup: **a new key signs every reader out.**
71
74
 
72
75
  On the server, also set `ATMOSPHERE_DATA_DIR` to a folder **outside your deploy folder** (for example `/var/lib/example-com/atmosphere`), so deploys don't sign readers out. It defaults to `.data/atmosphere` in the project.
73
76
 
@@ -97,7 +100,7 @@ Locally the integration uses the protocol's development mode, so no key is neede
97
100
 
98
101
  | Option | Required | What it does |
99
102
  | --- | --- | --- |
100
- | `clientName` | yes | Your site's name on the reader's sign-in screen. |
103
+ | `clientName` | yes | Your site's name, given to readers' account hosts. (Bluesky's approval page shows your site's address instead, unless Bluesky has marked your site as trusted.) |
101
104
  | `allowedAuthors` | yes | DIDs of the accounts whose posts can be commented on through your site. Comments from these accounts get the Author badge. |
102
105
  | `logoUri` | | A square logo for the sign-in screen (absolute `https://` address). |
103
106
  | `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. |
@@ -109,13 +112,14 @@ Locally the integration uses the protocol's development mode, so no key is neede
109
112
  | Prop | What it does |
110
113
  | --- | --- |
111
114
  | `post` | The announcement: bsky.app address or AT URI. Optional with Standard.site (above). |
112
- | `label` | The small words on the button above "Atmosphere Account". Default "Respond with your". |
115
+ | `label` | The sign-in form's heading. Default "Sign in to leave a comment, like or repost". |
113
116
  | `authorLabel` | The badge on your own comments. Default "Author". |
117
+ | `blueskyLink` | "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form, linking to the announcement (with `u-syndication`, so microformats readers learn it's the post's copy on Bluesky). Default `true`; `blueskyLink={false}` hides it. |
114
118
  | `credit` | A small "Powered by Atmosphere Comments for Astro" link at the end. Default `true`; set `credit={false}` to hide it. |
115
119
 
116
120
  **Slots:** `after-box` goes between the comment box and the comments (a note about other ways to respond, say); the default slot goes after the comments (other responses, older comments).
117
121
 
118
- **Layout, top to bottom:** likes and reposts, the button (or, signed in, Like, Repost, Quote and the comment box), your `after-box` content, the comments, your default slot content.
122
+ **Layout, top to bottom:** likes and reposts, the sign-in form and the "Reply on Bluesky instead" line (or, signed in, Like, Repost, Quote and the comment box), your `after-box` content, the comments, your default slot content.
119
123
 
120
124
  ## Styling
121
125
 
@@ -130,8 +134,9 @@ It has a plain default look and takes your text colour. Colours come from CSS cu
130
134
  --atmo-border: #ddd;
131
135
  --atmo-field-bg: #f6f6f6; /* text boxes */
132
136
  --atmo-card-bg: #fafafa; /* comments */
133
- --atmo-menu-bg: #fff; /* @mention suggestions */
137
+ --atmo-menu-bg: #fff; /* account and @mention suggestions */
134
138
  --atmo-ring: #fff; /* the outline round faces */
139
+ --atmo-danger: #c0392b; /* Delete, when confirming */
135
140
  --atmo-radius: 10px;
136
141
  }
137
142
  ```
@@ -144,11 +149,12 @@ The component fires (bubbling):
144
149
 
145
150
  - `atmosphere:commented` after a comment, reply or quote is posted (`detail`: `uri`, `url`, `text`, `author`, `createdAt`, `quote`, `parent`);
146
151
  - `atmosphere:reacted` after a like or repost of the post, or undoing one (`detail`: `kind`, `on`, `author`).
152
+ - `atmosphere:deleted` after a reader deletes one of their comments (`detail`: `uri`).
147
153
 
148
154
  ## Security and privacy
149
155
 
150
- - **What your server stores:** for each signed-in reader, their DID and their OAuth tokens (which can only post, like and repost as described above), plus a random cookie value. Files are readable only by your site's user. Mention this in your privacy policy.
151
- - **What it refuses:** requests that change something must come from your own pages (checked by `Origin`); only your announcements and comments in their threads can be acted on; comments, likes and searches are rate-limited per account; the image route only passes on avatars and link pictures it has just shown.
156
+ - **What your server stores:** for each signed-in reader, their DID and their OAuth tokens (which can only post, like and repost as described above, and delete posts if the reader allowed it), plus a random cookie value. Files are readable only by your site's user. Mention this in your privacy policy.
157
+ - **What it refuses:** requests that change something must come from your own pages (checked by `Origin`); only your announcements and comments in their threads can be acted on; only a reader's own replies under your announcements can be deleted; comments, likes and searches are rate-limited per account, and searches by readers who aren't signed in per address and overall; the image route only passes on avatars and link pictures it has just shown.
152
158
  - **Moderation:** comments appear automatically. Hide one in the Bluesky app (it disappears here too), block the account, or add it to `hidden`.
153
159
  - Comments are plain text, as on Bluesky: links, @mentions and #hashtags become links.
154
160
 
@@ -1,19 +1,22 @@
1
1
  ---
2
- // The comment box under a post: "Respond with your Atmosphere Account", then,
3
- // once signed in, Like, Repost and Quote buttons and a box to write a
2
+ // The comment box under a post: a form to sign in with an Atmosphere account
3
+ // (Bluesky or any other), then, once signed in, Like, Repost and Quote buttons and a box to write a
4
4
  // comment (a reply to the post's announcement, or to one of its comments).
5
5
  //
6
6
  // Props:
7
7
  // post the announcement's AT URI (at://did/app.bsky.feed.post/rkey).
8
- // label the small words on the button above "Atmosphere Account"
9
- // (default "Respond with your").
8
+ // label the sign-in form's heading
9
+ // (default "Sign in to leave a comment, like or repost").
10
+ // blueskyLink "Rather not sign in here? Reply on Bluesky instead →" under the
11
+ // sign-in form (default true; false hides it).
10
12
  // credit a small "Powered by Atmosphere Comments for Astro" link under the
11
13
  // comments (default true; false hides it).
12
14
  //
13
15
  // Quote shows a preview of the reader's post with the announcement quoted
14
16
  // inside, avatars and link-card picture included (passed on by this site).
15
17
  //
16
- // Typing @ and the start of a handle suggests accounts to mention.
18
+ // Typing @ and the start of a handle suggests accounts to mention, and typing
19
+ // in the sign-in box suggests matching accounts to sign in with.
17
20
  //
18
21
  // Comments: under every element on the page with a `data-atmosphere-uri`
19
22
  // attribute (a comment's AT URI) the component adds Reply, Like and Repost.
@@ -38,15 +41,32 @@ interface Props {
38
41
  * is used to find it.
39
42
  */
40
43
  post?: string;
44
+ /** The sign-in form's heading. */
41
45
  label?: string;
42
46
  /** The badge on comments by the site's own accounts. */
43
47
  authorLabel?: string;
44
48
  /** A small "Powered by Atmosphere Comments for Astro" link under the comments. Default true. */
45
49
  credit?: boolean;
50
+ /** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
51
+ blueskyLink?: boolean;
46
52
  }
47
53
 
48
- const { post = "", label = "Respond with your", authorLabel = "Author", credit = true } = Astro.props;
49
- const CREDIT_URL = "https://saltedlolly.com/2026/11/03/comment-like-and-repost-with-your-bluesky-account/";
54
+ const {
55
+ post = "",
56
+ label = "Sign in to leave a comment, like or repost",
57
+ authorLabel = "Author",
58
+ credit = true,
59
+ blueskyLink = true,
60
+ } = Astro.props;
61
+ // The announcement on bsky.app, when the page is given it (otherwise the
62
+ // script fills it in once the thread has loaded).
63
+ const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
64
+ const blueskyUrl = atUri
65
+ ? `https://bsky.app/profile/${atUri[1]}/post/${atUri[2]}`
66
+ : /^https:\/\/bsky\.app\/profile\/[^/]+\/post\/[^/]+$/.test(post)
67
+ ? post
68
+ : "";
69
+ const CREDIT_URL = "https://saltedlolly.com/atmosphere-comments-for-astro/";
50
70
  const { base } = settings;
51
71
  const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
52
72
  ---
@@ -55,30 +75,33 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
55
75
  <div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
56
76
 
57
77
  <div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
58
- <button type="button" class="atmo__badge-button" data-open-login aria-expanded="false" aria-controls="atmo-login" hidden>
59
- <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
60
- <span class="atmo__badge-text">
61
- <span class="atmo__badge-small">{label}</span>
62
- <span class="atmo__badge-big">Atmosphere Account</span>
63
- </span>
64
- </button>
65
78
  <form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
66
- <label for="atmo-handle">Sign in to leave a comment, like or repost</label>
79
+ <label for="atmo-handle">{label}</label>
67
80
  <p class="atmo__hint">Any <a href="https://atmosphereaccount.com">Atmosphere account</a> works, including <a href="https://bsky.app">Bluesky</a>: one account for a whole network of open apps.</p>
68
81
  <div class="atmo__row">
69
- <input
82
+ <span class="atmo__handle">
83
+ <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
84
+ <input
70
85
  id="atmo-handle"
71
86
  name="handle"
72
87
  type="text"
73
88
  inputmode="url"
74
- autocomplete="username"
89
+ data-1p-ignore
90
+ data-lpignore="true"
91
+ data-bwignore
92
+ data-form-type="other"
75
93
  autocapitalize="none"
76
94
  spellcheck="false"
77
95
  placeholder="alice.bsky.social"
78
96
  required
79
- />
97
+ />
98
+ <ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label="Matching accounts" data-handle-suggestions hidden></ul>
99
+ </span>
80
100
  <input type="hidden" name="returnTo" value={returnTo} />
81
- <button type="submit" class="atmo__button">Continue</button>
101
+ <button type="submit" class="atmo__button atmo__button--sign-in">
102
+ <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
103
+ Sign in with Atmosphere
104
+ </button>
82
105
  </div>
83
106
  <p class="atmo__hint">
84
107
  You'll approve this on your account's own site (Bluesky's, for most people); your password
@@ -86,6 +109,14 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
86
109
  No account yet? <a href="https://atmosphereaccount.com">Create a free Atmosphere account</a>.
87
110
  </p>
88
111
  </form>
112
+ {blueskyLink && (
113
+ <p class="atmo__elsewhere" data-elsewhere hidden={!blueskyUrl}>
114
+ Rather not sign in here?{" "}
115
+ {/* u-syndication: microformats readers learn this is the post's copy on Bluesky. */}
116
+ <a class="atmo__elsewhere-link u-syndication" href={blueskyUrl || undefined} data-elsewhere-link>Reply on Bluesky instead →</a>{" "}
117
+ It shows up here just the same, as do likes and reposts.
118
+ </p>
119
+ )}
89
120
  </div>
90
121
 
91
122
  <div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
@@ -185,6 +216,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
185
216
  "account-not-found": "We couldn't find that account. Check the handle and try again.",
186
217
  "invalid-handle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
187
218
  "signed-out": "You've been signed out. Please sign in again.",
219
+ "needs-permission": "To delete comments here, this site needs one more permission from your account.",
220
+ "not-allowed": "That can't be done here.",
188
221
  "new-permissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
189
222
  "too-long": `Posts can be up to ${MAX} characters.`,
190
223
  "too-many": "You're going quickly. Please wait a minute and try again.",
@@ -206,7 +239,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
206
239
  const composer = $<HTMLFormElement>("[data-composer]");
207
240
  // A clean copy of the comment box, for reply boxes under comments.
208
241
  const template = composer.cloneNode(true) as HTMLFormElement;
209
- const loginButton = $<HTMLButtonElement>("[data-open-login]");
210
242
  const loginForm = $<HTMLFormElement>("[data-login-form]");
211
243
  const status = $<HTMLElement>("[data-status]");
212
244
  let isSignedIn = false;
@@ -245,7 +277,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
245
277
  likeCount: number;
246
278
  repostCount: number;
247
279
  };
248
- type Thread = { uri: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
280
+ type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
249
281
  let thread: Thread | null = null;
250
282
  const reactionsBox = $<HTMLElement>("[data-reactions]");
251
283
  const repliesList = $<HTMLOListElement>("[data-replies]");
@@ -359,6 +391,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
359
391
  }
360
392
  thread = data.thread;
361
393
  post = thread.uri;
394
+ const elsewhere = this.querySelector<HTMLElement>("[data-elsewhere]");
395
+ if (elsewhere && thread.url) {
396
+ elsewhere.querySelector("a")!.href = thread.url;
397
+ elsewhere.hidden = false;
398
+ }
362
399
  renderReactions();
363
400
  repliesList.replaceChildren(...thread.replies.map(replyElement));
364
401
  } catch {
@@ -404,19 +441,100 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
404
441
  renderReactions();
405
442
  };
406
443
 
407
- // With JavaScript, the handle form opens from the Atmosphere button.
408
- loginButton.hidden = false;
409
- loginForm.hidden = true;
410
- const openLogin = () => {
411
- loginForm.hidden = false;
412
- loginButton.setAttribute("aria-expanded", "true");
413
- loginForm.querySelector("input")?.focus();
414
- };
415
- loginButton.addEventListener("click", () => {
416
- if (loginForm.hidden) return openLogin();
417
- loginForm.hidden = true;
418
- loginButton.setAttribute("aria-expanded", "false");
419
- });
444
+ const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
445
+
446
+ // Matching accounts under the handle box as the reader types, with their
447
+ // avatars, so they can see they've found the right one. Picking one signs
448
+ // in with it. Without JavaScript the box works as a plain text box.
449
+ {
450
+ const input = loginForm.querySelector<HTMLInputElement>("#atmo-handle")!;
451
+ const list = loginForm.querySelector<HTMLUListElement>("[data-handle-suggestions]")!;
452
+ let found: Suggestion[] = [];
453
+ let active = -1;
454
+ let latest = "";
455
+ let timer: number | undefined;
456
+ input.setAttribute("role", "combobox");
457
+ input.setAttribute("aria-autocomplete", "list");
458
+ input.setAttribute("aria-controls", list.id);
459
+ input.setAttribute("aria-expanded", "false");
460
+
461
+ const close = () => {
462
+ list.hidden = true;
463
+ list.replaceChildren();
464
+ found = [];
465
+ active = -1;
466
+ input.setAttribute("aria-expanded", "false");
467
+ input.removeAttribute("aria-activedescendant");
468
+ };
469
+ const highlight = (index: number) => {
470
+ active = index;
471
+ list.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
472
+ if (index >= 0) input.setAttribute("aria-activedescendant", `${list.id}-option-${index}`);
473
+ else input.removeAttribute("aria-activedescendant");
474
+ };
475
+ const choose = (index: number) => {
476
+ const option = found[index];
477
+ if (!option) return;
478
+ input.value = option.handle;
479
+ close();
480
+ loginForm.requestSubmit();
481
+ };
482
+ const show = (actors: Suggestion[]) => {
483
+ found = actors.slice(0, 5);
484
+ if (!found.length || document.activeElement !== input) return close();
485
+ list.replaceChildren(
486
+ ...found.map((option, index) => {
487
+ const item = make("li");
488
+ item.id = `${list.id}-option-${index}`;
489
+ item.setAttribute("role", "option");
490
+ const avatar = make("span", "atmo__avatar atmo__avatar--small");
491
+ avatar.setAttribute("aria-hidden", "true");
492
+ setAvatar(avatar, option.displayName || option.handle, option.avatar);
493
+ item.append(avatar, make("strong", "", option.displayName || option.handle), make("span", "", `@${option.handle}`));
494
+ // mousedown keeps the focus in the handle box.
495
+ item.addEventListener("mousedown", (event) => event.preventDefault());
496
+ item.addEventListener("click", () => choose(index));
497
+ return item;
498
+ }),
499
+ );
500
+ list.hidden = false;
501
+ input.setAttribute("aria-expanded", "true");
502
+ // Nothing is picked until the reader chooses, so Enter still signs in
503
+ // with exactly what they typed.
504
+ highlight(-1);
505
+ };
506
+
507
+ input.addEventListener("input", () => {
508
+ window.clearTimeout(timer);
509
+ const query = input.value.trim().replace(/^@/, "");
510
+ latest = query;
511
+ if (query.length < 2 || query.startsWith("did:") || /\s/.test(query)) return close();
512
+ timer = window.setTimeout(async () => {
513
+ try {
514
+ const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
515
+ const data = (await response.json()) as { actors?: Suggestion[] };
516
+ if (query === latest) show(data.actors ?? []);
517
+ } catch {
518
+ close();
519
+ }
520
+ }, 250);
521
+ });
522
+ input.addEventListener("keydown", (event) => {
523
+ if (list.hidden) return;
524
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
525
+ event.preventDefault();
526
+ const step = event.key === "ArrowDown" ? 1 : -1;
527
+ highlight(active < 0 && step < 0 ? found.length - 1 : (active + step + found.length) % found.length);
528
+ } else if (event.key === "Enter" && active >= 0) {
529
+ event.preventDefault();
530
+ choose(active);
531
+ } else if (event.key === "Escape") {
532
+ event.preventDefault();
533
+ close();
534
+ }
535
+ });
536
+ input.addEventListener("blur", () => window.setTimeout(close, 150));
537
+ }
420
538
 
421
539
  // An avatar picture, or the name's first letter if there isn't one.
422
540
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -765,9 +883,89 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
765
883
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
766
884
  (comment.querySelector("article") ?? comment).append(actions);
767
885
  });
886
+ addDeleteButtons();
768
887
  return added;
769
888
  };
770
889
 
890
+ // Delete, under the signed-in reader's own comments only.
891
+ const isMine = (comment: HTMLElement) => !!me.did && !!comment.dataset.atmosphereUri?.startsWith(`at://${me.did}/`);
892
+
893
+ const deleteComment = async (comment: HTMLElement, panel: HTMLElement) => {
894
+ const uri = comment.dataset.atmosphereUri!;
895
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", "Deleting…"));
896
+ try {
897
+ const response = await send("delete", { post: uri });
898
+ const data = (await response.json()) as { error?: string };
899
+ if (response.ok) {
900
+ comment.remove();
901
+ say("Your comment was deleted.");
902
+ this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
903
+ return;
904
+ }
905
+ if (data.error === "needs-permission") return askDeletePermission(comment, panel);
906
+ panel.remove();
907
+ handleError(data.error ?? "error");
908
+ } catch {
909
+ panel.remove();
910
+ say(MESSAGES.error);
911
+ }
912
+ };
913
+
914
+ // Deleting posts isn't part of signing in, so the first delete asks the
915
+ // reader's account host for it, then carries on when they come back.
916
+ const askDeletePermission = (comment: HTMLElement, panel: HTMLElement) => {
917
+ const form = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
918
+ const field = (name: string, value: string) =>
919
+ form.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
920
+ field("handle", me.did!);
921
+ field("permission", "delete");
922
+ field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
923
+ const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Continue to approve"), { type: "submit" });
924
+ const cancel = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
925
+ cancel.addEventListener("click", () => panel.remove());
926
+ form.append(go, cancel);
927
+ panel.replaceChildren(
928
+ make(
929
+ "span",
930
+ "atmo-comment-delete__text",
931
+ "To delete comments here, this site needs one more permission from your account. You can also delete it in any Bluesky app.",
932
+ ),
933
+ form,
934
+ );
935
+ go.focus();
936
+ };
937
+
938
+ const deleteButton = (comment: HTMLElement) => {
939
+ const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", "Delete"), { type: "button" });
940
+ button.setAttribute("aria-label", "Delete your comment");
941
+ button.addEventListener("click", () => {
942
+ const actions = button.parentElement!;
943
+ if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
944
+ const panel = make("div", "atmo-comment-delete");
945
+ const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Delete"), { type: "button" });
946
+ const no = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
947
+ yes.addEventListener("click", () => deleteComment(comment, panel));
948
+ no.addEventListener("click", () => {
949
+ panel.remove();
950
+ button.focus();
951
+ });
952
+ panel.append(make("span", "atmo-comment-delete__text", "Delete this comment from Bluesky?"), yes, no);
953
+ panel.setAttribute("role", "group");
954
+ panel.setAttribute("aria-label", "Delete your comment");
955
+ actions.after(panel);
956
+ no.focus();
957
+ });
958
+ return button;
959
+ };
960
+
961
+ const addDeleteButtons = () => {
962
+ document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]").forEach((comment) => {
963
+ const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
964
+ if (!actions || actions.querySelector(".atmo-comment-action--delete")) return;
965
+ if (isSignedIn && isMine(comment)) actions.append(deleteButton(comment));
966
+ });
967
+ };
968
+
771
969
  // Which of the post and its comments the reader has already liked or reposted.
772
970
  const loadState = async () => {
773
971
  if (!post) return;
@@ -809,9 +1007,26 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
809
1007
  isSignedIn = signed;
810
1008
  signedOut.hidden = signed;
811
1009
  signedIn.hidden = !signed;
812
- if (!signed) return closeReplies();
1010
+ if (!signed) {
1011
+ document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-delete").forEach((part) => part.remove());
1012
+ return closeReplies();
1013
+ }
813
1014
  addCommentActions();
814
1015
  await loadState();
1016
+ // Back from approving the delete permission: finish the delete they asked for.
1017
+ const pending = location.hash.match(/^#atmo-delete=(.+)$/);
1018
+ if (pending) {
1019
+ history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
1020
+ const uri = decodeURIComponent(pending[1]);
1021
+ const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`);
1022
+ const actions = comment?.querySelector<HTMLElement>(":scope .atmo-comment-actions");
1023
+ if (comment && actions && isMine(comment)) {
1024
+ const panel = make("div", "atmo-comment-delete");
1025
+ actions.after(panel);
1026
+ comment.scrollIntoView({ block: "center" });
1027
+ await deleteComment(comment, panel);
1028
+ }
1029
+ }
815
1030
  };
816
1031
 
817
1032
  // A message from the sign-in round trip (?atmosphere=…), then tidy the address.
@@ -900,50 +1115,49 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
900
1115
  display: none !important;
901
1116
  }
902
1117
 
903
- /* The button follows the Atmosphere Account sign-in badge: the logo, then
904
- two lines of monospaced text, in a rounded outline. */
905
- .atmo__badge-button {
906
- display: inline-flex;
907
- align-items: center;
908
- gap: 12px;
909
- padding: 9px 16px 9px 12px;
910
- border: 2px solid currentColor;
911
- border-radius: 12px;
912
- background: none;
913
- color: var(--atmo-text, currentColor);
914
- font-family: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
915
- text-align: left;
916
- cursor: pointer;
1118
+ /* The Atmosphere Account logo inside the handle box, on the left, and at
1119
+ the start of the sign-in button. It is single-colour, so it is drawn in
1120
+ the muted text colour in the box and the button's text colour on it. */
1121
+ .atmo__handle {
1122
+ position: relative;
1123
+ display: flex;
1124
+ flex: 1 1 220px;
1125
+ min-width: 0;
917
1126
  }
918
1127
 
919
- .atmo__badge-button:hover,
920
- .atmo__badge-button:focus-visible,
921
- .atmo__badge-button[aria-expanded="true"] {
922
- color: var(--atmo-accent, #1185fe);
1128
+ /* Matching accounts sit under the handle box, as wide as it. */
1129
+ .atmo__handle .atmo__suggestions {
1130
+ width: 100%;
923
1131
  }
924
1132
 
925
- /* The logo is single-colour, so it is drawn in the text colour. */
926
- .atmo__logo {
927
- flex: none;
928
- width: 40px;
929
- height: 40px;
930
- background-color: currentColor;
1133
+ .atmo__handle .atmo__logo {
1134
+ position: absolute;
1135
+ top: 50%;
1136
+ left: 11px;
1137
+ width: 20px;
1138
+ height: 20px;
1139
+ transform: translateY(-50%);
1140
+ background-color: var(--atmo-muted, currentColor);
931
1141
  mask: var(--atmo-logo) center / contain no-repeat;
1142
+ pointer-events: none;
932
1143
  }
933
1144
 
934
- .atmo__badge-text {
935
- display: grid;
936
- line-height: 1.15;
1145
+ .atmo .atmo__handle input[type="text"] {
1146
+ padding-left: 40px;
937
1147
  }
938
1148
 
939
- .atmo__badge-small {
940
- font-size: 13px;
1149
+ .atmo__button--sign-in {
1150
+ display: inline-flex;
1151
+ align-items: center;
1152
+ gap: 8px;
941
1153
  }
942
1154
 
943
- .atmo__badge-big {
944
- font-size: 19px;
945
- font-weight: 700;
946
- white-space: nowrap;
1155
+ .atmo__button--sign-in .atmo__logo {
1156
+ flex: none;
1157
+ width: 20px;
1158
+ height: 20px;
1159
+ background-color: currentColor;
1160
+ mask: var(--atmo-logo) center / contain no-repeat;
947
1161
  }
948
1162
 
949
1163
  /* The comment box when it sits under a comment being replied to. */
@@ -955,6 +1169,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
955
1169
  margin-top: 10px;
956
1170
  }
957
1171
 
1172
+ .atmo__elsewhere {
1173
+ max-width: 560px;
1174
+ margin: 14px 0 0;
1175
+ font-size: 0.9em;
1176
+ }
1177
+
1178
+ .atmo__elsewhere-link {
1179
+ font-weight: 700;
1180
+ }
1181
+
958
1182
  .atmo__login,
959
1183
  .atmo__composer {
960
1184
  display: grid;
@@ -1459,6 +1683,24 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1459
1683
  margin-top: 8px;
1460
1684
  }
1461
1685
 
1686
+ :global(.atmo-comment-delete) {
1687
+ display: flex;
1688
+ flex-wrap: wrap;
1689
+ align-items: center;
1690
+ gap: 6px 12px;
1691
+ margin-top: 8px;
1692
+ font-size: 0.9em;
1693
+ }
1694
+
1695
+ :global(.atmo-comment-delete form) {
1696
+ display: contents;
1697
+ }
1698
+
1699
+ :global(.atmo-comment-delete__text) {
1700
+ color: var(--atmo-muted, inherit);
1701
+ }
1702
+
1703
+
1462
1704
  :global(.atmo-comment-action) {
1463
1705
  display: inline-flex;
1464
1706
  align-items: center;
@@ -1473,6 +1715,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1473
1715
  cursor: pointer;
1474
1716
  }
1475
1717
 
1718
+ :global(.atmo-comment-action--delete) {
1719
+ margin-left: auto;
1720
+ }
1721
+
1722
+ :global(.atmo-comment-action--danger) {
1723
+ color: var(--atmo-danger, #c0392b);
1724
+ font-weight: 700;
1725
+ }
1726
+
1476
1727
  :global(.atmo-comment-action:hover),
1477
1728
  :global(.atmo-comment-action:focus-visible),
1478
1729
  :global(.atmo-comment-action[aria-pressed="true"]) {
package/index.mjs CHANGED
@@ -6,11 +6,13 @@
6
6
  //
7
7
  // Sign-in uses AT Protocol OAuth straight to the reader's own account host:
8
8
  // no third-party login service is involved. This site only ever holds a
9
- // grant to create and delete posts, likes and reposts (see SCOPE in
10
- // lib/oauth.ts).
9
+ // grant to create posts and to create and delete likes and reposts, plus, if
10
+ // a reader allows it when they first delete a comment, to delete posts (see
11
+ // SCOPE and DELETE_SCOPE in lib/oauth.ts).
11
12
  //
12
- // It adds these routes under `base` (default /atmosphere):
13
- // client-metadata.json, jwks.json what account hosts read to know the site
13
+ // It adds /oauth-client-metadata.json at the site's root (what account hosts
14
+ // read to know the site), and these routes under `base` (default /atmosphere):
15
+ // jwks.json the public half of the site's signing key
14
16
  // login (POST) starts sign-in with the reader's handle
15
17
  // callback where the reader's host sends them back
16
18
  // logout (POST) signs out and revokes the site's grant
@@ -30,7 +32,6 @@ import { fileURLToPath } from "node:url";
30
32
  const VIRTUAL_ID = "virtual:atmosphere-comments/settings";
31
33
 
32
34
  const ROUTES = {
33
- "client-metadata.json": "client-metadata.json.ts",
34
35
  "jwks.json": "jwks.json.ts",
35
36
  login: "login.ts",
36
37
  callback: "callback.ts",
@@ -39,6 +40,7 @@ const ROUTES = {
39
40
  comment: "comment.ts",
40
41
  state: "state.ts",
41
42
  react: "react.ts",
43
+ delete: "delete.ts",
42
44
  post: "post.ts",
43
45
  media: "media.ts",
44
46
  typeahead: "typeahead.ts",
@@ -80,6 +82,15 @@ export default function atmosphereComments(options) {
80
82
  hidden: options.hidden ?? [],
81
83
  };
82
84
 
85
+ // The client metadata lives at the address account hosts treat as the
86
+ // convention, so Bluesky's approval page names the app by the site's
87
+ // address alone (saltedlolly.com) rather than the full file address.
88
+ injectRoute({
89
+ pattern: "/oauth-client-metadata.json",
90
+ entrypoint: fileURLToPath(new URL("./routes/client-metadata.json.ts", import.meta.url)),
91
+ prerender: false,
92
+ });
93
+
83
94
  for (const [path, file] of Object.entries(ROUTES)) {
84
95
  injectRoute({
85
96
  pattern: `${base}/${path}`,
package/lib/oauth.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // permission to post comments on their behalf.
3
3
  //
4
4
  // - Live, the site is a "confidential client": account hosts read its
5
- // client-metadata.json, and it proves who it is with a private key
5
+ // /oauth-client-metadata.json, and it proves who it is with a private key
6
6
  // (ATMOSPHERE_PRIVATE_JWK, server-only). Sign-ins can then last for months.
7
7
  // - In `astro dev`, it uses the protocol's local-testing client (no key, no
8
8
  // public address needed). Open the site at http://127.0.0.1:<port>, not
@@ -19,31 +19,42 @@ import { getSecret } from "astro:env/server";
19
19
  import settings from "./settings";
20
20
  import { fileStore } from "./store";
21
21
 
22
- // Only what commenting needs: create (and delete again) posts, likes and
23
- // reposts, and read posts as the reader sees them (to know whether they've
22
+ // Only what commenting needs: create posts, create (and delete again) likes
23
+ // and reposts, and read posts as the reader sees them (to know whether they've
24
24
  // already liked or reposted), through Bluesky's AppView.
25
25
  export const SCOPE = [
26
26
  "atproto",
27
- "repo:app.bsky.feed.post?action=create&action=delete",
27
+ "repo:app.bsky.feed.post?action=create",
28
28
  "repo:app.bsky.feed.like?action=create&action=delete",
29
29
  "repo:app.bsky.feed.repost?action=create&action=delete",
30
30
  "rpc:app.bsky.feed.getPosts?aud=did:web:api.bsky.app%23bsky_appview",
31
31
  ].join(" ");
32
32
 
33
+ // Deleting posts is asked for separately, only when a reader first deletes a
34
+ // comment here, so most readers never grant it. (A permission can only name a
35
+ // kind of record, not particular posts: the delete route itself makes sure it
36
+ // is only used on the reader's own comments under the site's posts.)
37
+ export const DELETE_SCOPE = "repo:app.bsky.feed.post?action=delete";
38
+
39
+ // Everything the site may ask for, which its client metadata has to list.
40
+ const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE}`;
41
+
33
42
  const KEY_ID = "atmosphere-comments-1";
34
43
 
35
44
  export const callbackPath = `${settings.base}/callback`;
36
45
 
37
46
  export function clientMetadata() {
38
47
  return {
39
- client_id: `${settings.site}${settings.base}/client-metadata.json`,
48
+ // At the site's root, the address account hosts treat as the convention:
49
+ // Bluesky's approval page then shows just the site's address.
50
+ client_id: `${settings.site}/oauth-client-metadata.json`,
40
51
  client_name: settings.clientName,
41
52
  client_uri: settings.site,
42
53
  ...(settings.logoUri && { logo_uri: settings.logoUri }),
43
54
  ...(settings.policyUri && { policy_uri: settings.policyUri }),
44
55
  ...(settings.tosUri && { tos_uri: settings.tosUri }),
45
56
  redirect_uris: [`${settings.site}${callbackPath}`] as [string],
46
- scope: SCOPE,
57
+ scope: ALL_SCOPES,
47
58
  grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
48
59
  response_types: ["code"] as ["code"],
49
60
  application_type: "web" as const,
@@ -84,7 +95,7 @@ export function oauthClient(origin: string): Promise<NodeOAuthClient> {
84
95
  return new NodeOAuthClient({
85
96
  ...common,
86
97
  clientMetadata: buildAtprotoLoopbackClientMetadata({
87
- scope: SCOPE,
98
+ scope: ALL_SCOPES,
88
99
  redirect_uris: [`${local.origin}${callbackPath}`],
89
100
  }),
90
101
  });
package/lib/reader.ts CHANGED
@@ -9,7 +9,7 @@ import { oauthClient, SCOPE } from "./oauth";
9
9
  import settings from "./settings";
10
10
  import { signedInDid, signOut } from "./visitor";
11
11
 
12
- export type Reader = { did: string; session: OAuthSession };
12
+ export type Reader = { did: string; session: OAuthSession; scopes: string[] };
13
13
 
14
14
  const APPVIEW_PROXY = "did:web:api.bsky.app#bsky_appview";
15
15
  const POST_URI = /^at:\/\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/app\.bsky\.feed\.post\/([a-zA-Z0-9._~-]+)$/;
@@ -29,12 +29,13 @@ export async function reader(request: Request, url: URL, cookies: AstroCookies):
29
29
  const session = await (await oauthClient(url.origin)).restore(did);
30
30
  // Compared by permission name ("repo:app.bsky.feed.like"), since a host
31
31
  // may write the parameters after it differently.
32
- const granted = (await session.getTokenInfo(false)).scope.split(" ").map((part) => part.split("?")[0]);
32
+ const scopes = (await session.getTokenInfo(false)).scope.split(" ");
33
+ const granted = scopes.map((part) => part.split("?")[0]);
33
34
  if (!SCOPE.split(" ").every((part) => granted.includes(part.split("?")[0]))) {
34
35
  await signOut(cookies);
35
36
  return json({ error: "new-permissions" }, 401);
36
37
  }
37
- return { did, session };
38
+ return { did, session, scopes };
38
39
  } catch {
39
40
  // The grant has expired or been revoked: the reader needs to sign in again.
40
41
  await signOut(cookies);
@@ -42,6 +43,20 @@ export async function reader(request: Request, url: URL, cookies: AstroCookies):
42
43
  }
43
44
  }
44
45
 
46
+ /**
47
+ * Whether the reader's grant allows `action` on records of `collection`: a
48
+ * repo permission for that collection (or all of them) listing the action,
49
+ * or listing no actions, which means all of them.
50
+ */
51
+ export function allows(reader: Reader, collection: string, action: string): boolean {
52
+ return reader.scopes.some((scope) => {
53
+ const [name, query = ""] = scope.split("?");
54
+ if (name !== `repo:${collection}` && name !== "repo:*") return false;
55
+ const actions = new URLSearchParams(query).getAll("action");
56
+ return !actions.length || actions.includes(action);
57
+ });
58
+ }
59
+
45
60
  /** True for a post by one of the site's own accounts (an announcement). */
46
61
  export function isSitePost(uri: unknown): uri is string {
47
62
  const match = typeof uri === "string" ? POST_URI.exec(uri) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-atmosphere-comments",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Atmosphere Comments for Astro: Bluesky comments, likes, reposts and quote posts for Astro sites; readers sign in with their Atmosphere (AT Protocol) account and respond without leaving the page.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "types": "./index.d.ts",
33
33
  "bin": {
34
- "astro-atmosphere-comments": "./bin/cli.mjs"
34
+ "astro-atmosphere-comments": "bin/cli.mjs"
35
35
  },
36
36
  "files": [
37
37
  "index.mjs",
@@ -0,0 +1,41 @@
1
+ // Deletes one of the signed-in reader's own comments: a post of theirs that
2
+ // is a reply somewhere under one of the site's announcements. Nothing else,
3
+ // even though the permission behind it covers all of the reader's posts.
4
+ // Body: { post }. Readers who haven't allowed deleting yet get
5
+ // "needs-permission", and the page offers to ask their account host for it.
6
+ import type { APIRoute } from "astro";
7
+ import { json } from "../lib/http";
8
+ import { allows, deleteRecord, isPostUri, isSitePost, postsAsReader, rateLimit, reader } from "../lib/reader";
9
+ import { forgetThread } from "../lib/thread";
10
+
11
+ const tooMany = rateLimit(10, 60);
12
+
13
+ export const POST: APIRoute = async ({ request, url, cookies }) => {
14
+ const signedIn = await reader(request, url, cookies);
15
+ if (signedIn instanceof Response) return signedIn;
16
+
17
+ let body: { post?: unknown };
18
+ try {
19
+ body = await request.json();
20
+ } catch {
21
+ return json({ error: "bad-request" }, 400);
22
+ }
23
+ const { post } = body;
24
+ if (!isPostUri(post) || !post.startsWith(`at://${signedIn.did}/`)) return json({ error: "not-allowed" }, 400);
25
+ if (!allows(signedIn, "app.bsky.feed.post", "delete")) return json({ error: "needs-permission" }, 403);
26
+ if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
27
+
28
+ try {
29
+ const [view] = await postsAsReader(signedIn.session, [post]);
30
+ if (!view) return json({ error: "not-found" }, 404);
31
+ // Only a reply under one of the site's announcements, never a post of its own.
32
+ const root = view.record?.reply?.root?.uri;
33
+ if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
34
+ await deleteRecord(signedIn, view.uri);
35
+ forgetThread(root);
36
+ return json({ deleted: view.uri });
37
+ } catch (error) {
38
+ console.warn("[atmosphere] delete failed:", error instanceof Error ? error.message : error);
39
+ return json({ error: "post-failed" }, 502);
40
+ }
41
+ };
package/routes/login.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // approve this site's request to post comments for them.
4
4
  import type { APIRoute } from "astro";
5
5
  import { safeReturnPath, withStatus } from "../lib/http";
6
- import { oauthClient, SCOPE } from "../lib/oauth";
6
+ import { DELETE_SCOPE, oauthClient, SCOPE } from "../lib/oauth";
7
7
 
8
8
  export const POST: APIRoute = async ({ request, url, redirect }) => {
9
9
  const form = await request.formData();
@@ -19,7 +19,9 @@ export const POST: APIRoute = async ({ request, url, redirect }) => {
19
19
  try {
20
20
  const client = await oauthClient(url.origin);
21
21
  const authorizeUrl = await client.authorize(handle, {
22
- scope: SCOPE,
22
+ // The delete route sends readers back here (with permission=delete) when
23
+ // they first delete a comment and haven't allowed that yet.
24
+ scope: form.get("permission") === "delete" ? `${SCOPE} ${DELETE_SCOPE}` : SCOPE,
23
25
  state: JSON.stringify({ returnTo }),
24
26
  });
25
27
  return redirect(authorizeUrl.href, 303);
@@ -1,6 +1,10 @@
1
- // Suggestions for an @mention as the reader types (?q=start of a handle or
2
- // name), from Bluesky's public typeahead search. Only for signed-in readers,
3
- // so it can't be used as a free search proxy; avatars come through this site.
1
+ // Account suggestions as the reader types (?q=start of a handle or name), from
2
+ // Bluesky's public typeahead search: for @mentions in the comment box, and for
3
+ // the handle box when signing in, so readers can see they've found the right
4
+ // account. Avatars come through this site. Signed-in readers are limited per
5
+ // account; everyone else per address, with an overall cap as well (behind a
6
+ // proxy every visitor may share one address), so the site can't be used to
7
+ // flood Bluesky's search. Answers are remembered for a minute.
4
8
  import type { APIRoute } from "astro";
5
9
  import { json } from "../lib/http";
6
10
  import { rateLimit } from "../lib/reader";
@@ -8,24 +12,50 @@ import settings from "../lib/settings";
8
12
  import { rememberAvatar } from "../lib/avatars";
9
13
  import { signedInDid } from "../lib/visitor";
10
14
 
11
- const tooMany = rateLimit(60, 1200);
15
+ const perReader = rateLimit(60, 1200);
16
+ const perAddress = rateLimit(30, 300);
17
+ const signedOutOverall = rateLimit(300, 6000);
12
18
 
13
- export const GET: APIRoute = async ({ url, cookies }) => {
19
+ type Actor = { did: string; handle: string; displayName?: string; avatar?: string };
20
+ const recent = new Map<string, { actors: Actor[]; at: number }>();
21
+ const REMEMBER_MS = 60 * 1000;
22
+ const MAX_REMEMBERED = 500;
23
+
24
+ async function search(q: string): Promise<Actor[]> {
25
+ const key = q.toLowerCase();
26
+ const known = recent.get(key);
27
+ if (known && Date.now() - known.at < REMEMBER_MS) return known.actors;
28
+ const response = await fetch(
29
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`,
30
+ { signal: AbortSignal.timeout(4000) },
31
+ );
32
+ if (!response.ok) return [];
33
+ const actors = ((await response.json()) as { actors?: Actor[] }).actors ?? [];
34
+ if (recent.size >= MAX_REMEMBERED) recent.delete(recent.keys().next().value!);
35
+ recent.set(key, { actors, at: Date.now() });
36
+ return actors;
37
+ }
38
+
39
+ export const GET: APIRoute = async ({ url, cookies, clientAddress }) => {
40
+ const q = (url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
14
41
  const did = await signedInDid(cookies);
15
- if (!did) return json({ error: "signed-out" }, 401);
16
- const q = (url.searchParams.get("q") ?? "").trim();
17
- if (!q || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
18
- if (tooMany(did)) return json({ error: "too-many" }, 429);
42
+ // One letter is enough for an @mention; signing in needs two.
43
+ if (!q || (!did && q.length < 2) || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
44
+
45
+ if (did) {
46
+ if (perReader(did)) return json({ error: "too-many" }, 429);
47
+ } else {
48
+ let address = "unknown";
49
+ try {
50
+ address = clientAddress;
51
+ } catch {
52
+ // Some adapters can't tell; the overall cap still applies.
53
+ }
54
+ if (perAddress(address) || signedOutOverall("all")) return json({ error: "too-many" }, 429);
55
+ }
56
+
19
57
  try {
20
- const response = await fetch(
21
- `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`,
22
- { signal: AbortSignal.timeout(4000) },
23
- );
24
- if (!response.ok) return json({ actors: [] });
25
- const data = (await response.json()) as {
26
- actors?: { did: string; handle: string; displayName?: string; avatar?: string }[];
27
- };
28
- const actors = (data.actors ?? []).map((actor) => {
58
+ const actors = (await search(q)).map((actor) => {
29
59
  if (actor.avatar) rememberAvatar(actor.did, actor.avatar);
30
60
  return {
31
61
  handle: actor.handle,