Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
30 changes: 30 additions & 0 deletions .claude/hooks/check-migration-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
# Hook: Prevent editing existing migration files (forward-only migrations)
# Rule: "Forward-only. Idempotent." — CLAUDE.md
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -er '.tool_input.file_path // .tool_input.filePath // empty') || {
echo "BLOCKED: invalid hook payload (missing/invalid tool_input.file_path)" >&2
exit 2
}
[ -z "$FILE_PATH" ] && exit 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Only check migration files
case "$FILE_PATH" in
*migrations/*.sql|*migrations/*.ts)
# Allow creating NEW migration files (Write tool with no existing file)
TOOL_NAME=$(echo "$INPUT" | jq -er '.tool_name // empty') || TOOL_NAME=""
if [ "$TOOL_NAME" = "Write" ] && [ ! -f "$FILE_PATH" ]; then
exit 0
fi
# Block editing existing migration files
if [ -f "$FILE_PATH" ]; then
echo "BLOCKED: Cannot edit existing migration file: $(basename "$FILE_PATH")" >&2
echo "Rule: Migrations are forward-only. Create a new migration instead." >&2
echo "Use: npm run db:generate" >&2
exit 2
fi
;;
esac

exit 0
71 changes: 71 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# NeoBoard — Environment Variables
# Copy to app/.env.local and fill in values.
# For dev setup, scripts/setup.sh generates these automatically.

# PostgreSQL connection (required)
DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard

# 32-byte hex key for AES-256-GCM credential encryption (required)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WARNING: losing this key makes all stored credentials unrecoverable.
ENCRYPTION_KEY=

# Previous encryption key — set this when rotating ENCRYPTION_KEY (optional)
# Rotation flow: 1) copy current ENCRYPTION_KEY to ENCRYPTION_KEY_OLD,
# 2) generate and set a new ENCRYPTION_KEY, 3) restart the app,
# 4) call POST /api/admin/rotate-key (admin only) to re-encrypt all credentials,
# 5) remove ENCRYPTION_KEY_OLD after successful rotation.
# ENCRYPTION_KEY_OLD=

# Auth.js session secret (required)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
NEXTAUTH_SECRET=

# Application URL (required)
NEXTAUTH_URL=http://localhost:3000

# One-time token for creating the first admin account via /signup (optional, dev only)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ADMIN_BOOTSTRAP_TOKEN=

# HMAC secret for API key hashing — required if using API keys (optional)
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
API_KEY_HMAC_SECRET=

# Self-registration toggle — set to "false" to disable /signup (optional, default: true)
# REGISTRATION_ENABLED=true

# Tenant ID — defaults to "default" if unset (optional)
# TENANT_ID=default

# Session max age in seconds — defaults to 28800 (8 hours) if unset (optional)
# SESSION_MAX_AGE=28800

# Log level — one of: fatal, error, warn, info, debug, trace (optional, default: info)
# LOG_LEVEL=info

# Per-user query rate limit — max queries per minute per user (optional, default: 60)
# QUERY_RATE_LIMIT=60

# ── SSO / OIDC (optional) ────────────────────────────────────────────────────
# Set all four required vars to enable a single OIDC provider via env.
# Requires NEOBOARD_EDITION=enterprise.
# For multiple providers, use the Admin UI (Settings > Authentication).

# Required (all four must be set to activate SSO)
# NEOBOARD_EDITION=enterprise
# OIDC_ISSUER=https://myorg.okta.com
# OIDC_CLIENT_ID=neoboard
# OIDC_CLIENT_SECRET=your-client-secret

# Optional
# OIDC_DISPLAY_NAME=Company SSO
# OIDC_SCOPES=openid profile email
# OIDC_CLAIM_KEY=groups
# OIDC_ADMIN_VALUE=neoboard-admins
# OIDC_CREATOR_VALUE=neoboard-editors
# OIDC_READER_VALUE=neoboard-viewers
# OIDC_AUTO_PROVISION=true
# OIDC_DEFAULT_ROLE=creator
# OIDC_ENFORCE_SSO=false

2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ on:
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [main, dev, 'release/*']
branches: [main, dev, 'release/*', 'feat/*']
paths:
- 'app/**'
- 'component/**'
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "enterprise"]
path = enterprise
url = https://github.com/alfredo1996/neoboard-enterprise.git
13 changes: 9 additions & 4 deletions app/src/app/api/query/write/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,27 +242,32 @@ describe("POST /api/query/write", () => {
);
});

it("returns 500 when executeQuery throws", async () => {
it("returns 500 with sanitized message when executeQuery throws (no driver leak)", async () => {
mockRequireSession.mockResolvedValue(writerSession);
mockConnectionAndDashboard();
mockDecryptJson.mockReturnValue({
uri: "bolt://localhost",
username: "neo4j",
password: "pass",
});
mockExecuteQuery.mockRejectedValue(new Error("Driver error"));
// Driver errors echo user-supplied SQL — must never bleed into the
// response body (security/PII consideration).
mockExecuteQuery.mockRejectedValue(
new Error('syntax error at or near "THIS"'),
);

const res = await POST(
makeRequest({
connectionId: "c1",
query: "CREATE (n:Test)",
query: "THIS IS NOT VALID SQL",
widgetId: "w1",
dashboardId: "d1",
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toBe("Driver error");
expect(body.error.message).toBe("Write query execution failed");
expect(body.error.message).not.toMatch(/syntax error/i);
});

it("returns 404 when connection belongs to another user", async () => {
Expand Down
5 changes: 4 additions & 1 deletion app/src/app/api/query/write/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ async function handleWriteQuery(request: Request): Promise<Response> {
},
"write_query_failed",
);
return handleRouteError(error, "Write query execution failed");
// safeMessage: write queries echo user SQL in driver errors — never leak.
return handleRouteError(error, "Write query execution failed", {
safeMessage: true,
});
}
}
18 changes: 15 additions & 3 deletions app/src/components/dashboard-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,26 @@ export function DashboardContainer({
onRefresh={
showRefresh
? () => {
// Invalidate all TanStack Query entries matching this widget's
// connection + query combo. This triggers a refetch.
// Invalidate the TanStack Query entry for this widget so
// it refetches. We must mirror the prefix shape used by
// useWidgetQuery exactly:
// ["widget-query", connectionId, database, query, params, staleTime]
// Earlier we omitted `database`, which made position 2
// mismatch (null vs query string), so invalidation never
// matched and the refresh button silently no-op'd.
//
// We intentionally stop the prefix at `query` — the hook
// merges $param_xxx values into `params` at call time, so
// `widget.params` here is not deep-equal to the hook's
// mergedParams when parameters are referenced. Stopping
// at `query` guarantees prefix match for both the
// parameterless and parameterised cases.
void queryClient.invalidateQueries({
queryKey: [
"widget-query",
widget.connectionId,
widget.database ?? null,
widget.query,
widget.params,
],
});
}
Expand Down
33 changes: 33 additions & 0 deletions app/src/lib/__tests__/api/api-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ describe("handleRouteError", () => {
expect(body.error.code).toBe("REQUEST_TIMEOUT");
expect(res.headers.get("Retry-After")).toBe("5");
});

describe("safeMessage option", () => {
it("collapses raw driver errors to fallback when safeMessage=true", async () => {
const res = handleRouteError(
new Error('syntax error at or near "THIS"'),
"Write query execution failed",
{ safeMessage: true },
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toBe("Write query execution failed");
expect(body.error.message).not.toMatch(/syntax error/i);
});

it("still returns raw driver errors when safeMessage is omitted", async () => {
const res = handleRouteError(
new Error('syntax error at or near "THIS"'),
"Write query execution failed",
);
const body = await res.json();
expect(body.error.message).toBe('syntax error at or near "THIS"');
});

it("safeMessage does not bypass typed app errors (Queue/Auth/etc.)", async () => {
const { QueueTimeoutError } = await import("@/lib/query/scheduler");
const res = handleRouteError(new QueueTimeoutError(), "Write failed", {
safeMessage: true,
});
// QueueTimeoutError still gets its specific 408 + Retry-After handling.
expect(res.status).toBe(408);
expect(res.headers.get("Retry-After")).toBe("5");
});
});
});

describe("validateBody", () => {
Expand Down
15 changes: 15 additions & 0 deletions app/src/lib/api/api-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ export function validateBody<T>(
export function handleRouteError(
error: unknown,
fallbackMsg = "Internal server error",
options?: {
/**
* When true, untyped errors (e.g. raw driver/query errors) collapse to
* `fallbackMsg` instead of being passed through `sanitizeErrorMessage`.
* Use this on routes where the underlying error message could leak schema
* details, query structure, or other sensitive shape — most notably the
* write-query route where pg syntax errors echo the user-supplied SQL.
*/
safeMessage?: boolean;
},
): ReturnType<typeof apiError> {
if (error instanceof EnterpriseRequiredError) {
return apiError("ENTERPRISE_REQUIRED", error.message);
Expand Down Expand Up @@ -130,5 +140,10 @@ export function handleRouteError(
// Return a sanitized error message to the client. Raw driver/DB errors
// can leak query structure and schema details, so sanitizeErrorMessage
// strips bundler internals while preserving meaningful messages.
// Routes that opt into `safeMessage` collapse to the fallback unconditionally
// — used by the write route to keep pg/Cypher syntax errors out of responses.
if (options?.safeMessage) {
return serverError(fallbackMsg);
}
return serverError(sanitizeErrorMessage(message, fallbackMsg));
}
11 changes: 9 additions & 2 deletions connection/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest", { diagnostics: false }],
"^.+\\.tsx?$": ["ts-jest", { diagnostics: false }],
"^.+\\.m?js$": ["ts-jest", { diagnostics: false, useESM: false }],
},
testPathIgnorePatterns: ["utils"],
// uuid v14+ ships ESM only; transform it (and any future ESM-only deps in
// the testcontainers→dockerode chain) so Jest's CJS runtime can require them.
transformIgnorePatterns: ["/node_modules/(?!(uuid)/)"],
// Skip the built `dist/` output — adding the JS transform above means jest
// would otherwise pick up compiled `.test.js` and `.test.d.ts` files from
// a previous `tsc -p tsconfig.build.json` and double-run them.
testPathIgnorePatterns: ["utils", "/dist/"],
globalSetup: "./__tests__/utils/setup.ts",
globalTeardown: "./__tests__/utils/teardown.ts",
// Integration tests hit a live Neo4j/PostgreSQL testcontainer.
Expand Down
2 changes: 1 addition & 1 deletion connection/src/generalized/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect } from "@jest/globals";
import {
ConnectionError,
QueryError,
Expand Down
72 changes: 72 additions & 0 deletions docker/docker-compose.prod-full.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Full-stack NeoBoard: app + PostgreSQL in a single compose.
# For single-server deployments where you don't have an external PostgreSQL.
#
# Usage:
# cp ../.env.example .env
# # Fill in ENCRYPTION_KEY, NEXTAUTH_SECRET, NEXTAUTH_URL
# docker compose -f docker/docker-compose.prod-full.yml up -d
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-neoboard}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-neoboard}
POSTGRES_DB: ${POSTGRES_DB:-neoboard}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-neoboard}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped

neoboard:
build:
context: ..
dockerfile: Dockerfile
ports:
- "${PORT:-3000}:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-neoboard}:${POSTGRES_PASSWORD:-neoboard}@postgres:5432/${POSTGRES_DB:-neoboard}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
FORCE_HTTPS: ${FORCE_HTTPS:-false}
API_KEY_HMAC_SECRET: ${API_KEY_HMAC_SECRET:-}
depends_on:
postgres:
condition: service_healthy
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped

volumes:
pgdata:
1 change: 1 addition & 0 deletions enterprise
Submodule enterprise added at 9461e0
Loading