diff --git a/BETTERBASE.md b/BETTERBASE.md new file mode 100644 index 0000000..cbee4a6 --- /dev/null +++ b/BETTERBASE.md @@ -0,0 +1,272 @@ +# BetterBase Agent Skill + +Version: betterbase-skill@1.0.0 + +BetterBase is a Backend-as-a-Service layer that auto-generates APIs from schema definitions. + +--- + +## When To Use This + +Use this skill when working inside a BetterBase project created via `bb init`. + +--- + +## Mental Model + +- Built on Hono (HTTP framework) + Drizzle (ORM) +- Schema-driven: define tables in `src/db/schema.ts`, APIs auto-generate +- Strong conventions: follow CLI workflows over manual configuration +- Auth via BetterAuth integration +- RLS for Postgres/Supabase; policy-based for SQLite + +--- + +## Project Structure + +``` +my-project/ +├── betterbase.config.ts # Project configuration (defineConfig) +├── drizzle.config.ts # Drizzle ORM config +├── package.json +├── src/ +│ ├── index.ts # App entry (Hono instance) +│ ├── db/ +│ │ ├── schema.ts # Drizzle table definitions +│ │ ├── index.ts # DB client export +│ │ ├── migrate.ts # Migration runner +│ │ └── auth-schema.ts # BetterAuth tables (if auth enabled) +│ ├── routes/ +│ │ ├── index.ts # Route registration +│ │ ├── health.ts # Health check +│ │ └── users.ts # Custom routes +│ ├── auth/ +│ │ ├── index.ts # BetterAuth instance +│ │ └── types.ts # Type exports +│ ├── middleware/ +│ │ ├── auth.ts # Auth middleware (requireAuth, optionalAuth) +│ │ └── validation.ts # Request validation helpers +│ └── lib/ +│ └── env.ts # Zod-validated env vars +└── drizzle/ # Generated migrations +``` + +--- + +## Core Patterns + +### Schema (Drizzle) + +```typescript +// src/db/schema.ts +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +// Timestamp helpers (createdAt, updatedAt) +export const timestamps = { + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }) + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), +}; + +// UUID primary key +export const uuid = (name = 'id') => text(name).primaryKey().$defaultFn(() => crypto.randomUUID()); + +// Soft delete +export const softDelete = { + deletedAt: integer('deleted_at', { mode: 'timestamp' }), +}; + +// Status enum +export const statusEnum = (name = 'status') => text(name, { enum: ['active', 'inactive', 'pending'] }).default('active'); + +// Table definition +export const users = sqliteTable('users', { + id: uuid(), + email: text('email').notNull().unique(), + name: text('name'), + status: statusEnum(), + ...timestamps, + ...softDelete, +}); +``` + +### Migrations + +```bash +# Generate migration from schema changes +bb migrate generate + +# Apply migrations locally +bb migrate + +# Preview without applying +bb migrate preview + +# Rollback last migration +bb migrate rollback +``` + +### Routes (Hono) + +```typescript +// src/routes/users.ts +import { Hono } from 'hono'; +import { db } from '../db'; +import { users } from '../db/schema'; +import { parseBody } from '../middleware/validation'; + +const createUserSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), +}); + +export const usersRoute = new Hono(); + +usersRoute.get('/', async (c) => { + const rows = await db.select().from(users).limit(25); + return c.json({ users: rows }); +}); + +usersRoute.post('/', async (c) => { + const body = await c.req.json(); + const parsed = parseBody(createUserSchema, body); + const created = await db.insert(users).values(parsed).returning(); + return c.json({ user: created[0] }, 201); +}); +``` + +### Config + +```typescript +// betterbase.config.ts +import { defineConfig } from "@betterbase/core"; + +export default defineConfig({ + project: { name: "my-project" }, + provider: { + type: "turso", + url: process.env.TURSO_URL, + authToken: process.env.TURSO_AUTH_TOKEN, + }, + graphql: { enabled: true }, +}); +``` + +### RLS (Row Level Security) + +```typescript +// src/rls/policies.ts +import { definePolicy } from "@betterbase/core/rls"; +import { policyToSQL } from "@betterbase/core/rls/generator"; + +const userPolicy = definePolicy("users", { + select: "auth.uid() = id", + update: "auth.uid() = id", + delete: "auth.uid() = id", +}); + +const sql = policyToSQL(userPolicy); +// Returns SQL to enable RLS and create policies +``` + +### Client Usage + +```typescript +// In frontend app +import { createClient } from "@betterbase/client"; + +const client = createClient({ + url: "http://localhost:3000", + key: process.env.VITE_API_KEY, +}); + +// Query data +const users = await client.from("users").select().limit(10); + +// Auth +const { user, session } = await client.auth.signIn({ + email: "user@example.com", + password: "password", +}); + +// Storage +const uploadResult = await client.storage.upload(file, { bucket: "avatars" }); +``` + +--- + +## CLI Commands + +- `bb init [name]` - Initialize new project +- `bb dev` - Watch mode for development +- `bb migrate generate` - Generate migration from schema +- `bb migrate` - Apply pending migrations +- `bb migrate preview` - Preview migration diff +- `bb migrate rollback` - Rollback migrations +- `bb generate crud ` - Generate CRUD routes +- `bb auth setup` - Install BetterAuth +- `bb auth add-provider ` - Add OAuth provider +- `bb rls create
` - Create RLS policy +- `bb rls list` - List RLS policies +- `bb storage init` - Initialize storage +- `bb function create ` - Create edge function +- `bb function deploy ` - Deploy function +- `bb branch create ` - Create preview environment + +--- + +## Critical Rules + +- Use `process.execPath` instead of hardcoding "bun" for runtime detection +- Validate environment variables ONCE using Zod in `src/lib/env.ts`, never reassign +- The `init` command must remain public (no auth required) +- Edge function deployment requires `--no-verify-jwt` flag +- Admin keys are hashed server-side only; never store plaintext +- Supabase edge runtime cannot serve HTML responses +- Ignore `MaxListenersExceededWarning` in test environments +- Always use `parseInt` when converting string CLI args to numbers + +--- + +## Forbidden Actions + +- Do NOT import from `@betterbase/core` in user-land code (only `@betterbase/core/config`, `@betterbase/core/rls`) +- Do NOT manually write SQL migration files; use CLI +- Do NOT bypass auth middleware; always use `requireAuth` +- Do NOT assume Node.js runtime; BetterBase uses Bun exclusively +- Do NOT duplicate schema logic in routes; import from `src/db/schema.ts` +- Do NOT use raw SQL queries; use Drizzle ORM +- Do NOT hardcode environment variable parsing in multiple files + +--- + +## Conditional Logic + +- If auth enabled → use `requireAuth` middleware on protected routes +- If RLS enabled (Postgres/Supabase) → apply policies via `definePolicy` +- If storage enabled → use client SDK `storage.upload()`, not direct S3 +- If using Turso/SQLite → use SQLite schema helpers, not Postgres-specific features +- If deploying edge functions → require `--no-verify-jwt` for unauthenticated endpoints + +--- + +## Anti-Patterns + +- Writing raw SQL instead of Drizzle queries +- Hardcoding env parsing: `const dbUrl = process.env.DATABASE_URL` repeated across files +- Creating custom auth instead of using BetterAuth +- Bypassing CLI: manually editing `drizzle/` folder +- Skipping migrations before deploying schema changes +- Using Supabase client in edge functions (use REST API instead) +- Forgetting to run `bb migrate` after schema changes in production + +--- + +## Out of Scope + +- Modifying BetterBase core packages +- Editing CLI internals +- Monorepo-level configuration changes +- Contributing to `@betterbase/core` development + diff --git a/BetterBase_Dashboard_Backend_Spec.md b/BetterBase_Dashboard_Backend_Spec.md new file mode 100644 index 0000000..7719a17 --- /dev/null +++ b/BetterBase_Dashboard_Backend_Spec.md @@ -0,0 +1,2463 @@ +# BetterBase Dashboard Backend Extensions — Orchestrator Specification + +> **For Kilo Code Orchestrator** +> This document extends `BetterBase_SelfHosted_Spec.md` (SH-01 through SH-28). +> All task IDs use prefix **DB-** (Dashboard Backend). +> Execute tasks in strict order. Do not begin a task until all listed dependencies are marked complete. +> All file paths are relative to the monorepo root. + +--- + +## Overview + +The SH spec built the foundational self-hosted server. This spec adds every API route the dashboard frontend requires. Zero frontend code is written here — this is purely backend. + +**What this spec adds:** + +- Per-project schema provisioning (end-user data isolation) +- Instance settings (key-value config store) +- SMTP configuration and test endpoint +- Notification rules +- RBAC — roles, permissions, admin role assignments +- Audit log — immutable, append-only, queryable +- API keys — long-lived tokens for CI/CD +- CLI session management +- Enhanced metrics with time-series data +- Per-project: user management, auth config, database introspection, realtime stats, environment variables, webhooks, functions +- Webhook delivery logs + manual retry +- Function invocation logs +- Audit middleware wired to all mutating routes + +**Architecture: per-project data isolation** +Each project's end-user data lives in a dedicated Postgres schema named `project_{slug}`. The admin server queries these schemas using the same pool instance. All projects share the Postgres instance — standard for self-hosted v1. + +**Routing convention added by this spec:** +``` +/admin/instance — instance-wide settings +/admin/smtp — SMTP config +/admin/roles — RBAC +/admin/api-keys — long-lived API keys +/admin/cli-sessions — CLI session list + revoke +/admin/audit — audit log +/admin/metrics/timeseries — time-series data +/admin/projects/:id/users — per-project end-users +/admin/projects/:id/auth-config — per-project auth providers +/admin/projects/:id/database — per-project DB introspection +/admin/projects/:id/realtime — per-project realtime stats +/admin/projects/:id/env — per-project env vars +/admin/projects/:id/webhooks — per-project webhooks +/admin/projects/:id/functions — per-project functions +/admin/webhooks/:id/deliveries — webhook delivery log +/admin/functions/:id/invocations — function invocation log +``` + +--- + +## Phase 1 — Schema Extensions + +### Task DB-01 — Project Schema Provisioning Function + +**Depends on:** SH-28 + +**Create file:** `packages/server/migrations/005_project_schema_function.sql` + +```sql +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Provisions a BetterAuth-compatible schema for a project +CREATE OR REPLACE FUNCTION betterbase_meta.provision_project_schema(p_slug TEXT) +RETURNS VOID AS $$ +DECLARE + s TEXT := 'project_' || p_slug; +BEGIN + EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', s); + + EXECUTE format($f$ + CREATE TABLE IF NOT EXISTS %I."user" ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + image TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + banned BOOLEAN NOT NULL DEFAULT FALSE, + ban_reason TEXT, + ban_expires TIMESTAMPTZ + ) + $f$, s); + + EXECUTE format($f$ + CREATE TABLE IF NOT EXISTS %I.session ( + id TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ip_address TEXT, + user_agent TEXT, + user_id TEXT NOT NULL REFERENCES %I."user"(id) ON DELETE CASCADE + ) + $f$, s, s); + + EXECUTE format($f$ + CREATE TABLE IF NOT EXISTS %I.account ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES %I."user"(id) ON DELETE CASCADE, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at TIMESTAMPTZ, + refresh_token_expires_at TIMESTAMPTZ, + scope TEXT, + password TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + $f$, s, s); + + EXECUTE format($f$ + CREATE TABLE IF NOT EXISTS %I.verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + $f$, s); + + -- Auth config table (provider settings for this project) + EXECUTE format($f$ + CREATE TABLE IF NOT EXISTS %I.auth_config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + $f$, s); + + -- Environment variables for this project + EXECUTE format($f$ + CREATE TABLE IF NOT EXISTS %I.env_vars ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + is_secret BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + $f$, s); + +END; +$$ LANGUAGE plpgsql; +``` + +**Modify file:** `packages/server/src/routes/admin/projects.ts` + +In the `POST /` handler, after the INSERT query succeeds and before returning the response, add: + +```typescript +// Provision project schema +await pool.query( + "SELECT betterbase_meta.provision_project_schema($1)", + [slug] +); +``` + +**Acceptance criteria:** +- `project_{slug}` schema created with all 6 tables on project creation +- Function is idempotent — safe to call multiple times +- Schema name derived from slug (already constrained to `[a-z0-9-]+`) +- Called automatically in project creation route + +--- + +### Task DB-02 — RBAC Schema + +**Depends on:** DB-01 + +**Create file:** `packages/server/migrations/006_rbac.sql` + +```sql +-- Built-in roles (seeded, not user-created) +CREATE TABLE IF NOT EXISTS betterbase_meta.roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, -- owner | admin | developer | viewer + description TEXT NOT NULL, + is_system BOOLEAN NOT NULL DEFAULT FALSE, -- system roles cannot be deleted + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Granular permissions +CREATE TABLE IF NOT EXISTS betterbase_meta.permissions ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL, -- projects | users | storage | functions | webhooks | logs | team | settings | audit + action TEXT NOT NULL, -- view | create | edit | delete | export + UNIQUE (domain, action) +); + +-- Role ↔ permission mapping +CREATE TABLE IF NOT EXISTS betterbase_meta.role_permissions ( + role_id TEXT NOT NULL REFERENCES betterbase_meta.roles(id) ON DELETE CASCADE, + permission_id TEXT NOT NULL REFERENCES betterbase_meta.permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +-- Admin ↔ role assignment (scoped per project, NULL = instance-wide) +CREATE TABLE IF NOT EXISTS betterbase_meta.admin_roles ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + admin_user_id TEXT NOT NULL REFERENCES betterbase_meta.admin_users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES betterbase_meta.roles(id) ON DELETE CASCADE, + project_id TEXT REFERENCES betterbase_meta.projects(id) ON DELETE CASCADE, -- NULL = global + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (admin_user_id, role_id, project_id) +); + +-- Seed built-in roles +INSERT INTO betterbase_meta.roles (id, name, description, is_system) VALUES + ('role_owner', 'owner', 'Full access to everything. Cannot be deleted.', TRUE), + ('role_admin', 'admin', 'Full access except deleting other owners.', TRUE), + ('role_developer', 'developer', 'Can manage projects, functions, storage. Cannot manage team or settings.', TRUE), + ('role_viewer', 'viewer', 'Read-only access to all resources.', TRUE) +ON CONFLICT (name) DO NOTHING; + +-- Seed permissions +INSERT INTO betterbase_meta.permissions (id, domain, action) VALUES + ('perm_projects_view', 'projects', 'view'), + ('perm_projects_create', 'projects', 'create'), + ('perm_projects_edit', 'projects', 'edit'), + ('perm_projects_delete', 'projects', 'delete'), + ('perm_users_view', 'users', 'view'), + ('perm_users_create', 'users', 'create'), + ('perm_users_edit', 'users', 'edit'), + ('perm_users_delete', 'users', 'delete'), + ('perm_users_export', 'users', 'export'), + ('perm_storage_view', 'storage', 'view'), + ('perm_storage_create', 'storage', 'create'), + ('perm_storage_edit', 'storage', 'edit'), + ('perm_storage_delete', 'storage', 'delete'), + ('perm_functions_view', 'functions', 'view'), + ('perm_functions_create', 'functions', 'create'), + ('perm_functions_edit', 'functions', 'edit'), + ('perm_functions_delete', 'functions', 'delete'), + ('perm_webhooks_view', 'webhooks', 'view'), + ('perm_webhooks_create', 'webhooks', 'create'), + ('perm_webhooks_edit', 'webhooks', 'edit'), + ('perm_webhooks_delete', 'webhooks', 'delete'), + ('perm_logs_view', 'logs', 'view'), + ('perm_logs_export', 'logs', 'export'), + ('perm_team_view', 'team', 'view'), + ('perm_team_create', 'team', 'create'), + ('perm_team_edit', 'team', 'edit'), + ('perm_team_delete', 'team', 'delete'), + ('perm_settings_view', 'settings', 'view'), + ('perm_settings_edit', 'settings', 'edit'), + ('perm_audit_view', 'audit', 'view'), + ('perm_audit_export', 'audit', 'export') +ON CONFLICT (domain, action) DO NOTHING; + +-- Owner: all permissions +INSERT INTO betterbase_meta.role_permissions (role_id, permission_id) + SELECT 'role_owner', id FROM betterbase_meta.permissions +ON CONFLICT DO NOTHING; + +-- Admin: all except settings_edit and audit_export +INSERT INTO betterbase_meta.role_permissions (role_id, permission_id) + SELECT 'role_admin', id FROM betterbase_meta.permissions + WHERE id NOT IN ('perm_settings_edit') +ON CONFLICT DO NOTHING; + +-- Developer: projects+users+storage+functions+webhooks+logs (no team, no settings, no audit) +INSERT INTO betterbase_meta.role_permissions (role_id, permission_id) + SELECT 'role_developer', id FROM betterbase_meta.permissions + WHERE domain IN ('projects','users','storage','functions','webhooks','logs') +ON CONFLICT DO NOTHING; + +-- Viewer: all view permissions only +INSERT INTO betterbase_meta.role_permissions (role_id, permission_id) + SELECT 'role_viewer', id FROM betterbase_meta.permissions + WHERE action = 'view' +ON CONFLICT DO NOTHING; +``` + +**Acceptance criteria:** +- 4 system roles seeded with correct permission sets +- `admin_roles.project_id` nullable — NULL means instance-wide scope +- System roles cannot be deleted (enforced by route, is_system flag) + +--- + +### Task DB-03 — Audit Log Schema + +**Depends on:** DB-02 + +**Create file:** `packages/server/migrations/007_audit_log.sql` + +```sql +CREATE TABLE IF NOT EXISTS betterbase_meta.audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_id TEXT, -- admin_user.id, NULL for system events + actor_email TEXT, -- denormalized for log permanence + action TEXT NOT NULL, -- e.g. "project.create", "user.ban", "admin.login" + resource_type TEXT, -- "project" | "user" | "webhook" | etc. + resource_id TEXT, + resource_name TEXT, -- human-readable snapshot + before_data JSONB, -- state before mutation (NULL for creates) + after_data JSONB, -- state after mutation (NULL for deletes) + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Cannot UPDATE or DELETE from this table (enforced by route layer — no update/delete routes exist) +-- Index for dashboard queries +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON betterbase_meta.audit_log (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON betterbase_meta.audit_log (actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON betterbase_meta.audit_log (action); +CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON betterbase_meta.audit_log (resource_type, resource_id); +``` + +**Acceptance criteria:** +- No UPDATE or DELETE routes ever created for this table +- Indexes on all common filter columns +- `actor_email` denormalized so logs survive admin deletion + +--- + +### Task DB-04 — API Keys Schema + +**Depends on:** DB-03 + +**Create file:** `packages/server/migrations/008_api_keys.sql` + +```sql +CREATE TABLE IF NOT EXISTS betterbase_meta.api_keys ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + admin_user_id TEXT NOT NULL REFERENCES betterbase_meta.admin_users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the plaintext key + key_prefix TEXT NOT NULL, -- first 8 chars for identification, e.g. "bb_live_" + scopes TEXT[] NOT NULL DEFAULT '{}', -- [] = full access, or specific domains + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, -- NULL = never expires + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Acceptance criteria:** +- Key stored as SHA-256 hash, plaintext returned once on creation +- `key_prefix` allows identifying key without revealing it +- `scopes` array for future permission scoping + +--- + +### Task DB-05 — Instance Settings + SMTP Schema + +**Depends on:** DB-04 + +**Create file:** `packages/server/migrations/009_instance_settings.sql` + +```sql +-- Generic key-value store for instance settings +CREATE TABLE IF NOT EXISTS betterbase_meta.instance_settings ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by TEXT -- admin_user.id +); + +-- SMTP configuration +CREATE TABLE IF NOT EXISTS betterbase_meta.smtp_config ( + id TEXT PRIMARY KEY DEFAULT 'singleton', -- only one row ever + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 587, + username TEXT NOT NULL, + password TEXT NOT NULL, -- encrypted at rest in future; plaintext for v1 + from_email TEXT NOT NULL, + from_name TEXT NOT NULL DEFAULT 'Betterbase', + use_tls BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Notification rules +CREATE TABLE IF NOT EXISTS betterbase_meta.notification_rules ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + name TEXT NOT NULL, + metric TEXT NOT NULL, -- "error_rate" | "storage_pct" | "auth_failures" | "response_time_p99" + threshold NUMERIC NOT NULL, + channel TEXT NOT NULL, -- "email" | "webhook" + target TEXT NOT NULL, -- email address or webhook URL + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed default instance settings +INSERT INTO betterbase_meta.instance_settings (key, value) VALUES + ('instance_name', '"Betterbase"'), + ('public_url', '"http://localhost"'), + ('contact_email', '"admin@localhost"'), + ('log_retention_days', '30'), + ('max_sessions_per_user', '10'), + ('require_email_verification', 'false'), + ('ip_allowlist', '[]'), + ('cors_origins', '["http://localhost"]') +ON CONFLICT (key) DO NOTHING; +``` + +**Acceptance criteria:** +- `instance_settings` is a flexible key-value store (avoids schema migrations for new settings) +- SMTP table has single row enforced by `DEFAULT 'singleton'` primary key +- Default settings seeded for all expected keys + +--- + +### Task DB-06 — Webhook Delivery Logs + Function Invocation Logs + +**Depends on:** DB-05 + +**Create file:** `packages/server/migrations/010_delivery_invocation_logs.sql` + +```sql +-- Webhook delivery attempts +CREATE TABLE IF NOT EXISTS betterbase_meta.webhook_deliveries ( + id BIGSERIAL PRIMARY KEY, + webhook_id TEXT NOT NULL REFERENCES betterbase_meta.webhooks(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed + response_code INTEGER, + response_body TEXT, + duration_ms INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delivered_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON betterbase_meta.webhook_deliveries (webhook_id, created_at DESC); + +-- Function invocation log +CREATE TABLE IF NOT EXISTS betterbase_meta.function_invocations ( + id BIGSERIAL PRIMARY KEY, + function_id TEXT NOT NULL REFERENCES betterbase_meta.functions(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL DEFAULT 'http', -- http | schedule | event + status TEXT NOT NULL, -- success | error | timeout + duration_ms INTEGER, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_function_invocations_function_id ON betterbase_meta.function_invocations (function_id, created_at DESC); +``` + +**Acceptance criteria:** +- Delivery log cascades delete on webhook delete +- Invocation log cascades delete on function delete +- Indexes optimized for "latest N for this webhook/function" queries + +--- + +## Phase 2 — Audit Middleware + +### Task DB-07 — Audit Logger Utility + +**Depends on:** DB-06 + +**Create file:** `packages/server/src/lib/audit.ts` + +```typescript +import type { Pool } from "pg"; +import { getPool } from "./db"; + +export type AuditAction = + | "admin.login" | "admin.logout" | "admin.create" | "admin.delete" + | "project.create" | "project.update" | "project.delete" + | "project.user.ban" | "project.user.unban" | "project.user.delete" | "project.user.import" + | "webhook.create" | "webhook.update" | "webhook.delete" | "webhook.retry" + | "function.create" | "function.delete" | "function.deploy" + | "storage.bucket.create" | "storage.bucket.delete" | "storage.object.delete" + | "api_key.create" | "api_key.revoke" + | "role.assign" | "role.revoke" + | "settings.update" | "smtp.update" + | "audit.export"; + +export interface AuditEntry { + actorId?: string; + actorEmail?: string; + action: AuditAction; + resourceType?: string; + resourceId?: string; + resourceName?: string; + beforeData?: unknown; + afterData?: unknown; + ipAddress?: string; + userAgent?: string; +} + +export async function writeAuditLog(entry: AuditEntry): Promise { + const pool = getPool(); + // Fire and forget — never delay the response for audit logging + pool + .query( + `INSERT INTO betterbase_meta.audit_log + (actor_id, actor_email, action, resource_type, resource_id, resource_name, + before_data, after_data, ip_address, user_agent) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [ + entry.actorId ?? null, + entry.actorEmail ?? null, + entry.action, + entry.resourceType ?? null, + entry.resourceId ?? null, + entry.resourceName ?? null, + entry.beforeData ? JSON.stringify(entry.beforeData) : null, + entry.afterData ? JSON.stringify(entry.afterData) : null, + entry.ipAddress ?? null, + entry.userAgent ?? null, + ] + ) + .catch((err) => console.error("[audit] Failed to write log:", err)); +} + +// Helper: extract IP from Hono context +export function getClientIp(headers: Headers): string { + return ( + headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + headers.get("x-real-ip") ?? + "unknown" + ); +} +``` + +**Acceptance criteria:** +- Fire-and-forget — never blocks request processing +- Errors swallowed silently (log to console only) +- All AuditAction values match what routes will emit + +--- + +## Phase 3 — Instance Routes + +### Task DB-08 — Instance Settings Routes + +**Depends on:** DB-07 + +**Create file:** `packages/server/src/routes/admin/instance.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPool } from "../../lib/db"; +import { writeAuditLog, getClientIp } from "../../lib/audit"; + +export const instanceRoutes = new Hono(); + +// GET /admin/instance — all settings as key-value object +instanceRoutes.get("/", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT key, value, updated_at FROM betterbase_meta.instance_settings ORDER BY key" + ); + // Convert rows to a flat object { key: parsedValue } + const settings = Object.fromEntries(rows.map((r) => [r.key, r.value])); + return c.json({ settings }); +}); + +// PATCH /admin/instance — update one or more settings +instanceRoutes.patch( + "/", + zValidator( + "json", + z.object({ + instance_name: z.string().min(1).max(100).optional(), + public_url: z.string().url().optional(), + contact_email: z.string().email().optional(), + log_retention_days: z.number().int().min(1).max(3650).optional(), + max_sessions_per_user: z.number().int().min(1).max(1000).optional(), + require_email_verification: z.boolean().optional(), + ip_allowlist: z.array(z.string()).optional(), + cors_origins: z.array(z.string().url()).optional(), + }) + ), + async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const admin = c.get("adminUser") as { id: string; email: string }; + + const updates = Object.entries(data).filter(([, v]) => v !== undefined); + for (const [key, value] of updates) { + await pool.query( + `INSERT INTO betterbase_meta.instance_settings (key, value, updated_at, updated_by) + VALUES ($1, $2::jsonb, NOW(), $3) + ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, updated_at = NOW(), updated_by = $3`, + [key, JSON.stringify(value), admin.id] + ); + } + + await writeAuditLog({ + actorId: admin.id, + actorEmail: admin.email, + action: "settings.update", + afterData: data, + ipAddress: getClientIp(c.req.raw.headers), + }); + + return c.json({ success: true }); + } +); + +// GET /admin/instance/health — connection health checks +instanceRoutes.get("/health", async (c) => { + const pool = getPool(); + let dbStatus = "ok"; + let dbLatencyMs = 0; + + try { + const start = Date.now(); + await pool.query("SELECT 1"); + dbLatencyMs = Date.now() - start; + } catch { + dbStatus = "error"; + } + + return c.json({ + health: { + database: { status: dbStatus, latency_ms: dbLatencyMs }, + server: { status: "ok", uptime_seconds: Math.floor(process.uptime()) }, + }, + }); +}); +``` + +**Acceptance criteria:** +- GET returns all settings as a flat object (values are already parsed JSONB) +- PATCH is additive — only updates provided keys +- Health check responds even if DB is down (catches the error) + +--- + +### Task DB-09 — SMTP Routes + +**Depends on:** DB-08 + +**Create file:** `packages/server/src/routes/admin/smtp.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPool } from "../../lib/db"; +import { writeAuditLog, getClientIp } from "../../lib/audit"; + +export const smtpRoutes = new Hono(); + +const SmtpSchema = z.object({ + host: z.string().min(1), + port: z.number().int().min(1).max(65535).default(587), + username: z.string().min(1), + password: z.string().min(1), + from_email: z.string().email(), + from_name: z.string().default("Betterbase"), + use_tls: z.boolean().default(true), + enabled: z.boolean().default(false), +}); + +// GET /admin/smtp +smtpRoutes.get("/", async (c) => { + const pool = getPool(); + const { rows } = await pool.query("SELECT * FROM betterbase_meta.smtp_config WHERE id = 'singleton'"); + if (rows.length === 0) return c.json({ smtp: null }); + const row = { ...rows[0] }; + // Mask password in response + if (row.password) row.password = "••••••••"; + return c.json({ smtp: row }); +}); + +// PUT /admin/smtp — upsert +smtpRoutes.put( + "/", + zValidator("json", SmtpSchema), + async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const admin = c.get("adminUser") as { id: string; email: string }; + + await pool.query( + `INSERT INTO betterbase_meta.smtp_config + (id, host, port, username, password, from_email, from_name, use_tls, enabled, updated_at) + VALUES ('singleton', $1,$2,$3,$4,$5,$6,$7,$8, NOW()) + ON CONFLICT (id) DO UPDATE SET + host=$1, port=$2, username=$3, password=$4, + from_email=$5, from_name=$6, use_tls=$7, enabled=$8, updated_at=NOW()`, + [data.host, data.port, data.username, data.password, data.from_email, data.from_name, data.use_tls, data.enabled] + ); + + await writeAuditLog({ + actorId: admin.id, + actorEmail: admin.email, + action: "smtp.update", + ipAddress: getClientIp(c.req.raw.headers), + }); + + return c.json({ success: true }); + } +); + +// POST /admin/smtp/test — send test email +smtpRoutes.post( + "/test", + zValidator("json", z.object({ to: z.string().email() })), + async (c) => { + const { to } = c.req.valid("json"); + const pool = getPool(); + const { rows } = await pool.query("SELECT * FROM betterbase_meta.smtp_config WHERE id = 'singleton' AND enabled = TRUE"); + + if (rows.length === 0) { + return c.json({ error: "SMTP not configured or not enabled" }, 400); + } + + const config = rows[0]; + + // Dynamic import nodemailer (add to package.json: "nodemailer": "^6.9.0", "@types/nodemailer" dev) + const nodemailer = await import("nodemailer"); + const transporter = nodemailer.default.createTransport({ + host: config.host, + port: config.port, + secure: config.port === 465, + requireTLS: config.use_tls, + auth: { user: config.username, pass: config.password }, + }); + + try { + await transporter.sendMail({ + from: `"${config.from_name}" <${config.from_email}>`, + to, + subject: "Betterbase SMTP Test", + text: "SMTP is configured correctly.", + html: "

