Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
# [REQUIRED] Application URL (your production domain)
NEXT_PUBLIC_APP_URL=https://your-domain.com

# Optional: Override the origin used for billing-provider OAuth callback URLs
# (e.g. local dev through an alternate shell port like http://localhost:8935)
# If set, the billing-provider OAuth flow will use:
# ${BILLING_PROVIDER_OAUTH_CALLBACK_ORIGIN}/api/v1/auth/providers/:providerSlug/callback
BILLING_PROVIDER_OAUTH_CALLBACK_ORIGIN=http://localhost:3000

# [REQUIRED] NextAuth secret for session encryption (min 32 chars)
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=
Expand Down
5 changes: 4 additions & 1 deletion apps/web-next/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ const nextConfig = {
webpack: (config, { isServer }) => {
// Prisma: ensure engine binaries are included in the standalone bundle.
// This is the official fix for Prisma + Next.js monorepo deployments.
if (isServer) {
// Important: in `next dev`, the server output directories may not exist
// when the plugin runs, which can cause copyfile ENOENT errors.
// We only need this plugin for production (standalone) builds.
if (isServer && process.env.NODE_ENV === 'production') {
config.plugins = [...config.plugins, new PrismaPlugin()];
}

Expand Down
27 changes: 24 additions & 3 deletions apps/web-next/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* - Test team
*/

import { PrismaClient } from '@naap/database';
import { BILLING_PROVIDERS, PrismaClient } from '@naap/database';
import * as crypto from 'crypto';
import * as path from 'path';

Expand Down Expand Up @@ -653,7 +653,28 @@ async function main() {
console.log(` ✅ Created ${prefCount} user plugin preferences for core plugins`);

// ============================================
// 11. Historical Stats (Observability)
// 11. Billing Providers
// ============================================
console.log('💳 Seeding billing providers...');

for (const provider of BILLING_PROVIDERS) {
await prisma.billingProvider.upsert({
where: { slug: provider.slug },
update: {
displayName: provider.displayName,
description: provider.description,
icon: provider.icon,
authType: provider.authType,
enabled: provider.enabled,
sortOrder: provider.sortOrder,
},
create: provider,
});
}
console.log(` ✅ Created ${BILLING_PROVIDERS.length} billing providers`);

// ============================================
// 12. Historical Stats (Observability)
// ============================================
console.log('📊 Creating historical stats...');

Expand All @@ -673,7 +694,7 @@ async function main() {
console.log(` ✅ Created ${stats.length} historical stats`);

// ============================================
// 11. Job Feeds (Recent Activity)
// 13. Job Feeds (Recent Activity)
// ============================================
console.log('📡 Creating job feeds...');

Expand Down
16 changes: 10 additions & 6 deletions apps/web-next/src/app/api/v1/[plugin]/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,6 @@ async function handleRequest(
);
}

// Get auth token if present
const token = getAuthToken(request);

// Build the proxy URL
const pathString = path.join('/');
const targetUrl = `${serviceUrl}/api/v1/${pathString}${request.nextUrl.search}`;
Expand All @@ -118,9 +115,16 @@ async function handleRequest(
const headers = new Headers();
headers.set('Content-Type', request.headers.get('Content-Type') || 'application/json');

// Forward auth token
if (token) {
headers.set('Authorization', `Bearer ${token}`);
// Forward Authorization exactly as received when present.
// Fallback to cookie-derived Bearer token for browser flows that rely on session cookies.
const incomingAuthorization = request.headers.get('authorization');
if (incomingAuthorization) {
headers.set('Authorization', incomingAuthorization);
} else {
const token = getAuthToken(request);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}

// Forward observability headers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* GET /api/v1/auth/providers/:providerSlug/callback
* Provider redirects the browser here after user authentication.
*/

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { encryptToken } from '@naap/database';

const DAYDREAM_API_BASE = process.env.DAYDREAM_API_BASE || 'https://api.daydream.live';

function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

async function exchangeTokenForApiKey(providerSlug: string, token: string): Promise<string> {
if (providerSlug !== 'daydream') {
throw new Error(`Unsupported billing provider for OAuth callback: ${providerSlug}`);
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
let response: Response;
try {
response = await fetch(`${DAYDREAM_API_BASE}/v1/api-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name: 'dd_naap_linked' }),
signal: controller.signal,
});
} catch (err) {
if ((err as { name?: string })?.name === 'AbortError') {
throw new Error('Daydream token exchange timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}

if (!response.ok) {
const text = await response.text();
throw new Error(`Daydream token exchange failed: ${response.status} ${text}`);
}

const result = await response.json();
const apiKey = result.api_key || result.apiKey || result.key;
if (!apiKey) {
throw new Error('Daydream token exchange failed: no API key in response');
}
return apiKey;
}
Comment thread
eliteprox marked this conversation as resolved.

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ providerSlug: string }> }
): Promise<NextResponse> {
const { providerSlug } = await params;
const searchParams = request.nextUrl.searchParams;
const token = searchParams.get('token');
const state = searchParams.get('state');

const htmlResponse = (title: string, message: string, isError = false) => {
const safeTitle = escapeHtml(title);
const safeMessage = escapeHtml(message);
return new NextResponse(
`<!DOCTYPE html>
<html><head><title>${safeTitle}</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
.card { text-align: center; padding: 2rem; border-radius: 1rem;
background: #1a1a1a; border: 1px solid ${isError ? '#ef4444' : '#22c55e'}; max-width: 400px; }
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; color: ${isError ? '#ef4444' : '#22c55e'}; }
p { color: #a1a1aa; font-size: 0.9rem; }
</style>
${!isError ? '<script>setTimeout(function(){ window.close(); }, 3000);</script>' : ''}
</head>
<body><div class="card"><h1>${safeTitle}</h1><p>${safeMessage}</p></div></body></html>`,
{ status: isError ? 400 : 200, headers: { 'Content-Type': 'text/html' } }
);
};

if (!token || !state) {
return htmlResponse(
'Authentication Failed',
'Missing token or state parameter from billing provider.',
true
);
}

const session = await prisma.billingProviderOAuthSession.findUnique({
where: { state },
});
if (!session) {
return htmlResponse(
'Session Expired',
'The login session has expired or was already used. Please try again from NaaP.',
true
);
}

if (session.providerSlug !== providerSlug) {
return htmlResponse('Authentication Failed', 'Provider/session mismatch detected.', true);
}

if (Date.now() >= new Date(session.expiresAt).getTime()) {
await prisma.billingProviderOAuthSession
.updateMany({
where: {
loginSessionId: session.loginSessionId,
status: 'pending',
},
data: { status: 'expired' },
})
.catch(() => null);

return htmlResponse(
'Session Expired',
'The login session has expired or was already used. Please try again from NaaP.',
true
);
}

try {
const apiKey = await exchangeTokenForApiKey(providerSlug, token);

await prisma.billingProviderOAuthSession.update({
where: { loginSessionId: session.loginSessionId },
data: {
status: 'complete',
accessToken: encryptToken(apiKey),
},
});

Comment thread
eliteprox marked this conversation as resolved.
console.log(
`[billing-auth:${providerSlug}] Callback complete for session ${session.loginSessionId.slice(0, 8)}...`
);

return htmlResponse('Authentication Complete', 'You can close this tab and return to NaaP.');
} catch (err) {
console.error(`[billing-auth:${providerSlug}] Callback error:`, err);
return htmlResponse(
'Authentication Failed',
err instanceof Error ? err.message : 'Failed to authenticate with billing provider.',
true
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* GET /api/v1/auth/providers/:providerSlug/result?login_session_id=...
* Poll the status of a brokered billing-provider authentication session.
*/

import { NextRequest, NextResponse } from 'next/server';
import { validateSession } from '@/lib/api/auth';
import { success, errors, getAuthToken } from '@/lib/api/response';
import { prisma } from '@/lib/db';
import { decryptToken } from '@naap/database';

let lastCleanup = 0;
const CLEANUP_INTERVAL_MS = 5 * 60_000;

async function cleanupExpiredSessions(): Promise<void> {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
try {
const { count } = await prisma.billingProviderOAuthSession.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
if (count > 0) {
console.log(`[billing-auth] Cleaned up ${count} expired OAuth sessions`);
}
} catch {
// non-critical
}
}

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ providerSlug: string }> }
): Promise<NextResponse> {
const { providerSlug } = await params;
const loginSessionId = request.nextUrl.searchParams.get('login_session_id');

if (!loginSessionId) {
return errors.badRequest('login_session_id is required');
}

const now = new Date();
const session = await prisma.billingProviderOAuthSession.findUnique({
where: { loginSessionId },
});

if (!session) {
const response = success({ status: 'expired' });
response.headers.set('Cache-Control', 'no-store');
return response;
}

if (session.expiresAt <= now) {
await prisma.billingProviderOAuthSession.delete({
where: { loginSessionId },
}).catch(() => null);
const response = success({ status: 'expired' });
response.headers.set('Cache-Control', 'no-store');
return response;
}

if (session.providerSlug !== providerSlug) {
return errors.forbidden('Session does not belong to this billing provider');
}

if (session.naapUserId) {
const authToken = getAuthToken(request);
const authenticatedUser = authToken ? await validateSession(authToken) : null;
if (authenticatedUser?.id !== session.naapUserId) {
return errors.forbidden('Session does not belong to this user');
}
}

if (session.status === 'complete') {
if (session.redeemedAt) {
const response = success({ status: 'redeemed' });
response.headers.set('Cache-Control', 'no-store');
return response;
}

const [redeemResult] = await prisma.$transaction([
prisma.billingProviderOAuthSession.updateMany({
where: {
loginSessionId,
redeemedAt: null,
status: 'complete',
expiresAt: { gt: now },
},
data: { redeemedAt: now },
}),
]);

if (redeemResult.count !== 1) {
const response = success({ status: 'redeemed' });
response.headers.set('Cache-Control', 'no-store');
return response;
}

const accessToken = session.accessToken ? decryptToken(session.accessToken) : null;
if (!accessToken) {
return errors.internal('Failed to retrieve access token');
}

const response = success({
status: 'complete',
access_token: accessToken,
user_id: session.providerUserId,
expires_in: Math.max(0, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000)),
});
response.headers.set('Cache-Control', 'no-store');
return response;
}

// Opportunistic cleanup of expired sessions (non-blocking)
cleanupExpiredSessions().catch(() => null);

const response = success({ status: session.status });
response.headers.set('Cache-Control', 'no-store');
return response;
}
Loading
Loading