feat: Implement OAuth with DPoP using @atproto/oauth-client-node

Replace manual OAuth implementation with official @atproto/oauth-client-node library to properly support DPoP (Demonstrating Proof of Possession) authentication.

Changes:
- Added @atproto/oauth-client-node dependency
- Created OAuth state store (SurrealDB-backed) for CSRF protection
- Created OAuth session store (SurrealDB-backed) for token persistence
- Created OAuth client singleton with localhost exception for development
- Rewrote /api/auth/login to use client.authorize()
- Rewrote /api/auth/callback to use client.callback() with DPoP
- Updated lib/auth/session.ts with getAuthenticatedAgent() for ATproto API calls
- Updated db/schema.surql with oauth_state and oauth_session tables
- Added scripts/apply-schema.js for database schema management
- Created plans/oauth-dpop-implementation.md with detailed implementation plan
- Removed legacy lib/auth/atproto.ts and lib/auth/oauth-state.ts
- Updated .env to use localhost exception (removed BLUESKY_CLIENT_ID)

The OAuth client now handles:
- PKCE code generation and verification
- DPoP proof generation and signing
- Automatic token refresh
- Session persistence across server restarts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 01:40:04 +00:00
parent e39f5b857e
commit 6ff6bae270
13 changed files with 2104 additions and 333 deletions

View File

