diff --git a/.dockerignore b/.dockerignore index b757fb7..b1c3b61 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ node_modules -.next data .env .env.* diff --git a/AGENTS.md b/AGENTS.md index b8cfd70..9d19213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,12 +355,10 @@ All previously listed follow-ups have been shipped. See the live issue list for - File-based Persistence ✅ — `.sin-webui/` workspace with JSON stores (settings, workspaces, memories, projects) ### Open Issues (check before starting any new task) -- **#51** — feat: Implement Better Auth multi-user authentication system -- **#52** — feat: Undo/Redo history for Design Mode -- **#53** — fix: Eliminate Turbopack/NFT build warning -- **#54** — feat: Screenshot area capture (⌘+Drag) in Design Mode -- **#55** — feat: Real Vercel deployment behind Publish button -- Real Vercel-Deploy behind the Publish button — out of scope (real deploys go through Docker + Cloudflare Tunnel, see `PLAN_DEPLOY.md`) — superseded by #55 +- **#59** — fix(auth): resolve 401 on all /api/* routes after Better Auth + Kysely integration +- **#60** — fix(build): eliminate the last Turbopack NFT warning in design-edit route +- **#61** — feat(design-mode): wire ⌘Z / ⌘⇧Z keyboard shortcuts for Undo/Redo +- **#62** — ci: verify ceo-audit + tsc-check are green on main @ 30de716 Check the live issue list before starting any new task. @@ -390,6 +388,33 @@ auf Redirects umgestellt werden. Ein PR ist erst fertig, wenn die betroffenen Routen im Browser (Screenshot) verifiziert wurden — ein grüner tsc/build-Check ist KEIN Beweis für korrektes Rendering. +## §5.10 No Orphaned Components + +After every UI refactoring, verify that all old entry points are migrated or +redirected. A PR is only done when the affected routes are verified in the +browser (screenshot) — a green tsc/build check is NO proof of correct +rendering. Use `sin-discover` to detect orphaned components (exported but not +imported anywhere in nav/sidebar). After wiring a new component, delete the +legacy file in the same PR. Never leave dangling components. + +## §5.11 Routing Protection + +When deploying to Docker + Cloudflare Tunnel on macOS: + +- Port 3000 may be occupied by other processes (whatsapp-bridge, other dev + servers) — always check with `lsof -i :3000` before assuming port is free +- Remap Docker container to `3100:3000` in docker-compose.yml to avoid conflicts +- Update `~/.cloudflared/config-sin-code-webui.yml` to point to `localhost:3100` +- Restart cloudflared tunnel after config changes: + ```bash + pkill -f "cloudflared tunnel" + nohup cloudflared tunnel --config ~/.cloudflared/config-sin-code-webui.yml run sin-code-webui & + ``` +- Verify with `curl -s https://sincode-webui.delqhi.com/api/health` — expect + Next.js JSON, NOT Express "Invalid Host header" +- If live domain returns Express headers, a local process (not the container) + is hijacking the tunnel + --- -Last updated: aligned with main @ `86872a4` (post deploy work). +Last updated: aligned with main @ `30de716` (post #58 merge). diff --git a/app/api/settings/mcp/route.ts b/app/api/settings/mcp/route.ts index 315652a..5dc319c 100644 --- a/app/api/settings/mcp/route.ts +++ b/app/api/settings/mcp/route.ts @@ -5,12 +5,17 @@ import crypto from "crypto" import { guardRequest } from "@/lib/sin/run" import { getSession } from "@/lib/session" -const BASE = path.join(process.cwd(), ".sin-webui") +let _base: string | null = null +// @turbopack-disable-next-line +function base(): string { + if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui") + return _base +} function mcpFile(userId: string): string { - if (userId === "global") return path.join(BASE, "mcp-connections.json") + if (userId === "global") return path.join(base(), "mcp-connections.json") const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_") - return path.join(BASE, "users", safe, "mcp-connections.json") + return path.join(base(), "users", safe, "mcp-connections.json") } export interface McpConnection { diff --git a/app/api/settings/workspace/route.ts b/app/api/settings/workspace/route.ts index de484da..590386d 100644 --- a/app/api/settings/workspace/route.ts +++ b/app/api/settings/workspace/route.ts @@ -4,12 +4,17 @@ import path from "path" import { guardRequest } from "@/lib/sin/run" import { getSession } from "@/lib/session" -const BASE = path.join(process.cwd(), ".sin-webui") +let _base: string | null = null +// @turbopack-disable-next-line +function base(): string { + if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui") + return _base +} function workspaceFile(userId: string): string { - if (userId === "global") return path.join(BASE, "workspace.json") + if (userId === "global") return path.join(base(), "workspace.json") const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_") - return path.join(BASE, "users", safe, "workspace.json") + return path.join(base(), "users", safe, "workspace.json") } interface Workspace { diff --git a/app/api/workspace/design-edit/route.ts b/app/api/workspace/design-edit/route.ts index b603294..c80ad15 100644 --- a/app/api/workspace/design-edit/route.ts +++ b/app/api/workspace/design-edit/route.ts @@ -3,11 +3,16 @@ import { promises as fs } from "fs" import path from "path" import { pushEntry } from "@/lib/workspace/design-history" -const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd() +let _root: string | null = null +// @turbopack-disable-next-line +function root(): string { + if (!_root) _root = /*turbopackIgnore: true*/ (process.env.SIN_WORKSPACE_DIR ?? process.cwd()) + return _root +} function safeResolve(rel: string): string { - const resolved = path.resolve(ROOT, "." + path.sep + rel) - if (!resolved.startsWith(ROOT)) throw new Error("Invalid path") + const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel) + if (!resolved.startsWith(root())) throw new Error("Invalid path") return resolved } @@ -36,7 +41,7 @@ export async function POST(req: Request) { let content: string try { - content = await fs.readFile(abs, "utf8") + content = await /*turbopackIgnore: true*/ fs.readFile(abs, "utf8") } catch { return NextResponse.json({ error: "File not found" }, { status: 404 }) } @@ -61,7 +66,7 @@ export async function POST(req: Request) { ) } - await fs.writeFile(abs, lines.join("\n"), "utf8") + await /*turbopackIgnore: true*/ fs.writeFile(abs, lines.join("\n"), "utf8") const relativeFilePath = file const tagName = "div" // or get from the request context await pushEntry({ diff --git a/app/api/workspace/files/route.ts b/app/api/workspace/files/route.ts index 6b44194..76bb997 100644 --- a/app/api/workspace/files/route.ts +++ b/app/api/workspace/files/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server" import { promises as fs } from "fs" import path from "path" -const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd() +let _root: string | null = null +// @turbopack-disable-next-line +function root(): string { + if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? (/*turbopackIgnore: true*/ process.cwd()) + return _root +} const IGNORE = new Set(["node_modules", ".git", ".next", ".sin-webui", "dist"]) const MAX_FILE_SIZE = 512 * 1024 @@ -15,8 +20,8 @@ interface TreeNode { } function safeResolve(rel: string): string { - const resolved = path.resolve(ROOT, "." + path.sep + rel) - if (!resolved.startsWith(ROOT)) throw new Error("Invalid path") + const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel) + if (!resolved.startsWith(root())) throw new Error("Invalid path") return resolved } @@ -31,7 +36,7 @@ async function buildTree(dir: string, relBase = ""): Promise { name: entry.name, path: relPath, type: "dir", - children: await buildTree(path.join(dir, entry.name), relPath), + children: await buildTree(/*turbopackIgnore: true*/ path.join(dir, entry.name), relPath), }) } else { nodes.push({ name: entry.name, path: relPath, type: "file" }) @@ -60,5 +65,5 @@ export async function GET(req: Request) { } } - return NextResponse.json({ nodes: await buildTree(ROOT) }) + return NextResponse.json({ nodes: await buildTree(root()) }) } diff --git a/app/api/workspace/screenshot/[filename]/route.ts b/app/api/workspace/screenshot/[filename]/route.ts index 077a4cf..29acabe 100644 --- a/app/api/workspace/screenshot/[filename]/route.ts +++ b/app/api/workspace/screenshot/[filename]/route.ts @@ -1,7 +1,12 @@ import path from 'node:path' import { promises as fs } from 'node:fs' -const DIR = path.join(process.cwd(), '.sin-webui', 'screenshots') +let _dir: string | null = null +// @turbopack-disable-next-line +function dir(): string { + if (!_dir) _dir = /*turbopackIgnore: true*/ path.join(process.cwd(), '.sin-webui', 'screenshots') + return _dir +} export async function GET( _req: Request, @@ -10,7 +15,7 @@ export async function GET( const { filename } = await params const safe = path.basename(filename) // path-traversal guard try { - const buffer = await fs.readFile(path.join(DIR, safe)) + const buffer = await fs.readFile(/*turbopackIgnore: true*/ path.join(dir(), safe)) return new Response(new Uint8Array(buffer), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'private, max-age=31536000' }, }) diff --git a/app/api/workspace/screenshot/route.ts b/app/api/workspace/screenshot/route.ts index 32132c5..22e4217 100644 --- a/app/api/workspace/screenshot/route.ts +++ b/app/api/workspace/screenshot/route.ts @@ -3,14 +3,25 @@ import path from 'node:path' import { promises as fs } from 'node:fs' import { randomUUID } from 'node:crypto' -const DIR = path.join(process.cwd(), '.sin-webui', 'screenshots') -const INDEX = path.join(process.cwd(), '.sin-webui', 'screenshots.json') +let _dir: string | null = null +// @turbopack-disable-next-line +function dir(): string { + if (!_dir) _dir = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui', 'screenshots') + return _dir +} + +let _index: string | null = null +// @turbopack-disable-next-line +function indexPath(): string { + if (!_index) _index = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui', 'screenshots.json') + return _index +} type Meta = { id: string; filename: string; createdAt: string; size: number } async function readIndex(): Promise { try { - return JSON.parse(await fs.readFile(INDEX, 'utf8')) as Meta[] + return JSON.parse(await fs.readFile(indexPath(), 'utf8')) as Meta[] } catch { return [] } @@ -29,14 +40,14 @@ export async function POST(req: Request) { if (buffer.length > 10 * 1024 * 1024) { return NextResponse.json({ error: 'Screenshot too large' }, { status: 413 }) } - await fs.mkdir(DIR, { recursive: true }) + await fs.mkdir(dir(), { recursive: true }) const id = randomUUID() const filename = `${Date.now()}-${id.slice(0, 8)}.png` - await fs.writeFile(path.join(DIR, filename), buffer) + await fs.writeFile(/*turbopackIgnore: true*/ path.join(dir(), filename), buffer) const index = await readIndex() index.unshift({ id, filename, createdAt: new Date().toISOString(), size: buffer.length }) - await fs.writeFile(INDEX, JSON.stringify(index.slice(0, 200), null, 2)) + await fs.writeFile(indexPath(), JSON.stringify(index.slice(0, 200), null, 2)) return NextResponse.json({ id, url: `/api/workspace/screenshot/${filename}` }) } @@ -46,8 +57,8 @@ export async function DELETE(req: Request) { const index = await readIndex() const meta = index.find((m) => m.id === id) if (meta) { - await fs.rm(path.join(DIR, meta.filename), { force: true }) - await fs.writeFile(INDEX, JSON.stringify(index.filter((m) => m.id !== id), null, 2)) + await fs.rm(/*turbopackIgnore: true*/ path.join(dir(), meta.filename), { force: true }) + await fs.writeFile(indexPath(), JSON.stringify(index.filter((m) => m.id !== id), null, 2)) } return NextResponse.json({ ok: true }) } diff --git a/app/api/workspace/versions/route.ts b/app/api/workspace/versions/route.ts index 139d65a..fe62015 100644 --- a/app/api/workspace/versions/route.ts +++ b/app/api/workspace/versions/route.ts @@ -3,14 +3,19 @@ import { promisify } from "util" import { execFile } from "child_process" const execFileAsync = promisify(execFile) -const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd() +let _root: string | null = null +// @turbopack-disable-next-line +function root(): string { + if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? (/*turbopackIgnore: true*/ process.cwd()) + return _root +} export async function GET() { try { const { stdout } = await execFileAsync( "git", ["log", "--pretty=format:%H|%cI|%s", "-n", "50"], - { cwd: ROOT }, + { cwd: root() }, ) const versions = stdout .trim() diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ffd2aad..a620988 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -74,14 +74,18 @@ const json = await res.json() setShared(Boolean(json.data?.slug)) } -async function handleExport() { -const url = `${window.location.origin}/chat/${chat.id}` +async function handleExport(format: 'md' | 'json') { +const res = await fetch(`/api/chats/${chat.id}/export?format=${format}`) +if (!res.ok) return +const blob = await res.blob() +const objectUrl = URL.createObjectURL(blob) const a = document.createElement('a') -a.href = url -a.download = `${chat.label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.html` +a.href = objectUrl +a.download = `${chat.label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.${format}` document.body.appendChild(a) a.click() a.remove() +URL.revokeObjectURL(objectUrl) } function handleMoveToNewProject() { @@ -108,7 +112,16 @@ return ( {shared ? 'Unshare' : 'Share (copy link)'} -Export as HTML + + + + Export + + + handleExport('md')}>As Markdown + handleExport('json')}>As JSON + + Move to Project diff --git a/docker-compose.yml b/docker-compose.yml index 2b6dccf..fa414e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,38 @@ services: + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_USER=app + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme} + - POSTGRES_DB=sin_webui + volumes: + - postgres-data:/var/lib/postgresql/data + - ./scripts/better-auth-schema.sql:/docker-entrypoint-initdb.d/010-better-auth-schema.sql:ro + - ./scripts/001_init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d sin_webui"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + webui: build: . ports: - - "3000:3000" + - "3100:3000" + depends_on: + postgres: + condition: service_healthy environment: - AI_GATEWAY_API_KEY=${AI_GATEWAY_API_KEY} - SIN_CHAT_MODEL=${SIN_CHAT_MODEL:-openai/gpt-5-mini} - SIN_CODE_BIN=/usr/local/bin/sin-code + - DATABASE_URL=postgresql://app:${POSTGRES_PASSWORD:-changeme}@postgres:5432/sin_webui?sslmode=disable + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-changeme-insecure-dev-secret-32chars-min} + - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3100} volumes: - sin-webui-data:/app/.sin-webui volumes: - sin-webui-data: + postgres-data: + sin-webui-data: \ No newline at end of file diff --git a/lib/audit.ts b/lib/audit.ts index 602e4d3..7137d3e 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -7,8 +7,17 @@ import { appendFile, mkdir, readFile, readdir } from 'node:fs/promises' import path from 'node:path' -const DATA_DIR = path.join(process.cwd(), '.sin-webui') -const AUDIT_DIR = path.join(DATA_DIR, 'audit') +let _dataDir: string | null = null +function dataDir(): string { + if (!_dataDir) _dataDir = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui') + return _dataDir +} + +let _auditDir: string | null = null +function auditDir(): string { + if (!_auditDir) _auditDir = path.join(dataDir(), 'audit') + return _auditDir +} export type AuditEntry = { ts: string @@ -24,12 +33,12 @@ export type AuditEntry = { function currentFile(): string { const now = new Date() const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` - return path.join(AUDIT_DIR, `audit-${month}.jsonl`) + return path.join(auditDir(), `audit-${month}.jsonl`) } export async function audit(entry: Omit): Promise { try { - await mkdir(AUDIT_DIR, { recursive: true }) + await mkdir(auditDir(), { recursive: true }) const record: AuditEntry = { ts: new Date().toISOString(), ...entry } await appendFile(currentFile(), `${JSON.stringify(record)}\n`, 'utf8') } catch { @@ -46,7 +55,7 @@ export async function readAudit(options?: { const limit = Math.min(options?.limit ?? 200, 1000) let files: string[] = [] try { - files = (await readdir(AUDIT_DIR)) + files = (await readdir(auditDir())) .filter((f) => f.startsWith('audit-') && f.endsWith('.jsonl')) .sort() .reverse() @@ -58,7 +67,7 @@ export async function readAudit(options?: { const entries: AuditEntry[] = [] for (const file of files) { try { - const raw = await readFile(path.join(AUDIT_DIR, file), 'utf8') + const raw = await readFile(path.join(auditDir(), file), 'utf8') for (const line of raw.split('\n')) { if (!line.trim()) continue try { diff --git a/lib/auth.ts b/lib/auth.ts index 68d3794..9d8ec29 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -27,5 +27,5 @@ export async function verifyAnyToken( ): Promise { if (!token) return false if (verifyToken(token)) return true - return verifyStoredToken(token) + return await verifyStoredToken(token) } diff --git a/lib/auth/better-auth.ts b/lib/auth/better-auth.ts index 4ed0247..44510ba 100644 --- a/lib/auth/better-auth.ts +++ b/lib/auth/better-auth.ts @@ -1,12 +1,15 @@ /** - * Purpose: Better Auth configuration on the existing pg pool. + * Purpose: Better Auth configuration using Kysely + @better-auth/kysely-adapter. * Requires DATABASE_URL + BETTER_AUTH_SECRET. When either is missing, * isBetterAuthEnabled() is false and the legacy token auth * (lib/auth.ts / lib/session.ts) remains the active system. * Lazy-initialized to avoid build-time database connection errors. + * Docs: lib/auth/better-auth.doc.md */ import { betterAuth } from 'better-auth' -import { getPool, isDbConfigured } from '@/lib/db' +import { kyselyAdapter } from '@better-auth/kysely-adapter' +import { getDb, getPool } from '@/lib/db' +import { isDbConfigured } from '@/lib/is-db-configured' export function isBetterAuthEnabled(): boolean { return isDbConfigured() && Boolean(process.env.BETTER_AUTH_SECRET) @@ -23,11 +26,13 @@ export function getAuth() { _auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET, baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000', - database: getPool(), + database: { + type: 'postgres', + adapter: kyselyAdapter(getDb()), + }, emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { - // TODO: real mail provider; for now log to server console console.log(`[auth] Password reset for ${user.email}: ${url}`) }, }, @@ -40,8 +45,9 @@ export function getAuth() { user: { create: { before: async (user: any) => { - // First registered user becomes owner (bootstrap). - const { rows } = await getPool().query(`SELECT COUNT(*)::int AS n FROM "user"`) + const { rows } = await getPool().query( + `SELECT COUNT(*)::int AS n FROM "user"` + ) return { data: { ...user, role: rows[0].n === 0 ? 'owner' : 'member' } } }, }, @@ -52,4 +58,4 @@ export function getAuth() { } export type AuthInstance = any -export type Session = any +export type Session = any \ No newline at end of file diff --git a/lib/chat-history.ts b/lib/chat-history.ts index 2c25d80..529bb45 100644 --- a/lib/chat-history.ts +++ b/lib/chat-history.ts @@ -9,7 +9,13 @@ import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/pro import path from 'node:path' import type { UIMessage } from 'ai' -const DATA_DIR = path.join(process.cwd(), 'data', 'chats') +// Lazy-initialized to prevent Turbopack's NFT tracer from seeing path.join(process.cwd(),...) +// at module scope. The ensureDir() call triggers initialization on first use. +let _dataDir: string | null = null +function dataDir(): string { + if (!_dataDir) _dataDir = path.join(/*turbopackIgnore: true*/ process.cwd(), 'data', 'chats') + return _dataDir +} const SAFE_ID = /^[a-z0-9-]{1,80}$/ export type ChatMeta = { @@ -23,15 +29,15 @@ export type ChatMeta = { } async function ensureDir() { - await mkdir(DATA_DIR, { recursive: true }) + await mkdir(dataDir(), { recursive: true }) } function indexPath() { - return path.join(DATA_DIR, 'index.json') + return path.join(dataDir(), 'index.json') } function chatPath(id: string) { - return path.join(DATA_DIR, `${id}.json`) + return path.join(dataDir(), `${id}.json`) } async function atomicWrite(filePath: string, content: string) { @@ -121,13 +127,13 @@ export async function pruneOrphans(): Promise { await ensureDir() const chats = await listChats() const ids = new Set(chats.map((c) => c.id)) - const files = await readdir(DATA_DIR) + const files = await readdir(dataDir()) for (const file of files) { if (!file.endsWith('.json') || file === 'index.json') continue const id = file.replace(/\.json$/, '') if (!ids.has(id) && SAFE_ID.test(id)) { try { - await unlink(path.join(DATA_DIR, file)) + await unlink(path.join(dataDir(), file)) } catch { /* ignore */ } diff --git a/lib/db.ts b/lib/db.ts index 8446800..e1b4cc6 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,13 +1,15 @@ /** - * Purpose: Postgres pool singleton. Works with Neon (sslmode=require in - * the connection string) and self-hosted Postgres alike. - * Set DATABASE_URL to enable the Postgres store; without it, the - * file-based store remains active (see lib/storage.ts). + * Purpose: Postgres pool singleton + Kysely instance for better-auth. + * Set DATABASE_URL to enable. pg Pool for direct queries (health checks); + * Kysely + PostgresDialect for better-auth's kysely-adapter. + * Docs: lib/db.doc.md */ import { Pool } from 'pg' +import { Kysely, PostgresDialect } from 'kysely' declare global { var __sinPgPool: Pool | undefined + var __sinKysely: Kysely | undefined } export function isDbConfigured(): boolean { @@ -27,3 +29,15 @@ export function getPool(): Pool { } return globalThis.__sinPgPool } + +export function getDb(): Kysely { + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is not set') + } + if (!globalThis.__sinKysely) { + globalThis.__sinKysely = new Kysely({ + dialect: new PostgresDialect({ pool: getPool() }), + }) + } + return globalThis.__sinKysely +} \ No newline at end of file diff --git a/lib/is-db-configured.ts b/lib/is-db-configured.ts new file mode 100644 index 0000000..564d177 --- /dev/null +++ b/lib/is-db-configured.ts @@ -0,0 +1,8 @@ +/** + * Purpose: Lightweight env check with no external dependencies. + * Avoids importing lib/db.ts (which pulls in the pg native addon) + * into modules that only need to know whether the database is configured. + */ +export function isDbConfigured(): boolean { + return Boolean(process.env.DATABASE_URL) +} \ No newline at end of file diff --git a/lib/session.ts b/lib/session.ts index 5a55b5b..a1dc911 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -13,7 +13,6 @@ import { cookies, headers } from 'next/headers' import { AUTH_COOKIE, isAuthConfigured, verifyToken } from '@/lib/auth' import { findUserByTokenHash, isMultiUserEnabled, type User } from '@/lib/users' import { findTokenName } from '@/lib/storage' -import { getAuth, isBetterAuthEnabled } from '@/lib/auth/better-auth' export type Session = | { kind: 'root'; isAdmin: true; userId: null; actor: 'root' } @@ -33,6 +32,7 @@ async function presentedToken(): Promise { } async function getBetterAuthSession(): Promise { + const { isBetterAuthEnabled, getAuth } = await import('@/lib/auth/better-auth') if (!isBetterAuthEnabled()) return null try { const auth = getAuth() diff --git a/lib/settings/activity.ts b/lib/settings/activity.ts index 5d1099b..8b62556 100644 --- a/lib/settings/activity.ts +++ b/lib/settings/activity.ts @@ -1,12 +1,16 @@ import { promises as fs } from "fs" import path from "path" -const BASE = path.join(process.cwd(), ".sin-webui") +let _base: string | null = null +function base(): string { + if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui") + return _base +} function activityFile(userId: string): string { - if (userId === "global") return path.join(BASE, "activity.jsonl") + if (userId === "global") return path.join(base(), "activity.jsonl") const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_") - return path.join(BASE, "users", safe, "activity.jsonl") + return path.join(base(), "users", safe, "activity.jsonl") } export interface ActivityEvent { diff --git a/lib/settings/api-keys.ts b/lib/settings/api-keys.ts index 29e91be..e48a9d7 100644 --- a/lib/settings/api-keys.ts +++ b/lib/settings/api-keys.ts @@ -2,12 +2,16 @@ import { promises as fs } from "fs" import path from "path" import crypto from "crypto" -const BASE = path.join(process.cwd(), ".sin-webui") +let _base: string | null = null +function base(): string { + if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui") + return _base +} function scopedDir(userId: string): string { - if (userId === "global") return BASE + if (userId === "global") return base() const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_") - return path.join(BASE, "users", safe) + return path.join(base(), "users", safe) } function keysFile(userId: string): string { diff --git a/lib/settings/store.ts b/lib/settings/store.ts index 9645c13..6f7cdb9 100644 --- a/lib/settings/store.ts +++ b/lib/settings/store.ts @@ -1,13 +1,17 @@ import { promises as fs } from "fs" import path from "path" -const BASE = path.join(process.cwd(), ".sin-webui") +let _base: string | null = null +function base(): string { + if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui") + return _base +} /** Per-user settings directory. 'global' keeps the legacy single-user path. */ function scopedDir(userId: string): string { - if (userId === "global") return BASE + if (userId === "global") return base() const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_") - return path.join(BASE, "users", safe) + return path.join(base(), "users", safe) } export type Scope = "user" | "team" diff --git a/lib/shares.ts b/lib/shares.ts index d8adb6d..26f134e 100644 --- a/lib/shares.ts +++ b/lib/shares.ts @@ -2,11 +2,14 @@ * Purpose: Share link store. A share maps an unguessable slug to a chat * and makes it publicly readable (read-only). Postgres when DATABASE_URL * is set, otherwise data/shares.json. + * + * NOTE: pg is lazy-loaded inside isDbConfigured() branches to prevent + * Turbopack's NFT tracer from pulling native bindings into the server chunk. */ import { randomBytes } from 'node:crypto' import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import path from 'node:path' -import { getPool, isDbConfigured } from '@/lib/db' +import { isDbConfigured } from '@/lib/db' export type Share = { slug: string @@ -16,8 +19,18 @@ export type Share = { } const SAFE_SLUG = /^[a-f0-9]{16}$/ -const DATA_DIR = path.join(process.cwd(), '.sin-webui') -const SHARES_FILE = path.join(DATA_DIR, 'shares.json') + +let _dataDir: string | null = null +function dataDir(): string { + if (!_dataDir) _dataDir = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui') + return _dataDir +} + +let _sharesFile: string | null = null +function sharesFile(): string { + if (!_sharesFile) _sharesFile = path.join(dataDir(), 'shares.json') + return _sharesFile +} export function isValidSlug(slug: string): boolean { return SAFE_SLUG.test(slug) @@ -26,17 +39,25 @@ export function isValidSlug(slug: string): boolean { // ── File fallback ────────────────────────────────────────────────────── async function readFileShares(): Promise { try { - return JSON.parse(await readFile(SHARES_FILE, 'utf8')) as Share[] + return JSON.parse(await readFile(sharesFile(), 'utf8')) as Share[] } catch { return [] } } async function writeFileShares(shares: Share[]): Promise { - await mkdir(DATA_DIR, { recursive: true }) - const tmp = `${SHARES_FILE}.tmp-${Date.now()}` + await mkdir(dataDir(), { recursive: true }) + const tmp = `${sharesFile()}.tmp-${Date.now()}` await writeFile(tmp, JSON.stringify(shares, null, 2), 'utf8') - await rename(tmp, SHARES_FILE) + await rename(tmp, sharesFile()) +} + +async function pgQuery( + sql: string, + params?: unknown[], +): Promise<{ rows: T[]; rowCount?: number }> { + const { getPool } = await import('@/lib/db') + return getPool().query(sql, params) as unknown as Promise<{ rows: T[]; rowCount?: number }> } // ── Public API ───────────────────────────────────────────────────────── @@ -47,7 +68,9 @@ export async function shareChat( ): Promise { const slug = randomBytes(8).toString('hex') if (isDbConfigured()) { - const { rows } = await getPool().query( + const { rows } = await pgQuery<{ + slug: string; chat_id: string; created_by: string | null; created_at: Date + }>( `INSERT INTO chat_shares (slug, chat_id, created_by) VALUES ($1, $2, $3) ON CONFLICT (chat_id) DO UPDATE SET chat_id = EXCLUDED.chat_id @@ -77,7 +100,7 @@ export async function shareChat( export async function unshareChat(chatId: string): Promise { if (isDbConfigured()) { - const result = await getPool().query( + const result = await pgQuery( `DELETE FROM chat_shares WHERE chat_id = $1`, [chatId], ) @@ -92,7 +115,9 @@ export async function unshareChat(chatId: string): Promise { export async function getShareByChatId(chatId: string): Promise { if (isDbConfigured()) { - const { rows } = await getPool().query( + const { rows } = await pgQuery<{ + slug: string; chat_id: string; created_by: string | null; created_at: Date + }>( `SELECT slug, chat_id, created_by, created_at FROM chat_shares WHERE chat_id = $1`, [chatId], ) @@ -111,7 +136,9 @@ export async function getShareByChatId(chatId: string): Promise { export async function getShareBySlug(slug: string): Promise { if (!isValidSlug(slug)) return null if (isDbConfigured()) { - const { rows } = await getPool().query( + const { rows } = await pgQuery<{ + slug: string; chat_id: string; created_by: string | null; created_at: Date + }>( `SELECT slug, chat_id, created_by, created_at FROM chat_shares WHERE slug = $1`, [slug], ) @@ -125,4 +152,4 @@ export async function getShareBySlug(slug: string): Promise { } } return (await readFileShares()).find((s) => s.slug === slug) ?? null -} +} \ No newline at end of file diff --git a/lib/sin/orchestrator-runner.ts b/lib/sin/orchestrator-runner.ts index 08f2115..63fa5de 100644 --- a/lib/sin/orchestrator-runner.ts +++ b/lib/sin/orchestrator-runner.ts @@ -8,7 +8,7 @@ export function runOrchestratorStream(task: string): Response { const stream = new ReadableStream({ start(controller) { const child = spawn(BIN, ["orchestrator-run", task], { - cwd: process.env.SIN_WORKSPACE_DIR || process.cwd(), + cwd: process.env.SIN_WORKSPACE_DIR || (/*turbopackIgnore: true*/ process.cwd()), env: process.env, }) child.stdout.on("data", (chunk: Buffer) => controller.enqueue(encoder.encode(chunk.toString()))) diff --git a/lib/sin/run.ts b/lib/sin/run.ts index 2df2782..df434b8 100644 --- a/lib/sin/run.ts +++ b/lib/sin/run.ts @@ -94,7 +94,7 @@ export async function runSin( ) const raw = stdout.trim() - void audit({ + void await audit({ actor, action: subcommand, args: args.join(' ').slice(0, 200), @@ -114,7 +114,7 @@ export async function runSin( ? 'sin-code binary not installed' : e.stderr?.trim() || e.message || 'sin-code failed' - void audit({ + void await audit({ actor, action: subcommand, args: args.join(' ').slice(0, 200), diff --git a/lib/storage.ts b/lib/storage.ts index 34912f9..3141f3e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -2,40 +2,126 @@ * Purpose: Storage adapter switch. With DATABASE_URL set, all persistence * (chats, tokens, audit) goes to Postgres; otherwise the file-based * stores under data/ remain active. Consumers import from here only. + * + * NOTE: pg-heavy imports are lazy-loaded inside functions to prevent + * Turbopack's NFT tracer from pulling native bindings into the server chunk. + * See Issue #53. */ -import { isDbConfigured } from '@/lib/db' +import { isDbConfigured } from '@/lib/is-db-configured' -import * as chatFile from '@/lib/chat-history' -import * as chatPg from '@/lib/storage/chat-history-pg' -import * as tokensFile from '@/lib/tokens' -import * as tokensPg from '@/lib/storage/tokens-pg' -import * as auditFile from '@/lib/audit' -import * as auditPg from '@/lib/storage/audit-pg' - -const usePg = isDbConfigured() +/*turbopackIgnore: true*/ import * as chatFile from '@/lib/chat-history' +/*turbopackIgnore: true*/ import * as tokensFile from '@/lib/tokens' +/*turbopackIgnore: true*/ import * as auditFile from '@/lib/audit' // Chats — re-export validation + types from the file module (storage-agnostic) export { isValidChatId, type ChatMeta } from '@/lib/chat-history' -export const listChats = usePg ? chatPg.listChats : chatFile.listChats -export const upsertChatMeta = usePg ? chatPg.upsertChatMeta : chatFile.upsertChatMeta -export const deleteChat = usePg ? chatPg.deleteChat : chatFile.deleteChat -export const loadMessages = usePg ? chatPg.loadMessages : chatFile.loadMessages -export const saveMessages = usePg ? chatPg.saveMessages : chatFile.saveMessages -export const ownsChat = usePg ? chatPg.ownsChat : chatFile.ownsChat + +export async function listChats(userId?: string | null) { + if (isDbConfigured()) { + const chatPg = await import('@/lib/storage/chat-history-pg') + return chatPg.listChats(userId) + } + return chatFile.listChats() +} + +export async function upsertChatMeta( + meta: Parameters[0], + userId?: string | null, +) { + if (isDbConfigured()) { + const chatPg = await import('@/lib/storage/chat-history-pg') + return chatPg.upsertChatMeta(meta, userId) + } + return chatFile.upsertChatMeta(meta) +} + +export async function deleteChat(id: string, userId?: string | null) { + if (isDbConfigured()) { + const chatPg = await import('@/lib/storage/chat-history-pg') + return chatPg.deleteChat(id, userId) + } + return chatFile.deleteChat(id) +} + +export async function loadMessages(id: string) { + if (isDbConfigured()) { + const chatPg = await import('@/lib/storage/chat-history-pg') + return chatPg.loadMessages(id) + } + return chatFile.loadMessages(id) +} + +export async function saveMessages( + id: string, + messages: Parameters[1], + userId?: string | null, +) { + if (isDbConfigured()) { + const chatPg = await import('@/lib/storage/chat-history-pg') + return chatPg.saveMessages(id, messages, userId) + } + return chatFile.saveMessages(id, messages) +} + +export async function ownsChat(id: string, userId: string | null) { + if (isDbConfigured()) { + const chatPg = await import('@/lib/storage/chat-history-pg') + return chatPg.ownsChat(id, userId) + } + return chatFile.ownsChat(id, userId) +} // Tokens export type { TokenRecord } from '@/lib/tokens' -export const listTokens = usePg ? tokensPg.listTokens : tokensFile.listTokens -export const createToken = usePg ? tokensPg.createToken : tokensFile.createToken -export const revokeToken = usePg ? tokensPg.revokeToken : tokensFile.revokeToken -export const verifyStoredToken = usePg - ? tokensPg.verifyStoredToken - : tokensFile.verifyStoredToken -export const findTokenName = usePg - ? tokensPg.findTokenName - : tokensFile.findTokenName + +export async function listTokens() { + if (isDbConfigured()) { + const tokensPg = await import('@/lib/storage/tokens-pg') + return tokensPg.listTokens() + } + return tokensFile.listTokens() +} + +export async function createToken(...args: Parameters) { + if (isDbConfigured()) { + const tokensPg = await import('@/lib/storage/tokens-pg') + return tokensPg.createToken(...args) + } + return tokensFile.createToken(...args) +} + +export async function revokeToken(...args: Parameters) { + if (isDbConfigured()) { + const tokensPg = await import('@/lib/storage/tokens-pg') + return tokensPg.revokeToken(...args) + } + return tokensFile.revokeToken(...args) +} + +export async function verifyStoredToken(...args: Parameters) { + if (isDbConfigured()) { + const tokensPg = await import('@/lib/storage/tokens-pg') + return tokensPg.verifyStoredToken(...args) + } + return tokensFile.verifyStoredToken(...args) +} + +export async function findTokenName(...args: Parameters) { + if (isDbConfigured()) { + const tokensPg = await import('@/lib/storage/tokens-pg') + return tokensPg.findTokenName(...args) + } + return tokensFile.findTokenName(...args) +} // Audit — auditToCsv is pure, always from the file module export { auditToCsv, type AuditEntry } from '@/lib/audit' -export const audit = usePg ? auditPg.audit : auditFile.audit -export const readAudit = usePg ? auditPg.readAudit : auditFile.readAudit +export { readAudit } from '@/lib/audit' + +export async function audit(...args: Parameters) { + if (isDbConfigured()) { + const auditPg = await import('@/lib/storage/audit-pg') + return auditPg.audit(...args) + } + return auditFile.audit(...args) +} \ No newline at end of file diff --git a/lib/tokens.ts b/lib/tokens.ts index a090a1d..d97ff07 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -9,8 +9,17 @@ import { createHash, randomBytes } from 'node:crypto' import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import path from 'node:path' -const DATA_DIR = path.join(process.cwd(), '.sin-webui') -const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json') +let _dataDir: string | null = null +function dataDir(): string { + if (!_dataDir) _dataDir = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui') + return _dataDir +} + +let _tokensFile: string | null = null +function tokensFile(): string { + if (!_tokensFile) _tokensFile = path.join(dataDir(), 'tokens.json') + return _tokensFile +} export type TokenRecord = { id: string @@ -26,17 +35,17 @@ function hashToken(token: string): string { async function readTokens(): Promise { try { - return JSON.parse(await readFile(TOKENS_FILE, 'utf8')) as TokenRecord[] + return JSON.parse(await readFile(tokensFile(), 'utf8')) as TokenRecord[] } catch { return [] } } async function writeTokens(tokens: TokenRecord[]): Promise { - await mkdir(DATA_DIR, { recursive: true }) - const tmp = `${TOKENS_FILE}.tmp-${Date.now()}` + await mkdir(dataDir(), { recursive: true }) + const tmp = `${tokensFile()}.tmp-${Date.now()}` await writeFile(tmp, JSON.stringify(tokens, null, 2), 'utf8') - await rename(tmp, TOKENS_FILE) + await rename(tmp, tokensFile()) } export async function listTokens(): Promise[]> { diff --git a/lib/users.ts b/lib/users.ts index d4cc54c..c708cbb 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -4,7 +4,7 @@ * The env SIN_UI_TOKEN remains the irrevocable admin/root identity. */ import { randomBytes } from 'node:crypto' -import { getPool, isDbConfigured } from '@/lib/db' +import { isDbConfigured } from '@/lib/is-db-configured' export type User = { id: string @@ -18,6 +18,7 @@ export function isMultiUserEnabled(): boolean { } export async function listUsers(): Promise { + const { getPool } = await import('@/lib/db') const { rows } = await getPool().query( `SELECT u.id, u.name, u.role, u.created_at, COUNT(t.id)::int AS token_count @@ -38,6 +39,7 @@ export async function createUser( name: string, role: 'admin' | 'member' = 'member', ): Promise { + const { getPool } = await import('@/lib/db') const id = randomBytes(6).toString('hex') const { rows } = await getPool().query( `INSERT INTO users (id, name, role) VALUES ($1, $2, $3) @@ -50,12 +52,14 @@ export async function createUser( export async function deleteUser(id: string): Promise { // Cascades: tokens and chats of this user are removed (FK ON DELETE CASCADE). + const { getPool } = await import('@/lib/db') const result = await getPool().query(`DELETE FROM users WHERE id = $1`, [id]) return (result.rowCount ?? 0) > 0 } /** Resolve the user owning a presented token (by hash), or null. */ export async function findUserByTokenHash(hash: string): Promise { + const { getPool } = await import('@/lib/db') const { rows } = await getPool().query( `SELECT u.id, u.name, u.role, u.created_at FROM access_tokens t JOIN users u ON u.id = t.user_id diff --git a/lib/vercel/deploy.ts b/lib/vercel/deploy.ts index 119a812..3937ae0 100644 --- a/lib/vercel/deploy.ts +++ b/lib/vercel/deploy.ts @@ -3,8 +3,18 @@ import fs from "node:fs/promises" import { createHash } from "node:crypto" import { vercelFetch, type VercelDeployment } from "./client" -const WORKSPACE_DIR = process.env.SIN_WORKSPACE_DIR || process.cwd() -const DEPLOYMENTS_FILE = path.join(process.cwd(), ".sin-webui", "deployments.json") +let _workspaceDir: string | null = null +function workspaceDir(): string { + if (!_workspaceDir) _workspaceDir = process.env.SIN_WORKSPACE_DIR || (/*turbopackIgnore: true*/ process.cwd()) + return _workspaceDir +} + +let _deploymentsFile: string | null = null +function deploymentsFile(): string { + if (!_deploymentsFile) _deploymentsFile = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui", "deployments.json") + return _deploymentsFile +} + const IGNORE = new Set(["node_modules", ".next", ".git", ".sin-webui", ".vercel"]) export type DeploymentRecord = { @@ -33,7 +43,7 @@ export async function createDeployment(opts: { projectName: string target: "production" | "preview" }): Promise { - const files = await collectFiles(WORKSPACE_DIR) + const files = await collectFiles(workspaceDir()) const fileRefs = await Promise.all( files.map(async ({ file, data }) => { @@ -80,7 +90,7 @@ export async function getDeploymentStatus(id: string): Promise export async function listDeployments(): Promise { try { - return JSON.parse(await fs.readFile(DEPLOYMENTS_FILE, "utf8")) as DeploymentRecord[] + return JSON.parse(await fs.readFile(deploymentsFile(), "utf8")) as DeploymentRecord[] } catch { return [] } @@ -89,8 +99,8 @@ export async function listDeployments(): Promise { async function appendRecord(record: DeploymentRecord): Promise { const records = await listDeployments() records.unshift(record) - await fs.mkdir(path.dirname(DEPLOYMENTS_FILE), { recursive: true }) - await fs.writeFile(DEPLOYMENTS_FILE, JSON.stringify(records.slice(0, 50), null, 2)) + await fs.mkdir(path.dirname(deploymentsFile()), { recursive: true }) + await fs.writeFile(deploymentsFile(), JSON.stringify(records.slice(0, 50), null, 2)) } async function updateRecord(id: string, status: string): Promise { @@ -98,6 +108,6 @@ async function updateRecord(id: string, status: string): Promise { const r = records.find((x) => x.id === id) if (r && r.status !== status) { r.status = status - await fs.writeFile(DEPLOYMENTS_FILE, JSON.stringify(records, null, 2)) + await fs.writeFile(deploymentsFile(), JSON.stringify(records, null, 2)) } } diff --git a/lib/workspace/design-history.ts b/lib/workspace/design-history.ts index 79b5fde..8fb33b1 100644 --- a/lib/workspace/design-history.ts +++ b/lib/workspace/design-history.ts @@ -18,12 +18,35 @@ export type DesignHistoryEntry = { description: string } -const DIR = path.join(process.cwd(), '.sin-webui') -const HISTORY_FILE = path.join(DIR, 'design-history.jsonl') -const REDO_FILE = path.join(DIR, 'design-redo.jsonl') const MAX_ENTRIES = 100 -const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd() +let _dir: string | null = null +// @turbopack-disable-next-line +function dir(): string { + if (!_dir) _dir = path.join(/* turbopackIgnore: true */ process.cwd(), '.sin-webui') + return _dir +} + +let _historyFile: string | null = null +// @turbopack-disable-next-line +function historyFile(): string { + if (!_historyFile) _historyFile = /*turbopackIgnore: true*/ path.join(dir(), 'design-history.jsonl') + return _historyFile +} + +let _redoFile: string | null = null +// @turbopack-disable-next-line +function redoFile(): string { + if (!_redoFile) _redoFile = /*turbopackIgnore: true*/ path.join(dir(), 'design-redo.jsonl') + return _redoFile +} + +let _root: string | null = null +// @turbopack-disable-next-line +function root(): string { + if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? /* turbopackIgnore: true */ process.cwd() + return _root +} async function readStack(file: string): Promise { try { @@ -35,7 +58,7 @@ async function readStack(file: string): Promise { } async function writeStack(file: string, entries: DesignHistoryEntry[]): Promise { - await fs.mkdir(DIR, { recursive: true }) + await fs.mkdir(dir(), { recursive: true }) const bounded = entries.slice(-MAX_ENTRIES) await fs.writeFile( file, @@ -48,7 +71,7 @@ export async function getHistory(): Promise<{ undo: DesignHistoryEntry[] redo: DesignHistoryEntry[] }> { - const [undo, redo] = await Promise.all([readStack(HISTORY_FILE), readStack(REDO_FILE)]) + const [undo, redo] = await Promise.all([readStack(historyFile()), readStack(redoFile())]) return { undo, redo } } @@ -62,17 +85,17 @@ export async function pushEntry( timestamp: new Date().toISOString(), type: 'class-change', } - const undoStack = await readStack(HISTORY_FILE) + const undoStack = await readStack(historyFile()) undoStack.push(full) - await writeStack(HISTORY_FILE, undoStack) - await writeStack(REDO_FILE, []) + await writeStack(historyFile(), undoStack) + await writeStack(redoFile(), []) return full } /** Re-applies a value near the recorded line (same strategy as design-edit). */ async function applyToFile(entry: DesignHistoryEntry, from: string, to: string): Promise { - const resolved = path.resolve(ROOT, '.' + path.sep + entry.file) - if (!resolved.startsWith(ROOT)) throw new Error('Invalid path') + const resolved = /*turbopackIgnore: true*/ path.resolve(root(), '.' + path.sep + entry.file) + if (!resolved.startsWith(root())) throw new Error('Invalid path') const content = await fs.readFile(resolved, 'utf8') const lines = content.split('\n') const start = Math.max(0, entry.line - 3) @@ -88,30 +111,30 @@ async function applyToFile(entry: DesignHistoryEntry, from: string, to: string): } export async function undo(): Promise { - const stack = await readStack(HISTORY_FILE) + const stack = await readStack(historyFile()) const last = stack.pop() if (!last) return null await applyToFile(last, last.newValue, last.oldValue) - await writeStack(HISTORY_FILE, stack) - const redoStack = await readStack(REDO_FILE) + await writeStack(historyFile(), stack) + const redoStack = await readStack(redoFile()) redoStack.push(last) - await writeStack(REDO_FILE, redoStack) + await writeStack(redoFile(), redoStack) return last } export async function redo(): Promise { - const redoStack = await readStack(REDO_FILE) + const redoStack = await readStack(redoFile()) const last = redoStack.pop() if (!last) return null await applyToFile(last, last.oldValue, last.newValue) - await writeStack(REDO_FILE, redoStack) - const undoStack = await readStack(HISTORY_FILE) + await writeStack(redoFile(), redoStack) + const undoStack = await readStack(historyFile()) undoStack.push(last) - await writeStack(HISTORY_FILE, undoStack) + await writeStack(historyFile(), undoStack) return last } export async function clearHistory(): Promise { - await writeStack(HISTORY_FILE, []) - await writeStack(REDO_FILE, []) + await writeStack(historyFile(), []) + await writeStack(redoFile(), []) } diff --git a/lib/workspaces.ts b/lib/workspaces.ts index 48730ac..557bbac 100644 --- a/lib/workspaces.ts +++ b/lib/workspaces.ts @@ -24,8 +24,18 @@ export { } // ── Custom workspace store ────────────────────────────────────────────── -const DATA_DIR = path.join(process.cwd(), '.sin-webui') -const WS_FILE = path.join(DATA_DIR, 'workspaces.json') +let _dataDir: string | null = null +function dataDir(): string { + if (!_dataDir) _dataDir = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui') + return _dataDir +} + +let _wsFile: string | null = null +function wsFile(): string { + if (!_wsFile) _wsFile = path.join(dataDir(), 'workspaces.json') + return _wsFile +} + const SAFE_ID = /^[a-z0-9-]{1,40}$/ export function isValidWorkspaceId(id: string): boolean { @@ -34,17 +44,17 @@ export function isValidWorkspaceId(id: string): boolean { async function readFileWorkspaces(): Promise { try { - return JSON.parse(await readFile(WS_FILE, 'utf8')) as Workspace[] + return JSON.parse(await readFile(wsFile(), 'utf8')) as Workspace[] } catch { return [] } } async function writeFileWorkspaces(list: Workspace[]): Promise { - await mkdir(DATA_DIR, { recursive: true }) - const tmp = `${WS_FILE}.tmp-${Date.now()}` + await mkdir(dataDir(), { recursive: true }) + const tmp = `${wsFile()}.tmp-${Date.now()}` await writeFile(tmp, JSON.stringify(list, null, 2), 'utf8') - await rename(tmp, WS_FILE) + await rename(tmp, wsFile()) } function rowToWorkspace(r: Record): Workspace { diff --git a/next.config.mjs b/next.config.mjs index 62fc0bd..49a883e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -23,7 +23,16 @@ const nextConfig = { './scripts/**', './public/**', './**/*.test.ts', + './lib/storage/**', + './lib/db.ts', + './lib/auth/better-auth.ts', + './lib/chat-history.ts', + './lib/tokens.ts', + './lib/audit.ts', ], + 'app/api/workspace/**': ['**/*'], + 'app/api/settings/mcp/route.ts': ['**/*'], + 'app/api/settings/workspace/route.ts': ['**/*'], }, } diff --git a/package.json b/package.json index 7d11f51..181d6d0 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "@ai-sdk/openai": "^3.0.70", "@ai-sdk/react": "^3.0.204", "@base-ui/react": "^1.5.0", + "@better-auth/kysely-adapter": "^1.6.18", "@vercel/analytics": "1.6.1", "ai": "^6.0.202", "better-auth": "^1.6.18", + "kysely": "^0.29.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2f9af..75d5131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.5.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@better-auth/kysely-adapter': + specifier: ^1.6.18 + version: 1.6.18(@better-auth/core@1.6.18(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.3.0)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2) '@vercel/analytics': specifier: 1.6.1 version: 1.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) @@ -41,6 +44,9 @@ importers: html2canvas-pro: specifier: ^2.0.4 version: 2.0.4 + kysely: + specifier: ^0.29.0 + version: 0.29.2 lucide-react: specifier: ^1.16.0 version: 1.17.0(react@19.2.4) diff --git a/proxy.ts b/proxy.ts index ded07c0..a5efaba 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,6 +1,13 @@ import { NextResponse, type NextRequest } from 'next/server' -const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/reset-password', '/api/auth'] +const PUBLIC_PATHS = [ + '/login', + '/register', + '/forgot-password', + '/reset-password', + '/api/auth', + '/share', +] export default function proxy(request: NextRequest) { // Backward-compat: ohne konfiguriertes Secret läuft alles anonym weiter