SMTP is configured correctly.

", + }); + return c.json({ success: true, message: `Test email sent to ${to}` }); + } catch (err: any) { + return c.json({ error: `SMTP error: ${err.message}` }, 400); + } + } +); +``` + +**Also add to `packages/server/package.json` dependencies:** +```json +"nodemailer": "^6.9.0" +``` +And devDependencies: +```json +"@types/nodemailer": "^6.4.0" +``` + +**Acceptance criteria:** +- Password masked in GET response +- Test email endpoint attempts real delivery and returns clear success/error +- PUT is a full upsert — always safe to call + +--- + +### Task DB-10 — RBAC Routes + +**Depends on:** DB-09 + +**Create file:** `packages/server/src/routes/admin/roles.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPool } from "../../lib/db"; +import { writeAuditLog, getClientIp } from "../../lib/audit"; + +export const roleRoutes = new Hono(); + +// GET /admin/roles — list all roles with their permissions +roleRoutes.get("/", async (c) => { + const pool = getPool(); + const { rows: roles } = await pool.query( + "SELECT id, name, description, is_system, created_at FROM betterbase_meta.roles ORDER BY name" + ); + + const { rows: perms } = await pool.query(` + SELECT rp.role_id, p.id, p.domain, p.action + FROM betterbase_meta.role_permissions rp + JOIN betterbase_meta.permissions p ON p.id = rp.permission_id + `); + + const permsByRole: Record = {}; + for (const p of perms) { + if (!permsByRole[p.role_id]) permsByRole[p.role_id] = []; + permsByRole[p.role_id].push({ id: p.id, domain: p.domain, action: p.action }); + } + + return c.json({ + roles: roles.map((r) => ({ ...r, permissions: permsByRole[r.id] ?? [] })), + }); +}); + +// GET /admin/roles/permissions — all available permissions +roleRoutes.get("/permissions", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT id, domain, action FROM betterbase_meta.permissions ORDER BY domain, action" + ); + return c.json({ permissions: rows }); +}); + +// GET /admin/roles/assignments — all admin role assignments +roleRoutes.get("/assignments", async (c) => { + const pool = getPool(); + const { rows } = await pool.query(` + SELECT ar.id, ar.admin_user_id, au.email AS admin_email, + ar.role_id, r.name AS role_name, + ar.project_id, p.name AS project_name, + ar.created_at + FROM betterbase_meta.admin_roles ar + JOIN betterbase_meta.admin_users au ON au.id = ar.admin_user_id + JOIN betterbase_meta.roles r ON r.id = ar.role_id + LEFT JOIN betterbase_meta.projects p ON p.id = ar.project_id + ORDER BY ar.created_at DESC + `); + return c.json({ assignments: rows }); +}); + +// POST /admin/roles/assignments — assign role to admin +roleRoutes.post( + "/assignments", + zValidator("json", z.object({ + admin_user_id: z.string().min(1), + role_id: z.string().min(1), + project_id: z.string().optional(), + })), + async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const admin = c.get("adminUser") as { id: string; email: string }; + + const { rows } = await pool.query( + `INSERT INTO betterbase_meta.admin_roles (admin_user_id, role_id, project_id) + VALUES ($1, $2, $3) + ON CONFLICT (admin_user_id, role_id, project_id) DO NOTHING + RETURNING id`, + [data.admin_user_id, data.role_id, data.project_id ?? null] + ); + + await writeAuditLog({ + actorId: admin.id, actorEmail: admin.email, + action: "role.assign", + resourceType: "admin_user", resourceId: data.admin_user_id, + afterData: data, + ipAddress: getClientIp(c.req.raw.headers), + }); + + return c.json({ assignment: rows[0] }, 201); + } +); + +// DELETE /admin/roles/assignments/:id +roleRoutes.delete("/assignments/:id", async (c) => { + const pool = getPool(); + const admin = c.get("adminUser") as { id: string; email: string }; + + const { rows } = await pool.query( + "DELETE FROM betterbase_meta.admin_roles WHERE id = $1 RETURNING id, admin_user_id", + [c.req.param("id")] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + + await writeAuditLog({ + actorId: admin.id, actorEmail: admin.email, + action: "role.revoke", + resourceType: "admin_role", resourceId: c.req.param("id"), + ipAddress: getClientIp(c.req.raw.headers), + }); + + return c.json({ success: true }); +}); +``` + +**Acceptance criteria:** +- GET /roles includes permissions array per role +- Assignments support both global (project_id null) and project-scoped roles +- System roles cannot be deleted (no delete route for roles, only assignments) + +--- + +### Task DB-11 — API Keys Routes + +**Depends on:** DB-10 + +**Create file:** `packages/server/src/routes/admin/api-keys.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { createHash, randomBytes } from "crypto"; +import { getPool } from "../../lib/db"; +import { writeAuditLog, getClientIp } from "../../lib/audit"; + +export const apiKeyRoutes = new Hono(); + +// GET /admin/api-keys +apiKeyRoutes.get("/", async (c) => { + const pool = getPool(); + const admin = c.get("adminUser") as { id: string }; + const { rows } = await pool.query( + `SELECT id, name, key_prefix, scopes, last_used_at, expires_at, created_at + FROM betterbase_meta.api_keys + WHERE admin_user_id = $1 + ORDER BY created_at DESC`, + [admin.id] + ); + return c.json({ api_keys: rows }); +}); + +// POST /admin/api-keys +apiKeyRoutes.post( + "/", + zValidator("json", z.object({ + name: z.string().min(1).max(100), + scopes: z.array(z.string()).default([]), + expires_at: z.string().datetime().optional(), + })), + async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const admin = c.get("adminUser") as { id: string; email: string }; + + const rawKey = `bb_live_${randomBytes(32).toString("hex")}`; + const keyHash = createHash("sha256").update(rawKey).digest("hex"); + const keyPrefix = rawKey.slice(0, 16); + + const { rows } = await pool.query( + `INSERT INTO betterbase_meta.api_keys + (admin_user_id, name, key_hash, key_prefix, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, key_prefix, scopes, expires_at, created_at`, + [admin.id, data.name, keyHash, keyPrefix, data.scopes, data.expires_at ?? null] + ); + + await writeAuditLog({ + actorId: admin.id, actorEmail: admin.email, + action: "api_key.create", + resourceType: "api_key", resourceId: rows[0].id, + resourceName: data.name, + ipAddress: getClientIp(c.req.raw.headers), + }); + + // Return plaintext key ONCE — not stored, cannot be recovered + return c.json({ api_key: rows[0], key: rawKey }, 201); + } +); + +// DELETE /admin/api-keys/:id +apiKeyRoutes.delete("/:id", async (c) => { + const pool = getPool(); + const admin = c.get("adminUser") as { id: string; email: string }; + + const { rows } = await pool.query( + "DELETE FROM betterbase_meta.api_keys WHERE id = $1 AND admin_user_id = $2 RETURNING id, name", + [c.req.param("id"), admin.id] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + + await writeAuditLog({ + actorId: admin.id, actorEmail: admin.email, + action: "api_key.revoke", + resourceType: "api_key", resourceId: c.req.param("id"), + resourceName: rows[0].name, + ipAddress: getClientIp(c.req.raw.headers), + }); + + return c.json({ success: true }); +}); +``` + +**Update:** `packages/server/src/lib/admin-middleware.ts` — extend to also accept API key auth: + +```typescript +// At the top of requireAdmin, before JWT check: +const authHeader = c.req.header("Authorization"); + +// API key auth (prefix: "bb_live_") +if (authHeader?.startsWith("Bearer bb_live_")) { + const rawKey = authHeader.slice(7); + const keyHash = createHash("sha256").update(rawKey).digest("hex"); + const pool = getPool(); + + const { rows: keyRows } = await pool.query( + `SELECT ak.admin_user_id, au.id, au.email + FROM betterbase_meta.api_keys ak + JOIN betterbase_meta.admin_users au ON au.id = ak.admin_user_id + WHERE ak.key_hash = $1 + AND (ak.expires_at IS NULL OR ak.expires_at > NOW())`, + [keyHash] + ); + + if (keyRows.length === 0) return c.json({ error: "Invalid API key" }, 401); + + // Update last_used_at fire-and-forget + pool.query("UPDATE betterbase_meta.api_keys SET last_used_at = NOW() WHERE key_hash = $1", [keyHash]).catch(() => {}); + + c.set("adminUser", { id: keyRows[0].id, email: keyRows[0].email }); + await next(); + return; +} +// ... rest of existing JWT logic +``` + +**Add import at top of admin-middleware.ts:** +```typescript +import { createHash } from "crypto"; +``` + +**Acceptance criteria:** +- Plaintext key returned only once on creation +- API keys accepted anywhere JWT tokens are accepted +- `last_used_at` updated on every successful use (fire-and-forget) +- Keys scoped to the creating admin only + +--- + +### Task DB-12 — CLI Sessions Routes + +**Depends on:** DB-11 + +**Create file:** `packages/server/src/routes/admin/cli-sessions.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../lib/db"; + +export const cliSessionRoutes = new Hono(); + +// GET /admin/cli-sessions — active device codes + CLI sessions for this admin +cliSessionRoutes.get("/", async (c) => { + const pool = getPool(); + const admin = c.get("adminUser") as { id: string }; + + // Active unverified device codes (pending authorization) + const { rows: pending } = await pool.query( + `SELECT user_code, created_at, expires_at + FROM betterbase_meta.device_codes + WHERE verified = FALSE AND expires_at > NOW() + ORDER BY created_at DESC` + ); + + // API keys as a proxy for "CLI connections" (each key = one CLI instance) + const { rows: keys } = await pool.query( + `SELECT id, name, key_prefix, last_used_at, expires_at, created_at + FROM betterbase_meta.api_keys + WHERE admin_user_id = $1 + ORDER BY last_used_at DESC NULLS LAST`, + [admin.id] + ); + + return c.json({ pending_authorizations: pending, active_keys: keys }); +}); + +// DELETE /admin/cli-sessions/pending/:userCode — revoke pending authorization +cliSessionRoutes.delete("/pending/:userCode", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "DELETE FROM betterbase_meta.device_codes WHERE user_code = $1 RETURNING user_code", + [c.req.param("userCode")] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json({ success: true }); +}); +``` + +**Acceptance criteria:** +- Shows pending device code authorizations (not yet approved) +- Shows active API keys as CLI connection proxies +- Pending codes can be revoked before user approves + +--- + +### Task DB-13 — Audit Log Routes + +**Depends on:** DB-12 + +**Create file:** `packages/server/src/routes/admin/audit.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../lib/db"; + +export const auditRoutes = new Hono(); + +// GET /admin/audit?limit=50&offset=0&actor=&action=&resource_type=&from=&to= +auditRoutes.get("/", async (c) => { + const pool = getPool(); + const limit = Math.min(parseInt(c.req.query("limit") ?? "50"), 200); + const offset = parseInt(c.req.query("offset") ?? "0"); + const actor = c.req.query("actor"); + const action = c.req.query("action"); + const resourceType = c.req.query("resource_type"); + const from = c.req.query("from"); + const to = c.req.query("to"); + + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (actor) { conditions.push(`(actor_id = $${idx} OR actor_email ILIKE $${idx+1})`); params.push(actor, `%${actor}%`); idx += 2; } + if (action) { conditions.push(`action = $${idx}`); params.push(action); idx++; } + if (resourceType) { conditions.push(`resource_type = $${idx}`); params.push(resourceType); idx++; } + if (from) { conditions.push(`created_at >= $${idx}`); params.push(from); idx++; } + if (to) { conditions.push(`created_at <= $${idx}`); params.push(to); idx++; } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + + const { rows } = await pool.query( + `SELECT id, actor_id, actor_email, action, resource_type, resource_id, resource_name, + before_data, after_data, ip_address, created_at + FROM betterbase_meta.audit_log + ${where} + ORDER BY created_at DESC + LIMIT $${idx} OFFSET $${idx+1}`, + [...params, limit, offset] + ); + + const { rows: countRows } = await pool.query( + `SELECT COUNT(*)::int AS total FROM betterbase_meta.audit_log ${where}`, + params + ); + + return c.json({ logs: rows, total: countRows[0].total, limit, offset }); +}); + +// GET /admin/audit/actions — distinct action types for filter dropdown +auditRoutes.get("/actions", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT DISTINCT action FROM betterbase_meta.audit_log ORDER BY action" + ); + return c.json({ actions: rows.map((r) => r.action) }); +}); +``` + +**Acceptance criteria:** +- All filter params are optional and combinable +- Total count returned for pagination +- No mutation routes exist — audit log is read-only +- Parameterized queries throughout (SQL injection safe) + +--- + +### Task DB-14 — Enhanced Metrics Routes + +**Depends on:** DB-13 + +**Create file:** `packages/server/src/routes/admin/metrics-enhanced.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../lib/db"; + +export const metricsEnhancedRoutes = new Hono(); + +// GET /admin/metrics/overview — enriched overview +metricsEnhancedRoutes.get("/overview", async (c) => { + const pool = getPool(); + + const [projects, admins, webhooks, functions_, recentErrors] = await Promise.all([ + pool.query("SELECT COUNT(*)::int AS count FROM betterbase_meta.projects"), + pool.query("SELECT COUNT(*)::int AS count FROM betterbase_meta.admin_users"), + pool.query("SELECT COUNT(*)::int AS count FROM betterbase_meta.webhooks WHERE enabled = TRUE"), + pool.query("SELECT COUNT(*)::int AS count FROM betterbase_meta.functions WHERE status = 'active'"), + pool.query(` + SELECT COUNT(*)::int AS count FROM betterbase_meta.request_logs + WHERE status >= 500 AND created_at > NOW() - INTERVAL '1 hour' + `), + ]); + + // Per-project user counts + const { rows: projectRows } = await pool.query( + "SELECT id, slug FROM betterbase_meta.projects" + ); + + const userCounts: Record = {}; + for (const proj of projectRows) { + try { + const schemaName = `project_${proj.slug}`; + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS count FROM ${schemaName}."user"` + ); + userCounts[proj.id] = rows[0].count; + } catch { + userCounts[proj.id] = 0; + } + } + + const totalUsers = Object.values(userCounts).reduce((a, b) => a + b, 0); + + return c.json({ + metrics: { + projects: projects.rows[0].count, + admin_users: admins.rows[0].count, + total_end_users: totalUsers, + active_webhooks: webhooks.rows[0].count, + active_functions: functions_.rows[0].count, + recent_errors_1h: recentErrors.rows[0].count, + uptime_seconds: Math.floor(process.uptime()), + timestamp: new Date().toISOString(), + }, + user_counts_by_project: userCounts, + }); +}); + +// GET /admin/metrics/timeseries?metric=requests&period=24h|7d|30d +metricsEnhancedRoutes.get("/timeseries", async (c) => { + const pool = getPool(); + const metric = c.req.query("metric") ?? "requests"; + const period = c.req.query("period") ?? "24h"; + + const intervalMap: Record = { + "24h": { trunc: "hour", interval: "24 hours" }, + "7d": { trunc: "day", interval: "7 days" }, + "30d": { trunc: "day", interval: "30 days" }, + }; + const { trunc, interval } = intervalMap[period] ?? intervalMap["24h"]; + + if (metric === "requests") { + const { rows } = await pool.query(` + SELECT date_trunc($1, created_at) AS ts, + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE status >= 500)::int AS errors, + COUNT(*) FILTER (WHERE status >= 400 AND status < 500)::int AS client_errors, + ROUND(AVG(duration_ms))::int AS avg_duration_ms + FROM betterbase_meta.request_logs + WHERE created_at > NOW() - INTERVAL '${interval}' + GROUP BY 1 ORDER BY 1 + `, [trunc]); + return c.json({ metric, period, series: rows }); + } + + if (metric === "status_codes") { + const { rows } = await pool.query(` + SELECT date_trunc($1, created_at) AS ts, + status, + COUNT(*)::int AS count + FROM betterbase_meta.request_logs + WHERE created_at > NOW() - INTERVAL '${interval}' + GROUP BY 1, 2 ORDER BY 1, 2 + `, [trunc]); + return c.json({ metric, period, series: rows }); + } + + return c.json({ error: "Unknown metric" }, 400); +}); + +// GET /admin/metrics/latency — percentiles +metricsEnhancedRoutes.get("/latency", async (c) => { + const pool = getPool(); + const period = c.req.query("period") ?? "1h"; + const intervalMap: Record = { "1h": "1 hour", "24h": "24 hours", "7d": "7 days" }; + const interval = intervalMap[period] ?? "1 hour"; + + const { rows } = await pool.query(` + SELECT + ROUND(PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY duration_ms))::int AS p50, + ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms))::int AS p95, + ROUND(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms))::int AS p99, + ROUND(AVG(duration_ms))::int AS avg, + MAX(duration_ms)::int AS max + FROM betterbase_meta.request_logs + WHERE created_at > NOW() - INTERVAL '${interval}' + `); + + return c.json({ period, latency: rows[0] }); +}); + +// GET /admin/metrics/top-endpoints?limit=10&period=24h +metricsEnhancedRoutes.get("/top-endpoints", async (c) => { + const pool = getPool(); + const limit = Math.min(parseInt(c.req.query("limit") ?? "10"), 50); + const period = c.req.query("period") ?? "24h"; + const intervalMap: Record = { "1h": "1 hour", "24h": "24 hours", "7d": "7 days" }; + const interval = intervalMap[period] ?? "24 hours"; + + const { rows } = await pool.query(` + SELECT path, + COUNT(*)::int AS requests, + ROUND(AVG(duration_ms))::int AS avg_ms, + COUNT(*) FILTER (WHERE status >= 500)::int AS errors + FROM betterbase_meta.request_logs + WHERE created_at > NOW() - INTERVAL '${interval}' + GROUP BY path + ORDER BY requests DESC + LIMIT $1 + `, [limit]); + + return c.json({ period, endpoints: rows }); +}); +``` + +**Acceptance criteria:** +- `timeseries` query uses `date_trunc` for clean bucketing +- Per-project user counts handle missing schemas gracefully (try/catch) +- All period params validated against the interval map + +--- + +## Phase 4 — Per-Project Routes + +### Task DB-15 — Per-Project Router Scaffold + +**Depends on:** DB-14 + +**Create file:** `packages/server/src/routes/admin/project-scoped/index.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../../lib/db"; +import { projectUserRoutes } from "./users"; +import { projectAuthConfigRoutes } from "./auth-config"; +import { projectDatabaseRoutes } from "./database"; +import { projectRealtimeRoutes } from "./realtime"; +import { projectEnvRoutes } from "./env"; +import { projectWebhookRoutes } from "./webhooks"; +import { projectFunctionRoutes } from "./functions"; + +export const projectScopedRouter = new Hono(); + +// Middleware: verify project exists and attach to context +projectScopedRouter.use("/:projectId/*", async (c, next) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT id, name, slug FROM betterbase_meta.projects WHERE id = $1", + [c.req.param("projectId")] + ); + if (rows.length === 0) return c.json({ error: "Project not found" }, 404); + c.set("project", rows[0]); + await next(); +}); + +projectScopedRouter.route("/:projectId/users", projectUserRoutes); +projectScopedRouter.route("/:projectId/auth-config", projectAuthConfigRoutes); +projectScopedRouter.route("/:projectId/database", projectDatabaseRoutes); +projectScopedRouter.route("/:projectId/realtime", projectRealtimeRoutes); +projectScopedRouter.route("/:projectId/env", projectEnvRoutes); +projectScopedRouter.route("/:projectId/webhooks", projectWebhookRoutes); +projectScopedRouter.route("/:projectId/functions", projectFunctionRoutes); +``` + +**Update:** `packages/server/src/routes/admin/index.ts` — add import and route: + +```typescript +import { projectScopedRouter } from "./project-scoped/index"; +// ... +adminRouter.route("/projects", projectScopedRouter); +// (existing /admin/projects CRUD stays as is — project-scoped routes are additive) +``` + +**Note:** The existing `projectRoutes` handles `/admin/projects` CRUD. The new `projectScopedRouter` handles `/admin/projects/:id/users`, `/admin/projects/:id/database`, etc. These don't conflict because the existing routes only handle `/`, `/:id` (GET/PATCH/DELETE). + +**Acceptance criteria:** +- Project existence verified before any scoped route handler runs +- `project` object (id, name, slug) available via `c.get("project")` in all child routes +- 404 returned for invalid project ID + +--- + +### Task DB-16 — Per-Project User Management Routes + +**Depends on:** DB-15 + +**Create file:** `packages/server/src/routes/admin/project-scoped/users.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPool } from "../../../lib/db"; +import { writeAuditLog, getClientIp } from "../../../lib/audit"; + +export const projectUserRoutes = new Hono(); + +function schemaName(project: { slug: string }) { + return `project_${project.slug}`; +} + +// GET /admin/projects/:id/users?limit=50&offset=0&search=&provider=&banned=&from=&to= +projectUserRoutes.get("/", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const limit = Math.min(parseInt(c.req.query("limit") ?? "50"), 200); + const offset = parseInt(c.req.query("offset") ?? "0"); + const search = c.req.query("search"); + const provider = c.req.query("provider"); + const banned = c.req.query("banned"); + const from = c.req.query("from"); + const to = c.req.query("to"); + + const conditions: string[] = []; + const params: unknown[] = []; + let idx = 1; + + if (search) { conditions.push(`(u.email ILIKE $${idx} OR u.name ILIKE $${idx})`); params.push(`%${search}%`); idx++; } + if (banned !== undefined) { conditions.push(`u.banned = $${idx}`); params.push(banned === "true"); idx++; } + if (from) { conditions.push(`u.created_at >= $${idx}`); params.push(from); idx++; } + if (to) { conditions.push(`u.created_at <= $${idx}`); params.push(to); idx++; } + + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + + const { rows: users } = await pool.query( + `SELECT u.id, u.name, u.email, u.email_verified, u.image, u.created_at, u.banned, u.ban_reason, u.ban_expires, + array_agg(DISTINCT a.provider_id) FILTER (WHERE a.provider_id IS NOT NULL) AS providers, + MAX(ses.created_at) AS last_sign_in + FROM ${s}."user" u + LEFT JOIN ${s}.account a ON a.user_id = u.id + LEFT JOIN ${s}.session ses ON ses.user_id = u.id + ${where} + GROUP BY u.id + ORDER BY u.created_at DESC + LIMIT $${idx} OFFSET $${idx+1}`, + [...params, limit, offset] + ); + + const { rows: countRows } = await pool.query( + `SELECT COUNT(*)::int AS total FROM ${s}."user" u ${where}`, + params + ); + + return c.json({ users, total: countRows[0].total, limit, offset }); +}); + +// GET /admin/projects/:id/users/:userId +projectUserRoutes.get("/:userId", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rows: users } = await pool.query( + `SELECT u.*, array_agg(DISTINCT a.provider_id) FILTER (WHERE a.provider_id IS NOT NULL) AS providers + FROM ${s}."user" u + LEFT JOIN ${s}.account a ON a.user_id = u.id + WHERE u.id = $1 + GROUP BY u.id`, + [c.req.param("userId")] + ); + if (users.length === 0) return c.json({ error: "User not found" }, 404); + + const { rows: sessions } = await pool.query( + `SELECT id, expires_at, ip_address, user_agent, created_at + FROM ${s}.session WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20`, + [c.req.param("userId")] + ); + + return c.json({ user: users[0], sessions }); +}); + +// PATCH /admin/projects/:id/users/:userId/ban +projectUserRoutes.patch( + "/:userId/ban", + zValidator("json", z.object({ + banned: z.boolean(), + ban_reason: z.string().optional(), + ban_expires: z.string().datetime().optional(), + })), + async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string; name: string }; + const admin = c.get("adminUser") as { id: string; email: string }; + const s = schemaName(project); + + const { rows: before } = await pool.query(`SELECT * FROM ${s}."user" WHERE id = $1`, [c.req.param("userId")]); + if (before.length === 0) return c.json({ error: "User not found" }, 404); + + const { rows } = await pool.query( + `UPDATE ${s}."user" + SET banned = $1, ban_reason = $2, ban_expires = $3, updated_at = NOW() + WHERE id = $4 + RETURNING id, email, banned, ban_reason, ban_expires`, + [data.banned, data.ban_reason ?? null, data.ban_expires ?? null, c.req.param("userId")] + ); + + await writeAuditLog({ + actorId: admin.id, actorEmail: admin.email, + action: data.banned ? "project.user.ban" : "project.user.unban", + resourceType: "user", resourceId: c.req.param("userId"), + resourceName: before[0].email, + beforeData: { banned: before[0].banned }, + afterData: { banned: data.banned, reason: data.ban_reason }, + ipAddress: getClientIp(c.req.raw.headers), + }); + + // Revoke all sessions if banned + if (data.banned) { + await pool.query(`DELETE FROM ${s}.session WHERE user_id = $1`, [c.req.param("userId")]); + } + + return c.json({ user: rows[0] }); + } +); + +// DELETE /admin/projects/:id/users/:userId +projectUserRoutes.delete("/:userId", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const admin = c.get("adminUser") as { id: string; email: string }; + const s = schemaName(project); + + const { rows } = await pool.query( + `DELETE FROM ${s}."user" WHERE id = $1 RETURNING id, email`, + [c.req.param("userId")] + ); + if (rows.length === 0) return c.json({ error: "User not found" }, 404); + + await writeAuditLog({ + actorId: admin.id, actorEmail: admin.email, + action: "project.user.delete", + resourceType: "user", resourceId: c.req.param("userId"), + resourceName: rows[0].email, + ipAddress: getClientIp(c.req.raw.headers), + }); + + return c.json({ success: true }); +}); + +// DELETE /admin/projects/:id/users/:userId/sessions — force logout +projectUserRoutes.delete("/:userId/sessions", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rowCount } = await pool.query( + `DELETE FROM ${s}.session WHERE user_id = $1`, + [c.req.param("userId")] + ); + + return c.json({ success: true, sessions_revoked: rowCount }); +}); + +// GET /admin/projects/:id/users/stats — growth + activity charts +projectUserRoutes.get("/stats/overview", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const [total, banned, daily, providers] = await Promise.all([ + pool.query(`SELECT COUNT(*)::int AS count FROM ${s}."user"`), + pool.query(`SELECT COUNT(*)::int AS count FROM ${s}."user" WHERE banned = TRUE`), + pool.query(` + SELECT date_trunc('day', created_at) AS day, COUNT(*)::int AS signups + FROM ${s}."user" + WHERE created_at > NOW() - INTERVAL '30 days' + GROUP BY 1 ORDER BY 1 + `), + pool.query(` + SELECT provider_id, COUNT(*)::int AS count + FROM ${s}.account + GROUP BY provider_id ORDER BY count DESC + `), + ]); + + return c.json({ + total: total.rows[0].count, + banned: banned.rows[0].count, + daily_signups_30d: daily.rows, + provider_breakdown: providers.rows, + }); +}); + +// POST /admin/projects/:id/users/export — CSV export +projectUserRoutes.post("/export", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rows } = await pool.query( + `SELECT id, name, email, email_verified, created_at, banned FROM ${s}."user" ORDER BY created_at DESC` + ); + + const header = "id,name,email,email_verified,created_at,banned\n"; + const csv = header + rows.map((r) => + `${r.id},"${r.name}","${r.email}",${r.email_verified},${r.created_at},${r.banned}` + ).join("\n"); + + return new Response(csv, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="users-${project.slug}-${Date.now()}.csv"`, + }, + }); +}); +``` + +**Acceptance criteria:** +- All queries use parameterized inputs +- Schema name built from slug (never from user input) +- Ban + unban revokes all active sessions +- Export returns proper CSV content-type + filename +- User stats endpoint returns data formatted for chart consumption + +--- + +### Task DB-17 — Per-Project Auth Config Routes + +**Depends on:** DB-16 + +**Create file:** `packages/server/src/routes/admin/project-scoped/auth-config.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPool } from "../../../lib/db"; + +export const projectAuthConfigRoutes = new Hono(); + +function schemaName(project: { slug: string }) { return `project_${project.slug}`; } + +// GET /admin/projects/:id/auth-config — all config as key-value object +projectAuthConfigRoutes.get("/", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rows } = await pool.query(`SELECT key, value, updated_at FROM ${s}.auth_config ORDER BY key`); + const config = Object.fromEntries(rows.map((r) => [r.key, r.value])); + + return c.json({ config }); +}); + +// PUT /admin/projects/:id/auth-config/:key — upsert a single config key +projectAuthConfigRoutes.put( + "/:key", + zValidator("json", z.object({ value: z.unknown() })), + async (c) => { + const { value } = c.req.valid("json"); + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + const key = c.req.param("key"); + + // Allowed keys whitelist + const ALLOWED_KEYS = [ + "email_password_enabled", "magic_link_enabled", "otp_enabled", "phone_enabled", + "password_min_length", "require_email_verification", + "session_expiry_seconds", "refresh_token_expiry_seconds", "max_sessions_per_user", + "allowed_email_domains", "blocked_email_domains", + "provider_google", "provider_github", "provider_discord", + "provider_apple", "provider_microsoft", "provider_twitter", "provider_facebook", + "twilio_account_sid", "twilio_auth_token", "twilio_phone_number", + ]; + + if (!ALLOWED_KEYS.includes(key)) { + return c.json({ error: "Unknown config key" }, 400); + } + + await pool.query( + `INSERT INTO ${s}.auth_config (key, value, updated_at) VALUES ($1, $2::jsonb, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, updated_at = NOW()`, + [key, JSON.stringify(value)] + ); + + return c.json({ success: true, key, value }); + } +); + +// DELETE /admin/projects/:id/auth-config/:key — reset to default +projectAuthConfigRoutes.delete("/:key", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + await pool.query(`DELETE FROM ${s}.auth_config WHERE key = $1`, [c.req.param("key")]); + return c.json({ success: true }); +}); +``` + +**Acceptance criteria:** +- Config keys whitelist prevents arbitrary key injection +- Provider config values stored as JSONB (can hold nested { clientId, clientSecret } objects) +- Sensitive values (secrets, tokens) stored but masked in GET — do this in frontend layer + +--- + +### Task DB-18 — Per-Project Database Introspection Routes + +**Depends on:** DB-17 + +**Create file:** `packages/server/src/routes/admin/project-scoped/database.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../../lib/db"; + +export const projectDatabaseRoutes = new Hono(); + +function schemaName(project: { slug: string }) { return `project_${project.slug}`; } + +// GET /admin/projects/:id/database/tables +projectDatabaseRoutes.get("/tables", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rows } = await pool.query( + `SELECT + t.table_name, + pg_class.reltuples::bigint AS estimated_row_count, + pg_size_pretty(pg_total_relation_size(quote_ident($1) || '.' || quote_ident(t.table_name))) AS total_size + FROM information_schema.tables t + JOIN pg_class ON pg_class.relname = t.table_name + WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' + ORDER BY t.table_name`, + [s] + ); + + return c.json({ tables: rows }); +}); + +// GET /admin/projects/:id/database/tables/:tableName/columns +projectDatabaseRoutes.get("/tables/:tableName/columns", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + const tableName = c.req.param("tableName"); + + const { rows } = await pool.query( + `SELECT column_name, data_type, is_nullable, column_default, character_maximum_length + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + ORDER BY ordinal_position`, + [s, tableName] + ); + + return c.json({ columns: rows }); +}); + +// GET /admin/projects/:id/database/status +projectDatabaseRoutes.get("/status", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const [schemaSize, connInfo] = await Promise.all([ + pool.query( + `SELECT pg_size_pretty(sum(pg_total_relation_size(quote_ident($1) || '.' || quote_ident(table_name)))::bigint) AS total_size + FROM information_schema.tables WHERE table_schema = $1`, + [s] + ), + pool.query( + `SELECT count FROM pg_stat_activity WHERE state = 'active'` + ), + ]); + + return c.json({ + schema_size: schemaSize.rows[0]?.total_size ?? "0 bytes", + active_connections: connInfo.rows.length, + }); +}); + +// GET /admin/projects/:id/database/migrations +projectDatabaseRoutes.get("/migrations", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT id, filename, applied_at FROM betterbase_meta.migrations ORDER BY applied_at DESC" + ); + return c.json({ migrations: rows }); +}); +``` + +**Acceptance criteria:** +- All table names come from `information_schema` — never from user input directly in SQL +- Schema name derived from project slug only +- `estimated_row_count` uses `pg_class.reltuples` (fast, no full scan) + +--- + +### Task DB-19 — Per-Project Realtime Stats Routes + +**Depends on:** DB-18 + +**Create file:** `packages/server/src/routes/admin/project-scoped/realtime.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../../lib/db"; + +export const projectRealtimeRoutes = new Hono(); + +// GET /admin/projects/:id/realtime/stats +// Note: v1 returns static/estimated stats. Real-time WebSocket tracking is a future enhancement. +// The server tracks connection counts in-memory via a global map if realtime is running. +projectRealtimeRoutes.get("/stats", async (c) => { + // Access global realtime manager if available (set on app startup) + const realtimeManager = (globalThis as any).__betterbaseRealtimeManager; + + if (!realtimeManager) { + return c.json({ + connected_clients: 0, + active_channels: 0, + channels: [], + note: "Realtime manager not initialized", + }); + } + + // RealtimeManager exposes getStats() — implement this in the realtime module + const stats = realtimeManager.getStats?.() ?? { clients: 0, channels: [] }; + + return c.json({ + connected_clients: stats.clients, + active_channels: stats.channels.length, + channels: stats.channels, + }); +}); +``` + +**Acceptance criteria:** +- Returns gracefully if realtime not running (no crash) +- `globalThis.__betterbaseRealtimeManager` pattern allows injection without coupling + +--- + +### Task DB-20 — Per-Project Environment Variables Routes + +**Depends on:** DB-19 + +**Create file:** `packages/server/src/routes/admin/project-scoped/env.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPool } from "../../../lib/db"; + +export const projectEnvRoutes = new Hono(); + +function schemaName(project: { slug: string }) { return `project_${project.slug}`; } + +// GET /admin/projects/:id/env +projectEnvRoutes.get("/", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rows } = await pool.query( + `SELECT key, is_secret, created_at, updated_at, + CASE WHEN is_secret THEN '••••••••' ELSE value END AS value + FROM ${s}.env_vars ORDER BY key` + ); + return c.json({ env_vars: rows }); +}); + +// PUT /admin/projects/:id/env/:key +projectEnvRoutes.put( + "/:key", + zValidator("json", z.object({ + value: z.string(), + is_secret: z.boolean().default(true), + })), + async (c) => { + const { value, is_secret } = c.req.valid("json"); + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + const key = c.req.param("key"); + + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) { + return c.json({ error: "Key must be uppercase, alphanumeric with underscores" }, 400); + } + + await pool.query( + `INSERT INTO ${s}.env_vars (key, value, is_secret, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (key) DO UPDATE SET value=$2, is_secret=$3, updated_at=NOW()`, + [key, value, is_secret] + ); + + return c.json({ success: true, key }); + } +); + +// DELETE /admin/projects/:id/env/:key +projectEnvRoutes.delete("/:key", async (c) => { + const pool = getPool(); + const project = c.get("project") as { id: string; slug: string }; + const s = schemaName(project); + + const { rows } = await pool.query( + `DELETE FROM ${s}.env_vars WHERE key = $1 RETURNING key`, + [c.req.param("key")] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json({ success: true }); +}); +``` + +**Acceptance criteria:** +- Secret values masked in GET response +- Key format validated (uppercase + underscores only) +- Upsert semantics (safe to PUT same key multiple times) + +--- + +### Task DB-21 — Per-Project Webhooks Routes + +**Depends on:** DB-20 + +**Create file:** `packages/server/src/routes/admin/project-scoped/webhooks.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { getPool } from "../../../lib/db"; + +export const projectWebhookRoutes = new Hono(); + +// GET /admin/projects/:id/webhooks +projectWebhookRoutes.get("/", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT w.*, + COUNT(wd.id)::int AS total_deliveries, + COUNT(wd.id) FILTER (WHERE wd.status = 'success')::int AS successful_deliveries, + MAX(wd.created_at) AS last_delivery_at + FROM betterbase_meta.webhooks w + LEFT JOIN betterbase_meta.webhook_deliveries wd ON wd.webhook_id = w.id + GROUP BY w.id ORDER BY w.created_at DESC` + ); + return c.json({ webhooks: rows }); +}); + +// GET /admin/projects/:id/webhooks/:webhookId/deliveries +projectWebhookRoutes.get("/:webhookId/deliveries", async (c) => { + const pool = getPool(); + const limit = Math.min(parseInt(c.req.query("limit") ?? "50"), 200); + const offset = parseInt(c.req.query("offset") ?? "0"); + + const { rows } = await pool.query( + `SELECT id, event_type, status, response_code, duration_ms, attempt_count, created_at, delivered_at + FROM betterbase_meta.webhook_deliveries + WHERE webhook_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [c.req.param("webhookId"), limit, offset] + ); + + return c.json({ deliveries: rows, limit, offset }); +}); + +// GET /admin/projects/:id/webhooks/:webhookId/deliveries/:deliveryId +projectWebhookRoutes.get("/:webhookId/deliveries/:deliveryId", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT * FROM betterbase_meta.webhook_deliveries WHERE id = $1 AND webhook_id = $2", + [c.req.param("deliveryId"), c.req.param("webhookId")] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json({ delivery: rows[0] }); +}); + +// POST /admin/projects/:id/webhooks/:webhookId/retry +projectWebhookRoutes.post("/:webhookId/retry", async (c) => { + const pool = getPool(); + const { rows: webhooks } = await pool.query( + "SELECT * FROM betterbase_meta.webhooks WHERE id = $1", + [c.req.param("webhookId")] + ); + if (webhooks.length === 0) return c.json({ error: "Webhook not found" }, 404); + + const webhook = webhooks[0]; + const syntheticPayload = { + id: nanoid(), + webhook_id: webhook.id, + table: webhook.table_name, + type: "RETRY", + record: {}, + timestamp: new Date().toISOString(), + }; + + // Fire delivery attempt + const start = Date.now(); + let status = "failed"; + let responseCode: number | null = null; + let responseBody: string | null = null; + + try { + const res = await fetch(webhook.url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Betterbase-Event": "RETRY" }, + body: JSON.stringify(syntheticPayload), + }); + responseCode = res.status; + responseBody = await res.text(); + status = res.ok ? "success" : "failed"; + } catch (err: any) { + responseBody = err.message; + } + + const duration = Date.now() - start; + + await pool.query( + `INSERT INTO betterbase_meta.webhook_deliveries + (webhook_id, event_type, payload, status, response_code, response_body, duration_ms, delivered_at) + VALUES ($1, 'RETRY', $2, $3, $4, $5, $6, NOW())`, + [webhook.id, JSON.stringify(syntheticPayload), status, responseCode, responseBody, duration] + ); + + return c.json({ success: status === "success", status, response_code: responseCode, duration_ms: duration }); +}); + +// POST /admin/projects/:id/webhooks/:webhookId/test — send synthetic test payload +projectWebhookRoutes.post("/:webhookId/test", async (c) => { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT * FROM betterbase_meta.webhooks WHERE id = $1", + [c.req.param("webhookId")] + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + + const webhook = rows[0]; + const payload = { + id: nanoid(), + webhook_id: webhook.id, + table: webhook.table_name, + type: "TEST", + record: { id: "test-123", example: "data" }, + timestamp: new Date().toISOString(), + }; + + try { + const res = await fetch(webhook.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return c.json({ success: res.ok, status_code: res.status }); + } catch (err: any) { + return c.json({ success: false, error: err.message }); + } +}); +``` + +**Acceptance criteria:** +- Delivery list returns health summary (total, successful, last delivery time) per webhook +- Retry creates a new delivery log entry +- Test endpoint does not create a delivery log entry (it's informal) + +--- + +### Task DB-22 — Per-Project Functions Routes + +**Depends on:** DB-21 + +**Create file:** `packages/server/src/routes/admin/project-scoped/functions.ts` + +```typescript +import { Hono } from "hono"; +import { getPool } from "../../../lib/db"; + +export const projectFunctionRoutes = new Hono(); + +// GET /admin/projects/:id/functions/:functionId/invocations +projectFunctionRoutes.get("/:functionId/invocations", async (c) => { + const pool = getPool(); + const limit = Math.min(parseInt(c.req.query("limit") ?? "50"), 200); + const offset = parseInt(c.req.query("offset") ?? "0"); + + const { rows } = await pool.query( + `SELECT id, trigger_type, status, duration_ms, error_message, created_at + FROM betterbase_meta.function_invocations + WHERE function_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [c.req.param("functionId"), limit, offset] + ); + + return c.json({ invocations: rows, limit, offset }); +}); + +// GET /admin/projects/:id/functions/:functionId/stats +projectFunctionRoutes.get("/:functionId/stats", async (c) => { + const pool = getPool(); + const period = c.req.query("period") ?? "24h"; + const intervalMap: Record = { "1h": "1 hour", "24h": "24 hours", "7d": "7 days" }; + const interval = intervalMap[period] ?? "24 hours"; + + const { rows: summary } = await pool.query(` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE status = 'success')::int AS successes, + COUNT(*) FILTER (WHERE status = 'error')::int AS errors, + ROUND(AVG(duration_ms))::int AS avg_duration_ms, + MAX(duration_ms)::int AS max_duration_ms + FROM betterbase_meta.function_invocations + WHERE function_id = $1 AND created_at > NOW() - INTERVAL '${interval}' + `, [c.req.param("functionId")]); + + const { rows: timeseries } = await pool.query(` + SELECT date_trunc('hour', created_at) AS ts, + COUNT(*)::int AS invocations, + COUNT(*) FILTER (WHERE status = 'error')::int AS errors + FROM betterbase_meta.function_invocations + WHERE function_id = $1 AND created_at > NOW() - INTERVAL '${interval}' + GROUP BY 1 ORDER BY 1 + `, [c.req.param("functionId")]); + + return c.json({ period, summary: summary[0], timeseries }); +}); +``` + +**Acceptance criteria:** +- Stats return correct period-filtered data +- Timeseries bucketed by hour for chart rendering + +--- + +## Phase 5 — Notification Rules + +### Task DB-23 — Notification Rules Routes + +**Depends on:** DB-22 + +**Create file:** `packages/server/src/routes/admin/notifications.ts` + +```typescript +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { getPool } from "../../lib/db"; + +export const notificationRoutes = new Hono(); + +const RuleSchema = z.object({ + name: z.string().min(1).max(100), + metric: z.enum(["error_rate", "storage_pct", "auth_failures", "response_time_p99"]), + threshold: z.number(), + channel: z.enum(["email", "webhook"]), + target: z.string().min(1), + enabled: z.boolean().default(true), +}); + +notificationRoutes.get("/", async (c) => { + const pool = getPool(); + const { rows } = await pool.query("SELECT * FROM betterbase_meta.notification_rules ORDER BY created_at DESC"); + return c.json({ rules: rows }); +}); + +notificationRoutes.post("/", zValidator("json", RuleSchema), async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const { rows } = await pool.query( + `INSERT INTO betterbase_meta.notification_rules (id, name, metric, threshold, channel, target, enabled) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [nanoid(), data.name, data.metric, data.threshold, data.channel, data.target, data.enabled] + ); + return c.json({ rule: rows[0] }, 201); +}); + +notificationRoutes.patch("/:id", zValidator("json", RuleSchema.partial()), async (c) => { + const data = c.req.valid("json"); + const pool = getPool(); + const sets: string[] = []; + const params: unknown[] = []; + let idx = 1; + for (const [k, v] of Object.entries(data)) { + if (v !== undefined) { sets.push(`${k} = $${idx}`); params.push(v); idx++; } + } + if (sets.length === 0) return c.json({ error: "Nothing to update" }, 400); + params.push(c.req.param("id")); + const { rows } = await pool.query( + `UPDATE betterbase_meta.notification_rules SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json({ rule: rows[0] }); +}); + +notificationRoutes.delete("/:id", async (c) => { + const pool = getPool(); + const { rows } = await pool.query("DELETE FROM betterbase_meta.notification_rules WHERE id = $1 RETURNING id", [c.req.param("id")]); + if (rows.length === 0) return c.json({ error: "Not found" }, 404); + return c.json({ success: true }); +}); +``` + +**Acceptance criteria:** +- Metric enum covers the metrics available from the metrics endpoints +- Partial PATCH supported + +--- + +## Phase 6 — Wire Everything Into Admin Router + +### Task DB-24 — Update Admin Router Index + +**Depends on:** DB-23 + +**Replace file:** `packages/server/src/routes/admin/index.ts` + +```typescript +import { Hono } from "hono"; +import { requireAdmin } from "../../lib/admin-middleware"; +import { authRoutes } from "./auth"; +import { projectRoutes } from "./projects"; +import { userRoutes } from "./users"; +import { metricsRoutes } from "./metrics"; +import { metricsEnhancedRoutes } from "./metrics-enhanced"; +import { storageRoutes } from "./storage"; +import { webhookRoutes } from "./webhooks"; +import { functionRoutes } from "./functions"; +import { logRoutes } from "./logs"; +import { instanceRoutes } from "./instance"; +import { smtpRoutes } from "./smtp"; +import { roleRoutes } from "./roles"; +import { apiKeyRoutes } from "./api-keys"; +import { cliSessionRoutes } from "./cli-sessions"; +import { auditRoutes } from "./audit"; +import { notificationRoutes } from "./notifications"; +import { projectScopedRouter } from "./project-scoped/index"; + +export const adminRouter = new Hono(); + +// Public: login/logout/setup +adminRouter.route("/auth", authRoutes); + +// All other admin routes require valid admin token or API key +adminRouter.use("/*", requireAdmin); + +// Instance-level resources +adminRouter.route("/projects", projectRoutes); +adminRouter.route("/projects", projectScopedRouter); // scoped sub-routes +adminRouter.route("/users", userRoutes); +adminRouter.route("/storage", storageRoutes); +adminRouter.route("/webhooks", webhookRoutes); +adminRouter.route("/functions", functionRoutes); +adminRouter.route("/logs", logRoutes); +adminRouter.route("/metrics", metricsRoutes); +adminRouter.route("/metrics", metricsEnhancedRoutes); + +// New routes from this spec +adminRouter.route("/instance", instanceRoutes); +adminRouter.route("/smtp", smtpRoutes); +adminRouter.route("/roles", roleRoutes); +adminRouter.route("/api-keys", apiKeyRoutes); +adminRouter.route("/cli-sessions", cliSessionRoutes); +adminRouter.route("/audit", auditRoutes); +adminRouter.route("/notifications", notificationRoutes); +``` + +**Acceptance criteria:** +- All new routers registered +- Auth routes remain public +- No route conflicts (verify `bun run dev` starts without errors) + +--- + +## Phase 7 — Update package.json and Add Missing Dependencies + +### Task DB-25 — Update Server Dependencies + +**Depends on:** DB-24 + +**Modify file:** `packages/server/package.json` + +Ensure the following are in `dependencies`: + +```json +{ + "dependencies": { + "@betterbase/core": "workspace:*", + "@betterbase/shared": "workspace:*", + "@hono/zod-validator": "^0.2.0", + "@aws-sdk/client-s3": "^3.0.0", + "hono": "^4.0.0", + "pg": "^8.11.0", + "bcryptjs": "^2.4.3", + "nanoid": "^5.0.0", + "jose": "^5.0.0", + "nodemailer": "^6.9.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@types/pg": "^8.11.0", + "@types/bcryptjs": "^2.4.6", + "@types/nodemailer": "^6.4.0", + "typescript": "^5.4.0" + } +} +``` + +**Acceptance criteria:** +- `bun install` completes without errors +- `bun run dev` starts server without TypeScript errors +- All imports resolve + +--- + +## Summary — Complete Route Map + +After this spec is implemented, the full admin API surface is: + +``` +POST /admin/auth/login +GET /admin/auth/me +POST /admin/auth/logout +POST /admin/auth/setup ← first-run only + +GET /admin/projects +POST /admin/projects +GET /admin/projects/:id +PATCH /admin/projects/:id +DELETE /admin/projects/:id + +GET /admin/projects/:id/users +GET /admin/projects/:id/users/stats/overview +POST /admin/projects/:id/users/export +GET /admin/projects/:id/users/:userId +PATCH /admin/projects/:id/users/:userId/ban +DELETE /admin/projects/:id/users/:userId +DELETE /admin/projects/:id/users/:userId/sessions + +GET /admin/projects/:id/auth-config +PUT /admin/projects/:id/auth-config/:key +DELETE /admin/projects/:id/auth-config/:key + +GET /admin/projects/:id/database/tables +GET /admin/projects/:id/database/tables/:table/columns +GET /admin/projects/:id/database/status +GET /admin/projects/:id/database/migrations + +GET /admin/projects/:id/realtime/stats + +GET /admin/projects/:id/env +PUT /admin/projects/:id/env/:key +DELETE /admin/projects/:id/env/:key + +GET /admin/projects/:id/webhooks +GET /admin/projects/:id/webhooks/:webhookId/deliveries +GET /admin/projects/:id/webhooks/:webhookId/deliveries/:deliveryId +POST /admin/projects/:id/webhooks/:webhookId/retry +POST /admin/projects/:id/webhooks/:webhookId/test + +GET /admin/projects/:id/functions/:functionId/invocations +GET /admin/projects/:id/functions/:functionId/stats + +GET /admin/users +POST /admin/users +DELETE /admin/users/:id + +GET /admin/storage/buckets +POST /admin/storage/buckets +DELETE /admin/storage/buckets/:name +GET /admin/storage/buckets/:name/objects + +GET /admin/webhooks +POST /admin/webhooks +PATCH /admin/webhooks/:id +DELETE /admin/webhooks/:id + +GET /admin/functions +POST /admin/functions +DELETE /admin/functions/:id + +GET /admin/metrics +GET /admin/metrics/overview +GET /admin/metrics/timeseries +GET /admin/metrics/latency +GET /admin/metrics/top-endpoints + +GET /admin/logs +GET /admin/instance +PATCH /admin/instance +GET /admin/instance/health + +GET /admin/smtp +PUT /admin/smtp +POST /admin/smtp/test + +GET /admin/roles +GET /admin/roles/permissions +GET /admin/roles/assignments +POST /admin/roles/assignments +DELETE /admin/roles/assignments/:id + +GET /admin/api-keys +POST /admin/api-keys +DELETE /admin/api-keys/:id + +GET /admin/cli-sessions +DELETE /admin/cli-sessions/pending/:userCode + +GET /admin/audit +GET /admin/audit/actions + +GET /admin/notifications +POST /admin/notifications +PATCH /admin/notifications/:id +DELETE /admin/notifications/:id + +GET /health +POST /device/code +GET /device/verify +POST /device/verify +POST /device/token +``` + +**Total tasks: 25 (DB-01 through DB-25)** + +--- + +## Execution Order + +``` +Phase 1 — Schema + DB-01 Project schema provisioning function + projects.ts update + DB-02 RBAC schema (006_rbac.sql) + DB-03 Audit log schema (007_audit_log.sql) + DB-04 API keys schema (008_api_keys.sql) + DB-05 Instance settings + SMTP schema (009_instance_settings.sql) + DB-06 Delivery + invocation logs (010_delivery_invocation_logs.sql) + +Phase 2 — Audit Middleware + DB-07 audit.ts utility + +Phase 3 — Instance Routes + DB-08 instance.ts + DB-09 smtp.ts (add nodemailer dep) + DB-10 roles.ts + DB-11 api-keys.ts + update admin-middleware.ts + DB-12 cli-sessions.ts + DB-13 audit.ts (routes) + DB-14 metrics-enhanced.ts + +Phase 4 — Per-Project Routes + DB-15 project-scoped/index.ts (scaffold + router middleware) + DB-16 project-scoped/users.ts + DB-17 project-scoped/auth-config.ts + DB-18 project-scoped/database.ts + DB-19 project-scoped/realtime.ts + DB-20 project-scoped/env.ts + DB-21 project-scoped/webhooks.ts + DB-22 project-scoped/functions.ts + +Phase 5 — Notifications + DB-23 notifications.ts + +Phase 6 — Wire Up + DB-24 Update admin/index.ts + +Phase 7 — Dependencies + DB-25 Update package.json, run bun install, verify startup +``` + +*End of backend specification. Pass to Kilo Code for implementation. Test by starting the server and verifying `/health` returns 200, then run `bb login --url http://localhost:3001` to verify the full auth flow before proceeding to the frontend spec.* diff --git a/BetterBase_Dashboard_Frontend_Spec.md b/BetterBase_Dashboard_Frontend_Spec.md new file mode 100644 index 0000000..e22bacb --- /dev/null +++ b/BetterBase_Dashboard_Frontend_Spec.md @@ -0,0 +1,2228 @@ +# BetterBase Dashboard Frontend — Orchestrator Specification + +> **For Kilo Code Orchestrator** +> Implement after `BetterBase_Dashboard_Backend_Spec.md` is complete and server is verified running. +> All task IDs use prefix **FE-** (Frontend). +> Execute tasks in strict order. Do not begin a task until all listed dependencies are marked complete. +> All file paths are relative to the dashboard repo root (separate repo from the monorepo). + +--- + +## Overview + +This spec builds the complete BetterBase self-hosted dashboard — a React single-page application that consumes every API endpoint defined in the backend spec. + +**Tech stack (locked, do not substitute):** +- **React Router v7** — routing with loaders/actions +- **TanStack Query v5** — all server state +- **Tailwind CSS v4** — styling +- **shadcn/ui** — component primitives (Radix-based, code-owned) +- **Motion (framer-motion v11)** — micro-interactions, surgically applied +- **TanStack Table v8** — all data tables +- **React Hook Form v7 + Zod** — all forms +- **Recharts** — all charts +- **Lucide React** — all icons + +**Design principles:** +- Dark theme first (light available via toggle, persisted to localStorage) +- Every list view has an empty state — no blank pages +- Loading states use skeletons, not spinners +- Every destructive action has a confirmation dialog requiring the resource name to be typed +- Error boundaries on every module — one broken section doesn't kill the page +- Command palette (⌘K) available globally +- No AI-generated color palettes — use a deliberate design token system + +--- + +## Phase 1 — Project Bootstrap + +### Task FE-01 — Initialize React Router v7 Project + +**Depends on:** nothing + +**Create the project:** + +```bash +npx create-react-router@latest betterbase-dashboard --template spa +cd betterbase-dashboard +``` + +This gives a Vite + React Router v7 SPA. If `create-react-router` is unavailable, use: + +```bash +npm create vite@latest betterbase-dashboard -- --template react-ts +cd betterbase-dashboard +npm install react-router@7 +``` + +**Install all dependencies:** + +```bash +npm install \ + react-router@7 \ + @tanstack/react-query@5 \ + @tanstack/react-table@8 \ + react-hook-form@7 \ + zod@3 \ + @hookform/resolvers@3 \ + recharts@2 \ + motion@11 \ + lucide-react \ + clsx \ + tailwind-merge \ + class-variance-authority \ + @radix-ui/react-dialog \ + @radix-ui/react-dropdown-menu \ + @radix-ui/react-select \ + @radix-ui/react-tabs \ + @radix-ui/react-tooltip \ + @radix-ui/react-popover \ + @radix-ui/react-alert-dialog \ + @radix-ui/react-switch \ + @radix-ui/react-badge \ + @radix-ui/react-separator \ + @radix-ui/react-avatar \ + @radix-ui/react-progress \ + @radix-ui/react-scroll-area \ + @radix-ui/react-collapsible \ + cmdk \ + sonner \ + date-fns +``` + +**Install dev dependencies:** + +```bash +npm install -D \ + tailwindcss@4 \ + @tailwindcss/vite \ + typescript \ + @types/react \ + @types/react-dom \ + vite \ + @vitejs/plugin-react \ + @tanstack/react-query-devtools +``` + +**Acceptance criteria:** +- `npm run dev` starts without errors +- TypeScript compiles without errors + +--- + +### Task FE-02 — Configure Tailwind v4 + Design Tokens + +**Depends on:** FE-01 + +**Replace file:** `src/index.css` + +```css +@import "tailwindcss"; + +@theme { + /* ─── Color Palette ─────────────────────────────────────────────── */ + --color-background: #0a0a0f; + --color-surface: #111118; + --color-surface-elevated: #16161f; + --color-surface-overlay: #1c1c27; + --color-border: #1e1e2e; + --color-border-subtle: #151520; + + /* Brand */ + --color-brand: #6366f1; + --color-brand-hover: #818cf8; + --color-brand-muted: rgba(99,102,241,0.12); + + /* Text */ + --color-text-primary: #e8e8f0; + --color-text-secondary: #8b8ba8; + --color-text-muted: #4b4b6a; + --color-text-inverted: #0a0a0f; + + /* Semantic */ + --color-success: #22c55e; + --color-success-muted: rgba(34,197,94,0.12); + --color-warning: #f59e0b; + --color-warning-muted: rgba(245,158,11,0.12); + --color-danger: #ef4444; + --color-danger-muted: rgba(239,68,68,0.12); + --color-info: #3b82f6; + --color-info-muted: rgba(59,130,246,0.12); + + /* ─── Typography ────────────────────────────────────────────────── */ + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + + /* ─── Radii ─────────────────────────────────────────────────────── */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* ─── Shadows ───────────────────────────────────────────────────── */ + --shadow-card: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.03); + --shadow-elevated: 0 4px 16px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.05); +} + +/* Light theme override */ +[data-theme="light"] { + --color-background: #fafafa; + --color-surface: #ffffff; + --color-surface-elevated: #f5f5f7; + --color-surface-overlay: #ebebf0; + --color-border: #e2e2ec; + --color-border-subtle: #f0f0f5; + --color-text-primary: #0f0f1a; + --color-text-secondary: #5c5c78; + --color-text-muted: #9999b5; +} + +* { box-sizing: border-box; } + +body { + background: var(--color-background); + color: var(--color-text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); } +``` + +**Replace file:** `vite.config.ts` + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + define: { + "import.meta.env.VITE_API_URL": JSON.stringify(process.env.VITE_API_URL ?? "http://localhost:3001"), + }, +}); +``` + +**Replace file:** `tsconfig.json` + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src"] +} +``` + +**Acceptance criteria:** +- CSS variables resolve correctly in browser +- `@` alias works for all imports +- Dark theme visible by default + +--- + +### Task FE-03 — Core Utilities and API Client + +**Depends on:** FE-02 + +**Create file:** `src/lib/utils.ts` + +```typescript +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatDate(date: string | Date, opts?: Intl.DateTimeFormatOptions) { + return new Intl.DateTimeFormat("en-US", { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + ...opts, + }).format(new Date(date)); +} + +export function formatRelative(date: string | Date): string { + const diff = Date.now() - new Date(date).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; +} + +export function truncate(str: string, n: number): string { + return str.length > n ? `${str.slice(0, n)}...` : str; +} + +export function copyToClipboard(text: string): Promise { + return navigator.clipboard.writeText(text); +} +``` + +**Create file:** `src/lib/api.ts` + +```typescript +const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = "ApiError"; + } +} + +function getToken(): string | null { + return localStorage.getItem("bb_token"); +} + +export function setToken(token: string): void { + localStorage.setItem("bb_token", token); +} + +export function clearToken(): void { + localStorage.removeItem("bb_token"); + localStorage.removeItem("bb_admin"); +} + +export function getStoredAdmin(): { id: string; email: string } | null { + const raw = localStorage.getItem("bb_admin"); + return raw ? JSON.parse(raw) : null; +} + +export function setStoredAdmin(admin: { id: string; email: string }): void { + localStorage.setItem("bb_admin", JSON.stringify(admin)); +} + +async function request( + path: string, + options: RequestInit = {}, + skipAuth = false +): Promise { + const token = getToken(); + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record ?? {}), + }; + if (token && !skipAuth) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (res.status === 401) { + clearToken(); + window.location.href = "/login"; + throw new ApiError(401, "Unauthorized"); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: "Request failed" })) as { error?: string }; + throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => request(path, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined }), + put: (path: string, body?: unknown) => request(path, { method: "PUT", body: JSON.stringify(body) }), + patch: (path: string, body?: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: "DELETE" }), + + // Public (no auth header) + postPublic: (path: string, body?: unknown) => + request(path, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined }, true), + + // File download (returns blob) + download: async (path: string): Promise => { + const token = getToken(); + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + }); + if (!res.ok) throw new ApiError(res.status, "Download failed"); + return res.blob(); + }, +}; +``` + +**Create file:** `src/lib/query-keys.ts` + +```typescript +// Centralized query key factory — prevents key typos and enables targeted invalidation +export const QK = { + // Instance + health: () => ["health"] as const, + instance: () => ["instance"] as const, + smtp: () => ["smtp"] as const, + notifications: () => ["notifications"] as const, + // Metrics + metrics: () => ["metrics"] as const, + metricsOverview: () => ["metrics", "overview"] as const, + metricsTimeseries: (metric: string, period: string) => ["metrics", "timeseries", metric, period] as const, + metricsLatency: (period: string) => ["metrics", "latency", period] as const, + metricsTopEndpoints:(period: string) => ["metrics", "top-endpoints", period] as const, + // Auth + adminMe: () => ["admin", "me"] as const, + // Admin users + adminUsers: () => ["admin-users"] as const, + // RBAC + roles: () => ["roles"] as const, + roleAssignments: () => ["role-assignments"] as const, + // API keys + apiKeys: () => ["api-keys"] as const, + cliSessions: () => ["cli-sessions"] as const, + // Projects + projects: () => ["projects"] as const, + project: (id: string) => ["projects", id] as const, + // Per-project + projectUsers: (id: string, params: Record) => ["projects", id, "users", params] as const, + projectUser: (id: string, userId: string) => ["projects", id, "users", userId] as const, + projectUserStats: (id: string) => ["projects", id, "users", "stats"] as const, + projectAuthConfig: (id: string) => ["projects", id, "auth-config"] as const, + projectDatabase: (id: string) => ["projects", id, "database"] as const, + projectTables: (id: string) => ["projects", id, "database", "tables"] as const, + projectColumns: (id: string, table: string) => ["projects", id, "database", "tables", table] as const, + projectRealtime: (id: string) => ["projects", id, "realtime"] as const, + projectEnv: (id: string) => ["projects", id, "env"] as const, + projectWebhooks: (id: string) => ["projects", id, "webhooks"] as const, + projectDeliveries: (id: string, webhookId: string) => ["projects", id, "webhooks", webhookId, "deliveries"] as const, + projectFunctions: (id: string) => ["projects", id, "functions"] as const, + projectInvocations: (id: string, fnId: string) => ["projects", id, "functions", fnId, "invocations"] as const, + projectFnStats: (id: string, fnId: string, period: string) => ["projects", id, "functions", fnId, "stats", period] as const, + // Logs + logs: (params: Record) => ["logs", params] as const, + audit: (params: Record) => ["audit", params] as const, + auditActions: () => ["audit", "actions"] as const, + // Storage + storageBuckets: () => ["storage", "buckets"] as const, + storageObjects: (bucket: string) => ["storage", "buckets", bucket, "objects"] as const, + // Webhooks (global) + webhooks: () => ["webhooks"] as const, + // Functions (global) + functions: () => ["functions"] as const, +}; +``` + +**Acceptance criteria:** +- `api.get/post/put/patch/delete` all functional +- 401 responses redirect to `/login` and clear stored token +- Query key factory covers every API endpoint + +--- + +### Task FE-04 — App Shell + Router + Query Provider + +**Depends on:** FE-03 + +**Create file:** `src/main.tsx` + +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { Toaster } from "sonner"; +import { routes } from "./routes"; +import "./index.css"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30 seconds + gcTime: 5 * 60 * 1000, // 5 minutes + retry: (failureCount, error: any) => { + if (error?.status === 401 || error?.status === 404) return false; + return failureCount < 2; + }, + }, + }, +}); + +const router = createBrowserRouter(routes); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + {import.meta.env.DEV && } + + +); +``` + +**Create file:** `src/routes.tsx` + +```tsx +import { type RouteObject } from "react-router-dom"; +import { AppLayout } from "@/layouts/AppLayout"; +import { AuthGuard } from "@/components/auth/AuthGuard"; +import { SetupGuard } from "@/components/auth/SetupGuard"; + +// Lazy imports for code splitting +import { lazy, Suspense } from "react"; +import { PageSkeleton } from "@/components/ui/PageSkeleton"; + +const wrap = (Component: React.LazyExoticComponent) => ( + }> +); + +// Pages +const SetupPage = lazy(() => import("@/pages/SetupPage")); +const LoginPage = lazy(() => import("@/pages/LoginPage")); +const OverviewPage = lazy(() => import("@/pages/OverviewPage")); +const ProjectsPage = lazy(() => import("@/pages/projects/ProjectsPage")); +const ProjectDetailPage = lazy(() => import("@/pages/projects/ProjectDetailPage")); +const ProjectUsersPage = lazy(() => import("@/pages/projects/users/ProjectUsersPage")); +const ProjectUserPage = lazy(() => import("@/pages/projects/users/ProjectUserPage")); +const ProjectAuthPage = lazy(() => import("@/pages/projects/ProjectAuthPage")); +const ProjectDatabasePage= lazy(() => import("@/pages/projects/ProjectDatabasePage")); +const ProjectRealtimePage= lazy(() => import("@/pages/projects/ProjectRealtimePage")); +const ProjectEnvPage = lazy(() => import("@/pages/projects/ProjectEnvPage")); +const ProjectWebhooksPage= lazy(() => import("@/pages/projects/ProjectWebhooksPage")); +const ProjectFunctionsPage= lazy(() => import("@/pages/projects/ProjectFunctionsPage")); +const StoragePage = lazy(() => import("@/pages/StoragePage")); +const StorageBucketPage = lazy(() => import("@/pages/StorageBucketPage")); +const LogsPage = lazy(() => import("@/pages/LogsPage")); +const AuditPage = lazy(() => import("@/pages/AuditPage")); +const TeamPage = lazy(() => import("@/pages/TeamPage")); +const SettingsPage = lazy(() => import("@/pages/SettingsPage")); +const SmtpPage = lazy(() => import("@/pages/SmtpPage")); +const NotificationsPage = lazy(() => import("@/pages/NotificationsPage")); +const ApiKeysPage = lazy(() => import("@/pages/ApiKeysPage")); +const NotFoundPage = lazy(() => import("@/pages/NotFoundPage")); + +export const routes: RouteObject[] = [ + // Setup — only accessible before any admin exists + { path: "/setup", element: {wrap(SetupPage)} }, + + // Auth + { path: "/login", element: wrap(LoginPage) }, + + // App — all routes behind auth guard + { + element: , + children: [ + { index: true, element: wrap(OverviewPage) }, + { path: "projects", element: wrap(ProjectsPage) }, + { path: "projects/:projectId", element: wrap(ProjectDetailPage) }, + { path: "projects/:projectId/users", element: wrap(ProjectUsersPage) }, + { path: "projects/:projectId/users/:userId", element: wrap(ProjectUserPage) }, + { path: "projects/:projectId/auth", element: wrap(ProjectAuthPage) }, + { path: "projects/:projectId/database", element: wrap(ProjectDatabasePage) }, + { path: "projects/:projectId/realtime", element: wrap(ProjectRealtimePage) }, + { path: "projects/:projectId/env", element: wrap(ProjectEnvPage) }, + { path: "projects/:projectId/webhooks", element: wrap(ProjectWebhooksPage) }, + { path: "projects/:projectId/functions", element: wrap(ProjectFunctionsPage) }, + { path: "storage", element: wrap(StoragePage) }, + { path: "storage/:bucketName", element: wrap(StorageBucketPage) }, + { path: "logs", element: wrap(LogsPage) }, + { path: "audit", element: wrap(AuditPage) }, + { path: "team", element: wrap(TeamPage) }, + { path: "settings", element: wrap(SettingsPage) }, + { path: "settings/smtp", element: wrap(SmtpPage) }, + { path: "settings/notifications", element: wrap(NotificationsPage) }, + { path: "settings/api-keys", element: wrap(ApiKeysPage) }, + ], + }, + { path: "*", element: wrap(NotFoundPage) }, +]; +``` + +**Acceptance criteria:** +- All routes registered — none return 404 in dev +- Auth guard redirects to /login when no token in localStorage +- Setup guard redirects to /login if admin already exists (API check) +- Code splitting works — each page is its own chunk + +--- + +## Phase 2 — Layout and Auth Components + +### Task FE-05 — App Layout (Sidebar + Header) + +**Depends on:** FE-04 + +**Create file:** `src/layouts/AppLayout.tsx` + +```tsx +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { + LayoutDashboard, FolderOpen, HardDrive, Webhook, Zap, + ScrollText, Shield, Settings, Users, LogOut, Command, + ChevronDown, ChevronRight, Bell, Sun, Moon, Database, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { clearToken, getStoredAdmin } from "@/lib/api"; +import { CommandPalette } from "@/components/CommandPalette"; +import { useTheme } from "@/hooks/useTheme"; +import { Avatar } from "@/components/ui/Avatar"; + +const nav = [ + { label: "Overview", href: "/", icon: LayoutDashboard }, + { label: "Projects", href: "/projects", icon: FolderOpen }, + { label: "Storage", href: "/storage", icon: HardDrive }, + { label: "Logs", href: "/logs", icon: ScrollText }, + { label: "Audit Log", href: "/audit", icon: Shield }, + { label: "Team", href: "/team", icon: Users }, + { + label: "Settings", href: "/settings", icon: Settings, + children: [ + { label: "General", href: "/settings" }, + { label: "SMTP", href: "/settings/smtp" }, + { label: "Notifications", href: "/settings/notifications" }, + { label: "API Keys", href: "/settings/api-keys" }, + ], + }, +]; + +export function AppLayout() { + const [cmdOpen, setCmdOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const { theme, toggle } = useTheme(); + const navigate = useNavigate(); + const admin = getStoredAdmin(); + + function handleLogout() { + clearToken(); + navigate("/login"); + } + + // Global ⌘K + if (typeof window !== "undefined") { + window.onkeydown = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setCmdOpen(true); } + }; + } + + return ( +
+ {/* Sidebar */} + + + {/* Main */} +
+ +
+ + setCmdOpen(false)} /> +
+ ); +} +``` + +**Acceptance criteria:** +- NavLinks show active state with brand color + background +- Settings section expands/collapses with animation +- ⌘K globally opens command palette +- Logout clears token and redirects to /login + +--- + +### Task FE-06 — Auth Guard + Setup Guard + +**Depends on:** FE-05 + +**Create file:** `src/components/auth/AuthGuard.tsx` + +```tsx +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { getToken } from "@/lib/api"; + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + + useEffect(() => { + if (!getToken()) { + navigate("/login", { replace: true }); + } + }, [navigate]); + + if (!getToken()) return null; + return <>{children}; +} +``` + +**Create file:** `src/components/auth/SetupGuard.tsx` + +```tsx +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export function SetupGuard({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + const [checking, setChecking] = useState(true); + + useEffect(() => { + // Try hitting /admin/auth/me without a token. + // If setup is complete, login page is appropriate. + // If setup is not done, /admin/auth/setup returns 201, not 410. + // We check by calling /admin/auth/setup with empty body and seeing if we get 410. + fetch(`${import.meta.env.VITE_API_URL ?? "http://localhost:3001"}/admin/auth/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ _check: true }), // Will fail validation but we only care about 410 + }).then((res) => { + if (res.status === 410) { + // Setup complete — redirect to login + navigate("/login", { replace: true }); + } + setChecking(false); + }).catch(() => setChecking(false)); + }, [navigate]); + + if (checking) return null; + return <>{children}; +} +``` + +**Acceptance criteria:** +- AuthGuard blocks unauthenticated access and redirects to /login +- SetupGuard redirects to /login if setup is already done (410 response) + +--- + +## Phase 3 — Reusable UI Components + +### Task FE-07 — Core UI Component Library + +**Depends on:** FE-06 + +Install shadcn/ui CLI and initialize: + +```bash +npx shadcn@latest init +``` + +Select: TypeScript, Tailwind, `src/components/ui` as path, no CSS variables (we use our own). + +Then add these components via CLI (run one command): + +```bash +npx shadcn@latest add button input label textarea badge card table dialog alert-dialog select tabs separator scroll-area tooltip dropdown-menu popover switch skeleton avatar progress sheet +``` + +After installation, **modify `src/components/ui/button.tsx`** to use design tokens instead of default colors — change the `default` variant background to `var(--color-brand)`. + +**Create file:** `src/components/ui/PageSkeleton.tsx` + +```tsx +export function PageSkeleton() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} +``` + +**Create file:** `src/components/ui/PageHeader.tsx` + +```tsx +interface PageHeaderProps { + title: string; + description?: string; + action?: React.ReactNode; +} + +export function PageHeader({ title, description, action }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +} +``` + +**Create file:** `src/components/ui/StatCard.tsx` + +```tsx +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; + +interface StatCardProps { + label: string; + value: string | number; + icon?: LucideIcon; + trend?: { value: number; period: string }; + color?: "default" | "success" | "warning" | "danger" | "brand"; +} + +const colorMap = { + default: { icon: "var(--color-text-muted)", bg: "var(--color-surface-overlay)" }, + brand: { icon: "var(--color-brand)", bg: "var(--color-brand-muted)" }, + success: { icon: "var(--color-success)", bg: "var(--color-success-muted)" }, + warning: { icon: "var(--color-warning)", bg: "var(--color-warning-muted)" }, + danger: { icon: "var(--color-danger)", bg: "var(--color-danger-muted)" }, +}; + +export function StatCard({ label, value, icon: Icon, trend, color = "default" }: StatCardProps) { + const colors = colorMap[color]; + return ( +
+
+ {label} + {Icon && ( +
+ +
+ )} +
+
{value}
+ {trend && ( +
= 0 ? "var(--color-success)" : "var(--color-danger)" }}> + {trend.value >= 0 ? "+" : ""}{trend.value}% vs {trend.period} +
+ )} +
+ ); +} +``` + +**Create file:** `src/components/ui/EmptyState.tsx` + +```tsx +import type { LucideIcon } from "lucide-react"; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description: string; + action?: React.ReactNode; +} + +export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( +
+
+ +
+
+

{title}

+

{description}

+
+ {action &&
{action}
} +
+ ); +} +``` + +**Create file:** `src/components/ui/ConfirmDialog.tsx` + +```tsx +import { useState } from "react"; +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, + AlertDialogDescription, AlertDialogFooter, AlertDialogCancel } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + confirmValue?: string; // If set, user must type this exact string + variant?: "danger" | "warning"; + onConfirm: () => void; + loading?: boolean; +} + +export function ConfirmDialog({ + open, onOpenChange, title, description, + confirmLabel = "Confirm", confirmValue, + variant = "danger", onConfirm, loading, +}: ConfirmDialogProps) { + const [typed, setTyped] = useState(""); + const canConfirm = confirmValue ? typed === confirmValue : true; + + return ( + + + + {title} + {description} + + {confirmValue && ( +
+