@@ -1,81 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getAuthEndpoints } from '@/lib/auth/atproto';
import { consumeOAuthState } from '@/lib/auth/oauth-state';
import { getOAuthClient } from '@/lib/auth/oauth-client';
import { mintSurrealJwt } from '@/lib/auth/jwt';
import { AtpAgent } from '@atproto/api';
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
const REDIRECT_URI = process.env.BLUESKY_REDIRECT_URI;
import { Agent } from '@atproto/api';
import Surreal from 'surrealdb';
/**
* GET /api/auth/callback
*
* OAuth callback endpoint. The user is redirected here from the
* ATproto PDS after authorizing the application.
*
* Query parameters (set by PDS):
* - code: Authorization code
* - state: CSRF protection token
*
* This endpoint:
* 1. Exchanges the code for an access token (with DPoP)
* 2. Retrieves the user's profile
* 3. Upserts the user in SurrealDB
* 4. Mints our app's JWT
* 5. Redirects to the chat page
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
// 1. Validate state parameter
if (!state || !code) {
return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url));
// Check for error from OAuth provider
const error = searchParams.get('error');
if (error) {
const errorDescription = searchParams.get('error_description') || 'Unknown error';
console.error('[OAuth Callback] Error from provider:', error, errorDescription);
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(errorDescription)}`, request.url)
);
}
// 2. Retrieve OAuth state from database (this also deletes it for one-time use)
console.log('[OAuth Callback] Looking up state:', state);
const oauthState = await consumeOAuthState(state);
console.log('[OAuth Callback] Retrieved oauthState:', oauthState);
if (!oauthState) {
console.error('[OAuth Callback] Invalid or expired state:', state);
return NextResponse.redirect(new URL('/login?error=Invalid or expired state', request.url));
}
const { codeVerifier: code_verifier, pdsUrl } = oauthState;
try {
// 3. Get the PDS's token endpoint
const { tokenEndpoint } = await getAuthEndpoints(pdsUrl);
console.log('[OAuth Callback] Processing callback...');
// 4. Exchange the code for an ATproto access token
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI!,
client_id: CLIENT_ID!,
code_verifier: code_verifier,
}),
});
// Get OAuth client
const client = await getOAuthClient();
if (!tokenResponse.ok) {
throw new Error('Failed to exchange code for token');
// Exchange authorization code for session
// The library handles:
// - PKCE verification (using stored code_verifier)
// - DPoP proof generation for token request
// - Token exchange with PDS
// - Token validation
// - Session storage in sessionStore
const { session, state } = await client.callback(searchParams);
console.log('[OAuth Callback] ✓ Successfully authenticated user:', session.did);
// Create ATproto agent with session
const agent = new Agent(session);
// Fetch user profile
const profileResponse = await agent.getProfile({ actor: session.did });
if (!profileResponse.success) {
throw new Error('Failed to fetch user profile');
}
const { access_token, refresh_token } = await tokenResponse.json();
const { did, handle } = profileResponse.data;
// 5. Use the ATproto token to get the user's session info (did, handle)
const agent = new AtpAgent({ service: pdsUrl });
console.log('[OAuth Callback] User profile:', { did, handle });
// Set the session with the tokens we just received
agent.resumeSession({
accessJwt: access_token,
refreshJwt: refresh_token,
did: '', // Will be populated by getSession call
handle: '', // Will be populated by getSession call
});
// Fetch the actual session info from the server
const sessionResponse = await agent.api.com.atproto.server.getSession();
if (!sessionResponse.success || !sessionResponse.data.did || !sessionResponse.data.handle) {
throw new Error('Failed to retrieve user session details');
}
const { did, handle } = sessionResponse.data;
// 6. Create or update user in SurrealDB
// We use root credentials here since the user doesn't have a JWT yet
const Surreal = (await import('surrealdb')).default;
// Upsert user in SurrealDB
const db = new Surreal();
await db.connect(process.env.SURREALDB_URL!);
await db.signin({
@@ -87,20 +77,36 @@ export async function GET(request: NextRequest) {
database: process.env.SURREALDB_DB!,
});
// Upsert the user (create if doesn't exist, update handle if it does)
await db.query(
'INSERT INTO user (did, handle) VALUES ($did, $handle) ON DUPLICATE KEY UPDATE handle = $handle',
`INSERT INTO user (did, handle)
VALUES ($did, $handle)
ON DUPLICATE KEY UPDATE handle = $handle`,
{ did, handle }
);
await db.close();
// 7. Mint OUR app's SurrealDB JWT
console.log('[OAuth Callback] ✓ Created/updated user in SurrealDB');
// Mint our app's SurrealDB JWT
const surrealJwt = mintSurrealJwt(did, handle);
// 8. Create redirect response
const response = NextResponse.redirect(new URL('/chat', request.url));
// Parse custom state to determine redirect URL
let returnTo = '/chat';
try {
const customState = JSON.parse(state);
if (customState.returnTo) {
returnTo = customState.returnTo;
}
} catch {
// Invalid state JSON, use default
console.warn('[OAuth Callback] Could not parse custom state, using default redirect');
}
// 9. Set the SurrealDB JWT in a secure cookie on the response
// Create redirect response
const response = NextResponse.redirect(new URL(returnTo, request.url));
// Set SurrealDB JWT cookie (for our app's authorization)
response.cookies.set('ponderants-auth', surrealJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
@@ -109,26 +115,34 @@ export async function GET(request: NextRequest) {
path: '/',
});
// Store the ATproto tokens for later use
response.cookies.set('atproto_access_token', access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60,
path: '/',
});
response.cookies.set('atproto_refresh_token', refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
console.log('[OAuth Callback] ✓ Authentication complete, redirecting to:', returnTo);
// Note: We do NOT store ATproto tokens in cookies
// The oauth-client library manages them in sessionStore
// and will automatically refresh them when needed
// 10. Redirect to the main application
return response;
} catch (error) {
console.error('Auth callback error:', error);
return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url));
console.error('[OAuth Callback] Error:', error);
// Check for specific OAuth errors
if (error instanceof Error) {
if (error.message.includes('Invalid state')) {
return NextResponse.redirect(
new URL('/login?error=Invalid or expired session', request.url)
);
}
if (error.message.includes('DPoP')) {
console.error('[OAuth Callback] DPoP error - this should not happen with the library!', error);
return NextResponse.redirect(
new URL('/login?error=Authentication protocol error', request.url)
);
}
}
return NextResponse.redirect(
new URL('/login?error=Authentication failed', request.url)
);
}
}

View File

@@ -1,52 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto';
import { storeOAuthState } from '@/lib/auth/oauth-state';
import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client';
import { getOAuthClient } from '@/lib/auth/oauth-client';
import { z } from 'zod';
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
const REDIRECT_URI = process.env.BLUESKY_REDIRECT_URI;
export async function GET(request: NextRequest) {
if (!CLIENT_ID || !REDIRECT_URI) {
throw new Error('Bluesky client configuration is missing.');
}
const { searchParams } = new URL(request.url);
const handle = searchParams.get('handle');
if (!handle) {
return NextResponse.redirect(new URL('/login?error=Handle missing', request.url));
}
const LoginRequestSchema = z.object({
handle: z.string().min(1, 'Handle is required'),
});
/**
* POST /api/auth/login
*
* Initiates the OAuth flow for a given ATproto handle.
*
* Request body: { handle: "user.bsky.social" }
* Response: { url: "https://bsky.social/oauth/authorize?..." }
*
* The client should redirect the user to the returned URL.
*/
export async function POST(request: NextRequest) {
try {
// 1. Resolve handle to get PDS
const { pdsUrl } = await resolveHandle(handle);
// Parse and validate request body
const body = await request.json();
const { handle } = LoginRequestSchema.parse(body);
// 2. Discover PDS-specific auth endpoints
const { authorizationEndpoint } = await getAuthEndpoints(pdsUrl);
console.log('[OAuth Login] Initiating OAuth flow for handle:', handle);
// 3. Generate PKCE challenge and state
const state = randomState();
const code_verifier = randomPKCECodeVerifier();
const code_challenge = await calculatePKCECodeChallenge(code_verifier);
// Get OAuth client
const client = await getOAuthClient();
// 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects)
await storeOAuthState(state, code_verifier, pdsUrl);
// Generate authorization URL
// The library handles:
// - Handle resolution to PDS
// - Authorization endpoint discovery
// - PKCE code generation (code_verifier, code_challenge)
// - DPoP key generation
// - State storage
const authUrl = await client.authorize(handle, {
// Custom state that will be returned in callback
state: JSON.stringify({
timestamp: Date.now(),
returnTo: '/chat',
}),
});
// 5. Construct the authorization URL
const authUrl = new URL(authorizationEndpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'atproto');
authUrl.searchParams.set('code_challenge', code_challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state);
console.log('[OAuth Login] ✓ Generated authorization URL');
// 6. Redirect user to the PDS login screen
return NextResponse.redirect(authUrl);
return NextResponse.json({ url: authUrl });
} catch (error) {
console.error('Auth login error:', error);
return NextResponse.redirect(new URL('/login?error=Invalid handle or PDS', request.url));
console.error('[OAuth Login] Error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request', details: error.errors },
{ status: 400 }
);
}
if (error instanceof Error && error.message.includes('Could not resolve handle')) {
return NextResponse.json(
{ error: 'Invalid handle or PDS not found' },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Failed to initiate OAuth flow' },
{ status: 500 }
);
}
}

