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.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +49 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- 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
|
+
}
|