astro-atmosphere-comments 0.4.2 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -23
  3. package/components/AtmosphereComments.astro +179 -78
  4. package/components/AtmosphereReactions.astro +86 -51
  5. package/components/SignInForm.astro +14 -3
  6. package/components/atmosphere.css +21 -0
  7. package/index.mjs +42 -32
  8. package/lib/client/account.ts +553 -0
  9. package/lib/client/composer.ts +7 -10
  10. package/lib/http.ts +28 -0
  11. package/lib/permissions.ts +108 -0
  12. package/lib/public-settings.ts +18 -0
  13. package/lib/thread.ts +36 -6
  14. package/lib/uris.ts +31 -0
  15. package/locales/de.json +20 -4
  16. package/locales/en.json +20 -4
  17. package/locales/es.json +20 -4
  18. package/locales/fr.json +20 -4
  19. package/locales/it.json +20 -4
  20. package/locales/ja.json +20 -4
  21. package/locales/nl.json +20 -4
  22. package/locales/pt.json +20 -4
  23. package/package.json +2 -7
  24. package/routes/callback.astro +42 -0
  25. package/routes/client-metadata.json.ts +3 -2
  26. package/routes/following.ts +28 -0
  27. package/routes/identity.ts +69 -0
  28. package/routes/media.ts +7 -10
  29. package/routes/post.ts +1 -1
  30. package/routes/profile.ts +20 -0
  31. package/routes/richtext.ts +30 -0
  32. package/routes/thread.ts +23 -3
  33. package/routes/typeahead.ts +7 -23
  34. package/virtual.d.ts +5 -0
  35. package/bin/cli.mjs +0 -31
  36. package/lib/oauth.ts +0 -110
  37. package/lib/reader.ts +0 -130
  38. package/lib/store.ts +0 -46
  39. package/lib/visitor.ts +0 -42
  40. package/routes/callback.ts +0 -30
  41. package/routes/comment.ts +0 -97
  42. package/routes/delete.ts +0 -41
  43. package/routes/follow.ts +0 -71
  44. package/routes/jwks.json.ts +0 -13
  45. package/routes/login.ts +0 -58
  46. package/routes/logout.ts +0 -20
  47. package/routes/me.ts +0 -12
  48. package/routes/react.ts +0 -48
  49. package/routes/state.ts +0 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-atmosphere-comments",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -34,14 +34,10 @@
34
34
  "./AtmosphereReactions.astro": "./components/AtmosphereReactions.astro"
35
35
  },
36
36
  "types": "./index.d.ts",
37
- "bin": {
38
- "astro-atmosphere-comments": "bin/cli.mjs"
39
- },
40
37
  "files": [
41
38
  "index.mjs",
42
39
  "index.d.ts",
43
40
  "virtual.d.ts",
44
- "bin",
45
41
  "components",
46
42
  "lib",
47
43
  "routes",
@@ -59,7 +55,6 @@
59
55
  },
60
56
  "dependencies": {
61
57
  "@atproto/api": "^0.25.0",
62
- "@atproto/oauth-client": "^0.9.0",
63
- "@atproto/oauth-client-node": "^0.5.13"
58
+ "@atproto/oauth-client-browser": "^0.5.11"
64
59
  }
65
60
  }