View File

@@ -89,3 +89,54 @@ DEFINE TABLE links_to SCHEMAFULL
-- (No fields needed, it's a simple relation)
-- Example usage: RELATE (node:1)-[links_to]->(node:2);
-- --------------------------------------------------
-- Table: oauth_state
-- --------------------------------------------------
-- Stores temporary OAuth state during the authorization flow.
-- Used for CSRF protection. States should expire after 1 hour.
DEFINE TABLE oauth_state SCHEMAFULL;
-- The state key (random string generated during authorize)
DEFINE FIELD key ON TABLE oauth_state TYPE string
ASSERT $value != NONE;
-- The state value (contains PKCE verifier, DPoP key, etc.)
DEFINE FIELD value ON TABLE oauth_state TYPE object
ASSERT $value != NONE;
-- Timestamp for cleanup
DEFINE FIELD created_at ON TABLE oauth_state TYPE datetime
DEFAULT time::now();
-- Index for fast lookups by key
DEFINE INDEX oauth_state_key_idx ON TABLE oauth_state COLUMNS key UNIQUE;
-- Event to auto-delete expired states (older than 1 hour)
DEFINE EVENT oauth_state_cleanup ON TABLE oauth_state WHEN time::now() - created_at > 1h THEN (
DELETE oauth_state WHERE id = $event.id
);
-- --------------------------------------------------
-- Table: oauth_session
-- --------------------------------------------------
-- Stores persistent OAuth sessions (access/refresh tokens).
-- Sessions are managed by the @atproto/oauth-client-node library.
DEFINE TABLE oauth_session SCHEMAFULL;
-- The user's DID (unique identifier)
DEFINE FIELD did ON TABLE oauth_session TYPE string
ASSERT $value != NONE;
-- The session data (contains tokens, DPoP key, etc.)
DEFINE FIELD session_data ON TABLE oauth_session TYPE object
ASSERT $value != NONE;
-- Timestamp for last update (useful for debugging)
DEFINE FIELD updated_at ON TABLE oauth_session TYPE datetime
DEFAULT time::now();
-- Index for fast lookups by DID
DEFINE INDEX oauth_session_did_idx ON TABLE oauth_session COLUMNS did UNIQUE;

View File

@@ -1,63 +0,0 @@
import { AtpAgent } from '@atproto/api';
/**
* Resolves a Bluesky handle (e.g., "user.bsky.social") to its
* corresponding PDS (Personal Data Server) and DID (Decentralized Identifier).
* This discovery step is mandatory before initiating OAuth.
*/
export async function resolveHandle(handle: string) {
try {
const agent = new AtpAgent({ service: 'https://bsky.social' });
const response = await agent.resolveHandle({ handle });
const did = response.data.did;
// Now, get the PDS from the DID document
const didDoc = await agent.com.atproto.identity.resolveHandle({ handle });
// Get the PDS service endpoint from the DID document
const pdsService = didDoc.data;
if (!pdsService) {
throw new Error('PDS service endpoint not found in DID document.');
}
return {
did,
pdsUrl: 'https://bsky.social', // For now, all Bluesky users use the main PDS
};
} catch (error) {
console.error('Error resolving handle:', error);
throw new Error('Could not resolve Bluesky handle.');
}
}
/**
* Fetches the specific OAuth endpoints for a given PDS.
* Each PDS has its own set of endpoints.
*/
export async function getAuthEndpoints(pdsUrl: string) {
try {
const metadataUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
const response = await fetch(metadataUrl);
if (!response.ok) {
throw new Error(`Failed to fetch auth metadata from ${pdsUrl}`);
}
const metadata = await response.json();
const { authorization_endpoint, token_endpoint } = metadata;
if (!authorization_endpoint || !token_endpoint) {
throw new Error('Invalid auth metadata received from PDS.');
}
return {
authorizationEndpoint: authorization_endpoint,
tokenEndpoint: token_endpoint,
};
} catch (error) {
console.error('Error getting auth endpoints:', error);
throw new Error('Could not discover OAuth endpoints.');
}
}

75
lib/auth/oauth-client.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* OAuth Client Singleton for ATproto
*
* This module provides a singleton instance of NodeOAuthClient
* that manages OAuth flows, DPoP proofs, and session persistence.
*/
import { NodeOAuthClient } from '@atproto/oauth-client-node';
import { createStateStore } from './oauth-state-store';
import { createSessionStore } from './oauth-session-store';
let clientInstance: NodeOAuthClient | null = null;
/**
* Get or create the singleton OAuth client instance.
*
* In development, uses the localhost client exception (no keys needed).
* In production, uses backend service with private keys (TODO).
*
* The client handles:
* - OAuth authorization flow with PKCE
* - DPoP (Demonstrating Proof of Possession) for token requests
* - Automatic token refresh
* - Session persistence in SurrealDB
*/
export async function getOAuthClient(): Promise<NodeOAuthClient> {
if (clientInstance) {
return clientInstance;
}
const isDev = process.env.NODE_ENV === 'development';
const callbackUrl = process.env.BLUESKY_REDIRECT_URI;
if (!callbackUrl) {
throw new Error('BLUESKY_REDIRECT_URI environment variable is required');
}
if (isDev) {
// Development: Use localhost exception
// Per ATproto spec, client_id must be exactly "http://localhost"
// (no port number) with metadata in query parameters
const clientId = `http://localhost?${new URLSearchParams({
redirect_uri: callbackUrl,
scope: 'atproto',
})}`;
console.log('[OAuth] Initializing development client with localhost exception');
console.log('[OAuth] client_id:', clientId);
clientInstance = await NodeOAuthClient.fromClientId({
clientId,
stateStore: createStateStore(),
sessionStore: createSessionStore(),
});
console.log('[OAuth] ✓ Development client initialized');
} else {
// Production: Backend service with keys
// TODO: Implement when deploying to production
// See plans/oauth-dpop-implementation.md for details
throw new Error(
'Production OAuth client not yet implemented. ' +
'See plans/oauth-dpop-implementation.md for production setup instructions.'
);
}
return clientInstance;
}
/**
* Clear the singleton instance (mainly for testing).
*/
export function clearOAuthClient(): void {
clientInstance = null;
}

View File

@@ -0,0 +1,84 @@
/**
* OAuth Session Store for @atproto/oauth-client-node
*
* Stores persistent OAuth sessions (access/refresh tokens, DPoP keys).
* Sessions are keyed by the user's DID.
*/
import Surreal from 'surrealdb';
import type { NodeSavedSessionStore, NodeSavedSession } from '@atproto/oauth-client-node';
/**
* Get a SurrealDB connection with root credentials.
* Used for OAuth session management.
*/
async function getDB(): Promise<Surreal> {
const db = new Surreal();
await db.connect(process.env.SURREALDB_URL!);
await db.signin({
username: process.env.SURREALDB_USER!,
password: process.env.SURREALDB_PASS!,
});
await db.use({
namespace: process.env.SURREALDB_NS!,
database: process.env.SURREALDB_DB!,
});
return db;
}
/**
* Create an OAuth session store backed by SurrealDB.
*
* The session store persists authenticated user sessions across
* server restarts. The @atproto/oauth-client-node library manages
* token refresh automatically, updating the store when tokens change.
*
* Sessions are indexed by DID (decentralized identifier).
*/
export function createSessionStore(): NodeSavedSessionStore {
return {
async set(did: string, sessionData: NodeSavedSession): Promise<void> {
const db = await getDB();
try {
// Upsert: create if doesn't exist, update if it does
await db.query(
`INSERT INTO oauth_session (did, session_data)
VALUES ($did, $session_data)
ON DUPLICATE KEY UPDATE session_data = $session_data, updated_at = time::now()`,
{ did, session_data: sessionData }
);
} finally {
await db.close();
}
},
async get(did: string): Promise<NodeSavedSession | undefined> {
const db = await getDB();
try {
const [result] = await db.query<[{ session_data: NodeSavedSession }[]]>(
'SELECT session_data FROM oauth_session WHERE did = $did',
{ did }
);
return result?.[0]?.session_data;
} finally {
await db.close();
}
},
async del(did: string): Promise<void> {
const db = await getDB();
try {
await db.query(
'DELETE oauth_session WHERE did = $did',
{ did }
);
} finally {
await db.close();
}
},
};
}

View File

@@ -0,0 +1,81 @@
/**
* OAuth State Store for @atproto/oauth-client-node
*
* Stores temporary OAuth state during the authorization flow.
* Used for CSRF protection and PKCE verification.
*/
import Surreal from 'surrealdb';
import type { NodeSavedStateStore, NodeSavedState } from '@atproto/oauth-client-node';
/**
* Get a SurrealDB connection with root credentials.
* Used for OAuth state management.
*/
async function getDB(): Promise<Surreal> {
const db = new Surreal();
await db.connect(process.env.SURREALDB_URL!);
await db.signin({
username: process.env.SURREALDB_USER!,
password: process.env.SURREALDB_PASS!,
});
await db.use({
namespace: process.env.SURREALDB_NS!,
database: process.env.SURREALDB_DB!,
});
return db;
}
/**
* Create an OAuth state store backed by SurrealDB.
*
* The state store is used during the OAuth flow to store
* temporary data (PKCE verifier, DPoP key, etc.) that is
* retrieved when the user returns from the authorization server.
*
* States expire after 1 hour (enforced by database event).
*/
export function createStateStore(): NodeSavedStateStore {
return {
async set(key: string, value: NodeSavedState): Promise<void> {
const db = await getDB();
try {
await db.query(
'CREATE oauth_state SET key = $key, value = $value',
{ key, value }
);
} finally {
await db.close();
}
},
async get(key: string): Promise<NodeSavedState | undefined> {
const db = await getDB();
try {
const [result] = await db.query<[{ value: NodeSavedState }[]]>(
'SELECT value FROM oauth_state WHERE key = $key',
{ key }
);
return result?.[0]?.value;
} finally {
await db.close();
}
},
async del(key: string): Promise<void> {
const db = await getDB();
try {
await db.query(
'DELETE oauth_state WHERE key = $key',
{ key }
);
} finally {
await db.close();
}
},
};
}

View File

@@ -1,143 +0,0 @@
import Surreal from 'surrealdb';
const SURREALDB_URL = process.env.SURREALDB_URL;
const SURREALDB_NAMESPACE = process.env.SURREALDB_NS;
const SURREALDB_DATABASE = process.env.SURREALDB_DB;
const SURREALDB_USER = process.env.SURREALDB_USER;
const SURREALDB_PASS = process.env.SURREALDB_PASS;
if (!SURREALDB_URL || !SURREALDB_NAMESPACE || !SURREALDB_DATABASE || !SURREALDB_USER || !SURREALDB_PASS) {
throw new Error('SurrealDB configuration is missing. Please set SURREALDB_URL, SURREALDB_NS, SURREALDB_DB, SURREALDB_USER, and SURREALDB_PASS in .env');
}
interface OAuthState {
state: string;
code_verifier: string;
pds_url: string;
created_at: string;
}
/**
* Gets a SurrealDB connection for OAuth state management.
* This uses a separate, unauthenticated connection since OAuth happens before user auth.
*/
async function getDb() {
const db = new Surreal();
await db.connect(SURREALDB_URL);
// Sign in with root credentials for OAuth state management
await db.signin({
username: SURREALDB_USER,
password: SURREALDB_PASS,
});
await db.use({ namespace: SURREALDB_NAMESPACE, database: SURREALDB_DATABASE });
return db;
}
/**
* Stores OAuth state parameters in SurrealDB for the duration of the OAuth flow.
* The state is used as the record ID for easy lookup.
*
* @param state - The OAuth state parameter (CSRF token)
* @param codeVerifier - The PKCE code verifier
* @param pdsUrl - The user's PDS URL
*/
export async function storeOAuthState(
state: string,
codeVerifier: string,
pdsUrl: string
): Promise<void> {
const db = await getDb();
try {
// Store with a 10 minute TTL (OAuth flows should complete quickly)
const created_at = new Date().toISOString();
console.log('[OAuth State] Storing state:', state);
// Use CREATE with CONTENT to store all fields
const result = await db.create(`oauth_state:⟨${state}`, {
state,
code_verifier: codeVerifier,
pds_url: pdsUrl,
created_at,
});
console.log('[OAuth State] Stored successfully:', JSON.stringify(result, null, 2));
} finally {
await db.close();
}
}
/**
* Retrieves and deletes OAuth state from SurrealDB.
* This ensures one-time use of the state parameter (security best practice).
*
* @param state - The OAuth state parameter to look up
* @returns The OAuth state data, or null if not found or expired
*/
export async function consumeOAuthState(
state: string
): Promise<{ codeVerifier: string; pdsUrl: string } | null> {
const db = await getDb();
try {
console.log('[OAuth State] Retrieving state:', state);
// Retrieve the state by record ID
const selectResult = await db.select<OAuthState>(`oauth_state:⟨${state}`);
console.log('[OAuth State] Select result:', JSON.stringify(selectResult, null, 2));
// db.select() returns an array when selecting a specific record ID
const result = Array.isArray(selectResult) ? selectResult[0] : selectResult;
console.log('[OAuth State] Retrieved record:', JSON.stringify(result, null, 2));
if (!result) {
console.log('[OAuth State] No result found for state:', state);
return null;
}
// Check if expired (older than 10 minutes)
const createdAt = new Date(result.created_at);
const now = new Date();
const ageMinutes = (now.getTime() - createdAt.getTime()) / 1000 / 60;
console.log('[OAuth State] State age:', ageMinutes, 'minutes');
if (ageMinutes > 10) {
console.log('[OAuth State] State expired, deleting');
// Delete expired state
await db.delete(`oauth_state:⟨${state}`);
return null;
}
// Delete the state (one-time use)
console.log('[OAuth State] Deleting state (one-time use)');
await db.delete(`oauth_state:⟨${state}`);
return {
codeVerifier: result.code_verifier,
pdsUrl: result.pds_url,
};
} finally {
await db.close();
}
}
/**
* Cleans up expired OAuth states (should be called periodically).
* In production, this would be a cron job or scheduled task.
*/
export async function cleanupExpiredOAuthStates(): Promise<number> {
const db = await getDb();
try {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const result = await db.query<[OAuthState[]]>(
'DELETE FROM oauth_state WHERE created_at < $cutoff RETURN BEFORE',
{ cutoff: tenMinutesAgo }
);
return result[0]?.length || 0;
} finally {
await db.close();
}
}

View File

@@ -1,5 +1,8 @@
import { cookies } from 'next/headers';
import { verifySurrealJwt, type UserSession } from './jwt';
import { getOAuthClient } from './oauth-client';
import { Agent } from '@atproto/api';
import jwt from 'jsonwebtoken';
/**
* Gets the current authenticated user from the session cookie.
@@ -17,3 +20,104 @@ export async function getCurrentUser(): Promise<UserSession | null> {
return verifySurrealJwt(authCookie.value);
}
/**
* Get the authenticated ATproto agent for the current user.
*
* Automatically refreshes tokens if needed. The OAuth client
* handles token refresh transparently and updates the sessionStore.
*
* @returns Agent instance for making authenticated ATproto API calls,
* or null if user is not authenticated
*/
export async function getAuthenticatedAgent(): Promise<Agent | null> {
try {
// Get user DID from our SurrealDB JWT cookie
const cookieStore = await cookies();
const authCookie = cookieStore.get('ponderants-auth');
if (!authCookie) {
return null;
}
// Decode JWT to get DID (we don't verify here since SurrealDB will verify)
const payload = jwt.decode(authCookie.value) as { sub: string } | null;
if (!payload?.sub) {
return null;
}
const did = payload.sub;
console.log('[Session] Restoring session for DID:', did);
// Restore session from OAuth client
// This will automatically refresh tokens if they're expired
const client = await getOAuthClient();
const session = await client.restore(did);
console.log('[Session] ✓ Session restored');
// Create agent with session
return new Agent(session);
} catch (error) {
console.error('[Session] Failed to restore session:', error);
return null;
}
}
/**
* Sign out the current user.
*
* Revokes tokens with the PDS and clears the session from storage.
* Also clears our app's JWT cookie.
*/
export async function signOut(): Promise<void> {
try {
const agent = await getAuthenticatedAgent();
if (agent) {
console.log('[Session] Signing out user:', agent.did);
// Revoke session (calls PDS to revoke tokens)
const session = (agent as any).session;
if (session?.signOut) {
await session.signOut();
console.log('[Session] ✓ Session revoked with PDS');
}
}
} catch (error) {
console.error('[Session] Sign out error:', error);
// Continue to clear cookie even if revocation fails
}
// Clear our app's cookie
const cookieStore = await cookies();
cookieStore.delete('ponderants-auth');
console.log('[Session] ✓ Local session cleared');
}
/**
* Get the current user's DID from the session cookie.
*
* This is a lightweight check that doesn't require restoring
* the full OAuth session. Useful for quick authorization checks.
*
* @returns User's DID or null if not authenticated
*/
export async function getCurrentUserDid(): Promise<string | null> {
try {
const cookieStore = await cookies();
const authCookie = cookieStore.get('ponderants-auth');
if (!authCookie) {
return null;
}
const payload = jwt.decode(authCookie.value) as { sub: string } | null;
return payload?.sub || null;
} catch {
return null;
}
}

View File

@@ -13,6 +13,7 @@
"@ai-sdk/google": "latest",
"@ai-sdk/react": "latest",
"@atproto/api": "latest",
"@atproto/oauth-client-node": "^0.3.10",
"@deepgram/sdk": "latest",
"@google/generative-ai": "^0.24.1",
"@mantine/core": "latest",

File diff suppressed because it is too large Load Diff

182
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@atproto/api':
specifier: latest
version: 0.18.0
'@atproto/oauth-client-node':
specifier: ^0.3.10
version: 0.3.10
'@deepgram/sdk':
specifier: latest
version: 4.11.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)
@@ -146,15 +149,66 @@ packages:
zod:
optional: true
'@atproto-labs/did-resolver@0.2.2':
resolution: {integrity: sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ==}
'@atproto-labs/fetch-node@0.2.0':
resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==}
engines: {node: '>=18.7.0'}
'@atproto-labs/fetch@0.2.3':
resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==}
'@atproto-labs/handle-resolver-node@0.1.21':
resolution: {integrity: sha512-fuJy5Px5pGF3lJX/ATdurbT8tbmaFWtf+PPxAQDFy7ot2no3t+iaAgymhyxYymrssOuWs6BwOP8tyF3VrfdwtQ==}
engines: {node: '>=18.7.0'}
'@atproto-labs/handle-resolver@0.3.2':
resolution: {integrity: sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A==}
'@atproto-labs/identity-resolver@0.3.2':
resolution: {integrity: sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw==}
'@atproto-labs/pipe@0.1.1':
resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==}
'@atproto-labs/simple-store-memory@0.1.4':
resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==}
'@atproto-labs/simple-store@0.3.0':
resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==}
'@atproto/api@0.18.0':
resolution: {integrity: sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA==}
'@atproto/common-web@0.4.3':
resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==}
'@atproto/did@0.2.1':
resolution: {integrity: sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA==}
'@atproto/jwk-jose@0.1.11':
resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==}
'@atproto/jwk-webcrypto@0.2.0':
resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==}
'@atproto/jwk@0.6.0':
resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==}
'@atproto/lexicon@0.5.1':
resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==}
'@atproto/oauth-client-node@0.3.10':
resolution: {integrity: sha512-6khKlJqu1Ed5rt3rzcTD5hymB6JUjKdOHWYXwiphw4inkAIo6GxLCighI4eGOqZorYk2j8ueeTNB6KsgH0kcRw==}
engines: {node: '>=18.7.0'}
'@atproto/oauth-client@0.5.8':
resolution: {integrity: sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg==}
'@atproto/oauth-types@0.5.0':
resolution: {integrity: sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ==}
'@atproto/syntax@0.4.1':
resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==}
@@ -1460,6 +1514,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-js@3.46.0:
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -2028,6 +2085,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
is-any-array@0.1.1:
resolution: {integrity: sha512-qTiELO+kpTKqPgxPYbshMERlzaFu29JDnpB8s3bjg+JkxBpw29/qqSaOdKv2pCdaG92rLGeG/zG2GauX58hfoA==}
@@ -2182,6 +2243,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
@@ -2293,6 +2357,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
@@ -3126,6 +3193,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@6.22.0:
resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==}
engines: {node: '>=18.17'}
undici@7.16.0:
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
engines: {node: '>=20.18.1'}
@@ -3378,6 +3449,53 @@ snapshots:
optionalDependencies:
zod: 4.1.12
'@atproto-labs/did-resolver@0.2.2':
dependencies:
'@atproto-labs/fetch': 0.2.3
'@atproto-labs/pipe': 0.1.1
'@atproto-labs/simple-store': 0.3.0
'@atproto-labs/simple-store-memory': 0.1.4
'@atproto/did': 0.2.1
zod: 3.25.76
'@atproto-labs/fetch-node@0.2.0':
dependencies:
'@atproto-labs/fetch': 0.2.3
'@atproto-labs/pipe': 0.1.1
ipaddr.js: 2.2.0
undici: 6.22.0
'@atproto-labs/fetch@0.2.3':
dependencies:
'@atproto-labs/pipe': 0.1.1
'@atproto-labs/handle-resolver-node@0.1.21':
dependencies:
'@atproto-labs/fetch-node': 0.2.0
'@atproto-labs/handle-resolver': 0.3.2
'@atproto/did': 0.2.1
'@atproto-labs/handle-resolver@0.3.2':
dependencies:
'@atproto-labs/simple-store': 0.3.0
'@atproto-labs/simple-store-memory': 0.1.4
'@atproto/did': 0.2.1
zod: 3.25.76
'@atproto-labs/identity-resolver@0.3.2':
dependencies:
'@atproto-labs/did-resolver': 0.2.2
'@atproto-labs/handle-resolver': 0.3.2
'@atproto-labs/pipe@0.1.1': {}
'@atproto-labs/simple-store-memory@0.1.4':
dependencies:
'@atproto-labs/simple-store': 0.3.0
lru-cache: 10.4.3
'@atproto-labs/simple-store@0.3.0': {}
'@atproto/api@0.18.0':
dependencies:
'@atproto/common-web': 0.4.3
@@ -3396,6 +3514,26 @@ snapshots:
uint8arrays: 3.0.0
zod: 3.25.76
'@atproto/did@0.2.1':
dependencies:
zod: 3.25.76
'@atproto/jwk-jose@0.1.11':
dependencies:
'@atproto/jwk': 0.6.0
jose: 5.10.0
'@atproto/jwk-webcrypto@0.2.0':
dependencies:
'@atproto/jwk': 0.6.0
'@atproto/jwk-jose': 0.1.11
zod: 3.25.76
'@atproto/jwk@0.6.0':
dependencies:
multiformats: 9.9.0
zod: 3.25.76
'@atproto/lexicon@0.5.1':
dependencies:
'@atproto/common-web': 0.4.3
@@ -3404,6 +3542,40 @@ snapshots:
multiformats: 9.9.0
zod: 3.25.76
'@atproto/oauth-client-node@0.3.10':
dependencies:
'@atproto-labs/did-resolver': 0.2.2
'@atproto-labs/handle-resolver-node': 0.1.21
'@atproto-labs/simple-store': 0.3.0
'@atproto/did': 0.2.1
'@atproto/jwk': 0.6.0
'@atproto/jwk-jose': 0.1.11
'@atproto/jwk-webcrypto': 0.2.0
'@atproto/oauth-client': 0.5.8
'@atproto/oauth-types': 0.5.0
'@atproto/oauth-client@0.5.8':
dependencies:
'@atproto-labs/did-resolver': 0.2.2
'@atproto-labs/fetch': 0.2.3
'@atproto-labs/handle-resolver': 0.3.2
'@atproto-labs/identity-resolver': 0.3.2
'@atproto-labs/simple-store': 0.3.0
'@atproto-labs/simple-store-memory': 0.1.4
'@atproto/did': 0.2.1
'@atproto/jwk': 0.6.0
'@atproto/oauth-types': 0.5.0
'@atproto/xrpc': 0.7.5
core-js: 3.46.0
multiformats: 9.9.0
zod: 3.25.76
'@atproto/oauth-types@0.5.0':
dependencies:
'@atproto/did': 0.2.1
'@atproto/jwk': 0.6.0
zod: 3.25.76
'@atproto/syntax@0.4.1': {}
'@atproto/xrpc@0.7.5':
@@ -4626,6 +4798,8 @@ snapshots:
convert-source-map@2.0.0: {}
core-js@3.46.0: {}
cross-env@7.0.3:
dependencies:
cross-spawn: 7.0.6
@@ -5345,6 +5519,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
ipaddr.js@2.2.0: {}
is-any-array@0.1.1: {}
is-any-array@2.0.1: {}
@@ -5503,6 +5679,8 @@ snapshots:
jiti@2.6.1: {}
jose@5.10.0: {}
jose@6.1.0: {}
joycon@3.1.1: {}
@@ -5613,6 +5791,8 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lru-cache@10.4.3: {}
lru-cache@11.2.2: {}
lru-cache@5.1.1:
@@ -6594,6 +6774,8 @@ snapshots:
undici-types@7.16.0: {}
undici@6.22.0: {}
undici@7.16.0: {}
unrs-resolver@1.11.1:

118
scripts/apply-schema.js Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env node
/**
* Apply SurrealDB schema from db/schema.surql
*/
const Surreal = require('surrealdb').default;
const fs = require('fs');
const path = require('path');
async function applySchema() {
const db = new Surreal();
try {
console.log('[Schema] Connecting to SurrealDB...');
await db.connect('http://localhost:8000/rpc');
console.log('[Schema] Signing in...');
await db.signin({
username: 'root',
password: 'root',
});
console.log('[Schema] Using namespace and database...');
await db.use({
namespace: 'ponderants',
database: 'main',
});
console.log('[Schema] Reading schema file...');
const schemaPath = path.join(__dirname, '..', 'db', 'schema.surql');
let schema = fs.readFileSync(schemaPath, 'utf-8');
// Load environment variables from .env file manually
const envPath = path.join(__dirname, '..', '.env');
const envContent = fs.readFileSync(envPath, 'utf-8');
const envVars = {};
envContent.split('\n').forEach(line => {
const match = line.match(/^([^#][^=]*)=(.*)$/);
if (match) {
envVars[match[1].trim()] = match[2].trim();
}
});
// Replace $env.SURREALDB_JWT_SECRET with actual value
const jwtSecret = envVars.SURREALDB_JWT_SECRET;
if (!jwtSecret) {
throw new Error('SURREALDB_JWT_SECRET not found in .env file');
}
schema = schema.replace('$env.SURREALDB_JWT_SECRET', `'${jwtSecret}'`);
console.log('[Schema] Executing schema...');
let result;
try {
result = await db.query(schema);
} catch (error) {
// If error contains "already exists", it's OK - schema was already applied
if (error.message.includes('already exists')) {
console.log('[Schema] ⚠ Some schema elements already exist (this is OK)');
console.log('[Schema] Continuing to ensure new tables are created...');
// Try to create just the new OAuth tables
const oauthSchema = `
DEFINE TABLE oauth_state SCHEMAFULL;
DEFINE FIELD key ON TABLE oauth_state TYPE string ASSERT $value != NONE;
DEFINE FIELD value ON TABLE oauth_state TYPE object ASSERT $value != NONE;
DEFINE FIELD created_at ON TABLE oauth_state TYPE datetime DEFAULT time::now();
DEFINE INDEX oauth_state_key_idx ON TABLE oauth_state COLUMNS key UNIQUE;
DEFINE EVENT oauth_state_cleanup ON TABLE oauth_state WHEN time::now() - created_at > 1h THEN (
DELETE oauth_state WHERE id = $event.id
);
DEFINE TABLE oauth_session SCHEMAFULL;
DEFINE FIELD did ON TABLE oauth_session TYPE string ASSERT $value != NONE;
DEFINE FIELD session_data ON TABLE oauth_session TYPE object ASSERT $value != NONE;
DEFINE FIELD updated_at ON TABLE oauth_session TYPE datetime DEFAULT time::now();
DEFINE INDEX oauth_session_did_idx ON TABLE oauth_session COLUMNS did UNIQUE;
`;
try {
result = await db.query(oauthSchema);
} catch (oauthError) {
if (oauthError.message.includes('already exists')) {
console.log('[Schema] ✓ OAuth tables already exist');
console.log('[Schema] ✓ Schema is up to date!');
return;
}
throw oauthError;
}
} else {
throw error;
}
}
if (result) {
console.log(`[Schema] Executed ${result.length} queries`);
// Log any errors
result.forEach((r, i) => {
if (r.status === 'ERR') {
console.error(`[Schema] Error in query ${i + 1}:`, r.result);
} else {
console.log(`[Schema] ✓ Query ${i + 1} succeeded`);
}
});
}
console.log('[Schema] ✓ Schema applied successfully!');
} catch (error) {
console.error('[Schema] ✗ Failed to apply schema:', error);
process.exit(1);
} finally {
await db.close();
}
}
applySchema();