+ Type {confirmValue} to confirm: +

+ setTyped(e.target.value)} placeholder={confirmValue} /> +
+ )} + + Cancel + + +
+
+ ); +} +``` + +**Create file:** `src/components/ui/Avatar.tsx` + +```tsx +interface AvatarProps { email: string; size?: number; } + +export function Avatar({ email, size = 32 }: AvatarProps) { + const initials = email.slice(0, 2).toUpperCase(); + const hue = Array.from(email).reduce((a, c) => a + c.charCodeAt(0), 0) % 360; + return ( +
+ {initials} +
+ ); +} +``` + +**Create file:** `src/hooks/useTheme.ts` + +```typescript +import { useState, useEffect } from "react"; + +type Theme = "dark" | "light"; + +export function useTheme() { + const [theme, setTheme] = useState(() => { + return (localStorage.getItem("bb_theme") as Theme) ?? "dark"; + }); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("bb_theme", theme); + }, [theme]); + + return { theme, toggle: () => setTheme((t) => t === "dark" ? "light" : "dark") }; +} +``` + +**Acceptance criteria:** +- All shadcn components installed and importable +- Design tokens used throughout — no hardcoded hex values in components +- ConfirmDialog requires typed confirmation for destructive actions + +--- + +### Task FE-08 — Command Palette + +**Depends on:** FE-07 + +**Create file:** `src/components/CommandPalette.tsx` + +```tsx +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Command } from "cmdk"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { QK } from "@/lib/query-keys"; +import { LayoutDashboard, FolderOpen, Users, Settings, ScrollText, Shield, HardDrive } from "lucide-react"; + +interface CommandPaletteProps { open: boolean; onClose: () => void; } + +const staticCommands = [ + { label: "Overview", href: "/", icon: LayoutDashboard }, + { label: "Projects", href: "/projects", icon: FolderOpen }, + { label: "Storage", href: "/storage", icon: HardDrive }, + { label: "Logs", href: "/logs", icon: ScrollText }, + { label: "Audit Log", href: "/audit", icon: Shield }, + { label: "Team", href: "/team", icon: Users }, + { label: "Settings", href: "/settings", icon: Settings }, + { label: "SMTP Settings", href: "/settings/smtp", icon: Settings }, + { label: "API Keys", href: "/settings/api-keys",icon: Settings }, +]; + +export function CommandPalette({ open, onClose }: CommandPaletteProps) { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + + const { data: projectsData } = useQuery({ + queryKey: QK.projects(), + queryFn: () => api.get<{ projects: { id: string; name: string }[] }>("/admin/projects"), + enabled: open, + }); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); + + if (!open) return null; + + function go(href: string) { navigate(href); onClose(); } + + return ( +
+
e.stopPropagation()}> + +
+ +
+ + + No results found. + + + + {staticCommands.map((cmd) => ( + go(cmd.href)} + className="flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer text-sm" + style={{ color: "var(--color-text-secondary)" }}> + + {cmd.label} + + ))} + + + {(projectsData?.projects?.length ?? 0) > 0 && ( + + {projectsData!.projects.map((p) => ( + go(`/projects/${p.id}`)} + className="flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer text-sm" + style={{ color: "var(--color-text-secondary)" }}> + + {p.name} + + ))} + + )} + +
+
+
+ ); +} +``` + +**Acceptance criteria:** +- Opens on ⌘K from anywhere in the app +- Shows static nav items + dynamic project list +- Keyboard navigable (cmdk handles this) +- Closes on Escape or backdrop click + +--- + +## Phase 4 — Auth Pages + +### Task FE-09 — Setup Page + Login Page + +**Depends on:** FE-08 + +**Create file:** `src/pages/SetupPage.tsx` + +```tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate } from "react-router-dom"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api, setToken, setStoredAdmin } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z.object({ + email: z.string().email(), + password: z.string().min(8, "Minimum 8 characters"), +}); +type FormData = z.infer; + +export default function SetupPage() { + const navigate = useNavigate(); + const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema) }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => api.postPublic<{ token: string; admin: { id: string; email: string } }>( + "/admin/auth/setup", data + ), + onSuccess: ({ token, admin }) => { + setToken(token); + setStoredAdmin(admin); + toast.success("Admin account created. Welcome to Betterbase."); + navigate("/", { replace: true }); + }, + onError: (err: any) => toast.error(err.message), + }); + + return ( +
+
+
+
B
+

Setup Betterbase

+

Create the first admin account to get started.

+
+
mutation.mutate(d))} className="space-y-4"> +
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + + {errors.password &&

{errors.password.message}

} +
+ + +
+
+ ); +} +``` + +**Create file:** `src/pages/LoginPage.tsx` + +```tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate } from "react-router-dom"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api, setToken, setStoredAdmin } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); +type FormData = z.infer; + +export default function LoginPage() { + const navigate = useNavigate(); + const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema) }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => api.postPublic<{ token: string; admin: { id: string; email: string } }>( + "/admin/auth/login", data + ), + onSuccess: ({ token, admin }) => { + setToken(token); + setStoredAdmin(admin); + navigate("/", { replace: true }); + }, + onError: () => toast.error("Invalid credentials"), + }); + + return ( +
+
+
+
B
+

Sign in

+

Access your Betterbase instance.

+
+
mutation.mutate(d))} className="space-y-4"> +
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + +
+ + +
+
+ ); +} +``` + +**Acceptance criteria:** +- SetupPage only accessible before admin exists (SetupGuard handles this) +- LoginPage stores token + admin on success +- Both pages match the minimal, high-contrast design aesthetic + +--- + +## Phase 5 — Dashboard Pages (All Modules) + +### Task FE-10 — Overview Page + +**Depends on:** FE-09 + +**Create file:** `src/pages/OverviewPage.tsx` + +This page shows: metric stat cards, request volume chart (24h), status code breakdown chart, top endpoints table, recent activity feed. + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { QK } from "@/lib/query-keys"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { StatCard } from "@/components/ui/StatCard"; +import { PageSkeleton } from "@/components/ui/PageSkeleton"; +import { FolderOpen, Users, Zap, Webhook, AlertTriangle, Clock } from "lucide-react"; +import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar } from "recharts"; +import { formatRelative } from "@/lib/utils"; +import type { AuditLog } from "@/types"; + +export default function OverviewPage() { + const { data: metrics, isLoading } = useQuery({ + queryKey: QK.metricsOverview(), + queryFn: () => api.get<{ metrics: any }>("/admin/metrics/overview"), + refetchInterval: 30_000, + }); + + const { data: ts } = useQuery({ + queryKey: QK.metricsTimeseries("requests", "24h"), + queryFn: () => api.get<{ series: any[] }>("/admin/metrics/timeseries?metric=requests&period=24h"), + refetchInterval: 60_000, + }); + + const { data: latency } = useQuery({ + queryKey: QK.metricsLatency("24h"), + queryFn: () => api.get<{ latency: any }>("/admin/metrics/latency?period=24h"), + refetchInterval: 60_000, + }); + + const { data: topEndpoints } = useQuery({ + queryKey: QK.metricsTopEndpoints("24h"), + queryFn: () => api.get<{ endpoints: any[] }>("/admin/metrics/top-endpoints?period=24h"), + refetchInterval: 60_000, + }); + + const { data: auditData } = useQuery({ + queryKey: QK.audit({ limit: "8" }), + queryFn: () => api.get<{ logs: AuditLog[] }>("/admin/audit?limit=8"), + refetchInterval: 30_000, + }); + + if (isLoading) return ; + const m = metrics?.metrics; + + return ( +
+ + +
+ {/* Stat cards */} +
+ + + + 0 ? "danger" : "default"} /> +
+ + {/* Latency pills */} + {latency?.latency && ( +
+ {[ + { label: "P50", value: `${latency.latency.p50}ms` }, + { label: "P95", value: `${latency.latency.p95}ms` }, + { label: "P99", value: `${latency.latency.p99}ms` }, + { label: "Avg", value: `${latency.latency.avg}ms` }, + ].map(({ label, value }) => ( +
+ + {label} + {value} +
+ ))} +
+ )} + + {/* Request volume chart */} +
+

Request Volume — 24h

+ + + + + + + + + new Date(v).getHours() + "h"} /> + + + + + + +
+ + {/* Bottom grid: top endpoints + recent audit */} +
+ {/* Top endpoints */} +
+

Top Endpoints

+
+ {(topEndpoints?.endpoints ?? []).slice(0, 8).map((ep, i) => ( +
+ {ep.path} + {ep.avg_ms}ms + {ep.requests.toLocaleString()} +
+ ))} +
+
+ + {/* Recent audit activity */} +
+

Recent Activity

+
+ {(auditData?.logs ?? []).map((log) => ( +
+
+
+ {log.actor_email ?? "system"} + {" "}{log.action} + {log.resource_name && <>{" "}{log.resource_name}} +
+ {formatRelative(log.created_at)} +
+ ))} +
+
+
+
+
+ ); +} +``` + +**Acceptance criteria:** +- All 4 stat cards, charts, latency pills, top endpoints, recent audit visible +- Charts use design token colors only +- Auto-refreshes every 30–60s + +--- + +### Task FE-11 — Projects Page + Project Detail Page + +**Depends on:** FE-10 + +**Create file:** `src/pages/projects/ProjectsPage.tsx` + +Features: project cards list, create project dialog (captures name + slug), empty state. The create dialog must show the admin key in a one-time reveal modal. + +Key implementation details: +- Query: `GET /admin/projects` +- Mutation: `POST /admin/projects` — response includes `admin_key` (plaintext, one-time) +- After mutation success: open a "Save your admin key" modal with the key in a code block + copy button + close button labeled "I've saved this key" +- Each project card shows: name, slug, created date, user count (from metrics overview `user_counts_by_project`) +- Links to `/projects/:id` + +**Create file:** `src/pages/projects/ProjectDetailPage.tsx` + +Project detail is a page with internal tab navigation (tabs rendered below the header, not in the sidebar): +- **Overview tab** — name, slug, status badge (active/suspended), created date, admin key (rotate button only — key not shown), danger zone (suspend, delete) +- **Users tab** — link to `/projects/:id/users` +- **Auth tab** — link to `/projects/:id/auth` +- **Database tab** — link to `/projects/:id/database` +- **Environment tab** — link to `/projects/:id/env` +- **Webhooks tab** — link to `/projects/:id/webhooks` +- **Functions tab** — link to `/projects/:id/functions` +- **Realtime tab** — link to `/projects/:id/realtime` + +Use `` from shadcn. The tabs change the URL (`/projects/:id` default = overview, or use URL params). + +Query: `GET /admin/projects/:id` + +Danger zone actions: +- Suspend/Unsuspend: `PATCH /admin/projects/:id` with `{ status: "suspended" | "active" }` (add this to backend if not yet present — note in your implementation) +- Delete: `DELETE /admin/projects/:id` — ConfirmDialog requiring project slug to be typed + +**Acceptance criteria:** +- Admin key one-time reveal modal appears only after successful project creation +- Rotate key button calls `POST /admin/projects/:id/rotate-key` (add to backend) +- Delete requires typing the slug + +--- + +### Task FE-12 — Per-Project Users Pages + +**Depends on:** FE-11 + +**Create file:** `src/pages/projects/users/ProjectUsersPage.tsx` + +This is the most complex page in the dashboard. Features: + +**Header section:** +- Total users count, banned count, user stat cards +- Line chart: daily signups last 30 days (from `GET /admin/projects/:id/users/stats/overview`) +- Provider breakdown: small bar chart + +**Filters bar:** +- Search input (debounced 300ms) +- Filter dropdown: All / Banned / Not banned +- Filter dropdown: Provider (populated from stats) +- Date range picker: From / To +- Export CSV button + +**Users table (TanStack Table):** +- Columns: Avatar+Name+Email, Providers (badge per provider), Created, Last sign-in, Status (Active/Banned badge), Actions +- Pagination controls +- Row click → navigate to user detail +- Bulk select → bulk ban/delete actions + +**Implementation pattern:** +```typescript +// URL-synced filter state +const [searchParams, setSearchParams] = useSearchParams(); +const search = searchParams.get("search") ?? ""; +const banned = searchParams.get("banned") ?? ""; +const offset = parseInt(searchParams.get("offset") ?? "0"); +const limit = 50; + +const { data } = useQuery({ + queryKey: QK.projectUsers(projectId, { search, banned, offset: String(offset) }), + queryFn: () => api.get(`/admin/projects/${projectId}/users?limit=${limit}&offset=${offset}&search=${search}&banned=${banned}`), +}); +``` + +**Create file:** `src/pages/projects/users/ProjectUserPage.tsx` + +User detail page shows: +- Header: avatar, name, email, status badge, created date +- Action buttons: Ban/Unban, Force logout, Delete +- Sessions table: device, IP, created, expires +- Auth providers list +- Audit trail for this user (filtered from audit log) + +**Acceptance criteria:** +- Search is debounced (no query per keystroke) +- Pagination synced to URL params +- Export triggers file download using `api.download()` +- Ban confirmation dialog captures reason + optional expiry + +--- + +### Task FE-13 — Auth Config Page + +**Depends on:** FE-12 + +**Create file:** `src/pages/projects/ProjectAuthPage.tsx` + +This page is organized into accordion sections: + +**Email/Password section:** +- Enable/disable toggle → `PUT /admin/projects/:id/auth-config/email_password_enabled` +- Min password length input +- Require email verification toggle + +**Magic Link section:** +- Enable/disable toggle + +**OTP section:** +- Enable/disable toggle + +**OAuth Providers section — one card per provider (Google, GitHub, Discord, Apple, Microsoft, Twitter, Facebook):** +- Each card: provider name + logo icon, enable/disable toggle, Client ID input (revealed), Client Secret input (masked, reveal on click) +- Saving: `PUT /admin/projects/:id/auth-config/provider_{name}` with `{ enabled, clientId, clientSecret }` + +**Phone/SMS section:** +- Enable/disable toggle +- Twilio Account SID, Auth Token, Phone Number inputs + +**Session Config section:** +- Session expiry (select: 1h, 6h, 24h, 7d, 30d, custom) +- Refresh token expiry +- Max sessions per user + +**Security section:** +- Allowed email domains (tag input — comma-separated) +- Blocked email domains (tag input) + +All saves use optimistic updates (toggle switches update UI immediately, revert on error). + +**Acceptance criteria:** +- Secrets always masked unless user clicks reveal +- Toggling enable/disable is instant (optimistic) +- Form fields save on blur with success toast + +--- + +### Task FE-14 — Database Page + +**Depends on:** FE-13 + +**Create file:** `src/pages/projects/ProjectDatabasePage.tsx` + +Sections: +- **Status bar** — schema size, active connections (from `GET /admin/projects/:id/database/status`) +- **Tables list** — name, estimated row count, size; click to expand columns +- **Column detail** (expandable per table) — column name, data type, nullable, default +- **Migration history** — table of applied migrations with filename and timestamp + +No query editor in v1. Tables and columns are read-only display. + +**Acceptance criteria:** +- Tables list collapsible per row to show columns +- Status bar auto-refreshes every 30s + +--- + +### Task FE-15 — Storage Page + Bucket Browser + +**Depends on:** FE-14 + +**Create file:** `src/pages/StoragePage.tsx` + +Bucket list page: +- Stat: total buckets, total objects (sum), total size +- Bucket cards: name, object count, size, public/private badge +- Create bucket dialog: name, public toggle +- Delete bucket: ConfirmDialog with bucket name typed + +**Create file:** `src/pages/StorageBucketPage.tsx` + +Object browser: +- Breadcrumb: `Storage > {bucket}` +- Object list table: name, size, content type, last modified, actions +- Upload button → file input dialog +- Delete single object, multi-select delete +- Copy public URL button (copies to clipboard with toast) +- Preview: images open in a lightbox dialog; other types show metadata + +**Acceptance criteria:** +- Upload via `` → `POST /storage/{bucket}/{filename}` using FormData (this hits MinIO directly via nginx, not the admin API — note the different base URL pattern) +- Delete calls `DELETE /admin/storage/buckets/:name/objects` — note: implement this endpoint if missing (it's not in the DB spec — add a stub `DELETE /admin/storage/objects` that calls S3 DeleteObject) + +--- + +### Task FE-16 — Webhooks Page + +**Depends on:** FE-15 + +**Create file:** `src/pages/projects/ProjectWebhooksPage.tsx` + +Features: +- Webhook list with health summary (success rate badge, last delivery timestamp) +- Create webhook dialog: name, table name, events (multi-select: INSERT/UPDATE/DELETE), URL, secret +- Enable/disable toggle per webhook (inline, optimistic) +- Delete webhook + +**Webhook detail panel (slide-out sheet):** +- Click a webhook row to open side sheet +- Shows: config details, delivery success rate chart (7d), delivery log table +- Delivery log table: timestamp, event, status badge, response code, duration +- Click delivery row → modal showing payload + response body +- Retry button per delivery +- Test button (sends synthetic payload) + +**Acceptance criteria:** +- Sheet slides in from right (Motion animation) +- Delivery log paginates within the sheet +- Retry shows loading state, updates delivery list on completion + +--- + +### Task FE-17 — Functions Page + +**Depends on:** FE-16 + +**Create file:** `src/pages/projects/ProjectFunctionsPage.tsx` + +Features: +- Function list: name, runtime badge, status badge (active/inactive/error), deploy target, last deployed, invocation count +- Register function dialog: name (slug format), runtime (bun), deploy target (cloudflare/vercel/none) +- Delete function + +**Function detail panel (side sheet):** +- Stats: invocations (24h), error rate, avg duration +- Invocation count chart (24h area chart) +- Error rate bar chart +- Invocation log table: trigger type, status badge, duration, timestamp +- Click row → detail modal showing error message (if error) + +**Acceptance criteria:** +- Status badge uses semantic colors: active=success, error=danger, inactive=muted +- Stats auto-refresh on sheet open + +--- + +### Task FE-18 — Logs Page + +**Depends on:** FE-17 + +**Create file:** `src/pages/LogsPage.tsx` + +Features: + +**Filter bar:** +- Method filter (All, GET, POST, PATCH, DELETE) +- Status filter (All, 2xx, 3xx, 4xx, 5xx) +- Path prefix input +- Time range (1h, 6h, 24h, 7d, custom) +- Export CSV button + +**Summary row:** +- Total requests, error count, avg duration + +**Log table (TanStack Table):** +- Columns: method badge, path, status badge (color-coded), duration, timestamp +- Virtualized rows if >500 items +- Click row → detail modal showing full log entry + +**Pagination controls:** +- Prev/Next + page info + +All filters sync to URL params. + +**Acceptance criteria:** +- Method badges colored (GET=blue, POST=green, DELETE=red, PATCH=orange) +- Status badges: 2xx=green, 3xx=blue, 4xx=orange, 5xx=red +- Export triggers CSV download + +--- + +### Task FE-19 — Audit Log Page + +**Depends on:** FE-18 + +**Create file:** `src/pages/AuditPage.tsx` + +Features: +- Filter bar: actor email search, action type select (populated from `GET /admin/audit/actions`), resource type, date range +- Audit log table: actor, action, resource type+name, IP, timestamp +- Click row → detail modal showing before/after JSON diff (use a simple two-column JSON viewer) +- Export CSV (download the filtered result set) + +**JSON diff viewer component (`src/components/ui/JsonDiff.tsx`):** +- Left column: before_data (red background for removed values) +- Right column: after_data (green background for new/changed values) +- Formatted with indentation, uses monospace font + +**Acceptance criteria:** +- Before/after diff clearly shows what changed +- Empty before_data = create event; empty after_data = delete event +- Action filter dropdown populated from API + +--- + +### Task FE-20 — Team Page + +**Depends on:** FE-19 + +**Create file:** `src/pages/TeamPage.tsx` + +Features: + +**Admin users section:** +- Table: avatar, email, created, last login, MFA status (show badge if MFA data available), active sessions count, role assignments +- Invite button → dialog with email + password fields → `POST /admin/users` +- Delete admin: ConfirmDialog (blocked if last admin — show disabled state) + +**Role assignments section:** +- Table: admin email, role name, scope (global or project name), created +- Assign role dialog: select admin, select role, optionally scope to project +- Revoke button per assignment + +**Roles section:** +- Accordion: one row per role, expand to see permission grid +- Permission grid: domains as rows, actions as columns, checkmarks showing what the role has + +**Acceptance criteria:** +- Cannot delete last admin — button disabled + tooltip explaining why +- Role assignment shows project scope when applicable +- Permission grid is read-only for system roles + +--- + +### Task FE-21 — Settings Pages + +**Depends on:** FE-20 + +**Create file:** `src/pages/SettingsPage.tsx` — General instance settings + +Sections: +- **Instance info**: name, public URL, contact email — editable form, saves on submit → `PATCH /admin/instance` +- **Health status**: database connection status + latency, server uptime — from `GET /admin/instance/health`, auto-refresh 30s +- **Security**: log retention days, max sessions per user, IP allowlist (tag input), CORS origins (tag input) +- **Danger zone**: Factory reset button (behind ConfirmDialog requiring instance name to be typed) — note: implement `POST /admin/instance/reset` as a stub that returns 501 Not Implemented in v1 + +**Create file:** `src/pages/SmtpPage.tsx` — SMTP configuration + +Sections: +- SMTP config form: host, port, username, password (masked), from email, from name, TLS toggle, enabled toggle +- Save → `PUT /admin/smtp` +- Test email section: email input + "Send test" button → `POST /admin/smtp/test` +- Status indicator: enabled/disabled badge + +**Create file:** `src/pages/NotificationsPage.tsx` — Notification rules + +Features: +- Rules table: name, metric, threshold, channel, target, enabled toggle, delete +- Create rule dialog: name, metric (select), threshold (number), channel (email/webhook), target +- Enable/disable toggle per rule + +**Create file:** `src/pages/ApiKeysPage.tsx` — API Keys + CLI sessions + +Sections: +- **API Keys**: list with name, prefix, scopes, last used, expires; create button → dialog (name, optional expiry, optional scopes); created key shown in one-time reveal modal; revoke button +- **CLI Sessions**: pending authorizations list + active API keys (duplicates ApiKeys section context) + +**Acceptance criteria:** +- SMTP test shows success/error inline below the button +- API key one-time reveal identical UX to project admin key reveal +- All settings forms show last-saved timestamp + +--- + +### Task FE-22 — Remaining Feature Pages + +**Depends on:** FE-21 + +**Create file:** `src/pages/projects/ProjectRealtimePage.tsx` + +Simple stats page: +- Connected clients count, active channels count +- Channel list table (if any): name, subscriber count +- Auto-refresh every 10s +- Empty state if no channels active + +**Create file:** `src/pages/projects/ProjectEnvPage.tsx` + +Environment variables page: +- Table: key, value (masked if secret, reveal button), secret badge, updated +- Add variable dialog: key (uppercase validation), value, secret toggle +- Edit: inline click-to-edit for value +- Delete per variable + +**Create file:** `src/pages/NotFoundPage.tsx` + +Simple 404 page with back-to-home button. + +**Acceptance criteria:** +- Env var keys displayed in monospace +- Secret values revealed only on explicit click (eye icon) +- Realtime page shows "no active channels" empty state correctly + +--- + +## Phase 6 — Types and Final Wiring + +### Task FE-23 — TypeScript Types + +**Depends on:** FE-22 + +**Create file:** `src/types/index.ts` + +```typescript +export interface Project { + id: string; name: string; slug: string; + created_at: string; updated_at: string; +} + +export interface AdminUser { + id: string; email: string; created_at: string; +} + +export interface EndUser { + id: string; name: string; email: string; email_verified: boolean; + image?: string; created_at: string; updated_at: string; + banned: boolean; ban_reason?: string; ban_expires?: string; + providers?: string[]; + last_sign_in?: string; +} + +export interface Session { + id: string; expires_at: string; ip_address?: string; + user_agent?: string; created_at: string; +} + +export interface AuditLog { + id: number; actor_id?: string; actor_email?: string; + action: string; resource_type?: string; resource_id?: string; resource_name?: string; + before_data?: unknown; after_data?: unknown; + ip_address?: string; created_at: string; +} + +export interface RequestLog { + id: number; method: string; path: string; status: number; + duration_ms?: number; created_at: string; +} + +export interface Webhook { + id: string; name: string; table_name: string; events: string[]; + url: string; secret?: string; enabled: boolean; created_at: string; + total_deliveries?: number; successful_deliveries?: number; last_delivery_at?: string; +} + +export interface WebhookDelivery { + id: number; webhook_id: string; event_type: string; payload: unknown; + status: string; response_code?: number; response_body?: string; + duration_ms?: number; attempt_count: number; created_at: string; delivered_at?: string; +} + +export interface Function_ { + id: string; name: string; runtime: string; status: string; + deploy_target?: string; created_at: string; +} + +export interface StorageBucket { + Name: string; CreationDate?: string; +} + +export interface StorageObject { + Key: string; Size?: number; LastModified?: string; ContentType?: string; +} + +export interface Role { + id: string; name: string; description: string; is_system: boolean; + permissions: { id: string; domain: string; action: string }[]; +} + +export interface ApiKey { + id: string; name: string; key_prefix: string; scopes: string[]; + last_used_at?: string; expires_at?: string; created_at: string; +} + +export interface Metrics { + projects: number; admin_users: number; total_end_users: number; + active_webhooks: number; active_functions: number; recent_errors_1h: number; + uptime_seconds: number; timestamp: string; +} + +export interface SmtpConfig { + host: string; port: number; username: string; password: string; + from_email: string; from_name: string; use_tls: boolean; enabled: boolean; +} +``` + +**Acceptance criteria:** +- All API response shapes covered +- Types imported throughout pages — no `any` except where explicitly unavoidable + +--- + +### Task FE-24 — Error Boundaries + +**Depends on:** FE-23 + +**Create file:** `src/components/ErrorBoundary.tsx` + +```tsx +import { Component, type ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface Props { children: ReactNode; fallback?: ReactNode; } +interface State { error: Error | null; } + +export class ErrorBoundary extends Component { + constructor(props: Props) { super(props); this.state = { error: null }; } + static getDerivedStateFromError(error: Error) { return { error }; } + + render() { + if (this.state.error) { + return this.props.fallback ?? ( +
+ +
+