@@ -0,0 +1,42 @@
1
+ ---
2
+ // Where a reader's account host sends them back after they approve (or
3
+ // decline) signing in. The browser finishes signing in here, keeping the
4
+ // sign-in in its own storage, then goes back to the page the reader was on
5
+ // (lib/client/account.ts). The approval arrives in the address's #fragment,
6
+ // which browsers never send to the server.
7
+ import settings from "../lib/settings";
8
+ import { messagesFor, pickLocale, translate } from "../lib/i18n";
9
+
10
+ const lang = pickLocale(undefined, settings.locale, Astro.currentLocale);
11
+ const t = (key: string) => translate(messagesFor(lang, settings.messages), lang, key);
12
+ ---
13
+
14
+ <!doctype html>
15
+ <html lang={lang}>
16
+ <head>
17
+ <meta charset="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <meta name="robots" content="noindex" />
20
+ <meta name="referrer" content="no-referrer" />
21
+ <title>{t("callback.signingIn")}</title>
22
+ <style>
23
+ :root {
24
+ color-scheme: light dark;
25
+ }
26
+ body {
27
+ display: grid;
28
+ min-height: 100vh;
29
+ margin: 0;
30
+ place-items: center;
31
+ font: 1rem/1.5 system-ui, sans-serif;
32
+ }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <p role="status">{t("callback.signingIn")}</p>
37
+ <script>
38
+ import { finishSignIn } from "../lib/client/account";
39
+ void finishSignIn();
40
+ </script>
41
+ </body>
42
+ </html>
@@ -1,7 +1,8 @@
1
1
  // The site's OAuth client metadata. Account hosts fetch this address (it is
2
2
  // the site's client ID) to learn its name, logo, callback address and scope.
3
3
  import type { APIRoute } from "astro";
4
- import { clientMetadata } from "../lib/oauth";
4
+ import { clientMetadata } from "../lib/permissions";
5
+ import settings from "../lib/settings";
5
6
 
6
7
  export const GET: APIRoute = () =>
