implement better-auth auth with postgres and route protection

This commit is contained in:
2026-02-24 13:32:43 -05:00
parent fd168f607c
commit 52a4ab38d3
31 changed files with 1202 additions and 89 deletions

View File

@@ -5,6 +5,13 @@ NPM_VERSION=latest
# Local docker host port (used by docker-compose.override.yml)
APP_PORT=3000
# Better Auth / PostgreSQL
DATABASE_URL=postgres://postgres:postgres@localhost:5432/fiscal_clone
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=http://localhost:3000
BETTER_AUTH_ADMIN_USER_IDS=
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
# OpenClaw / ZeroClaw (OpenAI-compatible)
OPENCLAW_BASE_URL=http://localhost:4000
OPENCLAW_API_KEY=replace-with-your-agent-key

View File

@@ -6,6 +6,8 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
- Next.js 16 App Router
- Turbopack for `dev` and `build`
- Better Auth (email/password, magic link, admin, organization plugins)
- PostgreSQL adapter for Better Auth
- Internal API routes (`app/api/*`)
- Durable local task engine and JSON data store
- OpenClaw/ZeroClaw analysis via OpenAI-compatible chat endpoint
@@ -19,6 +21,9 @@ npm run dev
Open [http://localhost:3000](http://localhost:3000).
Better Auth requires PostgreSQL. Set `DATABASE_URL`, `BETTER_AUTH_SECRET`, and `BETTER_AUTH_BASE_URL` in `.env.local`.
Auth tables are migrated automatically on first authenticated request.
## Production build
```bash
@@ -34,6 +39,7 @@ docker compose up --build -d
```
For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000`, configurable via `APP_PORT`).
The local override also starts PostgreSQL and wires `DATABASE_URL` to `postgres://postgres:postgres@postgres:5432/fiscal_clone`.
For Coolify/remote Docker Compose, only container port `3000` is exposed internally (no fixed host port bind), avoiding host port collisions.
Runtime data persists in the `app_data` volume (`/app/data` in container).
Docker builds install the npm version from `NPM_VERSION` (default `latest`).
@@ -45,6 +51,11 @@ Use root `.env` or root `.env.local`:
```env
# leave blank for same-origin API
NEXT_PUBLIC_API_URL=
DATABASE_URL=postgres://postgres:postgres@localhost:5432/fiscal_clone
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=http://localhost:3000
BETTER_AUTH_ADMIN_USER_IDS=
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
OPENCLAW_BASE_URL=http://localhost:4000
OPENCLAW_API_KEY=your_key
@@ -56,6 +67,7 @@ If OpenClaw is unset, the app uses local fallback analysis so task workflows sti
## API surface
- `GET|POST|PATCH|PUT|DELETE /api/auth/*` (Better Auth handler)
- `GET /api/health`
- `GET /api/me`
- `GET|POST /api/watchlist`

View File

@@ -0,0 +1,9 @@
import { toNextJsHandler } from 'better-auth/next-js';
import { ensureAuthSchema } from '@/lib/auth';
const authHandler = toNextJsHandler(async (request: Request) => {
const auth = await ensureAuthSchema();
return auth.handler(request);
});
export const { GET, POST, PATCH, PUT, DELETE } = authHandler;

View File

@@ -1,4 +1,5 @@
import { asErrorMessage, jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { enqueueTask } from '@/lib/server/tasks';
type Context = {
@@ -6,6 +7,11 @@ type Context = {
};
export async function POST(_request: Request, context: Context) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
try {
const { accessionNumber } = await context.params;
@@ -14,6 +20,7 @@ export async function POST(_request: Request, context: Context) {
}
const task = await enqueueTask({
userId: session.user.id,
taskType: 'analyze_filing',
payload: { accessionNumber: accessionNumber.trim() },
priority: 65

View File

@@ -1,6 +1,12 @@
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { getStoreSnapshot } from '@/lib/server/store';
export async function GET(request: Request) {
const { response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const url = new URL(request.url);
const tickerFilter = url.searchParams.get('ticker')?.trim().toUpperCase();
const limitValue = Number(url.searchParams.get('limit') ?? 50);

View File

@@ -1,7 +1,13 @@
import { asErrorMessage, jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { enqueueTask } from '@/lib/server/tasks';
export async function POST(request: Request) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
try {
const payload = await request.json() as {
ticker?: string;
@@ -13,6 +19,7 @@ export async function POST(request: Request) {
}
const task = await enqueueTask({
userId: session.user.id,
taskType: 'sync_filings',
payload: {
ticker: payload.ticker.trim().toUpperCase(),

View File

@@ -1,10 +1,17 @@
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
export async function GET() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
return Response.json({
user: {
id: 1,
email: 'operator@local.fiscal',
name: 'Local Operator',
image: null
id: session.user.id,
email: session.user.email,
name: session.user.name,
image: session.user.image
}
});
}

View File

@@ -1,4 +1,5 @@
import { jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { recalculateHolding } from '@/lib/server/portfolio';
import { withStore } from '@/lib/server/store';
@@ -16,6 +17,12 @@ function asPositiveNumber(value: unknown) {
}
export async function PATCH(request: Request, context: Context) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const { id } = await context.params;
const numericId = Number(id);
@@ -33,7 +40,7 @@ export async function PATCH(request: Request, context: Context) {
let updated: unknown = null;
await withStore((store) => {
const index = store.holdings.findIndex((entry) => entry.id === numericId);
const index = store.holdings.findIndex((entry) => entry.id === numericId && entry.user_id === userId);
if (index < 0) {
return;
}
@@ -66,6 +73,12 @@ export async function PATCH(request: Request, context: Context) {
}
export async function DELETE(_request: Request, context: Context) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const { id } = await context.params;
const numericId = Number(id);
@@ -76,7 +89,7 @@ export async function DELETE(_request: Request, context: Context) {
let removed = false;
await withStore((store) => {
const next = store.holdings.filter((holding) => holding.id !== numericId);
const next = store.holdings.filter((holding) => !(holding.id === numericId && holding.user_id === userId));
removed = next.length !== store.holdings.length;
store.holdings = next;
});

View File

@@ -1,5 +1,6 @@
import type { Holding } from '@/lib/types';
import { asErrorMessage, jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { recalculateHolding } from '@/lib/server/portfolio';
import { getStoreSnapshot, withStore } from '@/lib/server/store';
@@ -13,8 +14,15 @@ function asPositiveNumber(value: unknown) {
}
export async function GET() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const snapshot = await getStoreSnapshot();
const holdings = snapshot.holdings
.filter((holding) => holding.user_id === userId)
.slice()
.sort((a, b) => Number(b.market_value) - Number(a.market_value));
@@ -22,6 +30,13 @@ export async function GET() {
}
export async function POST(request: Request) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
try {
const payload = await request.json() as {
ticker?: string;
@@ -50,7 +65,7 @@ export async function POST(request: Request) {
let holding: Holding | null = null;
await withStore((store) => {
const existingIndex = store.holdings.findIndex((entry) => entry.ticker === ticker);
const existingIndex = store.holdings.findIndex((entry) => entry.user_id === userId && entry.ticker === ticker);
const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost;
if (existingIndex >= 0) {
@@ -73,7 +88,7 @@ export async function POST(request: Request) {
store.counters.holdings += 1;
const created = recalculateHolding({
id: store.counters.holdings,
user_id: 1,
user_id: userId,
ticker,
shares: shares.toFixed(6),
avg_cost: avgCost.toFixed(6),

View File

@@ -1,9 +1,16 @@
import { asErrorMessage, jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { enqueueTask } from '@/lib/server/tasks';
export async function POST() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
try {
const task = await enqueueTask({
userId: session.user.id,
taskType: 'portfolio_insights',
payload: {},
priority: 70

View File

@@ -1,8 +1,16 @@
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { getStoreSnapshot } from '@/lib/server/store';
export async function GET() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const snapshot = await getStoreSnapshot();
const insight = snapshot.insights
.filter((entry) => entry.user_id === userId)
.slice()
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] ?? null;

View File

@@ -1,9 +1,16 @@
import { asErrorMessage, jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { enqueueTask } from '@/lib/server/tasks';
export async function POST() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
try {
const task = await enqueueTask({
userId: session.user.id,
taskType: 'refresh_prices',
payload: {},
priority: 80

View File

@@ -1,8 +1,15 @@
import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { getStoreSnapshot } from '@/lib/server/store';
export async function GET() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const snapshot = await getStoreSnapshot();
const summary = buildPortfolioSummary(snapshot.holdings);
const summary = buildPortfolioSummary(snapshot.holdings.filter((holding) => holding.user_id === userId));
return Response.json({ summary });
}

View File

@@ -1,4 +1,5 @@
import { jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { getTaskById } from '@/lib/server/tasks';
type Context = {
@@ -6,8 +7,13 @@ type Context = {
};
export async function GET(_request: Request, context: Context) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const { taskId } = await context.params;
const task = await getTaskById(taskId);
const task = await getTaskById(taskId, session.user.id);
if (!task) {
return jsonError('Task not found', 404);

View File

@@ -1,9 +1,15 @@
import type { TaskStatus } from '@/lib/types';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { listRecentTasks } from '@/lib/server/tasks';
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
export async function GET(request: Request) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const url = new URL(request.url);
const limitValue = Number(url.searchParams.get('limit') ?? 20);
const limit = Number.isFinite(limitValue)
@@ -15,6 +21,6 @@ export async function GET(request: Request) {
return ALLOWED_STATUSES.includes(status as TaskStatus);
});
const tasks = await listRecentTasks(limit, statuses.length > 0 ? statuses : undefined);
const tasks = await listRecentTasks(session.user.id, limit, statuses.length > 0 ? statuses : undefined);
return Response.json({ tasks });
}

View File

@@ -1,4 +1,5 @@
import { jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { withStore } from '@/lib/server/store';
type Context = {
@@ -6,6 +7,12 @@ type Context = {
};
export async function DELETE(_request: Request, context: Context) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const { id } = await context.params;
const numericId = Number(id);
@@ -16,7 +23,7 @@ export async function DELETE(_request: Request, context: Context) {
let removed = false;
await withStore((store) => {
const next = store.watchlist.filter((item) => item.id !== numericId);
const next = store.watchlist.filter((item) => !(item.id === numericId && item.user_id === userId));
removed = next.length !== store.watchlist.length;
store.watchlist = next;
});

View File

@@ -1,5 +1,6 @@
import type { WatchlistItem } from '@/lib/types';
import { asErrorMessage, jsonError } from '@/lib/server/http';
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { getStoreSnapshot, withStore } from '@/lib/server/store';
function nowIso() {
@@ -7,8 +8,15 @@ function nowIso() {
}
export async function GET() {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
const snapshot = await getStoreSnapshot();
const items = snapshot.watchlist
.filter((item) => item.user_id === userId)
.slice()
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
@@ -16,6 +24,13 @@ export async function GET() {
}
export async function POST(request: Request) {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const userId = session.user.id;
try {
const payload = await request.json() as {
ticker?: string;
@@ -35,7 +50,7 @@ export async function POST(request: Request) {
await withStore((store) => {
const ticker = payload.ticker!.trim().toUpperCase();
const existingIndex = store.watchlist.findIndex((entry) => entry.ticker === ticker);
const existingIndex = store.watchlist.findIndex((entry) => entry.user_id === userId && entry.ticker === ticker);
if (existingIndex >= 0) {
const existing = store.watchlist[existingIndex];
@@ -53,7 +68,7 @@ export async function POST(request: Request) {
store.counters.watchlist += 1;
const created: WatchlistItem = {
id: store.counters.watchlist,
user_id: 1,
user_id: userId,
ticker,
company_name: payload.companyName!.trim(),
sector: payload.sector?.trim() || null,

View File

@@ -1,32 +1,151 @@
'use client';
import Link from 'next/link';
import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { AuthShell } from '@/components/auth/auth-shell';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/auth-client';
function sanitizeNextPath(value: string | null) {
if (!value || !value.startsWith('/')) {
return '/';
}
return value;
}
export default function SignInPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading sign in...</div>}>
<SignInPageContent />
</Suspense>
);
}
function SignInPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
const { data: rawSession, isPending } = authClient.useSession();
const session = (rawSession ?? null) as { user?: { id?: string } } | null;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [busyAction, setBusyAction] = useState<'password' | 'magic' | null>(null);
useEffect(() => {
if (!isPending && session?.user?.id) {
router.replace(nextPath);
}
}, [isPending, nextPath, router, session]);
const signInWithPassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setMessage(null);
setBusyAction('password');
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
callbackURL: nextPath
});
setBusyAction(null);
if (signInError) {
setError(signInError.message || 'Sign in failed.');
return;
}
router.replace(nextPath);
};
const signInWithMagicLink = async () => {
const targetEmail = email.trim();
if (!targetEmail) {
setError('Email is required for magic link sign in.');
return;
}
setError(null);
setMessage(null);
setBusyAction('magic');
const { error: magicError } = await authClient.signIn.magicLink({
email: targetEmail,
callbackURL: nextPath
});
setBusyAction(null);
if (magicError) {
setError(magicError.message || 'Unable to send magic link.');
return;
}
setMessage('Magic link sent. Check your inbox and open the link on this device.');
};
return (
<AuthShell
title="Local Runtime Mode"
subtitle="Authentication is disabled in this rebuilt local-first environment."
title="Secure Sign In"
subtitle="Use email/password or request a magic link."
footer={(
<>
Need multi-user auth later?{' '}
<Link href="/" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Open command center
Need an account?{' '}
<Link href={`/auth/signup${nextPath !== '/' ? `?next=${encodeURIComponent(nextPath)}` : ''}`} className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Create one
</Link>
</>
)}
>
<p className="text-sm text-[color:var(--terminal-muted)]">
Continue directly into the fiscal terminal. API routes are same-origin and task execution is fully local with OpenClaw support.
</p>
<form className="space-y-4" onSubmit={signInWithPassword}>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Email</label>
<Input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<Link href="/" className="mt-6 block">
<Button type="button" className="w-full">
Enter terminal
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Password</label>
<Input
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
{message ? <p className="text-sm text-[#9fffcf]">{message}</p> : null}
<Button type="submit" className="w-full" disabled={busyAction !== null}>
{busyAction === 'password' ? 'Signing in...' : 'Sign in with password'}
</Button>
</Link>
</form>
<div className="mt-4">
<Button
type="button"
variant="secondary"
className="w-full"
disabled={busyAction !== null}
onClick={() => void signInWithMagicLink()}
>
{busyAction === 'magic' ? 'Sending link...' : 'Send magic link'}
</Button>
</div>
</AuthShell>
);
}

View File

@@ -1,32 +1,143 @@
'use client';
import Link from 'next/link';
import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { AuthShell } from '@/components/auth/auth-shell';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/auth-client';
function sanitizeNextPath(value: string | null) {
if (!value || !value.startsWith('/')) {
return '/';
}
return value;
}
export default function SignUpPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading sign up...</div>}>
<SignUpPageContent />
</Suspense>
);
}
function SignUpPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
const { data: rawSession, isPending } = authClient.useSession();
const session = (rawSession ?? null) as { user?: { id?: string } } | null;
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!isPending && session?.user?.id) {
router.replace(nextPath);
}
}, [isPending, nextPath, router, session]);
const signUp = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setBusy(true);
const { error: signUpError } = await authClient.signUp.email({
name: name.trim(),
email: email.trim(),
password,
callbackURL: nextPath
});
setBusy(false);
if (signUpError) {
setError(signUpError.message || 'Unable to create account.');
return;
}
router.replace(nextPath);
};
return (
<AuthShell
title="Workspace Provisioned"
subtitle="This clone now runs in local-operator mode and does not require account creation."
title="Create Account"
subtitle="Set up your operator profile to access portfolio and filings intelligence."
footer={(
<>
Already set?{' '}
<Link href="/" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Launch dashboard
Already registered?{' '}
<Link href={`/auth/signin${nextPath !== '/' ? `?next=${encodeURIComponent(nextPath)}` : ''}`} className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Sign in
</Link>
</>
)}
>
<p className="text-sm text-[color:var(--terminal-muted)]">
For production deployment you can reintroduce full multi-user authentication, but this rebuild is intentionally self-contained for fast iteration.
</p>
<form className="space-y-4" onSubmit={signUp}>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Name</label>
<Input
type="text"
autoComplete="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<Link href="/" className="mt-6 block">
<Button type="button" className="w-full">
Open fiscal desk
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Email</label>
<Input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Password</label>
<Input
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
/>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Confirm Password</label>
<Input
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
required
minLength={8}
/>
</div>
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? 'Creating account...' : 'Create account'}
</Button>
</Link>
</form>
</AuthShell>
);
}

View File

@@ -1,8 +1,11 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Activity, BookOpenText, ChartCandlestick, Eye } from 'lucide-react';
import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react';
import { Activity, BookOpenText, ChartCandlestick, Eye, LogOut } from 'lucide-react';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type AppShellProps = {
@@ -21,6 +24,31 @@ const NAV_ITEMS = [
export function AppShell({ title, subtitle, actions, children }: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const { data: session } = authClient.useSession();
const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null;
const role = typeof sessionUser?.role === 'string'
? sessionUser.role
: Array.isArray(sessionUser?.role)
? sessionUser.role.filter((entry): entry is string => typeof entry === 'string').join(', ')
: null;
const displayName = sessionUser?.name || sessionUser?.email || 'Authenticated user';
const signOut = async () => {
if (isSigningOut) {
return;
}
setIsSigningOut(true);
try {
await authClient.signOut();
router.replace('/auth/signin');
} finally {
setIsSigningOut(false);
}
};
return (
<div className="app-surface">
@@ -62,10 +90,15 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
<div className="mt-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">local operator mode</p>
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
OpenClaw and market data are driven by environment configuration and live API tasks.
</p>
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" />
{isSigningOut ? 'Signing out...' : 'Sign out'}
</Button>
</div>
</aside>
@@ -79,7 +112,13 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
<div className="flex flex-wrap items-center gap-2">
{actions}
<Button variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" />
{isSigningOut ? 'Signing out...' : 'Sign out'}
</Button>
</div>
</div>
</header>

View File

@@ -2,3 +2,28 @@ services:
app:
ports:
- '${APP_PORT:-3000}:3000'
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal_clone}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3000}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:3000}
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: fiscal_clone
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d fiscal_clone']
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

View File

@@ -14,6 +14,11 @@ services:
PORT: 3000
HOSTNAME: 0.0.0.0
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
DATABASE_URL: ${DATABASE_URL:-}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-}
BETTER_AUTH_ADMIN_USER_IDS: ${BETTER_AUTH_ADMIN_USER_IDS:-}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}

View File

@@ -1,16 +1,47 @@
'use client';
export function useAuthGuard() {
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { authClient } from '@/lib/auth-client';
type UseAuthGuardOptions = {
required?: boolean;
};
export function useAuthGuard(options: UseAuthGuardOptions = {}) {
const { required = true } = options;
const pathname = usePathname();
const router = useRouter();
const { data: rawSession, isPending } = authClient.useSession();
const session = (rawSession ?? null) as {
user?: {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
} | null;
const isAuthenticated = Boolean(session?.user?.id);
useEffect(() => {
if (!required || isPending || isAuthenticated || pathname.startsWith('/auth')) {
return;
}
const currentPath = typeof window === 'undefined'
? pathname
: `${window.location.pathname}${window.location.search}`;
const query = currentPath && currentPath !== '/'
? `?next=${encodeURIComponent(currentPath)}`
: '';
router.replace(`/auth/signin${query}`);
}, [required, isPending, isAuthenticated, pathname, router]);
return {
session: {
user: {
id: 1,
name: 'Local Operator',
email: 'operator@local.fiscal',
image: null
}
},
isPending: false,
isAuthenticated: true
session,
isPending,
isAuthenticated
};
}

14
lib/auth-client.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createAuthClient } from 'better-auth/react';
import { adminClient, magicLinkClient, organizationClient } from 'better-auth/client/plugins';
import { resolveApiBaseURL } from '@/lib/runtime-url';
const baseURL = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL);
export const authClient = createAuthClient({
baseURL: baseURL || undefined,
plugins: [
adminClient(),
magicLinkClient(),
organizationClient()
]
});

92
lib/auth.ts Normal file
View File

@@ -0,0 +1,92 @@
import { betterAuth } from 'better-auth';
import { getMigrations } from 'better-auth/db';
import { nextCookies } from 'better-auth/next-js';
import { admin, magicLink, organization } from 'better-auth/plugins';
import { Pool } from 'pg';
declare global {
// eslint-disable-next-line no-var
var __fiscalAuthPgPool: Pool | undefined;
}
type BetterAuthInstance = ReturnType<typeof betterAuth>;
let authInstance: BetterAuthInstance | null = null;
let migrationPromise: Promise<void> | null = null;
function parseCsvList(value: string | undefined) {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function getPool() {
const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
throw new Error('DATABASE_URL is required for Better Auth PostgreSQL adapter.');
}
if (!globalThis.__fiscalAuthPgPool) {
globalThis.__fiscalAuthPgPool = new Pool({ connectionString });
}
return globalThis.__fiscalAuthPgPool;
}
function buildAuth() {
const adminUserIds = parseCsvList(process.env.BETTER_AUTH_ADMIN_USER_IDS);
const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS);
const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim()
|| process.env.BETTER_AUTH_URL?.trim()
|| undefined;
const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined;
return betterAuth({
database: getPool(),
baseURL,
secret,
emailAndPassword: {
enabled: true
},
trustedOrigins: trustedOrigins.length > 0 ? trustedOrigins : undefined,
plugins: [
admin(adminUserIds.length > 0 ? { adminUserIds } : undefined),
magicLink({
sendMagicLink: async ({ email, url }) => {
console.info(`[better-auth] Magic link requested for ${email}: ${url}`);
}
}),
organization(),
nextCookies()
]
});
}
export function getAuth() {
if (!authInstance) {
authInstance = buildAuth();
}
return authInstance;
}
export async function ensureAuthSchema() {
const auth = getAuth();
if (!migrationPromise) {
migrationPromise = (async () => {
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
})();
}
try {
await migrationPromise;
} catch (error) {
migrationPromise = null;
throw error;
}
return auth;
}

121
lib/server/auth-session.ts Normal file
View File

@@ -0,0 +1,121 @@
import { headers } from 'next/headers';
import { ensureAuthSchema } from '@/lib/auth';
import { asErrorMessage, jsonError } from '@/lib/server/http';
type RecordValue = Record<string, unknown>;
export type AuthenticatedUser = {
id: string;
email: string;
name: string | null;
image: string | null;
role?: string | string[];
};
export type AuthenticatedSession = {
user: AuthenticatedUser;
session: RecordValue | null;
raw: RecordValue;
};
const UNAUTHORIZED_SESSION: AuthenticatedSession = {
user: {
id: '',
email: '',
name: null,
image: null
},
session: null,
raw: {}
};
function asRecord(value: unknown): RecordValue | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as RecordValue;
}
function asString(value: unknown) {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function asNullableString(value: unknown) {
return typeof value === 'string' ? value : null;
}
function normalizeRole(value: unknown) {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
const roles = value.filter((entry): entry is string => typeof entry === 'string');
return roles.length > 0 ? roles : undefined;
}
return undefined;
}
function normalizeSession(rawSession: unknown): AuthenticatedSession | null {
const root = asRecord(rawSession);
if (!root) {
return null;
}
const rootSession = asRecord(root.session);
const userRecord = asRecord(root.user) ?? asRecord(rootSession?.user);
if (!userRecord) {
return null;
}
const id = asString(userRecord.id);
const email = asString(userRecord.email);
if (!id || !email) {
return null;
}
return {
user: {
id,
email,
name: asNullableString(userRecord.name),
image: asNullableString(userRecord.image),
role: normalizeRole(userRecord.role)
},
session: rootSession,
raw: root
};
}
export async function getAuthenticatedSession() {
const auth = await ensureAuthSchema();
const session = await auth.api.getSession({
headers: await headers()
});
return normalizeSession(session);
}
export async function requireAuthenticatedSession() {
try {
const session = await getAuthenticatedSession();
if (!session) {
return {
session: UNAUTHORIZED_SESSION,
response: jsonError('Unauthorized', 401)
};
}
return {
session,
response: null
};
} catch (error) {
return {
session: UNAUTHORIZED_SESSION,
response: jsonError(asErrorMessage(error, 'Authentication subsystem is unavailable.'), 500)
};
}
}

View File

@@ -21,13 +21,7 @@ const STORE_PATH = path.join(DATA_DIR, 'store.json');
let writeQueue = Promise.resolve();
function nowIso() {
return new Date().toISOString();
}
function createDefaultStore(): DataStore {
const now = nowIso();
return {
counters: {
watchlist: 0,
@@ -39,19 +33,7 @@ function createDefaultStore(): DataStore {
holdings: [],
filings: [],
tasks: [],
insights: [
{
id: 1,
user_id: 1,
provider: 'local-bootstrap',
model: 'zeroclaw',
content: [
'System initialized in local-first mode.',
'Add holdings and sync filings to produce a live AI brief via OpenClaw.'
].join('\n'),
created_at: now
}
]
insights: []
};
}

View File

@@ -7,6 +7,7 @@ import { fetchFilingMetrics, fetchRecentFilings } from '@/lib/server/sec';
import { getStoreSnapshot, withStore } from '@/lib/server/store';
type EnqueueTaskInput = {
userId: string;
taskType: TaskType;
payload?: Record<string, unknown>;
priority?: number;
@@ -137,9 +138,15 @@ async function processSyncFilings(task: Task) {
};
}
async function processRefreshPrices() {
async function processRefreshPrices(task: Task) {
const userId = task.user_id;
if (!userId) {
throw new Error('Task is missing user scope');
}
const snapshot = await getStoreSnapshot();
const tickers = [...new Set(snapshot.holdings.map((holding) => holding.ticker))];
const userHoldings = snapshot.holdings.filter((holding) => holding.user_id === userId);
const tickers = [...new Set(userHoldings.map((holding) => holding.ticker))];
const quotes = new Map<string, number>();
for (const ticker of tickers) {
@@ -152,6 +159,10 @@ async function processRefreshPrices() {
await withStore((store) => {
store.holdings = store.holdings.map((holding) => {
if (holding.user_id !== userId) {
return holding;
}
const quote = quotes.get(holding.ticker);
if (quote === undefined) {
return holding;
@@ -236,14 +247,20 @@ function holdingDigest(holdings: Holding[]) {
}));
}
async function processPortfolioInsights() {
async function processPortfolioInsights(task: Task) {
const userId = task.user_id;
if (!userId) {
throw new Error('Task is missing user scope');
}
const snapshot = await getStoreSnapshot();
const summary = buildPortfolioSummary(snapshot.holdings);
const userHoldings = snapshot.holdings.filter((holding) => holding.user_id === userId);
const summary = buildPortfolioSummary(userHoldings);
const prompt = [
'Generate portfolio intelligence with actionable recommendations.',
`Portfolio summary: ${JSON.stringify(summary)}`,
`Holdings: ${JSON.stringify(holdingDigest(snapshot.holdings))}`,
`Holdings: ${JSON.stringify(holdingDigest(userHoldings))}`,
'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
].join('\n');
@@ -255,7 +272,7 @@ async function processPortfolioInsights() {
const insight: PortfolioInsight = {
id: store.counters.insights,
user_id: 1,
user_id: userId,
provider: analysis.provider,
model: analysis.model,
content: analysis.text,
@@ -277,11 +294,11 @@ async function runTaskProcessor(task: Task) {
case 'sync_filings':
return await processSyncFilings(task);
case 'refresh_prices':
return await processRefreshPrices();
return await processRefreshPrices(task);
case 'analyze_filing':
return await processAnalyzeFiling(task);
case 'portfolio_insights':
return await processPortfolioInsights();
return await processPortfolioInsights(task);
default:
throw new Error(`Unsupported task type: ${task.task_type}`);
}
@@ -356,6 +373,7 @@ export async function enqueueTask(input: EnqueueTaskInput) {
const task: Task = {
id: randomUUID(),
user_id: input.userId,
task_type: input.taskType,
status: 'queued',
priority: input.priority ?? 50,
@@ -384,18 +402,19 @@ export async function enqueueTask(input: EnqueueTaskInput) {
return task;
}
export async function getTaskById(taskId: string) {
export async function getTaskById(taskId: string, userId: string) {
const snapshot = await getStoreSnapshot();
return snapshot.tasks.find((task) => task.id === taskId) ?? null;
return snapshot.tasks.find((task) => task.id === taskId && task.user_id === userId) ?? null;
}
export async function listRecentTasks(limit = 20, statuses?: TaskStatus[]) {
export async function listRecentTasks(userId: string, limit = 20, statuses?: TaskStatus[]) {
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200);
const snapshot = await getStoreSnapshot();
const scoped = snapshot.tasks.filter((task) => task.user_id === userId);
const filtered = statuses && statuses.length > 0
? snapshot.tasks.filter((task) => statuses.includes(task.status))
: snapshot.tasks;
? scoped.filter((task) => statuses.includes(task.status))
: scoped;
return filtered
.slice()

View File

@@ -1,5 +1,5 @@
export type User = {
id: number;
id: string;
email: string;
name: string | null;
image: string | null;
@@ -7,7 +7,7 @@ export type User = {
export type WatchlistItem = {
id: number;
user_id: number;
user_id: string;
ticker: string;
company_name: string;
sector: string | null;
@@ -16,7 +16,7 @@ export type WatchlistItem = {
export type Holding = {
id: number;
user_id: number;
user_id: string;
ticker: string;
shares: string;
avg_cost: string;
@@ -68,6 +68,7 @@ export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'p
export type Task = {
id: string;
user_id: string;
task_type: TaskType;
status: TaskStatus;
priority: number;
@@ -83,7 +84,7 @@ export type Task = {
export type PortfolioInsight = {
id: number;
user_id: number;
user_id: string;
provider: string;
model: string;
content: string;

405
package-lock.json generated
View File

@@ -9,16 +9,19 @@
"version": "2.0.0",
"dependencies": {
"@tailwindcss/postcss": "^4.2.1",
"better-auth": "^1.4.19",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.575.0",
"next": "^16.1.6",
"pg": "^8.18.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^25.3.0",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.24",
@@ -39,6 +42,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@better-auth/core": {
"version": "1.4.19",
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.19.tgz",
"integrity": "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"zod": "^4.3.5"
},
"peerDependencies": {
"@better-auth/utils": "0.3.0",
"@better-fetch/fetch": "1.1.21",
"better-call": "1.1.8",
"jose": "^6.1.0",
"kysely": "^0.28.5",
"nanostores": "^1.0.1"
}
},
"node_modules/@better-auth/telemetry": {
"version": "1.4.19",
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.19.tgz",
"integrity": "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==",
"dependencies": {
"@better-auth/utils": "0.3.0",
"@better-fetch/fetch": "1.1.21"
},
"peerDependencies": {
"@better-auth/core": "1.4.19"
}
},
"node_modules/@better-auth/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==",
"license": "MIT"
},
"node_modules/@better-fetch/fetch": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz",
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -694,6 +737,30 @@
"node": ">= 10"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -1080,6 +1147,18 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -1155,6 +1234,126 @@
"node": ">=6.0.0"
}
},
"node_modules/better-auth": {
"version": "1.4.19",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.19.tgz",
"integrity": "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ==",
"license": "MIT",
"dependencies": {
"@better-auth/core": "1.4.19",
"@better-auth/telemetry": "1.4.19",
"@better-auth/utils": "0.3.0",
"@better-fetch/fetch": "1.1.21",
"@noble/ciphers": "^2.0.0",
"@noble/hashes": "^2.0.0",
"better-call": "1.1.8",
"defu": "^6.1.4",
"jose": "^6.1.0",
"kysely": "^0.28.5",
"nanostores": "^1.0.1",
"zod": "^4.3.5"
},
"peerDependencies": {
"@lynx-js/react": "*",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"@sveltejs/kit": "^2.0.0",
"@tanstack/react-start": "^1.0.0",
"@tanstack/solid-start": "^1.0.0",
"better-sqlite3": "^12.0.0",
"drizzle-kit": ">=0.31.4",
"drizzle-orm": ">=0.41.0",
"mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
"pg": "^8.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"solid-js": "^1.0.0",
"svelte": "^4.0.0 || ^5.0.0",
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@lynx-js/react": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"@tanstack/react-start": {
"optional": true
},
"@tanstack/solid-start": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"drizzle-kit": {
"optional": true
},
"drizzle-orm": {
"optional": true
},
"mongodb": {
"optional": true
},
"mysql2": {
"optional": true
},
"next": {
"optional": true
},
"pg": {
"optional": true
},
"prisma": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vitest": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/better-call": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz",
"integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "^0.3.0",
"@better-fetch/fetch": "^1.1.4",
"rou3": "^0.7.10",
"set-cookie-parser": "^2.7.1"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -1368,6 +1567,12 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1471,6 +1676,24 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/kysely": {
"version": "0.28.11",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz",
"integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -1756,6 +1979,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanostores": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz",
"integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
@@ -1844,6 +2082,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1885,6 +2212,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -1987,6 +2353,12 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rou3": {
"version": "0.7.12",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2006,6 +2378,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -2060,6 +2438,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -2196,6 +2583,24 @@
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -10,16 +10,19 @@
},
"dependencies": {
"@tailwindcss/postcss": "^4.2.1",
"better-auth": "^1.4.19",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.575.0",
"next": "^16.1.6",
"pg": "^8.18.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^25.3.0",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.24",