Something went wrong

+

{this.state.error.message}

+
+ +
+ ); + } + return this.props.children; + } +} +``` + +**Wrap every page's export with ErrorBoundary in `AppLayout.tsx`:** + +```tsx +// In AppLayout, wrap with: + + + +``` + +**Acceptance criteria:** +- Any page-level error shows friendly UI instead of white screen +- "Try again" button resets the boundary + +--- + +### Task FE-25 — Production Build Config + +**Depends on:** FE-24 + +**Create file:** `.env.example` + +```bash +VITE_API_URL=http://localhost:3001 +``` + +**Create file:** `public/_redirects` (for Netlify / Vercel SPA routing) + +``` +/* /index.html 200 +``` + +**Update `package.json` scripts:** + +```json +{ + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + } +} +``` + +**Create file:** `apps/dashboard/Dockerfile` (in monorepo, referenced by docker-compose.self-hosted.yml) + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG VITE_API_URL=http://localhost:3001 +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +RUN printf 'server { \n listen 80; \n root /usr/share/nginx/html; \n index index.html; \n location / { try_files $uri $uri/ /index.html; } \n}' > /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +**Acceptance criteria:** +- `npm run build` completes with zero TypeScript errors +- Built output is a single `dist/` folder with no server requirement +- Docker build produces a working nginx + static files image + +--- + +## Complete File Structure + +After all tasks are complete, the dashboard repository contains: + +``` +betterbase-dashboard/ +├── public/ +│ └── _redirects +├── src/ +│ ├── main.tsx +│ ├── routes.tsx +│ ├── index.css +│ ├── types/ +│ │ └── index.ts +│ ├── lib/ +│ │ ├── api.ts +│ │ ├── query-keys.ts +│ │ └── utils.ts +│ ├── hooks/ +│ │ └── useTheme.ts +│ ├── layouts/ +│ │ └── AppLayout.tsx +│ ├── components/ +│ │ ├── CommandPalette.tsx +│ │ ├── ErrorBoundary.tsx +│ │ ├── auth/ +│ │ │ ├── AuthGuard.tsx +│ │ │ └── SetupGuard.tsx +│ │ └── ui/ +│ │ ├── [shadcn components] +│ │ ├── Avatar.tsx +│ │ ├── ConfirmDialog.tsx +│ │ ├── EmptyState.tsx +│ │ ├── JsonDiff.tsx +│ │ ├── PageHeader.tsx +│ │ ├── PageSkeleton.tsx +│ │ └── StatCard.tsx +│ └── pages/ +│ ├── SetupPage.tsx +│ ├── LoginPage.tsx +│ ├── OverviewPage.tsx +│ ├── StoragePage.tsx +│ ├── StorageBucketPage.tsx +│ ├── LogsPage.tsx +│ ├── AuditPage.tsx +│ ├── TeamPage.tsx +│ ├── NotFoundPage.tsx +│ ├── settings/ +│ │ ├── SettingsPage.tsx +│ │ ├── SmtpPage.tsx +│ │ ├── NotificationsPage.tsx +│ │ └── ApiKeysPage.tsx +│ └── projects/ +│ ├── ProjectsPage.tsx +│ ├── ProjectDetailPage.tsx +│ ├── ProjectAuthPage.tsx +│ ├── ProjectDatabasePage.tsx +│ ├── ProjectEnvPage.tsx +│ ├── ProjectRealtimePage.tsx +│ ├── ProjectWebhooksPage.tsx +│ ├── ProjectFunctionsPage.tsx +│ └── users/ +│ ├── ProjectUsersPage.tsx +│ └── ProjectUserPage.tsx +├── .env.example +├── package.json +├── tsconfig.json +├── vite.config.ts +└── Dockerfile +``` + +--- + +## Execution Order + +``` +Phase 1 — Bootstrap + FE-01 Init project + install deps + FE-02 Tailwind v4 + design tokens + FE-03 API client + query keys + utils + FE-04 App shell + router + query provider + +Phase 2 — Layout + FE-05 AppLayout (sidebar + header) + FE-06 AuthGuard + SetupGuard + +Phase 3 — UI Components + FE-07 Core UI components (shadcn install + custom) + FE-08 Command palette + +Phase 4 — Auth + FE-09 SetupPage + LoginPage + +Phase 5 — Dashboard Pages + FE-10 OverviewPage + FE-11 ProjectsPage + ProjectDetailPage + FE-12 ProjectUsersPage + ProjectUserPage + FE-13 ProjectAuthPage + FE-14 ProjectDatabasePage + FE-15 StoragePage + StorageBucketPage + FE-16 ProjectWebhooksPage + FE-17 ProjectFunctionsPage + FE-18 LogsPage + FE-19 AuditPage + FE-20 TeamPage + FE-21 SettingsPage + SmtpPage + NotificationsPage + ApiKeysPage + FE-22 ProjectRealtimePage + ProjectEnvPage + NotFoundPage + +Phase 6 — Polish + FE-23 TypeScript types + FE-24 Error boundaries + FE-25 Production build + Dockerfile +``` + +**Total: 25 tasks across 6 phases.** + +--- + +## Verification Checklist + +Before marking complete, verify: + +- [ ] `/setup` redirects to `/login` if admin already exists +- [ ] `/login` stores token and redirects to `/` on success +- [ ] Sidebar active states work for all routes +- [ ] ⌘K opens command palette from any page +- [ ] Projects list shows user counts +- [ ] Creating a project shows admin key one-time reveal +- [ ] User search is debounced +- [ ] Ban/unban shows confirmation dialog +- [ ] Export CSV triggers file download +- [ ] Webhook delivery log opens in side sheet +- [ ] Chart colors use CSS variables (not hardcoded hex) +- [ ] Dark mode toggle persists across page reload +- [ ] `npm run build` exits with code 0 + +*End of frontend specification. Do not begin implementation until backend spec is verified passing.* diff --git a/BetterBase_Observability_Spec.docx.md b/BetterBase_Observability_Spec.docx.md new file mode 100644 index 0000000..645fe54 --- /dev/null +++ b/BetterBase_Observability_Spec.docx.md @@ -0,0 +1,1190 @@ +**BetterBase** + +**Observability Suite** + +Orchestrator Specification — SH-29 through SH-36 \+ D-14 through D-19 + +| 8 Backend Tasks | 6 Frontend Tasks | 4 Migration Files | 1 Real-Time Stream | +| :---: | :---: | :---: | :---: | + +*Execute tasks in strict order within each phase. Frontend tasks depend on all backend tasks being complete. Do not begin D-14 until SH-36 is marked complete.* + +# **Overview** + +This specification adds a full observability layer to BetterBase — both the self-hosted server (packages/server) and the dashboard frontend (apps/dashboard). It extends the foundation laid in SH-01 through SH-28. + +| Capability | Backend Task(s) | Frontend Task(s) | +| :---- | :---- | :---- | +| Global metrics (overview, timeseries, latency, top endpoints) | SH-29, SH-32 | D-14 | +| Per-project observability | SH-33 | D-15 | +| Webhook delivery tracing | SH-30, SH-34 | D-16 | +| Function invocation tracing | SH-31, SH-35 | D-17 | +| Real-time log stream (polling) | SH-36 | D-18, D-19 | + +# **Phase A — Backend: Data Layer & API Endpoints** + +All tasks in this phase modify packages/server. Execute SH-29 → SH-36 in order. + +| SH-29 | Enhance request\_logs \+ Add Migration 005 | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-28 (all prior SH tasks complete) + +The existing request\_logs table (created in SH-16) only captures method, path, status, and duration. Observability requires project\_id association and endpoint categorisation. Add columns via a new migration and update the logging middleware. + +### **Create file: packages/server/migrations/005\_enhance\_request\_logs.sql** + +ALTER TABLE betterbase\_meta.request\_logs + + ADD COLUMN IF NOT EXISTS project\_id TEXT, + + ADD COLUMN IF NOT EXISTS user\_agent TEXT, + + ADD COLUMN IF NOT EXISTS ip TEXT, + + ADD COLUMN IF NOT EXISTS error\_message TEXT; + +\-- Index for per-project queries + +CREATE INDEX IF NOT EXISTS idx\_req\_logs\_project + + ON betterbase\_meta.request\_logs (project\_id, created\_at DESC); + +\-- Index for latency percentile queries + +CREATE INDEX IF NOT EXISTS idx\_req\_logs\_duration + + ON betterbase\_meta.request\_logs (duration\_ms, created\_at DESC); + +### **Update file: packages/server/src/index.ts — logging middleware** + +Replace the existing fire-and-forget log insert middleware with: + +app.use('\*', async (c, next) \=\> { + + const start \= Date.now(); + + await next(); + + const duration \= Date.now() \- start; + + const projectId \= c.req.header('X-Project-ID') ?? null; + + const userAgent \= c.req.header('User-Agent')?.slice(0, 255\) ?? null; + + const ip \= c.req.header('X-Forwarded-For')?.split(',')\[0\] ?? null; + + getPool().query( + + \`INSERT INTO betterbase\_meta.request\_logs + + (method, path, status, duration\_ms, project\_id, user\_agent, ip) + + VALUES ($1,$2,$3,$4,$5,$6,$7)\`, + + \[c.req.method, new URL(c.req.url).pathname, + + c.res.status, duration, projectId, userAgent, ip\] + + ).catch(() \=\> {}); + +}); + +**ACCEPTANCE CRITERIA** + +* Migration file is named 005\_ (runs after 004\_logs.sql) + +* Three new indexes: project\_id, duration\_ms, and existing created\_at from SH-16 + +* Middleware extracts X-Project-ID header — null if absent (never throws) + +* User-agent truncated to 255 chars to prevent oversized inserts + +| SH-30 | Webhook Delivery Logs Table \+ Migration 006 | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-29 + +Create a table to persist every webhook delivery attempt — status, response code, duration, and error message. This powers the webhook debugging UI in D-16. + +### **Create file: packages/server/migrations/006\_webhook\_delivery\_logs.sql** + +CREATE TABLE IF NOT EXISTS betterbase\_meta.webhook\_delivery\_logs ( + + id TEXT PRIMARY KEY, + + webhook\_id TEXT NOT NULL REFERENCES betterbase\_meta.webhooks(id) + + ON DELETE CASCADE, + + event\_type TEXT NOT NULL, + + table\_name TEXT NOT NULL, + + payload JSONB, + + status TEXT NOT NULL DEFAULT 'pending', + + \-- 'success' | 'failed' | 'pending' + + http\_status INT, + + duration\_ms INT, + + error\_msg TEXT, + + attempt INT NOT NULL DEFAULT 1, + + created\_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + +); + +CREATE INDEX IF NOT EXISTS idx\_wdl\_webhook\_id + + ON betterbase\_meta.webhook\_delivery\_logs (webhook\_id, created\_at DESC); + +CREATE INDEX IF NOT EXISTS idx\_wdl\_status + + ON betterbase\_meta.webhook\_delivery\_logs (status, created\_at DESC); + +### **Create file: packages/server/src/lib/webhook-logger.ts** + +import { getPool } from './db'; + +import { nanoid } from 'nanoid'; + +export interface WebhookDeliveryRecord { + + webhookId: string; + + eventType: string; + + tableName: string; + + payload?: unknown; + + status: 'success' | 'failed' | 'pending'; + + httpStatus?: number; + + durationMs?: number; + + errorMsg?: string; + + attempt?: number; + +} + +export async function logWebhookDelivery(r: WebhookDeliveryRecord): Promise\ { + + await getPool().query( + + \`INSERT INTO betterbase\_meta.webhook\_delivery\_logs + + (id, webhook\_id, event\_type, table\_name, payload, status, + + http\_status, duration\_ms, error\_msg, attempt) + + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)\`, + + \[nanoid(), r.webhookId, r.eventType, r.tableName, + + r.payload ? JSON.stringify(r.payload) : null, + + r.status, r.httpStatus ?? null, r.durationMs ?? null, + + r.errorMsg ?? null, r.attempt ?? 1\] + + ); + +} + +**ACCEPTANCE CRITERIA** + +* webhook\_delivery\_logs references webhooks(id) with ON DELETE CASCADE + +* payload stored as JSONB (not TEXT) + +* logWebhookDelivery is fire-and-not-forget — it awaits (callers need confirmation it was logged) + +* status constrained to 'success' | 'failed' | 'pending' via application logic + +* Two indexes: webhook\_id \+ status + +| SH-31 | Function Invocation Logs Table \+ Migration 007 | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-30 + +Create a table to track every function invocation: duration, status, cold start flag, and truncated response/error. Powers the function tracing UI in D-17. + +### **Create file: packages/server/migrations/007\_function\_invocation\_logs.sql** + +CREATE TABLE IF NOT EXISTS betterbase\_meta.function\_invocation\_logs ( + + id TEXT PRIMARY KEY, + + function\_id TEXT NOT NULL REFERENCES betterbase\_meta.functions(id) + + ON DELETE CASCADE, + + function\_name TEXT NOT NULL, + + status TEXT NOT NULL DEFAULT 'pending', + + \-- 'success' | 'error' | 'timeout' | 'pending' + + duration\_ms INT, + + cold\_start BOOLEAN NOT NULL DEFAULT FALSE, + + request\_method TEXT, + + request\_path TEXT, + + response\_status INT, + + error\_msg TEXT, + + created\_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + +); + +CREATE INDEX IF NOT EXISTS idx\_fil\_function\_id + + ON betterbase\_meta.function\_invocation\_logs (function\_id, created\_at DESC); + +CREATE INDEX IF NOT EXISTS idx\_fil\_status + + ON betterbase\_meta.function\_invocation\_logs (status, created\_at DESC); + +### **Create file: packages/server/src/lib/function-logger.ts** + +import { getPool } from './db'; + +import { nanoid } from 'nanoid'; + +export interface FunctionInvocationRecord { + + functionId: string; + + functionName: string; + + status: 'success' | 'error' | 'timeout' | 'pending'; + + durationMs?: number; + + coldStart?: boolean; + + requestMethod?: string; + + requestPath?: string; + + responseStatus?: number; + + errorMsg?: string; + +} + +export async function logFunctionInvocation( + + r: FunctionInvocationRecord + +): Promise\ { + + await getPool().query( + + \`INSERT INTO betterbase\_meta.function\_invocation\_logs + + (id, function\_id, function\_name, status, duration\_ms, cold\_start, + + request\_method, request\_path, response\_status, error\_msg) + + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)\`, + + \[nanoid(), r.functionId, r.functionName, r.status, + + r.durationMs ?? null, r.coldStart ?? false, + + r.requestMethod ?? null, r.requestPath ?? null, + + r.responseStatus ?? null, r.errorMsg ?? null\] + + ); + +} + +**ACCEPTANCE CRITERIA** + +* function\_invocation\_logs references functions(id) with ON DELETE CASCADE + +* cold\_start is a boolean column (default false) + +* error\_msg stored as TEXT — no truncation required at the DB level + +* Two indexes: function\_id \+ status + +| SH-32 | Global Metrics API Endpoints | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-31 + +Extend packages/server/src/routes/admin/metrics.ts with four sub-routes used by the dashboard ObservabilityPage. + +### **Update file: packages/server/src/routes/admin/metrics.ts** + +Replace the existing single GET / route with the following four-route implementation: + +import { Hono } from 'hono'; + +import { getPool } from '../../lib/db'; + +export const metricsRoutes \= new Hono(); + +// GET /admin/metrics/overview + +metricsRoutes.get('/overview', async (c) \=\> { + + const pool \= getPool(); + + const \[projects, admins, fns, errors\] \= await Promise.all(\[ + + pool.query('SELECT COUNT(\*)::int as count FROM betterbase\_meta.projects'), + + pool.query('SELECT COUNT(\*)::int as count FROM betterbase\_meta.admin\_users'), + + pool.query('SELECT COUNT(\*)::int as count FROM betterbase\_meta.functions'), + + pool.query(\`SELECT COUNT(\*)::int as count FROM betterbase\_meta.request\_logs + + WHERE status \>= 500 AND created\_at \> NOW() \- INTERVAL '24h'\`), + + \]); + + return c.json({ metrics: { + + projects: projects.rows\[0\].count, + + admin\_users: admins.rows\[0\].count, + + functions: fns.rows\[0\].count, + + errors\_24h: errors.rows\[0\].count, + + server\_uptime\_seconds: Math.floor(process.uptime()), + + timestamp: new Date().toISOString(), + + }}); + +}); + +// GET /admin/metrics/timeseries?period=24h|7d|30d + +metricsRoutes.get('/timeseries', async (c) \=\> { + + const period \= c.req.query('period') ?? '24h'; + + const intervalMap: Record\ \= { + + '24h': "1 hour", '7d': "1 day", '30d': "1 day" + + }; + + const rangeMap: Record\ \= { + + '24h': "24 hours", '7d': "7 days", '30d': "30 days" + + }; + + const interval \= intervalMap\[period\] ?? '1 hour'; + + const range \= rangeMap\[period\] ?? '24 hours'; + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT date\_trunc('${interval}', created\_at) AS bucket, + + COUNT(\*)::int AS total, + + COUNT(\*) FILTER (WHERE status \>= 500)::int AS errors + + FROM betterbase\_meta.request\_logs + + WHERE created\_at \> NOW() \- INTERVAL '${range}' + + GROUP BY bucket ORDER BY bucket ASC + + \`); + + return c.json({ timeseries: rows, period }); + +}); + +// GET /admin/metrics/latency?period=24h|7d + +metricsRoutes.get('/latency', async (c) \=\> { + + const period \= c.req.query('period') ?? '24h'; + + const range \= period \=== '7d' ? '7 days' : '24 hours'; + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT + + percentile\_cont(0.50) WITHIN GROUP (ORDER BY duration\_ms)::int AS p50, + + percentile\_cont(0.95) WITHIN GROUP (ORDER BY duration\_ms)::int AS p95, + + percentile\_cont(0.99) WITHIN GROUP (ORDER BY duration\_ms)::int AS p99, + + AVG(duration\_ms)::int AS avg + + FROM betterbase\_meta.request\_logs + + WHERE created\_at \> NOW() \- INTERVAL '${range}' + + AND duration\_ms IS NOT NULL + + \`); + + return c.json({ latency: rows\[0\] ?? { p50:0, p95:0, p99:0, avg:0 }, period }); + +}); + +// GET /admin/metrics/top-endpoints?period=24h\&limit=10 + +metricsRoutes.get('/top-endpoints', async (c) \=\> { + + const period \= c.req.query('period') ?? '24h'; + + const limit \= Math.min(parseInt(c.req.query('limit') ?? '10'), 50); + + const range \= period \=== '7d' ? '7 days' : '24 hours'; + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT path, method, + + COUNT(\*)::int AS count, + + AVG(duration\_ms)::int AS avg\_ms, + + COUNT(\*) FILTER (WHERE status \>= 500)::int AS errors + + FROM betterbase\_meta.request\_logs + + WHERE created\_at \> NOW() \- INTERVAL '${range}' + + GROUP BY path, method + + ORDER BY count DESC + + LIMIT $1 + + \`, \[limit\]); + + return c.json({ endpoints: rows, period }); + +}); + +*Also update packages/server/src/routes/admin/index.ts: replace adminRouter.route('/metrics', metricsRoutes) — no change needed if it already routes to metricsRoutes. The sub-routes (/overview, /timeseries, /latency, /top-endpoints) are defined within the router, so they resolve as /admin/metrics/overview etc.* + +**ACCEPTANCE CRITERIA** + +* GET /admin/metrics/overview returns projects, admin\_users, functions, errors\_24h, uptime + +* GET /admin/metrics/timeseries accepts period=24h|7d|30d — defaults to 24h + +* GET /admin/metrics/latency returns p50, p95, p99, avg in milliseconds + +* GET /admin/metrics/top-endpoints returns path, method, count, avg\_ms, errors — limit capped at 50 + +* All endpoints require admin auth (inherited from router middleware in index.ts) + +* percentile\_cont requires Postgres 9.4+ — safe assumption with Postgres 16 + +| SH-33 | Per-Project Metrics Endpoints | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-32 + +Add a nested metrics sub-router under /admin/projects/:projectId/metrics so the dashboard can show per-project observability. + +### **Create file: packages/server/src/routes/admin/project-metrics.ts** + +import { Hono } from 'hono'; + +import { getPool } from '../../lib/db'; + +export const projectMetricsRoutes \= new Hono\<{ Variables: { projectId: string } }\>(); + +// GET /admin/projects/:projectId/metrics/overview + +projectMetricsRoutes.get('/overview', async (c) \=\> { + + const projectId \= c.req.param('projectId'); + + const pool \= getPool(); + + const \[total, errors, latency\] \= await Promise.all(\[ + + pool.query(\`SELECT COUNT(\*)::int as count FROM betterbase\_meta.request\_logs + + WHERE project\_id=$1 AND created\_at \> NOW() \- INTERVAL '24h'\`,\[projectId\]), + + pool.query(\`SELECT COUNT(\*)::int as count FROM betterbase\_meta.request\_logs + + WHERE project\_id=$1 AND status\>=500 + + AND created\_at \> NOW() \- INTERVAL '24h'\`,\[projectId\]), + + pool.query(\`SELECT AVG(duration\_ms)::int AS avg\_ms + + FROM betterbase\_meta.request\_logs + + WHERE project\_id=$1 AND created\_at \> NOW() \- INTERVAL '24h'\`,\[projectId\]), + + \]); + + return c.json({ metrics: { + + requests\_24h: total.rows\[0\].count, + + errors\_24h: errors.rows\[0\].count, + + avg\_latency\_ms: latency.rows\[0\].avg\_ms ?? 0, + + project\_id: projectId, + + }}); + +}); + +// GET /admin/projects/:projectId/metrics/timeseries?period=24h|7d + +projectMetricsRoutes.get('/timeseries', async (c) \=\> { + + const projectId \= c.req.param('projectId'); + + const period \= c.req.query('period') ?? '24h'; + + const range \= period \=== '7d' ? '7 days' : '24 hours'; + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT date\_trunc('hour', created\_at) AS bucket, + + COUNT(\*)::int AS total, + + COUNT(\*) FILTER (WHERE status \>= 500)::int AS errors + + FROM betterbase\_meta.request\_logs + + WHERE project\_id \= $1 AND created\_at \> NOW() \- INTERVAL '${range}' + + GROUP BY bucket ORDER BY bucket ASC + + \`, \[projectId\]); + + return c.json({ timeseries: rows, period, project\_id: projectId }); + +}); + +### **Update file: packages/server/src/routes/admin/projects.ts** + +Add the following after the existing routes, before the module ends: + +import { projectMetricsRoutes } from './project-metrics'; + +// Mount per-project metrics + +projectRoutes.route('/:projectId/metrics', projectMetricsRoutes); + +**ACCEPTANCE CRITERIA** + +* GET /admin/projects/:projectId/metrics/overview returns request/error counts filtered by project\_id + +* GET /admin/projects/:projectId/metrics/timeseries returns hourly buckets for the project + +* projectId is taken from the URL param — never from the request body + +* No 404 if project has no logs — returns zeroes + +| SH-34 | Webhook Delivery History Endpoint | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-33 + +Add delivery history and retry endpoints to the webhook routes. + +### **Update file: packages/server/src/routes/admin/webhooks.ts** + +Append these routes to the existing webhookRoutes Hono instance: + +// GET /admin/webhooks/:id/deliveries?limit=50\&status=failed + +webhookRoutes.get('/:id/deliveries', async (c) \=\> { + + const limit \= Math.min(parseInt(c.req.query('limit') ?? '50'), 200); + + const status \= c.req.query('status'); + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT id, event\_type, table\_name, status, http\_status, + + duration\_ms, error\_msg, attempt, created\_at + + FROM betterbase\_meta.webhook\_delivery\_logs + + WHERE webhook\_id \= $1 + + ${status ? 'AND status \= $3' : ''} + + ORDER BY created\_at DESC LIMIT $2 + + \`, status ? \[c.req.param('id'), limit, status\] : \[c.req.param('id'), limit\]); + + return c.json({ deliveries: rows }); + +}); + +// GET /admin/webhooks/:id/stats + +webhookRoutes.get('/:id/stats', async (c) \=\> { + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT + + COUNT(\*)::int AS total, + + COUNT(\*) FILTER (WHERE status='success')::int AS success, + + COUNT(\*) FILTER (WHERE status='failed')::int AS failed, + + AVG(duration\_ms)::int AS avg\_duration\_ms, + + MAX(created\_at) AS last\_delivery + + FROM betterbase\_meta.webhook\_delivery\_logs + + WHERE webhook\_id \= $1 + + AND created\_at \> NOW() \- INTERVAL '30 days' + + \`, \[c.req.param('id')\]); + + return c.json({ stats: rows\[0\] }); + +}); + +**ACCEPTANCE CRITERIA** + +* GET /admin/webhooks/:id/deliveries supports optional ?status=failed filter + +* GET /admin/webhooks/:id/stats returns success/failed counts \+ avg duration over 30 days + +* limit capped at 200 + +* Both routes return empty/zero data (not 404\) if webhook has no deliveries + +| SH-35 | Function Invocation Tracing Endpoint | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-34 + +### **Update file: packages/server/src/routes/admin/functions.ts** + +Append these routes to the existing functionRoutes Hono instance: + +// GET /admin/functions/:id/invocations?limit=50\&status=error + +functionRoutes.get('/:id/invocations', async (c) \=\> { + + const limit \= Math.min(parseInt(c.req.query('limit') ?? '50'), 200); + + const status \= c.req.query('status'); + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT id, function\_name, status, duration\_ms, cold\_start, + + request\_method, request\_path, response\_status, error\_msg, created\_at + + FROM betterbase\_meta.function\_invocation\_logs + + WHERE function\_id \= $1 + + ${status ? 'AND status \= $3' : ''} + + ORDER BY created\_at DESC LIMIT $2 + + \`, status ? \[c.req.param('id'), limit, status\] : \[c.req.param('id'), limit\]); + + return c.json({ invocations: rows }); + +}); + +// GET /admin/functions/:id/stats + +functionRoutes.get('/:id/stats', async (c) \=\> { + + const pool \= getPool(); + + const { rows } \= await pool.query(\` + + SELECT + + COUNT(\*)::int AS total, + + COUNT(\*) FILTER (WHERE status='success')::int AS success, + + COUNT(\*) FILTER (WHERE status='error')::int AS errors, + + COUNT(\*) FILTER (WHERE cold\_start=TRUE)::int AS cold\_starts, + + percentile\_cont(0.50) WITHIN GROUP (ORDER BY duration\_ms)::int AS p50\_ms, + + percentile\_cont(0.95) WITHIN GROUP (ORDER BY duration\_ms)::int AS p95\_ms, + + AVG(duration\_ms)::int AS avg\_ms + + FROM betterbase\_meta.function\_invocation\_logs + + WHERE function\_id \= $1 + + AND created\_at \> NOW() \- INTERVAL '30 days' + + \`, \[c.req.param('id')\]); + + return c.json({ stats: rows\[0\] }); + +}); + +**ACCEPTANCE CRITERIA** + +* GET /admin/functions/:id/invocations supports optional ?status=error filter + +* GET /admin/functions/:id/stats returns cold\_starts count and p50/p95 latency + +* limit capped at 200 + +* cold\_start field included in every invocation row + +| SH-36 | Real-Time Log Stream Endpoint (Polling) | Phase A | +| :---: | :---- | :---: | + +Depends on: SH-35 + +The dashboard needs a live log feed. SSE is ideal but requires persistent connections that complicate Docker health checks. Use a polling endpoint instead: the frontend calls it every 3 seconds with a since timestamp and receives new log entries since that time. This is the pattern recommended for the self-hosted deployment model. + +### **Update file: packages/server/src/routes/admin/logs.ts** + +Replace the existing GET / route with two routes: + +// GET /admin/logs — paginated history (existing, keep as-is) + +logRoutes.get('/', async (c) \=\> { + + const limit \= Math.min(parseInt(c.req.query('limit') ?? '50'), 200); + + const offset \= parseInt(c.req.query('offset') ?? '0'); + + const pool \= getPool(); + + const { rows } \= await pool.query( + + \`SELECT id, method, path, status, duration\_ms, project\_id, created\_at + + FROM betterbase\_meta.request\_logs + + ORDER BY created\_at DESC LIMIT $1 OFFSET $2\`, + + \[limit, offset\] + + ); + + return c.json({ logs: rows, limit, offset }); + +}); + +// GET /admin/logs/stream?since=\\&limit=100 + +// Returns all logs created after ?since. Frontend polls this every 3s. + +// On first call, omit since to get the last 100 entries as seed data. + +logRoutes.get('/stream', async (c) \=\> { + + const since \= c.req.query('since'); + + const limit \= Math.min(parseInt(c.req.query('limit') ?? '100'), 500); + + const pool \= getPool(); + + let rows: any\[\]; + + if (since) { + + const result \= await pool.query( + + \`SELECT id, method, path, status, duration\_ms, project\_id, + + ip, user\_agent, created\_at + + FROM betterbase\_meta.request\_logs + + WHERE created\_at \> $1 + + ORDER BY created\_at ASC LIMIT $2\`, + + \[since, limit\] + + ); + + rows \= result.rows; + + } else { + + const result \= await pool.query( + + \`SELECT id, method, path, status, duration\_ms, project\_id, + + ip, user\_agent, created\_at + + FROM betterbase\_meta.request\_logs + + ORDER BY created\_at DESC LIMIT $1\`, + + \[limit\] + + ); + + rows \= result.rows.reverse(); // Chronological order for seeding + + } + + const lastTimestamp \= rows.length \> 0 + + ? rows\[rows.length \- 1\].created\_at + + : since ?? new Date().toISOString(); + + return c.json({ logs: rows, next\_since: lastTimestamp }); + +}); + +**ACCEPTANCE CRITERIA** + +* GET /admin/logs/stream without ?since returns last 100 entries in ascending order (seed data) + +* GET /admin/logs/stream?since=\ returns only entries created strictly after that timestamp + +* Response always includes next\_since for the frontend to use in the next poll + +* limit capped at 500 — not 200, because streaming needs more headroom + +* Frontend contract: poll interval is 3 seconds; dashboard implements this in D-19 + +# **Phase B — Frontend: Dashboard Pages** + +All tasks in this phase modify apps/dashboard. Execute D-14 → D-19 in order. All tasks depend on Phase A being complete. + +*The dashboard uses the @betterbase/client SDK and its existing mock-server.ts pattern. Replace mock calls with real apiRequest() calls from packages/cli/src/utils/api-client.ts or the dashboard's own equivalent fetch wrapper. Every component must handle loading, empty, and error states.* + +| D-14 | Global ObservabilityPage | Phase B | +| :---: | :---- | :---: | + +Depends on: SH-36 (all backend tasks complete) + +Create the top-level observability page — the main destination from the sidebar nav item that currently points nowhere. + +### **Create file: apps/dashboard/src/pages/ObservabilityPage.tsx** + +The page is composed of five sections rendered in order: + +| Section | Description | +| :---- | :---- | +| StatCards | Four cards: Total Projects, Total Functions, Errors (24h), Avg Latency (24h). Data from GET /admin/metrics/overview \+ /admin/metrics/latency | +| RequestVolumeChart | Recharts AreaChart of total requests vs errors over time. Period selector: 24h / 7d / 30d. Data from GET /admin/metrics/timeseries?period= | +| LatencyPills | Three pills showing P50 / P95 / P99. Colour-coded: green \<100ms, amber \<500ms, red ≥500ms. Data from GET /admin/metrics/latency | +| TopEndpointsTable | Table: Method, Path, Count, Avg Latency, Errors. Sortable by Count (default). Data from GET /admin/metrics/top-endpoints?limit=10 | +| RecentActivityFeed | Last 20 request log entries with method badge, path, status code, latency, and timestamp. Data from GET /admin/logs/stream (seed, no polling on this page) | + +### **Hook: apps/dashboard/src/hooks/useGlobalMetrics.ts** + +// Fetches overview \+ latency \+ timeseries in parallel. + +// Refreshes every 30 seconds. + +export function useGlobalMetrics(period: '24h' | '7d' | '30d' \= '24h') { + + // Returns: { overview, latency, timeseries, endpoints, loading, error } + + // Uses Promise.all to fetch all four endpoints simultaneously. + + // period state change triggers refetch of timeseries \+ latency. + +} + +**ACCEPTANCE CRITERIA** + +* Page mounts without errors with real API data + +* Period selector (24h/7d/30d) updates the chart and latency pills without full page reload + +* StatCards show skeleton loaders during initial fetch + +* LatencyPills apply correct colour coding: \<100ms green, \<500ms amber, \>=500ms red + +* TopEndpointsTable is sortable client-side by Count column (default sort) + +* RecentActivityFeed renders status codes with colour: 2xx green, 4xx amber, 5xx red + +* Page is added to the sidebar nav with an icon (e.g. BarChart2 from lucide-react) + +| D-15 | Per-Project ObservabilityPage | Phase B | +| :---: | :---- | :---: | + +Depends on: D-14 + +Add an Observability tab to the existing project detail page (wherever projects are displayed in the dashboard). + +### **Create file: apps/dashboard/src/pages/ProjectObservabilityPage.tsx** + +Props: { projectId: string } + +| Section | Description | +| :---- | :---- | +| ProjectStatCards | Requests (24h), Errors (24h), Avg Latency. Data from GET /admin/projects/:id/metrics/overview | +| ProjectTimeseries | Same AreaChart as D-14 but scoped to project. Data from GET /admin/projects/:id/metrics/timeseries | +| ProjectLogFeed | Last 50 request logs for this project only. No polling (static snapshot). Data from GET /admin/logs?project\_id= (add optional project\_id filter to the paginated logs endpoint — add WHERE project\_id=$3 WHEN query param present) | + +*The paginated GET /admin/logs endpoint in SH-16/SH-36 needs a minor update: add an optional ?project\_id query param that adds WHERE project\_id \= $n to the query. Add this as part of D-15 implementation — it is a one-line SQL change.* + +**ACCEPTANCE CRITERIA** + +* ProjectId sourced from URL param — never hardcoded + +* Shows zero-state with helpful message if project has no logged requests yet + +* Accessible from project detail page as an 'Observability' tab alongside Settings etc. + +* Reuses the same chart component from D-14 (no duplicate implementation) + +| D-16 | Webhook Delivery Debug Page | Phase B | +| :---: | :---- | :---: | + +Depends on: D-15 + +Extend the existing webhook detail view (wherever webhooks are listed/managed) with a Deliveries tab. + +### **Create file: apps/dashboard/src/pages/WebhookDeliveriesPage.tsx** + +Props: { webhookId: string } + +| UI Element | Detail | +| :---- | :---- | +| Stats bar | Total / Success / Failed counts \+ avg duration. Source: GET /admin/webhooks/:id/stats | +| Filter tabs | All | Success | Failed — filters the table below | +| Deliveries table | Columns: Timestamp, Event, Table, Status badge, HTTP Status, Duration, Error. Source: GET /admin/webhooks/:id/deliveries?status= | +| Expandable row | Clicking a row expands to show full payload JSON (pretty-printed) | +| Status badge | Green pill for success, red for failed, grey for pending | + +**ACCEPTANCE CRITERIA** + +* Filter tabs update the ?status= query param and refetch + +* Expandable rows show payload as formatted JSON — not raw string + +* Stats bar refreshes when filter tab changes + +* Empty state: 'No deliveries yet' with icon when table is empty + +* Error messages truncated to 200 chars in table — full text in expanded row + +| D-17 | Function Invocation Tracing UI | Phase B | +| :---: | :---- | :---: | + +Depends on: D-16 + +Extend the existing function detail view with an Invocations tab. + +### **Create file: apps/dashboard/src/pages/FunctionInvocationsPage.tsx** + +Props: { functionId: string } + +| UI Element | Detail | +| :---- | :---- | +| Stats bar | Total / Success / Errors / Cold Starts \+ P50 / P95 latency. Source: GET /admin/functions/:id/stats | +| Filter tabs | All | Success | Error — updates ?status= query | +| Invocations table | Columns: Timestamp, Method, Path, Status, Response Code, Duration, Cold Start badge. Source: GET /admin/functions/:id/invocations | +| Cold start badge | Small blue 'COLD' pill shown when cold\_start=true | +| Expandable row | Error message shown when status=error | + +**ACCEPTANCE CRITERIA** + +* Cold start badge visible only when cold\_start is true + +* P50/P95 latency displayed in ms with colour coding matching D-14 LatencyPills + +* Error rows highlighted with red left border or subtle red background + +* Empty state handled with message and icon + +| D-18 | Sidebar Nav \+ Route Registration | Phase B | +| :---: | :---- | :---: | + +Depends on: D-17 + +Wire all new pages into the router and sidebar. + +### **Update: apps/dashboard/src/App.tsx (or router file)** + +Add routes: + +\} /\> + +\} /\> + +\} /\> + +\} /\> + +### **Update: sidebar navigation component** + +Add nav item between Logs and Settings: + +{ label: 'Observability', path: '/observability', icon: BarChart2 } + +**ACCEPTANCE CRITERIA** + +* All four routes render without crashing + +* Sidebar Observability item is highlighted when on any /observability route + +* Project / webhook / function detail pages have the new tabs accessible + +* Deep links work (browser refresh on /observability stays on the page) + +| D-19 | Real-Time Log Stream Component | Phase B | +| :---: | :---- | :---: | + +Depends on: D-18 + +Create a reusable live log stream component backed by the polling endpoint from SH-36. + +### **Create file: apps/dashboard/src/components/LiveLogStream.tsx** + +Props: { projectId?: string; maxRows?: number; autoScroll?: boolean } + +| Behaviour | Detail | +| :---- | :---- | +| Polling | Calls GET /admin/logs/stream every 3 seconds. Stores next\_since in a ref. New entries prepended to the log list. | +| Max rows | Defaults to 200\. Oldest entries removed when limit exceeded (ring buffer). | +| Auto-scroll | When autoScroll=true, scrolls to newest entry on update. Paused when user scrolls up. | +| Pause/Resume | Toggle button to pause polling (useful for reading logs). | +| Row format | Timestamp | Method badge | Path | Status code | Duration | Project badge (if projectId omitted) | +| Empty state | Shows 'Waiting for requests...' with a pulsing dot when log is empty. | + +### **Create file: apps/dashboard/src/hooks/useLogStream.ts** + +// Manages polling state and log buffer. + +export function useLogStream(opts: { + + projectId?: string; + + maxRows?: number; + + enabled?: boolean; + + pollIntervalMs?: number; // default 3000 + +}) { + + // Returns: { logs, isPolling, pause, resume, clear } + + // Uses useRef for since timestamp — not state (avoids re-render on every poll) + + // Uses setInterval managed in useEffect with cleanup + +} + +The LiveLogStream component is used on the global ObservabilityPage as a replacement for the static RecentActivityFeed (update D-14 to use LiveLogStream instead of the static feed). + +**ACCEPTANCE CRITERIA** + +* Polling starts automatically on mount, stops on unmount (no memory leaks) + +* Pause/Resume toggle works correctly — poll stops/starts without resetting since timestamp + +* Ring buffer: list never exceeds maxRows (default 200\) + +* Auto-scroll pauses when user scrolls up — resumes when user scrolls to bottom + +* Clear button resets log and since timestamp + +* useLogStream uses useRef for next\_since (not useState — critical to avoid stale closure issues) + +# **Execution Order Summary** + +| Task | Title | File(s) Created / Modified | Depends On | +| :---- | :---- | :---- | :---- | +| **SH-29** | Enhance request\_logs \+ migration 005 | 005\_enhance\_request\_logs.sql, index.ts | SH-28 | +| **SH-30** | Webhook delivery logs \+ migration 006 | 006\_webhook\_delivery\_logs.sql, webhook-logger.ts | SH-29 | +| **SH-31** | Function invocation logs \+ migration 007 | 007\_function\_invocation\_logs.sql, function-logger.ts | SH-30 | +| **SH-32** | Global metrics API (4 endpoints) | routes/admin/metrics.ts (replace) | SH-31 | +| **SH-33** | Per-project metrics API | routes/admin/project-metrics.ts, projects.ts | SH-32 | +| **SH-34** | Webhook delivery history endpoint | routes/admin/webhooks.ts (append) | SH-33 | +| **SH-35** | Function invocation tracing endpoint | routes/admin/functions.ts (append) | SH-34 | +| **SH-36** | Live log stream endpoint | routes/admin/logs.ts (replace) | SH-35 | +| **D-14** | Global ObservabilityPage | ObservabilityPage.tsx, useGlobalMetrics.ts | SH-36 | +| **D-15** | Per-project ObservabilityPage | ProjectObservabilityPage.tsx | D-14 | +| **D-16** | Webhook delivery debug page | WebhookDeliveriesPage.tsx | D-15 | +| **D-17** | Function invocation tracing UI | FunctionInvocationsPage.tsx | D-16 | +| **D-18** | Sidebar nav \+ route registration | App.tsx, sidebar component | D-17 | +| **D-19** | Live log stream component | LiveLogStream.tsx, useLogStream.ts | D-18 | + +## **Migration Sequence** + +Migrations 005–007 extend the schema created in migrations 001–004. The migration runner (SH-02) applies them automatically on server start. + +| File | Purpose | +| :---- | :---- | +| 005\_enhance\_request\_logs.sql | Adds project\_id, user\_agent, ip, error\_message columns \+ 2 indexes to request\_logs | +| 006\_webhook\_delivery\_logs.sql | New table: webhook\_delivery\_logs with 2 indexes | +| 007\_function\_invocation\_logs.sql | New table: function\_invocation\_logs with 2 indexes | + +## **Dependencies Checklist** + +Verify before starting Phase A: + +* nanoid — already in packages/server/package.json (SH-04) + +* pg — already in packages/server/package.json (SH-04) + +* recharts — must be present in apps/dashboard/package.json for D-14 charts + +* lucide-react — must be present in apps/dashboard for icons (BarChart2, Activity, Zap) + +* percentile\_cont() — requires PostgreSQL 9.4+; Docker image uses postgres:16-alpine — safe + +*End of specification. 14 tasks across 2 phases. Execute in listed order.* \ No newline at end of file diff --git a/CODEBASE_MAP.md b/CODEBASE_MAP.md index cd4834b..3065be8 100644 --- a/CODEBASE_MAP.md +++ b/CODEBASE_MAP.md @@ -1,6 +1,6 @@ # BetterBase — Complete Codebase Map -> Last updated: 2026-03-21 +> Last updated: 2026-03-26 ## Project Identity @@ -49,13 +49,15 @@ graph TB end subgraph packages - CLI[packages/cli
14 commands
8 utils] - Client[packages/client
7 modules] + CLI[packages/cli
21 commands
8 utils] + Client[packages/client
9 modules] Core[packages/core
14 modules] Shared[packages/shared
5 modules] + Server[packages/server
Self-hosted API] end subgraph apps + Dashboard[apps/dashboard
Admin Dashboard] TestProject[apps/test-project
Example project] end @@ -66,15 +68,121 @@ graph TB subgraph external CliAuth[cli-auth-page
Auth UI] + Docker[docker/
Nginx config] + end + + subgraph infrastructure + DB[(Database
PostgreSQL
MySQL
SQLite)] + S3[(S3 Storage
R2, B2, MinIO)] end Root --> CLI Root --> Client Root --> Core Root --> Shared + Root --> Server + Root --> Dashboard Root --> TestProject Root --> Base Root --> Auth + + CLI -->|commands| Core + Core -->|queries| DB + Core -->|files| S3 + Dashboard -->|admin| Server + Server -->|projects| DB +``` + +### Architecture Flow Diagram + +``` +┌────────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Web SDK │ │ React Hooks │ │ Mobile │ │ GraphQL │ │ +│ │@betterbase │ │ @betterbase │ │ SDK │ │ Client │ │ +│ │ /client │ │ /client │ │ │ │ │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +└─────────┼──────────────────┼──────────────────┼──────────────────┼──────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ API GATEWAY (Hono) │ +│ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ REST API │ │ GraphQL │ │ Auth │ │ Storage │ │ Realtime │ │ Webhooks │ │ +│ │/api/v1/* │ │ /graphql│ │/api/auth│ │/storage │ │/realtime │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └────┬───┘ └────┬────┘ └────┬─────┘ └────┬─────┘ │ +└───────┼────────────┼────────────┼──────────┼────────────┼────────────┼────────┘ + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ CORE SERVICES LAYER │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Query │ │ Auth │ │ Realtime │ │ Storage │ │ Function │ │ Webhook │ │ +│ │ Engine │ │ Service │ │ Service │ │ Service │ │ Runtime │ │ Dispatch │ │ +│ │(Drizzle) │ │(Better │ │(WebSocket│ │ (S3) │ │ (Bun) │ │ │ │ +│ │ │ │ Auth) │ │) │ │ │ │ │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ │ │ │ +│ └────────────┴────────────┴────────────┴────────────┴────────────┘ │ +│ │ │ +└─────────────────────────────────────┼────────────────────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ SQLite │ │PostgreSQL│ │ MySQL │ │ Neon │ │ Turso │ │ Supabase │ │ +│ │ (dev) │ │ │ │ │ │(serverless│ │ (libSQL) │ │ │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ STORAGE LAYER │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ AWS S3 │ │Cloudflare│ │Backblaze │ │ MinIO │ │ Local │ │ │ +│ │ │ │ │ R2 │ │ B2 │ │ │ │ Disk │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +### Self-Hosted Deployment Architecture + +``` +┌────────────────────────────────────────────────────────────────────────────────┐ +│ SELF-HOSTED DEPLOYMENT │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ External Clients │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Web App │ │ CLI (bb) │ │ Mobile │ │ Dashboard │ │ │ +│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ +│ └─────────┼────────────────┼────────────────┼────────────────┼──────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ NGINX Reverse Proxy │ │ +│ │ (docker/nginx/nginx.conf) │ │ +│ └────────────────────────────────┬────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────┼───────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Dashboard │ │ Server │ │ Server │ │ +│ │ (React App) │ │ (@betterbase │ │ (Project API) │ │ +│ │ Port: 3001 │ │ /server) │ │ Port: 3000 │ │ +│ │ │ │ Port: 3000 │ │ │ │ +│ └─────────────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ └───────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ PostgreSQL │ │ +│ │ (Database) │ │ +│ └─────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────────────┘ ``` ``` @@ -354,8 +462,11 @@ Betterbase includes production-ready Docker configuration for self-hosted deploy | `Dockerfile.project` | Project template for deploying user projects | | `docker-compose.yml` | Development environment with PostgreSQL | | `docker-compose.production.yml` | Production-ready configuration | +| `docker-compose.self-hosted.yml` | Self-hosted deployment with dashboard | +| `docker/nginx/nginx.conf` | Nginx reverse proxy configuration | | `.dockerignore` | Optimizes Docker builds | | `.env.example` | Environment variable template | +| `.env.self-hosted.example` | Self-hosted environment variables | ### Quick Start @@ -1697,25 +1808,28 @@ bun test All notable changes to BetterBase will be documented in this file. -### Recent Updates (2026-03-19) +### Recent Updates (2026-03-26) #### New Features - **AI Context Generation**: Added intelligent context generation for AI assistants to understand project structure and provide more accurate recommendations - **Branch Management**: New `bb branch` command for creating and managing database branches for development, staging, and production - **Vector Search**: Integrated vector search capabilities for AI-powered semantic search functionality - **Auto-REST**: Automatic REST API generation from Drizzle schema definitions -- **Enhanced CLI**: Expanded from 11 to 12 commands with the addition of branch management +- **Enhanced CLI**: Expanded from 12 to 21 commands with improved migration, storage, webhook, and function management -#### Security Improvements -- Enhanced RLS (Row Level Security) policies -- Improved webhook signing and verification -- Better authentication middleware -- Secure credential handling across providers +#### Self-Hosted Deployment +- **Admin Dashboard**: New React-based admin dashboard (`apps/dashboard`) for self-hosted instances +- **Server Package**: Comprehensive `@betterbase/server` package with admin API, project management, metrics, and device authentication +- **Nginx Reverse Proxy**: Docker-based nginx configuration for production deployments #### Package Updates -- **packages/cli**: 12 commands (init, dev, migrate, auth, generate, function, graphql, login, rls, storage, webhook, branch) -- **packages/client**: Auth, Query Builder, Realtime, Storage modules -- **packages/core**: Config, Functions, GraphQL, Middleware, Migration, Providers, RLS, Storage, Vector, Branching, Auto-REST, Webhooks +- **packages/cli**: 21 commands (init, dev, migrate, migrate preview, migrate production, migrate rollback, migrate history, auth, auth add-provider, generate crud, graphql, graphql playground, storage init, storage list, storage upload, rls, rls create, rls list, rls disable, rls test, webhook, function, branch, login, logout) +- **packages/client**: Auth, Query Builder, Realtime, Storage, Errors modules +- **packages/core**: Config, Functions, GraphQL, Middleware, Migration, Providers, RLS, Storage, Vector, Branching, Auto-REST, Webhooks, Logger, Realtime +- **packages/server**: Self-hosted server with admin routes, device auth, migrations +- **packages/shared**: Types, Errors, Constants, Utils +- **apps/dashboard**: React admin dashboard with project management, metrics, storage, webhooks, functions pages +- **apps/test-project**: Example project demonstrating all features --- diff --git a/README.md b/README.md index b2853ca..a2093f2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Betterbase is an open-source alternative to Supabase, built with Bun for blazing
-**Last Updated: 2026-03-21** +**Last Updated: 2026-03-26** --- @@ -28,34 +28,33 @@ Betterbase is an open-source alternative to Supabase, built with Bun for blazing Traditional backend development is slow. You spend weeks setting up databases, authentication, APIs, and infrastructure before writing business logic. Betterbase changes that. ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ BETTERBASE ARCHITECTURE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Frontend │──────▶│ Betterbase │──────▶│ Database │ │ -│ │ (React, │ │ Core │ │ (SQLite, │ │ -│ │ Vue, │ │ │ │ Postgres, │ │ -│ │ Mobile) │ │ ┌────────┐ │ │ MySQL, │ │ -│ └──────────────┘ │ │ Auth │ │ │ Neon...) │ │ -│ │ ├────────┤ │ └──────────────┘ │ -│ ┌──────────────┐ │ │ Realtime│ │ │ -│ │ Serverless │──────▶│ ├────────┤ │ ┌──────────────┐ │ -│ │ Functions │ │ │ Storage │ │ │ S3 Storage │ │ -│ └──────────────┘ │ ├────────┤ │ │ (R2, B2, │ │ -│ │ │GraphQL │ │ │ MinIO...) │ │ -│ ┌──────────────┐ │ ├────────┤ │ └──────────────┘ │ -│ │ Webhooks │──────▶│ │ RLS │ │ │ -│ └──────────────┘ │ ├────────┤ │ ┌──────────────┐ │ -│ │ │ Vector │ │ │ External │ │ -│ ┌──────────────┐ │ ├────────┤ │ │ Services │ │ -│ │ Logger │──────▶│ │ Branch │ │ │ (AI APIs, │ │ -│ └──────────────┘ │ ├────────┤ │ │ OAuth...) │ │ -│ │ │ Logger │ │ └──────────────┘ │ -│ │ └────────┘ │ │ -│ └──────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ BETTERBASE ARCHITECTURE │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌────────────────────────────┐ ┌─────────────┐ │ +│ │ Frontend │ │ Betterbase Core │ │ Database │ │ +│ │ (React, │───────▶│ │───▶│ (SQLite, │ │ +│ │ Vue, │ │ ┌───────┐ ┌───────┐ │ │ Postgres, │ │ +│ │ Mobile) │ │ │ Auth │ │Realtime│ │ │ MySQL, │ │ +│ └─────────────────┘ │ ├───────┤ ├───────┤ │ │ Neon...) │ │ +│ │ │Storage │ │GraphQL│ │ └─────────────┘ │ +│ ┌─────────────────┐ │ ├───────┤ ├───────┤ │ │ +│ │ Serverless │───────▶│ │ RLS │ │Vector │ │ ┌─────────────┐ │ +│ │ Functions │ │ ├───────┤ ├───────┤ │ │ S3 Storage │ │ +│ └─────────────────┘ │ │Branch │ │Logger │ │ │ (R2, B2, │ │ +│ │ └───────┘ └───────┘ │ │ MinIO...) │ │ +│ ┌─────────────────┐ └────────────────────────────┘ └─────────────┘ │ +│ │ Webhooks │─────────────────────────────────────────▶ │ +│ └─────────────────┘ │ │ +│ │ ┌─────────────────────┐ │ +│ ┌─────────────────┐ │ │ External Services │ │ +│ │ Logger │──────────────────────────┼───▶│ (AI APIs, OAuth) │ │ +│ └─────────────────┘ │ └─────────────────────┘ │ +│ │ │ +└────────────────────────────────────────────────┼─────────────────────────────┘ + │ + (API Responses) ``` --- @@ -253,114 +252,96 @@ bb init my-project --template auth ### System Design ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ CLIENT LAYER │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Web SDK │ │ React Hooks│ │ Mobile │ │ GraphQL │ │ -│ │ @betterbase│ │ @betterbase│ │ SDK │ │ Client │ │ -│ │ /client │ │ /client │ │ │ │ │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -└─────────┼────────────────┼────────────────┼────────────────┼──────────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ API GATEWAY (Hono) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ REST API │ GraphQL │ Auth │ Storage │ Realtime │ Webhooks│ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ CORE SERVICES LAYER │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Query │ │ Auth │ │ Realtime │ │ Storage │ │Function │ │ -│ │ Engine │ │ Service │ │ Service │ │ Service │ │Runtime │ │ -│ │ (Drizzle)│ │(BetterAuth│ │(WebSocket)│ │ (S3) │ │ (Bun) │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ -│ │ │ │ │ │ │ -│ └────────────┴────────────┴────────────┴────────────┘ │ -│ │ │ -└──────────────────────────────┼────────────────────────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ DATA LAYER │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ SQLite │ │PostgreSQL│ │ MySQL │ │ Neon │ │ Turso │ │ -│ │(dev) │ │ │ │ │ │(serverless│ │(libSQL) │ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Web SDK │ │ React Hooks │ │ Mobile │ │ GraphQL │ │ +│ │@betterbase │ │ @betterbase │ │ SDK │ │ Client │ │ +│ │ /client │ │ /client │ │ │ │ │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +└─────────┼──────────────────┼──────────────────┼──────────────────┼──────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ API GATEWAY (Hono) │ +│ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ REST API │ │ GraphQL │ │ Auth │ │ Storage │ │ Realtime │ │ Webhooks │ │ +│ └────┬─────┘ └────┬─────┘ └────┬───┘ └────┬────┘ └────┬─────┘ └────┬─────┘ │ +└───────┼────────────┼────────────┼──────────┼────────────┼────────────┼────────┘ + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ CORE SERVICES LAYER │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Query │ │ Auth │ │ Realtime │ │ Storage │ │ Function │ │ Webhook │ │ +│ │ Engine │ │ Service │ │ Service │ │ Service │ │ Runtime │ │ Dispatch │ │ +│ │(Drizzle) │ │(Better │ │(WebSocket│ │ (S3) │ │ (Bun) │ │ │ │ +│ │ │ │ Auth) │ │) │ │ │ │ │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ │ │ │ +│ └────────────┴────────────┴────────────┴────────────┴────────────┘ │ +│ │ │ +└─────────────────────────────────────┼────────────────────────────────────────────┘ + ▼ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ SQLite │ │PostgreSQL│ │ MySQL │ │ Neon │ │ Turso │ │ Supabase │ │ +│ │ (dev) │ │ │ │ │ │(serverless│ │ (libSQL) │ │ │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└────────────────────────────────────────────────────────────────────────────────┘ ``` ### Package Architecture ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ TURBOREPO MONOREPO │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/cli │ │ -│ │ CLI tool with 17 commands for development and deployment │ │ -│ │ init, dev, migrate, auth, auth add-provider, generate, │ │ -│ │ function, graphql, login, rls, rls test, storage, │ │ -│ │ webhook, branch │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/client │ │ -│ │ TypeScript SDK for frontend integration │ │ -│ │ Auth, Query Builder, Realtime, Storage, Errors │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/core │ │ -│ │ Core backend engine with all server-side functionality │ │ -│ │ Database, Auth, GraphQL, RLS, Storage, Webhooks, Functions, │ │ -│ │ Vector Search, Branching, Auto-REST, Logger, Realtime │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/shared │ │ -│ │ Shared utilities, types, and constants across all packages │ │ -│ │ Types, Errors, Constants, Utils │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ templates/ │ │ -│ │ Project templates for quick initialization │ │ -│ │ base, auth │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ TURBOREPO MONOREPO │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/cli │ │ -│ │ CLI tool with 12 commands for development and deployment │ │ -│ │ init, dev, migrate, auth, generate, function, graphql, login, │ │ -│ │ rls, storage, webhook, branch │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/client │ │ -│ │ TypeScript SDK for frontend integration │ │ -│ │ Auth, Query Builder, Realtime, Storage │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @betterbase/core │ │ -│ │ Core backend engine with all server-side functionality │ │ -│ │ Database, Auth, GraphQL, RLS, Storage, Webhooks, Functions │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ - -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ TURBOREPO MONOREPO │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ @betterbase/cli │ │ +│ │ CLI tool with 21 commands for development and deployment │ │ +│ │ init, dev, migrate, auth, auth add-provider, generate, function, │ │ +│ │ graphql, login, rls, rls test, storage, webhook, branch │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ @betterbase/client │ │ +│ │ TypeScript SDK for frontend integration │ │ +│ │ Auth, Query Builder, Realtime, Storage, Errors │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ @betterbase/core │ │ +│ │ Core backend engine with all server-side functionality │ │ +│ │ Database, Auth, GraphQL, RLS, Storage, Webhooks, Functions, │ │ +│ │ Vector Search, Branching, Auto-REST, Logger, Realtime │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ @betterbase/shared │ │ +│ │ Shared utilities, types, and constants across all packages │ │ +│ │ Types, Errors, Constants, Utils │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ @betterbase/server │ │ +│ │ Self-hosted server with admin API and device authentication │ │ +│ │ Admin routes, metrics, project management, storage, webhooks │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ templates/ │ │ +│ │ Project templates for quick initialization │ │ +│ │ base, auth │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ apps/dashboard │ │ +│ │ React admin dashboard for self-hosted management │ │ +│ │ Projects, metrics, storage, webhooks, functions, settings │ │ +└────────────────────────────────────────────────────────────────────────────────┘ ``` --- @@ -450,7 +431,7 @@ export default defineConfig({ ## CLI Reference -The Betterbase CLI (`bb`) provides 17 commands for development and deployment: +The Betterbase CLI (`bb`) provides 21 commands for development and deployment: ### Core Commands @@ -489,23 +470,20 @@ bb dev --config production.config.ts Run database migrations. ```bash -# Generate migration from schema changes -bb migrate generate my-migration +# Generate and apply migrations +bb migrate -# Apply pending migrations -bb migrate up - -# Rollback last migration -bb migrate down - -# Reset database (warning: destructive) -bb migrate reset - -# Preview migration changes +# Preview migration diff without applying bb migrate preview -# Run in production mode +# Apply migrations to production bb migrate production + +# Rollback the last migration +bb migrate rollback + +# Show migration history +bb migrate history ``` ### Authentication @@ -1092,6 +1070,46 @@ docker-compose -f docker-compose.self-hosted.yml down See [SELF_HOSTED.md](SELF_HOSTED.md) for detailed documentation. +### Self-Hosted Architecture + +``` +┌────────────────────────────────────────────────────────────────────────────────┐ +│ SELF-HOSTED DEPLOYMENT │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ External Clients │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Web App │ │ CLI (bb) │ │ Mobile │ │ Dashboard │ │ │ +│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ +│ └─────────┼────────────────┼────────────────┼────────────────┼──────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ NGINX Reverse Proxy │ │ +│ │ (docker/nginx/nginx.conf) │ │ +│ └────────────────────────────────┬────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────┼───────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Dashboard │ │ Server │ │ Server │ │ +│ │ (React App) │ │ (@betterbase │ │ (Project API) │ │ +│ │ Port: 3001 │ │ /server) │ │ Port: 3000 │ │ +│ │ │ │ Port: 3000 │ │ │ │ +│ └─────────────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ └───────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ PostgreSQL │ │ +│ │ (Database) │ │ +│ └─────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + ### Cloud Providers | Provider | Deployment Method | diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/apps/dashboard/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile deleted file mode 100644 index 6f79f99..0000000 --- a/apps/dashboard/Dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -# Dashboard Dockerfile placeholder -# This is a placeholder. The actual dashboard needs to be created first. -# If the dashboard uses Bun instead of npm, replace node:20-alpine with oven/bun:1.2-alpine -# and replace npm install with bun install and npm run build with bun run build - -FROM node:20-alpine AS builder - -WORKDIR /app - -COPY apps/dashboard/package.json apps/dashboard/package-lock.json* ./ -RUN npm ci - -COPY apps/dashboard ./ - -# Inject API URL at build time -ARG VITE_API_URL=http://localhost -ENV VITE_API_URL=$VITE_API_URL - -RUN npm run build - -# --- Runtime: serve static files with nginx --- -FROM nginx:alpine - -# Create non-root user for nginx -RUN addgroup -g 1000 -S appgroup && \ - adduser -u 1000 -S appuser -G appgroup - -COPY --from=builder /app/dist /usr/share/nginx/html - -# Ensure non-root user can read the html directory -RUN chown -R appuser:appgroup /usr/share/nginx/html && \ - chown -R appuser:appgroup /etc/nginx/conf.d - -# SPA routing: serve index.html for all unknown paths -RUN echo 'server { \ - listen 80; \ - root /usr/share/nginx/html; \ - index index.html; \ - location / { try_files $uri $uri/ /index.html; } \ -}' > /etc/nginx/conf.d/default.conf - -# Switch to non-root user -USER appuser - -EXPOSE 80 \ No newline at end of file diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 0000000..863cf95 --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Betterbase Dashboard + + +
+ + + \ No newline at end of file diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 0000000..69d6307 --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,54 @@ +{ + "name": "betterbase-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.1.0", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-table": "^8.20.0", + "react-hook-form": "^7.54.0", + "zod": "^3.24.0", + "@hookform/resolvers": "^3.9.0", + "recharts": "^2.14.0", + "motion": "^11.15.0", + "lucide-react": "^0.469.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.6.0", + "class-variance-authority": "^0.7.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.6", + "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-progress": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-collapsible": "^1.1.2", + "cmdk": "^1.0.4", + "sonner": "^1.7.1", + "date-fns": "^4.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.6", + "tailwindcss": "^4.0.0", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/react-query-devtools": "^5.62.0" + } +} diff --git a/apps/dashboard/src/components/CommandPalette.tsx b/apps/dashboard/src/components/CommandPalette.tsx new file mode 100644 index 0000000..d97cedc --- /dev/null +++ b/apps/dashboard/src/components/CommandPalette.tsx @@ -0,0 +1,121 @@ +import { api } from "@/lib/api"; +import { QK } from "@/lib/query-keys"; +import { useQuery } from "@tanstack/react-query"; +import { Command } from "cmdk"; +import { + FolderOpen, + HardDrive, + LayoutDashboard, + ScrollText, + Settings, + Shield, + Users, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; + +interface CommandPaletteProps { + open: boolean; + onClose: () => void; +} + +const staticCommands = [ + { label: "Overview", href: "/", icon: LayoutDashboard }, + { label: "Projects", href: "/projects", icon: FolderOpen }, + { label: "Storage", href: "/storage", icon: HardDrive }, + { label: "Logs", href: "/logs", icon: ScrollText }, + { label: "Audit Log", href: "/audit", icon: Shield }, + { label: "Team", href: "/team", icon: Users }, + { label: "Settings", href: "/settings", icon: Settings }, + { label: "SMTP Settings", href: "/settings/smtp", icon: Settings }, + { label: "API Keys", href: "/settings/api-keys", icon: Settings }, +]; + +export function CommandPalette({ open, onClose }: CommandPaletteProps) { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + + const { data: projectsData } = useQuery({ + queryKey: QK.projects(), + queryFn: () => api.get<{ projects: { id: string; name: string }[] }>("/admin/projects"), + enabled: open, + }); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); + + if (!open) return null; + + function go(href: string) { + navigate(href); + onClose(); + } + + return ( +
+
e.stopPropagation()} + > + +
+ +
+ + + No results found. + + + + {staticCommands.map((cmd) => ( + go(cmd.href)} + className="flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer text-sm" + style={{ color: "var(--color-text-secondary)" }} + > + + {cmd.label} + + ))} + + + {(projectsData?.projects?.length ?? 0) > 0 && ( + + {projectsData!.projects.map((p) => ( + go(`/projects/${p.id}`)} + className="flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer text-sm" + style={{ color: "var(--color-text-secondary)" }} + > + + {p.name} + + ))} + + )} + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/ErrorBoundary.tsx b/apps/dashboard/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2e82ca8 --- /dev/null +++ b/apps/dashboard/src/components/ErrorBoundary.tsx @@ -0,0 +1,47 @@ +import { Button } from "@/components/ui/button"; +import { AlertTriangle } from "lucide-react"; +import { Component, type ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + if (this.state.error) { + return ( + this.props.fallback ?? ( +
+ +
+

+ Something went wrong +

+

+ {this.state.error.message} +

+
+ +
+ ) + ); + } + return this.props.children; + } +} diff --git a/apps/dashboard/src/components/LiveLogStream.tsx b/apps/dashboard/src/components/LiveLogStream.tsx new file mode 100644 index 0000000..88840bc --- /dev/null +++ b/apps/dashboard/src/components/LiveLogStream.tsx @@ -0,0 +1,166 @@ +import { Button } from "@/components/ui/button"; +import { useLogStream } from "@/hooks/useLogStream"; +import { Pause, Play, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +interface LiveLogStreamProps { + projectId?: string; + maxRows?: number; + autoScroll?: boolean; +} + +function getStatusColor(status: number): string { + if (status >= 200 && status < 300) return "text-green-500"; + if (status >= 400 && status < 500) return "text-amber-500"; + if (status >= 500) return "text-red-500"; + return "text-gray-500"; +} + +function formatTime(timestamp: string): string { + return new Date(timestamp).toLocaleTimeString(); +} + +export function LiveLogStream({ projectId, maxRows = 200, autoScroll = true }: LiveLogStreamProps) { + const containerRef = useRef(null); + const [userScrolledUp, setUserScrolledUp] = useState(false); + const { logs, isPolling, pause, resume, clear } = useLogStream({ + projectId, + maxRows, + enabled: true, + pollIntervalMs: 3000, + }); + + // Auto-scroll to top when new logs come in + useEffect(() => { + if (autoScroll && !userScrolledUp && containerRef.current) { + containerRef.current.scrollTop = 0; + } + }, [logs, autoScroll, userScrolledUp]); + + // Handle scroll to detect if user scrolled up + const handleScroll = (e: React.UIEvent) => { + const { scrollTop } = e.currentTarget; + // If user scrolls up (not at top), pause auto-scroll + if (scrollTop > 10) { + setUserScrolledUp(true); + } else { + setUserScrolledUp(false); + } + }; + + return ( +
+ {/* Controls */} +
+
+ {isPolling ? ( +
+ ) : ( +
+ )} + + {logs.length} entries + +
+
+ + +
+
+ + {/* Log table */} +
+ {logs.length === 0 ? ( +
+
+ Waiting for requests... +
+ ) : ( +
+ + + + + + + + {!projectId && } + + + + {logs.map((log, i) => ( + + + + + + + {!projectId && ( + + )} + + ))} + +
TimeMethodPathStatusDurationProject
+ {formatTime(log.created_at)} + + + {log.method} + + + {log.path} + + {log.status} + + {log.duration_ms}ms + + {log.project_id ? ( + + {log.project_id.slice(0, 8)} + + ) : ( + "-" + )} +
+ )} + + + ); +} diff --git a/apps/dashboard/src/components/auth/AuthGuard.tsx b/apps/dashboard/src/components/auth/AuthGuard.tsx new file mode 100644 index 0000000..f1c97dc --- /dev/null +++ b/apps/dashboard/src/components/auth/AuthGuard.tsx @@ -0,0 +1,16 @@ +import { getToken } from "@/lib/api"; +import { useEffect } from "react"; +import { useNavigate } from "react-router"; + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + + useEffect(() => { + if (!getToken()) { + navigate("/login", { replace: true }); + } + }, [navigate]); + + if (!getToken()) return null; + return <>{children}; +} diff --git a/apps/dashboard/src/components/auth/SetupGuard.tsx b/apps/dashboard/src/components/auth/SetupGuard.tsx new file mode 100644 index 0000000..0496bd9 --- /dev/null +++ b/apps/dashboard/src/components/auth/SetupGuard.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; + +export function SetupGuard({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + const [checking, setChecking] = useState(true); + + useEffect(() => { + // Try hitting /admin/auth/setup without a token. + // If setup is complete, login page is appropriate. + // If setup is not done, /admin/auth/setup returns 201, not 410. + fetch(`${import.meta.env.VITE_API_URL ?? "http://localhost:3001"}/admin/auth/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ _check: true }), // Will fail validation but we only care about 410 + }) + .then((res) => { + if (res.status === 410) { + // Setup complete — redirect to login + navigate("/login", { replace: true }); + } + setChecking(false); + }) + .catch(() => setChecking(false)); + }, [navigate]); + + if (checking) return null; + return <>{children}; +} diff --git a/apps/dashboard/src/components/ui/Avatar.tsx b/apps/dashboard/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..c1b042f --- /dev/null +++ b/apps/dashboard/src/components/ui/Avatar.tsx @@ -0,0 +1,22 @@ +interface AvatarProps { + email: string; + size?: number; +} + +export function Avatar({ email, size = 32 }: AvatarProps) { + const initials = email.slice(0, 2).toUpperCase(); + const hue = Array.from(email).reduce((a, c) => a + c.charCodeAt(0), 0) % 360; + return ( +
+ {initials} +
+ ); +} diff --git a/apps/dashboard/src/components/ui/ConfirmDialog.tsx b/apps/dashboard/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..61e4778 --- /dev/null +++ b/apps/dashboard/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,81 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + confirmValue?: string; // If set, user must type this exact string + variant?: "danger" | "warning"; + onConfirm: () => void; + loading?: boolean; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Confirm", + confirmValue, + variant = "danger", + onConfirm, + loading, +}: ConfirmDialogProps) { + const [typed, setTyped] = useState(""); + const canConfirm = confirmValue ? typed === confirmValue : true; + + return ( + + + + + {title} + + + {description} + + + {confirmValue && ( +
+

+ Type {confirmValue} to + confirm: +

+ setTyped(e.target.value)} + placeholder={confirmValue} + /> +
+ )} + + Cancel + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/ui/EmptyState.tsx b/apps/dashboard/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..cc922da --- /dev/null +++ b/apps/dashboard/src/components/ui/EmptyState.tsx @@ -0,0 +1,33 @@ +import type { LucideIcon } from "lucide-react"; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description: string; + action?: React.ReactNode; +} + +export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( +
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+ {action &&
{action}
} +
+ ); +} diff --git a/apps/dashboard/src/components/ui/PageHeader.tsx b/apps/dashboard/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..307fea4 --- /dev/null +++ b/apps/dashboard/src/components/ui/PageHeader.tsx @@ -0,0 +1,23 @@ +interface PageHeaderProps { + title: string; + description?: string; + action?: React.ReactNode; +} + +export function PageHeader({ title, description, action }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/apps/dashboard/src/components/ui/PageSkeleton.tsx b/apps/dashboard/src/components/ui/PageSkeleton.tsx new file mode 100644 index 0000000..4b6ae4a --- /dev/null +++ b/apps/dashboard/src/components/ui/PageSkeleton.tsx @@ -0,0 +1,20 @@ +export function PageSkeleton() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/ui/StatCard.tsx b/apps/dashboard/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..a6e6b7c --- /dev/null +++ b/apps/dashboard/src/components/ui/StatCard.tsx @@ -0,0 +1,54 @@ +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; + +interface StatCardProps { + label: string; + value: string | number; + icon?: LucideIcon; + trend?: { value: number; period: string }; + color?: "default" | "success" | "warning" | "danger" | "brand"; +} + +const colorMap = { + default: { icon: "var(--color-text-muted)", bg: "var(--color-surface-overlay)" }, + brand: { icon: "var(--color-brand)", bg: "var(--color-brand-muted)" }, + success: { icon: "var(--color-success)", bg: "var(--color-success-muted)" }, + warning: { icon: "var(--color-warning)", bg: "var(--color-warning-muted)" }, + danger: { icon: "var(--color-danger)", bg: "var(--color-danger-muted)" }, +}; + +export function StatCard({ label, value, icon: Icon, trend, color = "default" }: StatCardProps) { + const colors = colorMap[color]; + return ( +
+
+ + {label} + + {Icon && ( +
+ +
+ )} +
+
+ {value} +
+ {trend && ( +
= 0 ? "var(--color-success)" : "var(--color-danger)" }} + > + {trend.value >= 0 ? "+" : ""} + {trend.value}% vs {trend.period} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/ui/alert-dialog.tsx b/apps/dashboard/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9fe3bd6 --- /dev/null +++ b/apps/dashboard/src/components/ui/alert-dialog.tsx @@ -0,0 +1,112 @@ +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as React from "react"; + +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx new file mode 100644 index 0000000..14c0d89 --- /dev/null +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]", + { + variants: { + variant: { + default: "border-transparent bg-[var(--color-brand)] text-white", + secondary: + "border-transparent bg-[var(--color-surface-elevated)] text-[var(--color-text-secondary)]", + success: "border-transparent bg-[var(--color-success-muted)] text-[var(--color-success)]", + warning: "border-transparent bg-[var(--color-warning-muted)] text-[var(--color-warning)]", + destructive: "border-transparent bg-[var(--color-danger-muted)] text-[var(--color-danger)]", + outline: "border-[var(--color-border)] text-[var(--color-text-secondary)]", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx new file mode 100644 index 0000000..fb39771 --- /dev/null +++ b/apps/dashboard/src/components/ui/button.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-brand)] disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-[var(--color-brand)] text-white hover:bg-[var(--color-brand-hover)]", + destructive: "bg-[var(--color-danger)] text-white hover:bg-[var(--color-danger)]/90", + outline: + "border border-[var(--color-border)] bg-transparent hover:bg-[var(--color-surface-elevated)]", + secondary: + "bg-[var(--color-surface-elevated)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-overlay)]", + ghost: "hover:bg-[var(--color-surface-elevated)] text-[var(--color-text-secondary)]", + link: "text-[var(--color-brand)] underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/dashboard/src/components/ui/card.tsx b/apps/dashboard/src/components/ui/card.tsx new file mode 100644 index 0000000..7dee2c9 --- /dev/null +++ b/apps/dashboard/src/components/ui/card.tsx @@ -0,0 +1,58 @@ +import { cn } from "@/lib/utils"; +import * as React from "react"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/dashboard/src/components/ui/collapsible.tsx b/apps/dashboard/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..c3a916d --- /dev/null +++ b/apps/dashboard/src/components/ui/collapsible.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import * as React from "react"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); +CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b2a9b5f --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,98 @@ +import { cn } from "@/lib/utils"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import * as React from "react"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..2504de4 --- /dev/null +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,178 @@ +import { cn } from "@/lib/utils"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/dashboard/src/components/ui/input.tsx b/apps/dashboard/src/components/ui/input.tsx new file mode 100644 index 0000000..434fd27 --- /dev/null +++ b/apps/dashboard/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; +import * as React from "react"; + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/dashboard/src/components/ui/label.tsx b/apps/dashboard/src/components/ui/label.tsx new file mode 100644 index 0000000..ffda790 --- /dev/null +++ b/apps/dashboard/src/components/ui/label.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import * as React from "react"; + +export interface LabelProps extends React.LabelHTMLAttributes {} + +const Label = React.forwardRef(({ className, ...props }, ref) => ( +