Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ Agent-scoped writes need an Ed25519 request signature from the acting agent, or
- `GET /api/audit` hides `detail`, `GET /api/traces` replaces titles with `[redacted]`, and `GET /api/approvals` hides payload and note unless the caller is the operator (or local dev mode). Each response includes `redacted: true|false`.
- The dashboard and `/ops` pages have an operator token form. The token stays in `sessionStorage` for that tab only; it is not in the client bundle. It is sent as `x-operator-token`. The form shows the server's real state from `GET /api/operator`: dev mode, accepted, 401 (missing or wrong) or 503 (not configured in production).
- Limits: `sessionStorage` can be read by any script running on the page, so an XSS bug would expose the token. The AML routes and `/api/docs/posts` are not changed by this; the AML routes don't touch the DB, and `/api/docs/posts` has its own limiter.

## Database availability errors

When the SQLite file named by `DATABASE_URL` is missing or empty, `/api` routes return 503 `DB_UNAVAILABLE` with the resolved path and the fix (`npx prisma db push`). That path is a server filesystem path. A schema mismatch still gives a generic 500, and the Prisma message is written only to the server log.
2 changes: 1 addition & 1 deletion docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Status labels: **implemented** (code plus a passing test), **prototype** (works,
| Read-only MCP connectors (SQL, GitHub, OpenAPI, webhook) | implemented | `src/lib/mcp/connectors/` | `tests/connectors.test.mjs` | Read-only. Not verified against real third-party accounts. |
| MCP receipts | implemented | `logToolCall` in `src/lib/mcp/server.ts` | `tests/mcp-inprocess.test.mjs` | A SHA-256 digest identifying a log row. **Not a signature.** |
| AI provider (OpenRouter, Kilo, custom) with metering | implemented | `src/lib/ai/` | `tests/ai-provider.test.mjs` (mock server) | Off by default (503 `NO_AI_PROVIDER`). Never tested live in CI. Ollama profile is in PR #35. |
| SQLite and Turso/libSQL | implemented | `src/lib/db.ts`, `src/lib/db-config.ts`, `prisma/turso/` | `tests/turso-adapter.test.mjs` | Without `DATABASE_URL`: 503 `DB_NOT_CONFIGURED`. |
| SQLite and Turso/libSQL | implemented | `src/lib/db.ts`, `src/lib/db-config.ts`, `prisma/turso/` | `tests/turso-adapter.test.mjs`, `tests/db-url.test.mjs` | Without `DATABASE_URL`: 503 `DB_NOT_CONFIGURED`. A relative `file:` URL resolves against `<root>/prisma`, which also covers `npm start` from `.next/standalone`. A missing or empty SQLite file: 503 `DB_UNAVAILABLE` with the fix. A schema mismatch (tables or columns missing) is still a 500. |
| Live dashboard snapshot (SSE) | implemented | `/api/sync` | `tests/sync.test.mjs` | Polling interval `HELLO_SYNC_INTERVAL_MS`. |
| Docs/UGC board with receipts and rate limit | implemented | `/api/docs/posts` | `tests/docs.test.mjs` | In-memory rate limit, per process. |
| Task evaluation loop with human-approved promotion | implemented (open PR #34) | `src/lib/eval/` | `tests/eval-loop.test.mjs` | Small suite, string/regex graders, offline fixtures. No live model results. |
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Every variable the code reads (`rg "process.env" src mini-services scripts`). Ne

| Variable | Default | Notes |
| --- | --- | --- |
| `DATABASE_URL` | `file:./db/custom.db` (from `.env.example`) | `file:` = local SQLite. Relative paths resolve from `prisma/`, so the default file is `prisma/db/custom.db`. `libsql://…` = Turso. **Unset = every DB-backed `/api` route returns `503 DB_NOT_CONFIGURED`**, with the fix in the message. |
| `DATABASE_URL` | `file:./db/custom.db` (from `.env.example`) | `file:` = local SQLite. Relative paths resolve from `<project root>/prisma/` (in code too, so `npm start` from `.next/standalone` finds the same file), so the default file is `prisma/db/custom.db`. A missing or empty file: `503 DB_UNAVAILABLE`. `libsql://…` = Turso. **Unset = every DB-backed `/api` route returns `503 DB_NOT_CONFIGURED`**, with the fix in the message. |
| `TURSO_AUTH_TOKEN` | unset | Required with a `libsql://` URL. |
| `HELLO_DB_ADAPTER` | unset | `libsql` forces the libSQL adapter for a `file:` URL (used by tests). |
| `HELLO_PUBLIC_URL` | `http://localhost:3000` | Base URL for links, the agent card and the MCP handshake. |
Expand Down
3 changes: 2 additions & 1 deletion docs/getting-started/QUICKSTART-CHECK.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Commit tested: `main` @ `972b3e9`, fresh `git clone` into `/tmp/qs`, Node v22.23
| `npm run dev` | Turbopack crashed with "OS file watch limit reached". That's a limit of this box, not of the repo. `WATCHPACK_POLLING=true npx next dev --webpack` worked. Smoke results below. Raise `fs.inotify.max_user_watches` if you hit this. |
| `next dev` side effect | Next 16 appended a generated block to the repo's `AGENTS.md`. **Fixed: `agentRules: false` in `next.config.ts`.** |
| `npm run build` | ok (now with `typescript.ignoreBuildErrors: false`) |
| `npm start` (standalone) with the default relative `DATABASE_URL` | **500 "Unable to open the database file"**: the standalone server runs from `.next/standalone`, where `./db/custom.db` doesn't exist. Workaround: use an absolute path, e.g. `DATABASE_URL=file:$PWD/prisma/db/custom.db npm start`. Not fixed in code (see gaps). |
| `npm start` (standalone) with the default relative `DATABASE_URL` | Originally **500 "Unable to open the database file"**, because the standalone server runs from `.next/standalone`. **Fixed in `fix/db-url-resolution`:** `src/lib/db.ts` now resolves relative `file:` URLs against `<project root>/prisma`, after removing a trailing `.next/standalone` from the cwd. Re-checked on 2026-10-21 with `DATABASE_URL=file:./db/e2e.db PORT=3999 NODE_ENV=production node .next/standalone/server.js`: `GET /api/agents` → 200. |
| Same, but the DB file is missing (no `prisma db push` yet) | 503 `{"error":"DB_UNAVAILABLE","message":"The database file /…/prisma/db/missing.db does not exist. From the project root run: npx prisma db push …"}`. DB-free routes (e.g. `POST /api/aml/validate`) still answer (400 for an empty body). |
| Docker Compose | **Not run.** `docker` exists but the box user can't reach `/var/run/docker.sock` ("permission denied"), and the `docker compose` plugin isn't installed. |

Smoke against `next dev` (port 3112):
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"routes:manifest": "node scripts/gen-route-manifest.mjs",
"test:design": "node --experimental-strip-types --test tests/design.test.mjs",
"typecheck": "tsc --noEmit",
"test": "npm run test:aml && npm run test:evolution && npm run test:secrets && npm run test:gate && npm run test:scheduler && npm run test:connectors && npm run test:snapshots && npm run test:cache && npm run test:sandbox && npm run test:roadmap && npm run test:consensus && npm run test:truth && npm run test:ai && npm run test:turso && npm run test:eval && npm run test:ollama-gateway && npm run test:stax && npm run test:authz && npm run test:agent-auth && npm run test:gating && npm run test:operator-client",
"test": "npm run test:aml && npm run test:evolution && npm run test:secrets && npm run test:gate && npm run test:scheduler && npm run test:connectors && npm run test:snapshots && npm run test:cache && npm run test:sandbox && npm run test:roadmap && npm run test:consensus && npm run test:truth && npm run test:ai && npm run test:turso && npm run test:eval && npm run test:ollama-gateway && npm run test:stax && npm run test:authz && npm run test:agent-auth && npm run test:gating && npm run test:operator-client && npm run test:db-url",
"test:db-url": "node --experimental-strip-types --test tests/db-url.test.mjs",
"test:truth": "node --test tests/no-fake-data.test.mjs",
"test:ai": "prisma db push --skip-generate && node --experimental-strip-types --import ./tests/register.mjs --test tests/ai-provider.test.mjs",
"test:mcp": "prisma db push --skip-generate && node --experimental-strip-types --import ./tests/register.mjs --test tests/mcp-inprocess.test.mjs",
Expand Down
47 changes: 47 additions & 0 deletions src/lib/db-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Pure helpers for choosing the database driver. No Prisma import, so the
// request proxy (and tests) can use them without loading the client.

import { statSync } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'

export type DbMode = 'libsql' | 'file' | 'missing'

export function dbMode(url: string | undefined = process.env.DATABASE_URL): DbMode {
Expand Down Expand Up @@ -34,3 +37,47 @@ export const DB_FREE_API_PATHS = new Set([
'/api/integrations/webhook',
'/api/sandbox',
])

/** Project root: the working directory, minus a trailing `.next/standalone` (where `npm start` runs). */
export function projectRoot(cwd: string = process.cwd()): string {
return cwd.replace(/[\\/]\.next[\\/]standalone[\\/]?$/, '') || cwd
}

/**
* Prisma resolves a relative `file:` URL against the schema folder (`prisma/`).
* The standalone server has a different cwd, so resolve it explicitly against
* `<project root>/prisma`. Absolute paths, libsql:// and empty values pass through.
* Keep in sync with the inlined copy in ./db.ts (tests/db-url.test.mjs checks both).
*/
export function resolveDatabaseUrl(url: string | undefined, cwd: string = process.cwd()): string | undefined {
if (!url) return url
const m = /^file:(?!\/\/)(.*)$/i.exec(url.trim())
if (!m) return url
const q = m[1].indexOf('?')
const path = q < 0 ? m[1] : m[1].slice(0, q)
const query = q < 0 ? '' : m[1].slice(q + 1)
if (!path || isAbsolute(path)) return url
return `file:${resolve(join(projectRoot(cwd), 'prisma'), path)}${query ? `?${query}` : ''}`
}

/** For a file: database, an actionable reason it cannot be opened, or null. */
export function fileDbProblem(url: string | undefined = process.env.DATABASE_URL, cwd: string = process.cwd()): string | null {
if (dbMode(url) !== 'file') return null
const resolved = resolveDatabaseUrl(url, cwd) ?? ''
const file = resolved.replace(/^file:/i, '').split('?')[0]
try {
const st = statSync(file)
if (!st.isFile()) return `DATABASE_URL points to ${file}, which is not a file.`
if (st.size === 0) return `The database file ${file} is empty (no tables). Run: npx prisma db push`
return null
} catch {
return `The database file ${file} does not exist. From the project root run: npx prisma db push (it uses DATABASE_URL from .env), then restart.`
}
}

export function dbUnavailableResponse(problem: string): Response {
return new Response(JSON.stringify({ error: 'DB_UNAVAILABLE', message: problem }), {
status: 503,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
})
}
21 changes: 19 additions & 2 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { PrismaClient } from '@prisma/client'
import { PrismaLibSQL } from '@prisma/adapter-libsql'
import { isAbsolute, join, resolve } from 'node:path'

// Inlined copy of resolveDatabaseUrl() from ./db-config (see the note there).
export function resolveDatabaseUrl(url: string | undefined, cwd: string = process.cwd()): string | undefined {
if (!url) return url
const m = /^file:(?!\/\/)(.*)$/i.exec(url.trim())
if (!m) return url
const q = m[1].indexOf('?')
const path = q < 0 ? m[1] : m[1].slice(0, q)
const query = q < 0 ? '' : m[1].slice(q + 1)
if (!path || isAbsolute(path)) return url
const root = cwd.replace(/[\\/]\.next[\\/]standalone[\\/]?$/, '') || cwd
return `file:${resolve(join(root, 'prisma'), path)}${query ? `?${query}` : ''}`
}

// DATABASE_URL=libsql://... → Turso / sqld through the libSQL driver adapter
// (auth via TURSO_AUTH_TOKEN). Used on Vercel.
Expand All @@ -10,15 +24,18 @@ import { PrismaLibSQL } from '@prisma/adapter-libsql'
// Secret key material is never returned by default. Code that must sign
// has to opt in explicitly with `omit: { privateKeyHex: false }`.
function createClient() {
const url = process.env.DATABASE_URL ?? ''
const url = resolveDatabaseUrl(process.env.DATABASE_URL) ?? ''
const useAdapter = /^libsql:\/\//i.test(url.trim()) || (process.env.HELLO_DB_ADAPTER === 'libsql' && url.length > 0)
const options = {
log: ['error', 'warn'] as ('error' | 'warn')[],
omit: { agent: { privateKeyHex: true as const } },
datasourceUrl: url || undefined,
}
if (useAdapter) {
const adapter = new PrismaLibSQL({ url, authToken: process.env.TURSO_AUTH_TOKEN || undefined })
return new PrismaClient({ ...options, adapter })
const { datasourceUrl: _unused, ...rest } = options
void _unused
return new PrismaClient({ ...rest, adapter })
}
return new PrismaClient(options)
}
Expand Down
15 changes: 12 additions & 3 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { NextResponse, type NextRequest } from 'next/server'
import { DB_FREE_API_PATHS, dbNotConfiguredResponse, isDbConfigured } from '@/lib/db-config'
import { DB_FREE_API_PATHS, dbNotConfiguredResponse, dbUnavailableResponse, fileDbProblem, isDbConfigured } from '@/lib/db-config'

// Without DATABASE_URL every DB-backed API route answers a clear
// 503 DB_NOT_CONFIGURED instead of a Prisma crash.
// A file: DATABASE_URL whose file is missing or empty answers 503 DB_UNAVAILABLE
// with the fix, instead of an empty 500 from Prisma. Once the file is found it is
// not checked again for the life of the process.
let fileDbOk = false
export function proxy(req: NextRequest) {
if (isDbConfigured()) return NextResponse.next()
const p = req.nextUrl.pathname.replace(/\/+$/, '') || '/'
if (DB_FREE_API_PATHS.has(p)) return NextResponse.next()
return dbNotConfiguredResponse()
if (!isDbConfigured()) return dbNotConfiguredResponse()
if (!fileDbOk) {
const problem = fileDbProblem()
if (problem) return dbUnavailableResponse(problem)
fileDbOk = true
}
return NextResponse.next()
}

export const config = { matcher: ['/api/:path*'] }
55 changes: 55 additions & 0 deletions tests/db-url.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// DATABASE_URL resolution for `npm start` (standalone cwd) and the DB_UNAVAILABLE answer.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveDatabaseUrl, projectRoot, fileDbProblem, dbUnavailableResponse } from "../src/lib/db-config.ts";

const root = "/srv/app";
const cases = [
["file:./db/custom.db", root, "file:/srv/app/prisma/db/custom.db"],
["file:./db/custom.db", `${root}/.next/standalone`, "file:/srv/app/prisma/db/custom.db"],
["file:./db/custom.db", `${root}/.next/standalone/`, "file:/srv/app/prisma/db/custom.db"],
["file:db/x.db?connection_limit=1", `${root}/.next/standalone`, "file:/srv/app/prisma/db/x.db?connection_limit=1"],
["file:../data/x.db", root, "file:/srv/app/data/x.db"],
["file:/abs/x.db", `${root}/.next/standalone`, "file:/abs/x.db"],
["libsql://db-org.turso.io", root, "libsql://db-org.turso.io"],
[undefined, root, undefined],
["", root, ""],
];

test("relative file: URLs resolve against <project root>/prisma, also from .next/standalone", () => {
for (const [url, cwd, want] of cases) assert.equal(resolveDatabaseUrl(url, cwd), want, `${url} @ ${cwd}`);
assert.equal(projectRoot("/a/b/.next/standalone"), "/a/b");
assert.equal(projectRoot("/a/b"), "/a/b");
});

test("db.ts inlined copy matches db-config", async () => {
process.env.DATABASE_URL ||= "file:/tmp/hello-dburl-unused.db";
const { resolveDatabaseUrl: inlined, db } = await import("../src/lib/db.ts");
for (const [url, cwd, want] of cases) assert.equal(inlined(url, cwd), want, `${url} @ ${cwd}`);
await db.$disconnect();
});

test("fileDbProblem: missing file, empty file, ok file, non-file modes", () => {
const dir = mkdtempSync(join(tmpdir(), "hello-dburl-"));
mkdirSync(join(dir, "prisma", "db"), { recursive: true });
const missing = fileDbProblem("file:./db/none.db", dir);
assert.match(missing, /does not exist/); assert.match(missing, /prisma db push/);
assert.ok(missing.includes(join(dir, "prisma", "db", "none.db")));
writeFileSync(join(dir, "prisma", "db", "empty.db"), "");
assert.match(fileDbProblem("file:./db/empty.db", join(dir, ".next", "standalone")), /empty/);
writeFileSync(join(dir, "prisma", "db", "ok.db"), "SQLite format 3\0");
assert.equal(fileDbProblem("file:./db/ok.db", join(dir, ".next", "standalone")), null);
assert.equal(fileDbProblem("libsql://x.turso.io", dir), null);
assert.equal(fileDbProblem("", dir), null);
});

test("DB_UNAVAILABLE response is 503 JSON with the fix", async () => {
const r = dbUnavailableResponse("The database file /x does not exist. Run: npx prisma db push");
assert.equal(r.status, 503);
assert.equal(r.headers.get("content-type"), "application/json");
const j = await r.json();
assert.equal(j.error, "DB_UNAVAILABLE"); assert.match(j.message, /prisma db push/);
});
Loading