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:
@@ -1,81 +1,71 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getOAuthClient } from '@/lib/auth/oauth-client';
|
||||||
import { getAuthEndpoints } from '@/lib/auth/atproto';
|
|
||||||
import { consumeOAuthState } from '@/lib/auth/oauth-state';
|
|
||||||
import { mintSurrealJwt } from '@/lib/auth/jwt';
|
import { mintSurrealJwt } from '@/lib/auth/jwt';
|
||||||
import { AtpAgent } from '@atproto/api';
|
import { Agent } from '@atproto/api';
|
||||||
|
import Surreal from 'surrealdb';
|
||||||
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
|
|
||||||
const REDIRECT_URI = process.env.BLUESKY_REDIRECT_URI;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const code = searchParams.get('code');
|
|
||||||
const state = searchParams.get('state');
|
|
||||||
|
|
||||||
// 1. Validate state parameter
|
// Check for error from OAuth provider
|
||||||
if (!state || !code) {
|
const error = searchParams.get('error');
|
||||||
return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url));
|
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 {
|
try {
|
||||||
// 3. Get the PDS's token endpoint
|
console.log('[OAuth Callback] Processing callback...');
|
||||||
const { tokenEndpoint } = await getAuthEndpoints(pdsUrl);
|
|
||||||
|
|
||||||
// 4. Exchange the code for an ATproto access token
|
// Get OAuth client
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
const client = await getOAuthClient();
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
// Exchange authorization code for session
|
||||||
throw new Error('Failed to exchange code for token');
|
// 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)
|
console.log('[OAuth Callback] User profile:', { did, handle });
|
||||||
const agent = new AtpAgent({ service: pdsUrl });
|
|
||||||
|
|
||||||
// Set the session with the tokens we just received
|
// Upsert user in SurrealDB
|
||||||
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;
|
|
||||||
const db = new Surreal();
|
const db = new Surreal();
|
||||||
await db.connect(process.env.SURREALDB_URL!);
|
await db.connect(process.env.SURREALDB_URL!);
|
||||||
await db.signin({
|
await db.signin({
|
||||||
@@ -87,20 +77,36 @@ export async function GET(request: NextRequest) {
|
|||||||
database: process.env.SURREALDB_DB!,
|
database: process.env.SURREALDB_DB!,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert the user (create if doesn't exist, update handle if it does)
|
|
||||||
await db.query(
|
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 }
|
{ did, handle }
|
||||||
);
|
);
|
||||||
|
|
||||||
await db.close();
|
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);
|
const surrealJwt = mintSurrealJwt(did, handle);
|
||||||
|
|
||||||
// 8. Create redirect response
|
// Parse custom state to determine redirect URL
|
||||||
const response = NextResponse.redirect(new URL('/chat', request.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, {
|
response.cookies.set('ponderants-auth', surrealJwt, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
@@ -109,26 +115,34 @@ export async function GET(request: NextRequest) {
|
|||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the ATproto tokens for later use
|
console.log('[OAuth Callback] ✓ Authentication complete, redirecting to:', returnTo);
|
||||||
response.cookies.set('atproto_access_token', access_token, {
|
|
||||||
httpOnly: true,
|
// Note: We do NOT store ATproto tokens in cookies
|
||||||
secure: process.env.NODE_ENV === 'production',
|
// The oauth-client library manages them in sessionStore
|
||||||
sameSite: 'lax',
|
// and will automatically refresh them when needed
|
||||||
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: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 10. Redirect to the main application
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth callback error:', error);
|
console.error('[OAuth Callback] Error:', error);
|
||||||
return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url));
|
|
||||||
|
// 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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,70 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto';
|
import { getOAuthClient } from '@/lib/auth/oauth-client';
|
||||||
import { storeOAuthState } from '@/lib/auth/oauth-state';
|
import { z } from 'zod';
|
||||||
import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client';
|
|
||||||
|
|
||||||
const CLIENT_ID = process.env.BLUESKY_CLIENT_ID;
|
const LoginRequestSchema = z.object({
|
||||||
const REDIRECT_URI = process.env.BLUESKY_REDIRECT_URI;
|
handle: z.string().min(1, 'Handle is required'),
|
||||||
|
});
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
try {
|
||||||
// 1. Resolve handle to get PDS
|
// Parse and validate request body
|
||||||
const { pdsUrl } = await resolveHandle(handle);
|
const body = await request.json();
|
||||||
|
const { handle } = LoginRequestSchema.parse(body);
|
||||||
|
|
||||||
// 2. Discover PDS-specific auth endpoints
|
console.log('[OAuth Login] Initiating OAuth flow for handle:', handle);
|
||||||
const { authorizationEndpoint } = await getAuthEndpoints(pdsUrl);
|
|
||||||
|
|
||||||
// 3. Generate PKCE challenge and state
|
// Get OAuth client
|
||||||
const state = randomState();
|
const client = await getOAuthClient();
|
||||||
const code_verifier = randomPKCECodeVerifier();
|
|
||||||
const code_challenge = await calculatePKCECodeChallenge(code_verifier);
|
|
||||||
|
|
||||||
// 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects)
|
// Generate authorization URL
|
||||||
await storeOAuthState(state, code_verifier, pdsUrl);
|
// 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
|
console.log('[OAuth Login] ✓ Generated 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);
|
|
||||||
|
|
||||||
// 6. Redirect user to the PDS login screen
|
return NextResponse.json({ url: authUrl });
|
||||||
return NextResponse.redirect(authUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth login error:', error);
|
console.error('[OAuth Login] Error:', error);
|
||||||
return NextResponse.redirect(new URL('/login?error=Invalid handle or PDS', request.url));
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,3 +89,54 @@ DEFINE TABLE links_to SCHEMAFULL
|
|||||||
|
|
||||||
-- (No fields needed, it's a simple relation)
|
-- (No fields needed, it's a simple relation)
|
||||||
-- Example usage: RELATE (node:1)-[links_to]->(node:2);
|
-- 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;
|
||||||
|
|||||||
@@ -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
75
lib/auth/oauth-client.ts
Normal 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;
|
||||||
|
}
|
||||||
84
lib/auth/oauth-session-store.ts
Normal file
84
lib/auth/oauth-session-store.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
81
lib/auth/oauth-state-store.ts
Normal file
81
lib/auth/oauth-state-store.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { verifySurrealJwt, type UserSession } from './jwt';
|
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.
|
* Gets the current authenticated user from the session cookie.
|
||||||
@@ -17,3 +20,104 @@ export async function getCurrentUser(): Promise<UserSession | null> {
|
|||||||
|
|
||||||
return verifySurrealJwt(authCookie.value);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@ai-sdk/google": "latest",
|
"@ai-sdk/google": "latest",
|
||||||
"@ai-sdk/react": "latest",
|
"@ai-sdk/react": "latest",
|
||||||
"@atproto/api": "latest",
|
"@atproto/api": "latest",
|
||||||
|
"@atproto/oauth-client-node": "^0.3.10",
|
||||||
"@deepgram/sdk": "latest",
|
"@deepgram/sdk": "latest",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@mantine/core": "latest",
|
"@mantine/core": "latest",
|
||||||
|
|||||||
1249
plans/oauth-dpop-implementation.md
Normal file
1249
plans/oauth-dpop-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
182
pnpm-lock.yaml
generated
182
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@atproto/api':
|
'@atproto/api':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 0.18.0
|
version: 0.18.0
|
||||||
|
'@atproto/oauth-client-node':
|
||||||
|
specifier: ^0.3.10
|
||||||
|
version: 0.3.10
|
||||||
'@deepgram/sdk':
|
'@deepgram/sdk':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 4.11.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)
|
version: 4.11.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)
|
||||||
@@ -146,15 +149,66 @@ packages:
|
|||||||
zod:
|
zod:
|
||||||
optional: true
|
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':
|
'@atproto/api@0.18.0':
|
||||||
resolution: {integrity: sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA==}
|
resolution: {integrity: sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA==}
|
||||||
|
|
||||||
'@atproto/common-web@0.4.3':
|
'@atproto/common-web@0.4.3':
|
||||||
resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==}
|
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':
|
'@atproto/lexicon@0.5.1':
|
||||||
resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==}
|
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':
|
'@atproto/syntax@0.4.1':
|
||||||
resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==}
|
resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==}
|
||||||
|
|
||||||
@@ -1460,6 +1514,9 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
core-js@3.46.0:
|
||||||
|
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
|
||||||
|
|
||||||
cross-env@7.0.3:
|
cross-env@7.0.3:
|
||||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||||
@@ -2028,6 +2085,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ipaddr.js@2.2.0:
|
||||||
|
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
is-any-array@0.1.1:
|
is-any-array@0.1.1:
|
||||||
resolution: {integrity: sha512-qTiELO+kpTKqPgxPYbshMERlzaFu29JDnpB8s3bjg+JkxBpw29/qqSaOdKv2pCdaG92rLGeG/zG2GauX58hfoA==}
|
resolution: {integrity: sha512-qTiELO+kpTKqPgxPYbshMERlzaFu29JDnpB8s3bjg+JkxBpw29/qqSaOdKv2pCdaG92rLGeG/zG2GauX58hfoA==}
|
||||||
|
|
||||||
@@ -2182,6 +2243,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@5.10.0:
|
||||||
|
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||||
|
|
||||||
jose@6.1.0:
|
jose@6.1.0:
|
||||||
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
|
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
|
||||||
|
|
||||||
@@ -2293,6 +2357,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
lru-cache@10.4.3:
|
||||||
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
lru-cache@11.2.2:
|
lru-cache@11.2.2:
|
||||||
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -3126,6 +3193,10 @@ packages:
|
|||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
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:
|
undici@7.16.0:
|
||||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -3378,6 +3449,53 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 4.1.12
|
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':
|
'@atproto/api@0.18.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.4.3
|
'@atproto/common-web': 0.4.3
|
||||||
@@ -3396,6 +3514,26 @@ snapshots:
|
|||||||
uint8arrays: 3.0.0
|
uint8arrays: 3.0.0
|
||||||
zod: 3.25.76
|
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':
|
'@atproto/lexicon@0.5.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.4.3
|
'@atproto/common-web': 0.4.3
|
||||||
@@ -3404,6 +3542,40 @@ snapshots:
|
|||||||
multiformats: 9.9.0
|
multiformats: 9.9.0
|
||||||
zod: 3.25.76
|
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/syntax@0.4.1': {}
|
||||||
|
|
||||||
'@atproto/xrpc@0.7.5':
|
'@atproto/xrpc@0.7.5':
|
||||||
@@ -4626,6 +4798,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
core-js@3.46.0: {}
|
||||||
|
|
||||||
cross-env@7.0.3:
|
cross-env@7.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@@ -5345,6 +5519,8 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
|
ipaddr.js@2.2.0: {}
|
||||||
|
|
||||||
is-any-array@0.1.1: {}
|
is-any-array@0.1.1: {}
|
||||||
|
|
||||||
is-any-array@2.0.1: {}
|
is-any-array@2.0.1: {}
|
||||||
@@ -5503,6 +5679,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
jose@5.10.0: {}
|
||||||
|
|
||||||
jose@6.1.0: {}
|
jose@6.1.0: {}
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
@@ -5613,6 +5791,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@11.2.2: {}
|
lru-cache@11.2.2: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
@@ -6594,6 +6774,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
|
undici@6.22.0: {}
|
||||||
|
|
||||||
undici@7.16.0: {}
|
undici@7.16.0: {}
|
||||||
|
|
||||||
unrs-resolver@1.11.1:
|
unrs-resolver@1.11.1:
|
||||||
|
|||||||
118
scripts/apply-schema.js
Executable file
118
scripts/apply-schema.js
Executable 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();
|
||||||
Reference in New Issue
Block a user