7
- Response.json(clientMetadata(), { headers: { "Cache-Control": "public, max-age=600" } });
8
+ Response.json(clientMetadata(settings), { headers: { "Cache-Control": "public, max-age=600" } });
@@ -0,0 +1,28 @@
1
+ // Whether an account follows one of the site's own accounts, for the Follow
2
+ // button: ?actor=<reader's DID>&subject=<DID in allowedAuthors> gives
3
+ // { following } (the follow record's AT URI, or ""). From Bluesky's public
4
+ // AppView: who follows whom is public.
5
+ import type { APIRoute } from "astro";
6
+ import { addressOf, json, rateLimit } from "../lib/http";
7
+ import settings from "../lib/settings";
8
+
9
+ const DID = /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/;
10
+ const perAddress = rateLimit(30, 300);
11
+
12
+ export const GET: APIRoute = async (context) => {
13
+ const actor = context.url.searchParams.get("actor") ?? "";
14
+ const subject = context.url.searchParams.get("subject") ?? "";
15
+ if (!DID.test(actor) || !settings.allowedAuthors.includes(subject)) return json({ error: "not-allowed" }, 400);
16
+ if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
17
+ try {
18
+ const query = new URLSearchParams({ actor, others: subject });
19
+ const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.graph.getRelationships?${query}`, {
20
+ signal: AbortSignal.timeout(5000),
21
+ });
22
+ if (!response.ok) throw new Error(`getRelationships ${response.status}`);
23
+ const data = (await response.json()) as { relationships?: Array<{ did?: string; following?: string }> };
24
+ return json({ following: data.relationships?.find((relationship) => relationship.did === subject)?.following ?? "" });
25
+ } catch {
26
+ return json({ error: "unavailable" }, 502);
27
+ }
28
+ };
@@ -0,0 +1,69 @@
1
+ // Looks up an account for signing in: ?id=<handle or DID> gives its DID, its
2
+ // DID document (which names the reader's account host) and its handle, as
3
+ // the browser's OAuth library needs (lib/client/account.ts). Done here, so a
4
+ // reader's browser contacts only their own account host while signing in,
5
+ // not Bluesky's servers or the PLC directory. Nothing is stored; answers are
6
+ // remembered for ten minutes, and requests are limited per address.
7
+ import type { APIRoute } from "astro";
8
+ import { addressOf, json, rateLimit } from "../lib/http";
9
+
10
+ const APPVIEW = "https://public.api.bsky.app/xrpc/";
11
+ const HANDLE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/;
12
+ const DID_PLC = /^did:plc:[a-z2-7]{24}$/;
13
+ const DID_WEB = /^did:web:([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/;
14
+ const REMEMBER_MS = 10 * 60 * 1000;
15
+
16
+ const perAddress = rateLimit(20, 200);
17
+ const overall = rateLimit(300, 6000);
18
+ const recent = new Map<string, { answer: Identity; at: number }>();
19
+
20
+ type DidDocument = { id: string; alsoKnownAs?: string[]; service?: { id: string; type: string; serviceEndpoint: unknown }[] };
21
+ type Identity = { did: string; didDoc: DidDocument; handle: string };
22
+
23
+ async function getJson<T>(url: string): Promise<T> {
24
+ const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(5000) });
25
+ if (!response.ok) throw new Error(`${response.status}`);
26
+ if (Number(response.headers.get("content-length") || 0) > 100_000) throw new Error("too big");
27
+ return (await response.json()) as T;
28
+ }
29
+
30
+ async function didForHandle(handle: string): Promise<string> {
31
+ const { did } = await getJson<{ did?: string }>(`${APPVIEW}com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
32
+ if (!did || !(DID_PLC.test(did) || DID_WEB.test(did))) throw new Error("no DID");
33
+ return did;
34
+ }
35
+
36
+ async function documentFor(did: string): Promise<DidDocument> {
37
+ const document = await getJson<DidDocument>(
38
+ did.startsWith("did:web:") ? `https://${did.slice("did:web:".length)}/.well-known/did.json` : `https://plc.directory/${did}`,
39
+ );
40
+ if (document.id !== did) throw new Error("wrong document");
41
+ return document;
42
+ }
43
+
44
+ async function lookUp(id: string): Promise<Identity> {
45
+ const did = id.startsWith("did:") ? id : await didForHandle(id);
46
+ const didDoc = await documentFor(did);
47
+ // The handle counts only if the document claims it and it points back here.
48
+ const claimed = didDoc.alsoKnownAs?.find((alias) => alias.startsWith("at://"))?.slice("at://".length).toLowerCase();
49
+ let handle = "handle.invalid";
50
+ if (claimed && claimed === id) handle = claimed;
51
+ else if (claimed && HANDLE.test(claimed)) handle = (await didForHandle(claimed).catch(() => "")) === did ? claimed : handle;
52
+ return { did, didDoc, handle };
53
+ }
54
+
55
+ export const GET: APIRoute = async (context) => {
56
+ const id = (context.url.searchParams.get("id") ?? "").trim().toLowerCase();
57
+ if (!(HANDLE.test(id) || DID_PLC.test(id) || DID_WEB.test(id)) || id.length > 253) return json({ error: "bad-request" }, 400);
58
+ const known = recent.get(id);
59
+ if (known && Date.now() - known.at < REMEMBER_MS) return json(known.answer);
60
+ if (perAddress(addressOf(context)) || overall("all")) return json({ error: "too-many" }, 429);
61
+ try {
62
+ const answer = await lookUp(id);
63
+ if (recent.size >= 500) recent.delete(recent.keys().next().value!);
64
+ recent.set(id, { answer, at: Date.now() });
65
+ return json(answer);
66
+ } catch {
67
+ return json({ error: "not-found" }, 404);
68
+ }
69
+ };
package/routes/media.ts CHANGED
@@ -1,27 +1,26 @@
1
1
  // Passes on a few images from Bluesky's image server, so readers' browsers
2
2
  // never load anything from Bluesky directly:
3
- // ?avatar=<did> the signed-in reader's own avatar, a site account's, or
4
- // one shown recently (in a thread, or suggested for an @mention);
3
+ // ?avatar=<did> a site account's avatar, or one shown recently (in a
4
+ // thread, suggested for an @mention, or a signed-in reader's
5
+ // own, looked up by routes/profile.ts);
5
6
  // ?thumb=<uri> the link-card picture of one of the site's announcements.
6
7
  // Nothing else can be fetched through it.
7
8
  import type { APIRoute } from "astro";
8
9
  import { profileFor } from "../lib/profile";
9
10
  import { quotedPost } from "../lib/quoted";
10
- import { isSitePost } from "../lib/reader";
11
+ import { isSitePost } from "../lib/uris";
11
12
  import settings from "../lib/settings";
12
13
  import { knownAvatar } from "../lib/avatars";
13
- import { signedInDid } from "../lib/visitor";
14
14
 
15
15
  const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
16
16
  const MAX_BYTES = 3 * 1024 * 1024;
17
17
 
18
18
  const notFound = () => new Response("Not found", { status: 404 });
19
19
 
20
- export const GET: APIRoute = async ({ url, cookies }) => {
20
+ export const GET: APIRoute = async ({ url }) => {
21
21
  const avatar = url.searchParams.get("avatar");
22
22
  const thumb = url.searchParams.get("thumb");
23
23
  let source: string | undefined;
24
- let shared = true;
25
24
 
26
25
  if (avatar) {
27
26
  const isSiteAccount = settings.allowedAuthors.includes(avatar);
@@ -29,10 +28,9 @@ export const GET: APIRoute = async ({ url, cookies }) => {
29
28
  if (recentlyShown) {
30
29
  source = recentlyShown;
31
30
  } else {
32
- if (!isSiteAccount && avatar !== (await signedInDid(cookies))) return notFound();
31
+ if (!isSiteAccount) return notFound();
33
32
  source = (await profileFor(avatar)).avatarSource;
34
33
  }
35
- shared = isSiteAccount || !!recentlyShown;
36
34
  } else if (thumb && isSitePost(thumb)) {
37
35
  source = (await quotedPost(thumb).catch(() => null))?.external?.thumbSource;
38
36
  }
@@ -47,8 +45,7 @@ export const GET: APIRoute = async ({ url, cookies }) => {
47
45
  return new Response(body, {
48
46
  headers: {
49
47
  "Content-Type": type,
50
- // A reader's own avatar is only for them; site images can be shared.
51
- "Cache-Control": shared ? "public, max-age=3600" : "private, max-age=600",
48
+ "Cache-Control": "public, max-age=3600",
52
49
  "Content-Security-Policy": "default-src 'none'; sandbox",
53
50
  "X-Content-Type-Options": "nosniff",
54
51
  },
package/routes/post.ts CHANGED
@@ -5,7 +5,7 @@ import type { APIRoute } from "astro";
5
5
  import { json } from "../lib/http";
6
6
  import { publicProfile } from "../lib/profile";
7
7
  import { quotedPost } from "../lib/quoted";
8
- import { isSitePost } from "../lib/reader";
8
+ import { isSitePost } from "../lib/uris";
9
9
  import settings from "../lib/settings";
10
10
 
11
11
  export const GET: APIRoute = async ({ url }) => {
@@ -0,0 +1,20 @@
1
+ // A signed-in reader's public profile, for the comment box and the account
2
+ // menu: ?did=<DID> gives their handle, display name and avatar (through this
3
+ // site, routes/media.ts). The page asks for it after the reader signs in in
4
+ // their own browser; it's public, so no sign-in is needed to ask.
5
+ import type { APIRoute } from "astro";
6
+ import { rememberAvatar } from "../lib/avatars";
7
+ import { addressOf, json, rateLimit } from "../lib/http";
8
+ import { profileFor, publicProfile } from "../lib/profile";
9
+
10
+ const DID = /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/;
11
+ const perAddress = rateLimit(30, 300);
12
+
13
+ export const GET: APIRoute = async (context) => {
14
+ const did = context.url.searchParams.get("did") ?? "";
15
+ if (!DID.test(did) || did.length > 200) return json({ error: "bad-request" }, 400);
16
+ if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
17
+ const profile = await profileFor(did);
18
+ if (profile.avatarSource) rememberAvatar(did, profile.avatarSource);
19
+ return json(publicProfile(profile));
20
+ };
@@ -0,0 +1,30 @@
1
+ // Finds the links, @mentions and #hashtags in a comment before the reader's
2
+ // browser posts it, as the Bluesky app does: POST { text } gives { text,
3
+ // facets }. Mentions are looked up here (Bluesky's public API), so the
4
+ // reader's browser doesn't contact Bluesky. Nothing is posted or kept.
5
+ import { AtpAgent, RichText } from "@atproto/api";
6
+ import type { APIRoute } from "astro";
7
+ import { addressOf, fromThisSite, json, rateLimit } from "../lib/http";
8
+
9
+ const publicAgent = new AtpAgent({ service: "https://public.api.bsky.app" });
10
+ const perAddress = rateLimit(20, 200);
11
+
12
+ export const POST: APIRoute = async (context) => {
13
+ const { request, url } = context;
14
+ if (!fromThisSite(request, url)) return json({ error: "forbidden" }, 403);
15
+ let text: unknown;
16
+ try {
17
+ ({ text } = await request.json());
18
+ } catch {
19
+ return json({ error: "bad-request" }, 400);
20
+ }
21
+ if (typeof text !== "string" || !text.trim() || text.length > 3000) return json({ error: "bad-request" }, 400);
22
+ if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
23
+ const richText = new RichText({ text: text.trim() });
24
+ try {
25
+ await richText.detectFacets(publicAgent);
26
+ } catch {
27
+ richText.detectFacetsWithoutResolution();
28
+ }
29
+ return json({ text: richText.text, facets: richText.facets ?? [] });
30
+ };
package/routes/thread.ts CHANGED
@@ -3,11 +3,16 @@
3
3
  // ?document=<at:// URI of the page's Standard.site record>, whose
4
4
  // bskyPostRef names the announcement.
5
5
  // Only threads started by the site's own accounts are served.
6
+ //
7
+ // POST { root } forgets the cached copy of a thread, so a comment the
8
+ // reader's browser has just posted shows when the page is next loaded.
6
9
  import type { APIRoute } from "astro";
7
- import { json } from "../lib/http";
8
- import { isSitePost } from "../lib/reader";
10
+ import { addressOf, fromThisSite, json, rateLimit } from "../lib/http";
11
+ import { isSitePost } from "../lib/uris";
9
12
  import { announcementFromDocument, postUriFromUrl } from "../lib/resolve";
10
- import { getThread } from "../lib/thread";
13
+ import { forgetThread, getThread } from "../lib/thread";
14
+
15
+ const perAddress = rateLimit(10, 100);
11
16
 
12
17
  export const GET: APIRoute = async ({ url }) => {
13
18
  const post = url.searchParams.get("post");
@@ -25,3 +30,18 @@ export const GET: APIRoute = async ({ url }) => {
25
30
  const thread = await getThread(uri);
26
31
  return thread ? json({ thread }) : json({ error: "unavailable" }, 502);
27
32
  };
33
+
34
+ export const POST: APIRoute = async (context) => {
35
+ const { request, url } = context;
36
+ if (!fromThisSite(request, url)) return json({ error: "forbidden" }, 403);
37
+ let root: unknown;
38
+ try {
39
+ ({ root } = await request.json());
40
+ } catch {
41
+ return json({ error: "bad-request" }, 400);
42
+ }
43
+ if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
44
+ if (perAddress(addressOf(context))) return json({ error: "too-many" }, 429);
45
+ forgetThread(root);
46
+ return json({ ok: true });
47
+ };
@@ -6,15 +6,12 @@
6
6
  // proxy every visitor may share one address), so the site can't be used to
7
7
  // flood Bluesky's search. Answers are remembered for a minute.
8
8
  import type { APIRoute } from "astro";
9
- import { json } from "../lib/http";
10
- import { rateLimit } from "../lib/reader";
9
+ import { addressOf, json, rateLimit } from "../lib/http";
11
10
  import settings from "../lib/settings";
12
11
  import { rememberAvatar } from "../lib/avatars";
13
- import { signedInDid } from "../lib/visitor";
14
12
 
15
- const perReader = rateLimit(60, 1200);
16
- const perAddress = rateLimit(30, 300);
17
- const signedOutOverall = rateLimit(300, 6000);
13
+ const perAddress = rateLimit(60, 600);
14
+ const overall = rateLimit(600, 12000);
18
15
 
19
16
  type Actor = { did: string; handle: string; displayName?: string; avatar?: string };
20
17
  const recent = new Map<string, { actors: Actor[]; at: number }>();
@@ -36,23 +33,10 @@ async function search(q: string): Promise<Actor[]> {
36
33
  return actors;
37
34
  }
38
35
 
39
- export const GET: APIRoute = async ({ url, cookies, clientAddress }) => {
40
- const q = (url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
41
- const did = await signedInDid(cookies);
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
- }
36
+ export const GET: APIRoute = async (context) => {
37
+ const q = (context.url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
38
+ if (!q || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
39
+ if (perAddress(addressOf(context)) || overall("all")) return json({ error: "too-many" }, 429);
56
40
 
57
41
  try {
58
42
  const actors = (await search(q)).map((actor) => {
package/virtual.d.ts CHANGED
@@ -2,3 +2,8 @@ declare module "virtual:atmosphere-comments/settings" {
2
2
  const settings: import("./lib/settings").AtmosphereSettings;
3
3
  export default settings;
4
4
  }
5
+
6
+ declare module "virtual:atmosphere-comments/public-settings" {
7
+ const settings: import("./lib/public-settings").PublicSettings;
8
+ export default settings;
9
+ }
package/bin/cli.mjs DELETED
@@ -1,31 +0,0 @@
1
- #!/usr/bin/env node
2
- // npx astro-atmosphere-comments keygen
3
- //
4
- // Creates your site's private signing key and adds it to .env (in the folder
5
- // you run it from) as ATMOSPHERE_PRIVATE_JWK, without printing it. Account
6
- // hosts check your site's requests against the public half, which the
7
- // integration publishes at /atmosphere/jwks.json. It refuses if .env already
8
- // has a key: a new key signs every reader out.
9
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
- import { JoseKey } from "@atproto/oauth-client-node";
11
-
12
- const [command] = process.argv.slice(2);
13
- if (command !== "keygen") {
14
- console.log("Usage: npx astro-atmosphere-comments keygen\n\nAdds a new signing key to .env as ATMOSPHERE_PRIVATE_JWK.");
15
- process.exit(command ? 1 : 0);
16
- }
17
-
18
- const envFile = ".env";
19
- const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
20
- if (/^ATMOSPHERE_PRIVATE_JWK=.+/m.test(existing)) {
21
- console.error("ATMOSPHERE_PRIVATE_JWK is already set in .env; leaving it alone.");
22
- process.exit(1);
23
- }
24
-
25
- const key = await JoseKey.generate(["ES256"], "atmosphere-comments-1");
26
- const line = `ATMOSPHERE_PRIVATE_JWK='${JSON.stringify(key.privateJwk)}'`;
27
- const kept = existing.replace(/^ATMOSPHERE_PRIVATE_JWK=\s*$/m, "").replace(/\n*$/, existing ? "\n" : "");
28
- writeFileSync(envFile, `${kept}${line}\n`, { mode: 0o600 });
29
- console.log(
30
- "Added ATMOSPHERE_PRIVATE_JWK to .env (not shown). Set the same value on your server, keep .env out of Git, and keep a backup somewhere safe.",
31
- );
package/lib/oauth.ts DELETED
@@ -1,110 +0,0 @@
1
- // The AT Protocol OAuth client: how this site asks a reader's account host for
2
- // permission to post comments on their behalf.
3
- //
4
- // - Live, the site is a "confidential client": account hosts read its
5
- // /oauth-client-metadata.json, and it proves who it is with a private key
6
- // (ATMOSPHERE_PRIVATE_JWK, server-only). Sign-ins can then last for months.
7
- // - In `astro dev`, it uses the protocol's local-testing client (no key, no
8
- // public address needed). Open the site at http://127.0.0.1:<port>, not
9
- // localhost, because the host sends readers back to that address.
10
- import { requestLocalLock } from "@atproto/oauth-client";
11
- import {
12
- buildAtprotoLoopbackClientMetadata,
13
- JoseKey,
14
- NodeOAuthClient,
15
- type NodeSavedSession,
16
- type NodeSavedState,
17
- } from "@atproto/oauth-client-node";
18
- import { getSecret } from "astro:env/server";
19
- import settings from "./settings";
20
- import { fileStore } from "./store";
21
-
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
- // already liked or reposted), through Bluesky's AppView.
25
- export const SCOPE = [
26
- "atproto",
27
- "repo:app.bsky.feed.post?action=create",
28
- "repo:app.bsky.feed.like?action=create&action=delete",
29
- "repo:app.bsky.feed.repost?action=create&action=delete",
30
- "rpc:app.bsky.feed.getPosts?aud=did:web:api.bsky.app%23bsky_appview",
31
- ].join(" ");
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
- // Following (and unfollowing) the site's own accounts is asked for the same
40
- // way, the first time a reader presses Follow.
41
- export const FOLLOW_SCOPE = "repo:app.bsky.graph.follow?action=create&action=delete";
42
-
43
- // Everything the site may ask for, which its client metadata has to list.
44
- const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE} ${FOLLOW_SCOPE}`;
45
-
46
- const KEY_ID = "atmosphere-comments-1";
47
-
48
- export const callbackPath = `${settings.base}/callback`;
49
-
50
- export function clientMetadata() {
51
- return {
52
- // At the site's root, the address account hosts treat as the convention:
53
- // Bluesky's approval page then shows just the site's address.
54
- client_id: `${settings.site}/oauth-client-metadata.json`,
55
- client_name: settings.clientName,
56
- client_uri: settings.site,
57
- ...(settings.logoUri && { logo_uri: settings.logoUri }),
58
- ...(settings.policyUri && { policy_uri: settings.policyUri }),
59
- ...(settings.tosUri && { tos_uri: settings.tosUri }),
60
- redirect_uris: [`${settings.site}${callbackPath}`] as [string],
61
- scope: ALL_SCOPES,
62
- grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
63
- response_types: ["code"] as ["code"],
64
- application_type: "web" as const,
65
- token_endpoint_auth_method: "private_key_jwt" as const,
66
- token_endpoint_auth_signing_alg: "ES256",
67
- dpop_bound_access_tokens: true,
68
- jwks_uri: `${settings.site}${settings.base}/jwks.json`,
69
- };
70
- }
71
-
72
- let keyPromise: Promise<JoseKey> | undefined;
73
-
74
- export function privateKey(): Promise<JoseKey> {
75
- keyPromise ??= (async () => {
76
- const jwk = getSecret("ATMOSPHERE_PRIVATE_JWK");
77
- if (!jwk) throw new Error("ATMOSPHERE_PRIVATE_JWK is not set");
78
- return JoseKey.fromImportable(jwk, KEY_ID);
79
- })();
80
- return keyPromise;
81
- }
82
-
83
- const stores = {
84
- // A sign-in in progress: kept for an hour at most.
85
- state: fileStore<NodeSavedState>("oauth-state", 60 * 60 * 1000),
86
- // Each reader's tokens, by their DID.
87
- session: fileStore<NodeSavedSession>("oauth-sessions"),
88
- };
89
-
90
- let clientPromise: Promise<NodeOAuthClient> | undefined;
91
-
92
- /** The OAuth client. `origin` is only used in `astro dev`, for the local callback address. */
93
- export function oauthClient(origin: string): Promise<NodeOAuthClient> {
94
- clientPromise ??= (async () => {
95
- const common = { stateStore: stores.state, sessionStore: stores.session, requestLock: requestLocalLock };
96
- if (import.meta.env.DEV) {
97
- const local = new URL(origin);
98
- local.hostname = "127.0.0.1";
99
- return new NodeOAuthClient({
100
- ...common,
101
- clientMetadata: buildAtprotoLoopbackClientMetadata({
102
- scope: ALL_SCOPES,
103
- redirect_uris: [`${local.origin}${callbackPath}`],
104
- }),
105
- });
106
- }
107
- return new NodeOAuthClient({ ...common, clientMetadata: clientMetadata(), keyset: [await privateKey()] });
108
- })();
109
- return clientPromise;
110
- }