heartbeads 0.4.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 (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
package/lib/agent.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { Agent } from '@atproto/api'
2
+ import { getGlobalOAuthClient } from './auth/client'
3
+ import { getSession } from './session'
4
+
5
+ /**
6
+ * Get an authenticated ATProto agent for the current user.
7
+ * Uses the OAuth session stored in the cookie to restore credentials.
8
+ * Returns null if not authenticated.
9
+ */
10
+ export async function getAuthenticatedAgent(): Promise<Agent | null> {
11
+ const session = await getSession()
12
+ if (!session.did) {
13
+ return null
14
+ }
15
+
16
+ try {
17
+ const client = await getGlobalOAuthClient()
18
+ const oauthSession = await client.restore(session.did)
19
+
20
+ if (!oauthSession) {
21
+ return null
22
+ }
23
+
24
+ return new Agent(oauthSession)
25
+ } catch (err) {
26
+ console.error('Failed to restore authenticated agent:', err)
27
+ return null
28
+ }
29
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared helpers for /api/v1/* routes.
3
+ * Provides CORS headers, JSON response builders, and OPTIONS handler.
4
+ */
5
+
6
+ import { NextResponse } from "next/server";
7
+
8
+ const CORS_HEADERS = {
9
+ "Access-Control-Allow-Origin": "*",
10
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
11
+ "Access-Control-Allow-Headers": "Content-Type",
12
+ };
13
+
14
+ /**
15
+ * Build a JSON response with CORS and Cache-Control headers.
16
+ */
17
+ export function jsonResponse(data: unknown, status = 200) {
18
+ return NextResponse.json(data, {
19
+ status,
20
+ headers: {
21
+ ...CORS_HEADERS,
22
+ "Cache-Control": "public, max-age=30",
23
+ },
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Build an error JSON response with CORS headers (no caching).
29
+ */
30
+ export function errorResponse(
31
+ error: string,
32
+ status: number,
33
+ hint?: string
34
+ ) {
35
+ return NextResponse.json(
36
+ { error, ...(hint ? { hint } : {}) },
37
+ { status, headers: CORS_HEADERS }
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Preflight OPTIONS handler for CORS. Re-export from each route file.
43
+ */
44
+ export function OPTIONS() {
45
+ return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
46
+ }
@@ -0,0 +1,221 @@
1
+ import { NodeOAuthClient } from "@atproto/oauth-client-node";
2
+ import { JoseKey } from "@atproto/jwk-jose";
3
+ import { env } from "../env";
4
+ import { getRawSession } from "../session";
5
+
6
+ const oauthClientKey = "globalOAuthClient";
7
+ // In development, clear cached client on hot reload to pick up config changes
8
+ if (process.env.NODE_ENV !== "production") {
9
+ (global as Record<string, unknown>)[oauthClientKey] = null;
10
+ }
11
+ if (!(global as Record<string, unknown>)[oauthClientKey]) {
12
+ (global as Record<string, unknown>)[oauthClientKey] = null;
13
+ }
14
+
15
+ /**
16
+ * OAuth Client Configuration
17
+ *
18
+ * Supports two modes:
19
+ *
20
+ * 1. CONFIDENTIAL CLIENT (Production)
21
+ * - Requires ATPROTO_JWK_PRIVATE and PUBLIC_URL environment variables
22
+ * - Authenticates to auth servers using private key JWT
23
+ * - Longer session lifetimes
24
+ *
25
+ * 2. PUBLIC CLIENT (Development fallback)
26
+ * - Used when ATPROTO_JWK_PRIVATE is not set
27
+ * - No client authentication
28
+ * - Shorter session lifetimes
29
+ */
30
+
31
+ // ============================================================================
32
+ // In-Memory Store with Cookie Sync
33
+ // ============================================================================
34
+
35
+ const globalStoreKey = "oauthSharedStore";
36
+ if (!(global as Record<string, unknown>)[globalStoreKey]) {
37
+ (global as Record<string, unknown>)[globalStoreKey] = new Map();
38
+ }
39
+ const sharedStore: Map<string, unknown> = (
40
+ global as Record<string, unknown>
41
+ )[globalStoreKey] as Map<string, unknown>;
42
+
43
+ // State store - in-memory only, used during short-lived OAuth flow
44
+ const stateStore = {
45
+ async get(key: string) {
46
+ return sharedStore.get(`state:${key}`);
47
+ },
48
+ async set(key: string, value: unknown) {
49
+ sharedStore.set(`state:${key}`, value);
50
+ },
51
+ async del(key: string) {
52
+ sharedStore.delete(`state:${key}`);
53
+ },
54
+ };
55
+
56
+ // Session store - syncs with cookie for persistence
57
+ const sessionStore = {
58
+ async get(key: string) {
59
+ const memValue = sharedStore.get(`session:${key}`);
60
+ if (memValue) {
61
+ return memValue;
62
+ }
63
+
64
+ try {
65
+ const session = await getRawSession();
66
+ if (session.oauthSession && session.did === key) {
67
+ const parsed = JSON.parse(session.oauthSession);
68
+ sharedStore.set(`session:${key}`, parsed);
69
+ return parsed;
70
+ }
71
+ } catch (err) {
72
+ console.warn("Failed to restore OAuth session from cookie:", err);
73
+ }
74
+
75
+ return undefined;
76
+ },
77
+ async set(key: string, value: unknown) {
78
+ sharedStore.set(`session:${key}`, value);
79
+
80
+ try {
81
+ const session = await getRawSession();
82
+ session.oauthSession = JSON.stringify(value);
83
+ await session.save();
84
+ } catch (err) {
85
+ console.warn("Failed to save OAuth session to cookie:", err);
86
+ }
87
+ },
88
+ async del(key: string) {
89
+ sharedStore.delete(`session:${key}`);
90
+
91
+ try {
92
+ const session = await getRawSession();
93
+ session.oauthSession = undefined;
94
+ await session.save();
95
+ } catch (err) {
96
+ console.warn("Failed to clear OAuth session from cookie:", err);
97
+ }
98
+ },
99
+ };
100
+
101
+ // ============================================================================
102
+ // JWK Keyset Management
103
+ // ============================================================================
104
+
105
+ let cachedKeyset: Awaited<ReturnType<typeof JoseKey.fromImportable>>[] | null =
106
+ null;
107
+
108
+ async function getKeyset() {
109
+ if (cachedKeyset) {
110
+ return cachedKeyset;
111
+ }
112
+
113
+ const jwkPrivate = env.ATPROTO_JWK_PRIVATE;
114
+ if (!jwkPrivate) {
115
+ return null; // Public client mode
116
+ }
117
+
118
+ try {
119
+ const jwk = JSON.parse(jwkPrivate);
120
+ const key = await JoseKey.fromImportable(jwk, jwk.kid || "key-1");
121
+ cachedKeyset = [key];
122
+ return cachedKeyset;
123
+ } catch (err) {
124
+ console.error("Failed to parse ATPROTO_JWK_PRIVATE:", err);
125
+ return null;
126
+ }
127
+ }
128
+
129
+ // ============================================================================
130
+ // OAuth Client Factory
131
+ // ============================================================================
132
+
133
+ export const createClient = async () => {
134
+ const publicUrl = env.PUBLIC_URL;
135
+ // Must use 127.0.0.1 per RFC 8252 for ATProto OAuth localhost development
136
+ const localhostUrl = `http://127.0.0.1:${env.PORT}`;
137
+ const enc = encodeURIComponent;
138
+
139
+ // Confidential client mode is determined by having both PUBLIC_URL and a JWK
140
+ // private key — not by NODE_ENV. This lets remote deployments work regardless
141
+ // of how Next.js was started (next dev vs next start).
142
+ const hasJwk = !!env.ATPROTO_JWK_PRIVATE;
143
+ const hasPublicUrl = !!publicUrl;
144
+
145
+ let keyset = null;
146
+ try {
147
+ if (hasJwk && hasPublicUrl) {
148
+ keyset = await getKeyset();
149
+ }
150
+ } catch (err) {
151
+ console.error("Error getting keyset:", err);
152
+ }
153
+
154
+ const isConfidentialClient = keyset !== null && hasPublicUrl;
155
+ const url = isConfidentialClient ? publicUrl : localhostUrl;
156
+
157
+ // Build client metadata based on client type
158
+ const clientMetadata: Record<string, unknown> = {
159
+ client_name: "Beads Map",
160
+ client_uri: url,
161
+ dpop_bound_access_tokens: true,
162
+ grant_types: ["authorization_code", "refresh_token"],
163
+ response_types: ["code"],
164
+ scope: "atproto transition:generic",
165
+ application_type: "web",
166
+ };
167
+
168
+ if (isConfidentialClient) {
169
+ clientMetadata.client_id = `${publicUrl}/api/oauth/client-metadata.json`;
170
+ clientMetadata.redirect_uris = [`${publicUrl}/api/oauth/callback`];
171
+ clientMetadata.token_endpoint_auth_method = "private_key_jwt";
172
+ clientMetadata.token_endpoint_auth_signing_alg = "ES256";
173
+ clientMetadata.jwks_uri = `${publicUrl}/api/oauth/jwks.json`;
174
+ } else {
175
+ clientMetadata.client_id = `http://localhost?redirect_uri=${enc(`${url}/api/oauth/callback`)}&scope=${enc("atproto transition:generic")}`;
176
+ clientMetadata.redirect_uris = [`${url}/api/oauth/callback`];
177
+ clientMetadata.token_endpoint_auth_method = "none";
178
+ }
179
+
180
+ const clientConfig: Record<string, unknown> = {
181
+ clientMetadata,
182
+ stateStore,
183
+ sessionStore,
184
+ };
185
+
186
+ if (keyset) {
187
+ clientConfig.keyset = keyset;
188
+ }
189
+
190
+ return new NodeOAuthClient(
191
+ clientConfig as ConstructorParameters<typeof NodeOAuthClient>[0]
192
+ );
193
+ };
194
+
195
+ export const getGlobalOAuthClient = async () => {
196
+ const currentClient = (global as Record<string, unknown>)[oauthClientKey];
197
+ if (!currentClient) {
198
+ try {
199
+ const newClient = await createClient();
200
+ (global as Record<string, unknown>)[oauthClientKey] = newClient;
201
+ return newClient;
202
+ } catch (err) {
203
+ console.error("Failed to create OAuth client:", err);
204
+ throw err;
205
+ }
206
+ }
207
+ return currentClient as NodeOAuthClient;
208
+ };
209
+
210
+ /**
211
+ * Get the JWKS (public keys) for the confidential client.
212
+ */
213
+ export async function getJwks(): Promise<{ keys: unknown[] } | null> {
214
+ const client = await getGlobalOAuthClient();
215
+
216
+ if ("jwks" in client && client.jwks) {
217
+ return client.jwks as { keys: unknown[] };
218
+ }
219
+
220
+ return null;
221
+ }
package/lib/auth.tsx ADDED
@@ -0,0 +1,159 @@
1
+ "use client";
2
+
3
+ import {
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ createContext,
8
+ useContext,
9
+ } from "react";
10
+
11
+ export interface AuthSession {
12
+ did: string;
13
+ handle: string;
14
+ displayName?: string;
15
+ avatar?: string;
16
+ }
17
+
18
+ export type AuthStatus = "idle" | "authorizing" | "authenticated" | "error";
19
+
20
+ interface AuthState {
21
+ status: AuthStatus;
22
+ session: AuthSession | null;
23
+ error: Error | null;
24
+ isLoading: boolean;
25
+ }
26
+
27
+ const AuthContext = createContext<{
28
+ state: AuthState;
29
+ login: (handle: string) => Promise<void>;
30
+ logout: () => Promise<void>;
31
+ } | null>(null);
32
+
33
+ /**
34
+ * Auth Provider - wraps the app to provide authentication state.
35
+ * Checks session status on mount via /api/status.
36
+ */
37
+ export function AuthProvider({ children }: { children: React.ReactNode }) {
38
+ const [state, setState] = useState<AuthState>({
39
+ status: "idle",
40
+ session: null,
41
+ error: null,
42
+ isLoading: true,
43
+ });
44
+
45
+ // Check session status on mount
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+
49
+ const checkStatus = async () => {
50
+ try {
51
+ const response = await fetch("/api/status");
52
+ if (response.ok) {
53
+ const data = await response.json();
54
+ if (data.did && !cancelled) {
55
+ setState({
56
+ status: "authenticated",
57
+ session: {
58
+ did: data.did,
59
+ handle: data.handle || data.did,
60
+ displayName: data.displayName,
61
+ avatar: data.avatar,
62
+ },
63
+ error: null,
64
+ isLoading: false,
65
+ });
66
+ return;
67
+ }
68
+ }
69
+ } catch (error) {
70
+ console.error("Failed to check auth status:", error);
71
+ }
72
+ if (!cancelled) {
73
+ setState((prev) => ({ ...prev, isLoading: false }));
74
+ }
75
+ };
76
+
77
+ checkStatus();
78
+ return () => {
79
+ cancelled = true;
80
+ };
81
+ }, []);
82
+
83
+ // Login - initiates OAuth flow, redirects to PDS authorization
84
+ const login = useCallback(async (handle: string) => {
85
+ setState((prev) => ({
86
+ ...prev,
87
+ status: "authorizing",
88
+ isLoading: true,
89
+ error: null,
90
+ }));
91
+
92
+ try {
93
+ const normalizedHandle = handle.includes(".")
94
+ ? handle
95
+ : `${handle}.bsky.social`;
96
+
97
+ const response = await fetch("/api/login", {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({
101
+ handle: normalizedHandle,
102
+ returnTo: window.location.pathname + window.location.search,
103
+ }),
104
+ });
105
+
106
+ if (!response.ok) {
107
+ const data = await response.json();
108
+ throw new Error(data.error || "Login failed");
109
+ }
110
+
111
+ const data = await response.json();
112
+ window.location.href = data.redirectUrl;
113
+ } catch (err) {
114
+ const error = err instanceof Error ? err : new Error("Login failed");
115
+ setState({ status: "error", session: null, error, isLoading: false });
116
+ throw error;
117
+ }
118
+ }, []);
119
+
120
+ // Logout - clears server session
121
+ const logout = useCallback(async () => {
122
+ try {
123
+ await fetch("/api/logout", { method: "POST" });
124
+ } catch (error) {
125
+ console.error("Logout request failed:", error);
126
+ }
127
+ setState({ status: "idle", session: null, error: null, isLoading: false });
128
+ }, []);
129
+
130
+ return (
131
+ <AuthContext.Provider value={{ state, login, logout }}>
132
+ {children}
133
+ </AuthContext.Provider>
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Hook to access auth state and actions.
139
+ * Must be used within an AuthProvider.
140
+ */
141
+ export function useAuth() {
142
+ const context = useContext(AuthContext);
143
+
144
+ if (!context) {
145
+ throw new Error("useAuth must be used within an AuthProvider");
146
+ }
147
+
148
+ const { state, login, logout } = context;
149
+
150
+ return {
151
+ status: state.status,
152
+ session: state.session,
153
+ error: state.error,
154
+ isLoading: state.isLoading,
155
+ isAuthenticated: state.status === "authenticated",
156
+ login,
157
+ logout,
158
+ };
159
+ }