diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1a64e03d..9bbb5d14e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,43 @@ jobs: STRIPE_WEBHOOK_SECRET: "" run: cd apps/web && npx vitest run --reporter=default + # Proves the delivery CHECK constraints *behave*, against a real Postgres. + # The hermetic `test-frontend` job runs without a database, so a constraint + # whose predicate is wrong (e.g. accepting `https://localhost` as a shipped + # URL, which happened for weeks) is invisible to it. This job applies the + # shipped migrations to a throwaway Postgres and attempts every forbidden + # write; it fails if the engine accepts one. + db-constraints: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: eventrelay_test + ports: + - 5432:5432 + # Don't start the proof until the container answers, or the first + # connection races startup and the job flakes red. + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + - run: npm install --legacy-peer-deps + - name: Apply migrations and verify constraint behavior + env: + # localhost triggers the no-TLS branch in the script; the container + # is a sibling process on the runner with no credential to protect. + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/eventrelay_test + run: cd apps/web && node scripts/verify-constraints.mjs --apply-migrations + test: runs-on: ubuntu-latest steps: diff --git a/apps/web/drizzle/0000_delivery.sql b/apps/web/drizzle/0000_delivery.sql new file mode 100644 index 000000000..0f2a74487 --- /dev/null +++ b/apps/web/drizzle/0000_delivery.sql @@ -0,0 +1,177 @@ +-- Delivery pipeline schema. +-- +-- Idempotent: safe to re-run. Every statement guards on existence so this can +-- be applied to a database that is already partially migrated. +-- +-- The CHECK constraints at the bottom are the point of this file. They encode +-- "delivered means proven" in the database itself, so no application bug, +-- rewrite, or manual UPDATE can record a delivery that never happened. + +-- ── Enums ── + +DO $$ BEGIN + CREATE TYPE run_status AS ENUM ( + 'sourcing', 'requirements', 'planning', 'awaiting_approval', + 'building', 'verifying', 'deploying', + 'delivered', 'blocked', 'failed', 'cancelled' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE gate_kind AS ENUM ( + 'source_evidence', 'requirements_complete', 'plan_executable', + 'human_approved', 'build_succeeded', 'tests_passed', 'deployment_live' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE gate_result AS ENUM ('pass', 'fail', 'skipped'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE artifact_kind AS ENUM ( + 'repository', 'deployment', 'test_report', 'build_log', 'transcript' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- ── Tables ── + +CREATE TABLE IF NOT EXISTS delivery_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL, + title text NOT NULL, + status run_status NOT NULL DEFAULT 'sourcing', + source_kind text NOT NULL, + source_url text, + workflow_run_id text, + repo_url text, + tests_passed_at timestamptz, + deployment_url text, + delivered_at timestamptz, + blocked_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS delivery_runs_user_created_idx + ON delivery_runs (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS delivery_runs_status_idx ON delivery_runs (status); + +CREATE TABLE IF NOT EXISTS run_specs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES delivery_runs (id) ON DELETE CASCADE, + version integer NOT NULL DEFAULT 1, + requirements jsonb NOT NULL, + plan jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS run_specs_run_version_idx + ON run_specs (run_id, version); + +CREATE TABLE IF NOT EXISTS run_steps ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES delivery_runs (id) ON DELETE CASCADE, + seq integer NOT NULL, + phase run_status NOT NULL, + name text NOT NULL, + status text NOT NULL DEFAULT 'running', + detail jsonb, + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz +); + +-- Doubles as the idempotency guard for retried durable steps. +CREATE UNIQUE INDEX IF NOT EXISTS run_steps_run_seq_idx ON run_steps (run_id, seq); + +CREATE TABLE IF NOT EXISTS run_gates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES delivery_runs (id) ON DELETE CASCADE, + kind gate_kind NOT NULL, + result gate_result NOT NULL, + evidence jsonb NOT NULL, + evaluated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS run_gates_run_kind_idx ON run_gates (run_id, kind); + +CREATE TABLE IF NOT EXISTS run_artifacts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES delivery_runs (id) ON DELETE CASCADE, + kind artifact_kind NOT NULL, + uri text NOT NULL, + meta jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS run_artifacts_run_idx ON run_artifacts (run_id); + +CREATE TABLE IF NOT EXISTS run_approvals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES delivery_runs (id) ON DELETE CASCADE, + spec_id uuid NOT NULL REFERENCES run_specs (id) ON DELETE CASCADE, + decision text NOT NULL, + decided_by text NOT NULL, + note text, + decided_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS run_approvals_run_idx ON run_approvals (run_id); + +-- ── Preventative constraints ── +-- +-- Added with a guarded DO block rather than plain ALTER so re-running is safe. + +-- A run may only be 'delivered' with all three pieces of delivery evidence +-- present. This is the structural answer to "the system reported success it +-- could not prove": there is no code path, including a manual UPDATE, that can +-- store a delivered run without a repository, a passing test run, and a live +-- deployment URL. +DO $$ BEGIN + ALTER TABLE delivery_runs ADD CONSTRAINT delivery_runs_delivered_requires_evidence + CHECK ( + status <> 'delivered' + OR ( + repo_url IS NOT NULL + AND tests_passed_at IS NOT NULL + AND deployment_url IS NOT NULL + AND delivered_at IS NOT NULL + ) + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- 'blocked' must always say which gate refused. A blocked run with no reason is +-- indistinguishable from a crash, which defeats the purpose of separating the +-- two states. +DO $$ BEGIN + ALTER TABLE delivery_runs ADD CONSTRAINT delivery_runs_blocked_requires_reason + CHECK (status <> 'blocked' OR blocked_reason IS NOT NULL); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- A deployment URL must be a real https origin. Guards against storing a +-- placeholder like 'pending' or 'localhost' and treating it as a live delivery. +DO $$ BEGIN + ALTER TABLE delivery_runs ADD CONSTRAINT delivery_runs_deployment_url_is_https + CHECK (deployment_url IS NULL OR deployment_url ~ '^https://[^/]+'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- A video run needs a source URL; an idea run must not carry one. +DO $$ BEGIN + ALTER TABLE delivery_runs ADD CONSTRAINT delivery_runs_source_shape + CHECK ( + (source_kind = 'video' AND source_url IS NOT NULL) + OR (source_kind = 'idea' AND source_url IS NULL) + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- A passing gate must carry non-empty evidence. A pass with `{}` is the same +-- unfalsifiable claim this schema exists to prevent. +DO $$ BEGIN + ALTER TABLE run_gates ADD CONSTRAINT run_gates_pass_requires_evidence + CHECK (result <> 'pass' OR (evidence IS NOT NULL AND evidence <> '{}'::jsonb)); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE run_approvals ADD CONSTRAINT run_approvals_decision_valid + CHECK (decision IN ('approved', 'rejected', 'changes_requested')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/apps/web/drizzle/0001_training.sql b/apps/web/drizzle/0001_training.sql new file mode 100644 index 000000000..b36a5074d --- /dev/null +++ b/apps/web/drizzle/0001_training.sql @@ -0,0 +1,45 @@ +-- Training dataset storage. +-- +-- Replaces `data/training/video-analysis.jsonl` + `metadata.json`, which were +-- written under `process.cwd()`. On Vercel that path is a read-only bundle, so +-- every write threw EROFS (audit finding F4). Worse, `getMetadata()` called +-- `ensureDir()` before reading, so even the *read* path threw — meaning the +-- training status endpoint failed rather than reporting an empty dataset. +-- +-- Dedup moves from an application-level `videosProcessed.includes(url)` array +-- scan to a UNIQUE constraint. The array check was also a race: two concurrent +-- pipeline runs for the same video could both observe "not present" and both +-- append, silently corrupting the fine-tuning dataset with duplicates. A UNIQUE +-- index makes that outcome impossible regardless of concurrency. + +CREATE TABLE IF NOT EXISTS training_examples ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + video_url text NOT NULL, + video_title text NOT NULL DEFAULT 'Unknown', + -- Vertex AI SFT-formatted example, ready to serialise as JSONL. + example jsonb NOT NULL, + -- Raw analysis output retained so the example can be regenerated if the + -- prompt format changes, without re-running the (paid) analysis. + analysis jsonb NOT NULL, + exported_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- The dedup guarantee. +CREATE UNIQUE INDEX IF NOT EXISTS training_examples_video_url_idx + ON training_examples (video_url); + +-- Ordering for JSONL export. +CREATE INDEX IF NOT EXISTS training_examples_created_idx + ON training_examples (created_at); + +-- Singleton row tracking fine-tuning job state. `id` is pinned to a constant so +-- a second row cannot be created. +CREATE TABLE IF NOT EXISTS training_runs ( + id integer PRIMARY KEY DEFAULT 1, + tuning_triggered_at timestamptz, + tuning_job_id text, + CONSTRAINT training_runs_singleton CHECK (id = 1) +); + +INSERT INTO training_runs (id) VALUES (1) ON CONFLICT (id) DO NOTHING; diff --git a/apps/web/drizzle/0002_deployment_url_placeholders.sql b/apps/web/drizzle/0002_deployment_url_placeholders.sql new file mode 100644 index 000000000..6ded1e289 --- /dev/null +++ b/apps/web/drizzle/0002_deployment_url_placeholders.sql @@ -0,0 +1,45 @@ +-- Reject placeholder hosts in `deployment_url`. +-- +-- The original constraint (`delivery_runs_deployment_url_is_https`) only +-- required the URL to start with `https://`. That was too weak to mean +-- "shipped": `https://localhost`, `https://example.com/app`, and +-- `https://example.org` all satisfied it, so a run pointing at a local dev +-- server or a documentation placeholder could still be stored as `delivered`. +-- +-- The application-side guard (`isRealDeploymentUrl` in +-- `src/lib/delivery-lifecycle.ts`) already rejected those hosts, so the two +-- layers disagreed. The cross-layer parity suite +-- (`src/lib/__tests__/delivery-guard-parity.integration.test.ts`) exists to +-- fail CI when that happens, and this migration is the database half of the fix. +-- +-- Keep the host list here in lockstep with `PLACEHOLDER_HOSTS` in +-- `src/lib/delivery-lifecycle.ts`. The parity suite enforces that. + +ALTER TABLE delivery_runs + DROP CONSTRAINT IF EXISTS delivery_runs_deployment_url_is_https; + +ALTER TABLE delivery_runs + DROP CONSTRAINT IF EXISTS delivery_runs_deployment_url_real; + +-- A real deployment URL must: +-- 1. be absolute https with a non-empty host +-- 2. not be a loopback / unspecified address +-- 3. not be a reserved documentation domain (example.com/org/net), +-- including any subdomain of one +-- +-- The host is the run of characters after `https://` up to the first `/`, `?`, +-- or `#`, and may carry a `:port` suffix. +ALTER TABLE delivery_runs + ADD CONSTRAINT delivery_runs_deployment_url_real CHECK ( + deployment_url IS NULL + OR ( + deployment_url ~ '^https://[^/?#]+' + -- Loopback and unspecified addresses, plus bare/subdomain `localhost`. + AND deployment_url !~* '^https://([^/?#@]*\.)?localhost(:[0-9]+)?([/?#]|$)' + AND deployment_url !~* '^https://127\.0\.0\.1(:[0-9]+)?([/?#]|$)' + AND deployment_url !~* '^https://0\.0\.0\.0(:[0-9]+)?([/?#]|$)' + AND deployment_url !~* '^https://\[::1\](:[0-9]+)?([/?#]|$)' + -- RFC 2606 reserved documentation domains. + AND deployment_url !~* '^https://([^/?#@]*\.)?example\.(com|org|net)(:[0-9]+)?([/?#]|$)' + ) + ); diff --git a/apps/web/drizzle/0003_run_blocked_from.sql b/apps/web/drizzle/0003_run_blocked_from.sql new file mode 100644 index 000000000..22fcbef0c --- /dev/null +++ b/apps/web/drizzle/0003_run_blocked_from.sql @@ -0,0 +1,52 @@ +-- Resumption metadata for blocked and failed runs. +-- +-- `delivery_runs` recorded *that* a run blocked (`blocked_reason`) but not +-- *where* it stopped, so a blocked run could not be resumed from the failing +-- phase — the operator had to guess, or restart the whole pipeline and redo +-- work that had already succeeded. The lifecycle model in +-- `src/lib/delivery-lifecycle.ts` has always carried `blockedFrom` and `error`; +-- this migration makes the table match the contract the code already assumes. +-- +-- Both columns are nullable with no default: they are meaningful only for +-- blocked/failed runs, and back-filling a value for historical rows would +-- invent a phase that was never observed. + +ALTER TABLE delivery_runs + ADD COLUMN IF NOT EXISTS blocked_from text; + +ALTER TABLE delivery_runs + ADD COLUMN IF NOT EXISTS error text; + +-- A blocked run must say where it stopped, mirroring the existing +-- `delivery_runs_blocked_requires_reason` constraint. Together they guarantee a +-- blocked run is always fully diagnosable: it has both a reason and an origin. +-- +-- Scoped to rows created from here on: pre-existing blocked rows predate the +-- column and cannot retroactively know their origin phase, so validating them +-- would fail on data the application never had the chance to populate. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'delivery_runs_blocked_requires_origin' + ) THEN + ALTER TABLE delivery_runs ADD CONSTRAINT delivery_runs_blocked_requires_origin + CHECK (status <> 'blocked' OR blocked_from IS NOT NULL) NOT VALID; + END IF; +END $$; + +-- `blocked_from` must name a real phase; a typo would silently break resumption. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'delivery_runs_blocked_from_valid' + ) THEN + ALTER TABLE delivery_runs ADD CONSTRAINT delivery_runs_blocked_from_valid + CHECK ( + blocked_from IS NULL + OR blocked_from IN ( + 'sourcing', 'requirements', 'planning', 'awaiting_approval', + 'building', 'verifying', 'deploying' + ) + ) NOT VALID; + END IF; +END $$; diff --git a/apps/web/drizzle/0004_embeddings.sql b/apps/web/drizzle/0004_embeddings.sql new file mode 100644 index 000000000..7f6a02bf3 --- /dev/null +++ b/apps/web/drizzle/0004_embeddings.sql @@ -0,0 +1,32 @@ +-- Transcript embedding storage. +-- +-- Replaces `data/embeddings/.json` under `process.cwd()` — the last +-- remaining instance of audit finding F4. On Vercel that path is a read-only +-- bundle, so `saveEmbeddings()` threw EROFS on every pipeline run, and +-- `loadEmbeddings()` caught the resulting ENOENT and returned `null`. The +-- semantic search endpoint then reported "no embeddings for this video" +-- instead of "embeddings could never be stored": a silent, permanent +-- degradation that looked like an empty index. +-- +-- One row per video rather than one per chunk. Chunks are always read and +-- written as a complete set for a single video (the search endpoint scores all +-- of them in process), so per-chunk rows would add join cost and a partial-write +-- failure mode for no gain. If similarity search moves into the database with +-- pgvector, that is a per-chunk table and a deliberate migration, not a +-- silent reshaping of this one. + +CREATE TABLE IF NOT EXISTS video_embeddings ( + video_id text PRIMARY KEY, + -- ChunkEmbedding[]: { start, duration, text, embedding[] } + chunks jsonb NOT NULL, + chunk_count integer NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- An empty chunk set is not a saved index. Storing one would recreate the +-- exact ambiguity this table exists to remove: a present-but-useless row +-- reading as a successful save. +DO $$ BEGIN + ALTER TABLE video_embeddings ADD CONSTRAINT video_embeddings_non_empty + CHECK (chunk_count > 0 AND jsonb_array_length(chunks) = chunk_count); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/apps/web/package.json b/apps/web/package.json index d53680a1c..34c4122a2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,13 +10,17 @@ "type-check": "tsc --noEmit", "test": "vitest run", "analyze": "next experimental-analyze --output", - "postinstall": "node scripts/patch-world-vercel-undici-fetch.mjs" + "postinstall": "node scripts/patch-world-vercel-undici-fetch.mjs", + "db:migrate": "node --env-file-if-exists=../../.env.development.local scripts/apply-migrations.mjs", + "db:verify": "node --env-file-if-exists=../../.env.development.local scripts/apply-migrations.mjs --check", + "db:verify-constraints": "node --env-file-if-exists=../../.env.development.local scripts/verify-constraints.mjs" }, "dependencies": { "@ai-sdk/gateway": "^4.0.40", "@dataconnect/generated": "file:src/dataconnect-generated", "@google/genai": "^2.15.0", "@google/generative-ai": "^0.24.1", + "@neondatabase/serverless": "^1.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", @@ -35,6 +39,7 @@ "ai": "^7.0.51", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", "lucide-react": "^1.28.0", "next": "^16.2.11", "next-auth": "^4.24.15", @@ -54,11 +59,14 @@ "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^26", + "@types/pg": "^8.23.1", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.5.2", + "drizzle-kit": "^0.31.10", "eslint": "^9.39.5", "eslint-config-next": "^16.3.0", + "pg": "^8.23.0", "playwright": "^1.62.0", "postcss": "^8.5.23", "tailwindcss": "^4.3.2", diff --git a/apps/web/scripts/apply-migrations.mjs b/apps/web/scripts/apply-migrations.mjs new file mode 100644 index 000000000..a2af103b8 --- /dev/null +++ b/apps/web/scripts/apply-migrations.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Apply the SQL files in `drizzle/` to the configured Postgres database. + * + * Usage: + * node --env-file-if-exists=../../.env.development.local scripts/apply-migrations.mjs + * node scripts/apply-migrations.mjs --check # verify constraints exist, change nothing + * + * Why this exists rather than `drizzle-kit push`: + * + * The delivery schema's guarantees live in CHECK constraints and guarded + * `DO $$ ... $$` blocks (see drizzle/0000_delivery.sql). `drizzle-kit push` + * diffs table structure and does not reliably carry hand-written constraints, + * so pushing would silently produce a database that accepts a `delivered` run + * with no evidence — the exact failure the constraints prevent. + * + * Uses `Pool` (WebSocket) rather than `neon()` (HTTP): the HTTP driver rejects + * multi-statement SQL with "cannot insert multiple commands into a prepared + * statement", and these migrations are intentionally multi-statement. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Pool } from '@neondatabase/serverless'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = join(HERE, '..', 'drizzle'); + +/** + * Prefers an unpooled connection: DDL over the pooler can be routed across + * different backends mid-migration. + */ +const CANDIDATES = [ + 'NEON_DATABASE_URL_UNPOOLED', + 'POSTGRES_URL_NON_POOLING', + 'NEON_DATABASE_URL', + 'POSTGRES_URL', + 'DATABASE_URL', +]; + +/** Constraints that must exist for the delivery guarantees to hold. */ +const REQUIRED_CONSTRAINTS = [ + 'delivery_runs_delivered_requires_evidence', + 'delivery_runs_blocked_requires_reason', + // Renamed from `..._is_https` by 0002: requiring https alone was too weak, + // since `https://localhost` and `https://example.org` both satisfied it. + 'delivery_runs_deployment_url_real', + 'delivery_runs_source_shape', + 'run_gates_pass_requires_evidence', + 'run_approvals_decision_valid', + // 0003: a blocked run must name the phase it stopped in, or it cannot be + // resumed and the operator is left guessing. + 'delivery_runs_blocked_requires_origin', + 'delivery_runs_blocked_from_valid', + // 0004: an empty embedding set is not a saved index. + 'video_embeddings_non_empty', +]; + +function resolveConnectionString() { + for (const name of CANDIDATES) { + const raw = (process.env[name] ?? '').trim(); + if (raw) return { url: raw, source: name }; + } + return { url: null, source: null }; +} + +async function main() { + const checkOnly = process.argv.includes('--check'); + const { url, source } = resolveConnectionString(); + + if (!url) { + console.error(`[migrate] No connection string. Set one of: ${CANDIDATES.join(', ')}`); + process.exit(1); + } + + console.log(`[migrate] using ${source} -> ${new URL(url).host}`); + const pool = new Pool({ connectionString: url }); + + try { + if (!checkOnly) { + const files = readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .sort(); + + if (files.length === 0) { + console.error('[migrate] no .sql files found in drizzle/'); + process.exit(1); + } + + for (const file of files) { + // Migrations are written to be idempotent, so re-running is safe and no + // applied-migrations ledger is needed. + await pool.query(readFileSync(join(MIGRATIONS_DIR, file), 'utf8')); + console.log(`[migrate] applied ${file}`); + } + } + + // Always verify, including after --check. Applying without verifying is how + // a migration "succeeds" while leaving the guarantees absent. + const { rows } = await pool.query( + `SELECT conname FROM pg_constraint WHERE conname = ANY($1::text[])`, + [REQUIRED_CONSTRAINTS], + ); + const found = new Set(rows.map((r) => r.conname)); + const missing = REQUIRED_CONSTRAINTS.filter((c) => !found.has(c)); + + if (missing.length > 0) { + console.error(`[migrate] MISSING constraints: ${missing.join(', ')}`); + console.error('[migrate] The database would accept an unproven delivery. Failing.'); + process.exit(1); + } + + console.log(`[migrate] verified ${REQUIRED_CONSTRAINTS.length} delivery constraints present`); + } finally { + await pool.end(); + } +} + +main().catch((error) => { + console.error('[migrate] failed:', error.message); + process.exit(1); +}); diff --git a/apps/web/scripts/verify-constraints.mjs b/apps/web/scripts/verify-constraints.mjs new file mode 100644 index 000000000..2f6fd2d87 --- /dev/null +++ b/apps/web/scripts/verify-constraints.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * Prove the delivery constraints *behave*, not merely that they exist. + * + * `apply-migrations.mjs --check` queries `pg_constraint` for the expected + * names. That catches a migration that never ran; it cannot catch a constraint + * whose predicate is wrong. `delivery_runs_deployment_url_is_https` was present + * under its expected name for weeks while happily accepting `https://localhost` + * as a shipped deployment. This script attempts every forbidden write and fails + * if the database accepts one. + * + * Usage: + * node --env-file-if-exists=../../.env.development.local scripts/verify-constraints.mjs + * + * Uses `pg` (plain libpq protocol) rather than `@neondatabase/serverless` + * because it must run against both Neon and the throwaway Postgres container + * CI spins up. The SQL is identical either way — the engine is what is under + * test, not the driver. + * + * Safe against a shared database: the whole script runs in one transaction + * that ends in ROLLBACK, and every assertion is a rejected write. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SQL_FILE = join(HERE, 'verify-constraints.sql'); +const DRIZZLE_DIR = join(HERE, '..', 'drizzle'); + +// CI runs against a throwaway container with no schema; `--apply-migrations` +// applies the same `drizzle/*.sql` files the app ships before proving the +// constraints. The files are plain, idempotent SQL, so this is a no-op against +// an already-migrated database (e.g. a developer running it against Neon). +const APPLY_MIGRATIONS = process.argv.includes('--apply-migrations'); + +const CANDIDATES = [ + 'TEST_DATABASE_URL', + 'NEON_DATABASE_URL_UNPOOLED', + 'POSTGRES_URL_NON_POOLING', + 'NEON_DATABASE_URL', + 'POSTGRES_URL', + 'DATABASE_URL', +]; + +function resolveConnectionString() { + for (const name of CANDIDATES) { + const raw = (process.env[name] ?? '').trim(); + if (raw) return { url: raw, source: name }; + } + return { url: null, source: null }; +} + +async function main() { + const { url, source } = resolveConnectionString(); + if (!url) { + console.error(`[constraints] No connection string. Set one of: ${CANDIDATES.join(', ')}`); + process.exit(1); + } + + console.log(`[constraints] using ${source} -> ${new URL(url).host}`); + + const client = new pg.Client({ + connectionString: url, + // Neon requires TLS; the CI container does not offer it. `rejectUnauthorized` + // stays off only for the local container case, where the endpoint is a + // sibling process on the runner and there is no credential to protect. + ssl: url.includes('localhost') || url.includes('127.0.0.1') + ? false + : { rejectUnauthorized: false }, + }); + + await client.connect(); + + // Surface the per-assertion RAISE NOTICE output, so a passing run reads as a + // list of the specific things the database refused rather than a bare "ok". + client.on('notice', (notice) => console.log(` ${notice.message}`)); + + try { + if (APPLY_MIGRATIONS) { + const files = readdirSync(DRIZZLE_DIR) + .filter((name) => name.endsWith('.sql')) + .sort(); + for (const name of files) { + await client.query(readFileSync(join(DRIZZLE_DIR, name), 'utf8')); + console.log(` applied ${name}`); + } + } + + await client.query(readFileSync(SQL_FILE, 'utf8')); + console.log('[constraints] every forbidden write was rejected and every legitimate one accepted'); + } catch (error) { + // A CONSTRAINT GAP message means the database accepted something it must + // not. Anything else means the script itself could not run. + console.error(`[constraints] FAILED: ${error.message}`); + process.exitCode = 1; + // The transaction is left uncommitted; ending the connection discards it. + } finally { + await client.end(); + } +} + +main().catch((error) => { + console.error('[constraints] failed:', error.message); + process.exit(1); +}); diff --git a/apps/web/scripts/verify-constraints.sql b/apps/web/scripts/verify-constraints.sql new file mode 100644 index 000000000..459c8adaf --- /dev/null +++ b/apps/web/scripts/verify-constraints.sql @@ -0,0 +1,196 @@ +-- Behavioural proof of the delivery constraints. +-- +-- `apply-migrations.mjs --check` proves the constraints *exist*. Existence is +-- not the guarantee: a CHECK whose predicate is subtly wrong is present in +-- `pg_constraint` and still accepts the row it was written to reject. That is +-- exactly how `delivery_runs_deployment_url_is_https` passed every existence +-- check while accepting `https://localhost` (fixed in 0002). This file proves +-- behaviour by attempting each forbidden write and failing if it succeeds. +-- +-- Run against a database that has had `drizzle/*.sql` applied: +-- npm run db:verify-constraints +-- +-- Plain SQL with no psql meta-commands, so `scripts/verify-constraints.mjs` +-- can execute it through the same driver the app uses. Everything happens +-- inside one transaction that is rolled back at the end, so the script leaves +-- no rows behind and is safe to run against a shared development database. + +BEGIN; + +-- Assert that `stmt` is rejected. Fails loudly if the database accepts it. +CREATE OR REPLACE FUNCTION pg_temp.must_reject(label text, stmt text) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + BEGIN + EXECUTE stmt; + EXCEPTION + WHEN check_violation OR not_null_violation OR foreign_key_violation + OR unique_violation OR invalid_text_representation THEN + RAISE NOTICE 'ok: rejected %', label; + RETURN; + END; + RAISE EXCEPTION 'CONSTRAINT GAP: the database accepted "%" — the guarantee is not enforced', label; +END; +$fn$; + +-- Assert that `stmt` is accepted. Guards against a constraint tightened so far +-- that the legitimate path no longer works — a green "nothing bad is storable" +-- suite is worthless if nothing good is storable either. +CREATE OR REPLACE FUNCTION pg_temp.must_accept(label text, stmt text) +RETURNS void LANGUAGE plpgsql AS $fn$ +BEGIN + EXECUTE stmt; + RAISE NOTICE 'ok: accepted %', label; +END; +$fn$; + +-- ── delivered requires evidence ── + +SELECT pg_temp.must_reject( + 'delivered run with no repository, tests, or deployment', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind) + VALUES ('u1', 'phantom', 'delivered', 'idea')$$ +); + +SELECT pg_temp.must_reject( + 'delivered run with a repo but no passing tests', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, repo_url, deployment_url, delivered_at) + VALUES ('u1', 'untested', 'delivered', 'idea', + 'https://github.com/acme/x', 'https://x.vercel.app', now())$$ +); + +SELECT pg_temp.must_reject( + 'delivered run with no delivered_at timestamp', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, repo_url, tests_passed_at, deployment_url) + VALUES ('u1', 'timeless', 'delivered', 'idea', + 'https://github.com/acme/x', now(), 'https://x.vercel.app')$$ +); + +-- ── deployment_url must be a real live host ── + +SELECT pg_temp.must_reject( + 'localhost deployment URL', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, deployment_url) + VALUES ('u1', 'local', 'deploying', 'idea', 'https://localhost:3000')$$ +); + +SELECT pg_temp.must_reject( + 'documentation-domain deployment URL', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, deployment_url) + VALUES ('u1', 'docs', 'deploying', 'idea', 'https://app.example.com')$$ +); + +SELECT pg_temp.must_reject( + 'plain http deployment URL', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, deployment_url) + VALUES ('u1', 'insecure', 'deploying', 'idea', 'http://real-host.dev')$$ +); + +-- ── blocked must be diagnosable ── + +SELECT pg_temp.must_reject( + 'blocked run with no reason', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, blocked_from) + VALUES ('u1', 'silent', 'blocked', 'idea', 'building')$$ +); + +-- ── source shape ── + +SELECT pg_temp.must_reject( + 'video run with no source URL', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind) + VALUES ('u1', 'sourceless', 'sourcing', 'video')$$ +); + +SELECT pg_temp.must_reject( + 'idea run carrying a source URL', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, source_url) + VALUES ('u1', 'confused', 'sourcing', 'idea', 'https://youtu.be/auJzb1D-fag')$$ +); + +-- ── the legitimate paths still work ── + +SELECT pg_temp.must_accept( + 'an idea run in sourcing', + $$INSERT INTO delivery_runs (id, user_id, title, status, source_kind) + VALUES ('11111111-1111-1111-1111-111111111111', 'u1', 'good idea', 'sourcing', 'idea')$$ +); + +SELECT pg_temp.must_accept( + 'a fully evidenced delivered run', + $$INSERT INTO delivery_runs (user_id, title, status, source_kind, repo_url, tests_passed_at, deployment_url, delivered_at) + VALUES ('u1', 'shipped', 'delivered', 'idea', + 'https://github.com/acme/x', now(), 'https://x.vercel.app', now())$$ +); + +-- ── gates carry proof ── + +SELECT pg_temp.must_reject( + 'passing gate with empty evidence', + $$INSERT INTO run_gates (run_id, kind, result, evidence) + VALUES ('11111111-1111-1111-1111-111111111111', 'tests_passed', 'pass', '{}'::jsonb)$$ +); + +SELECT pg_temp.must_accept( + 'passing gate with real evidence', + $$INSERT INTO run_gates (run_id, kind, result, evidence) + VALUES ('11111111-1111-1111-1111-111111111111', 'tests_passed', 'pass', + '{"exitCode": 0, "passed": 42}'::jsonb)$$ +); + +SELECT pg_temp.must_reject( + 'a second evaluation of the same gate on the same run', + $$INSERT INTO run_gates (run_id, kind, result, evidence) + VALUES ('11111111-1111-1111-1111-111111111111', 'tests_passed', 'fail', + '{"exitCode": 1}'::jsonb)$$ +); + +-- ── approvals reference a spec ── + +SELECT pg_temp.must_reject( + 'approval with no spec version attached', + $$INSERT INTO run_approvals (run_id, decision, decided_by) + VALUES ('11111111-1111-1111-1111-111111111111', 'approved', 'founder@acme.test')$$ +); + +SELECT pg_temp.must_reject( + 'approval carrying an unrecognised decision', + $$WITH s AS ( + INSERT INTO run_specs (run_id, requirements, plan) + VALUES ('11111111-1111-1111-1111-111111111111', '{}'::jsonb, '{}'::jsonb) + RETURNING id + ) + INSERT INTO run_approvals (run_id, spec_id, decision, decided_by) + SELECT '11111111-1111-1111-1111-111111111111', s.id, 'probably', 'founder@acme.test' + FROM s$$ +); + +-- ── embeddings are never stored empty ── + +SELECT pg_temp.must_reject( + 'embedding row with zero chunks', + $$INSERT INTO video_embeddings (video_id, chunks, chunk_count) + VALUES ('auJzb1D-fag', '[]'::jsonb, 0)$$ +); + +SELECT pg_temp.must_reject( + 'embedding row whose count disagrees with its chunks', + $$INSERT INTO video_embeddings (video_id, chunks, chunk_count) + VALUES ('auJzb1D-fag', '[{"start":0}]'::jsonb, 7)$$ +); + +-- ── training dedup ── + +SELECT pg_temp.must_accept( + 'a training example', + $$INSERT INTO training_examples (video_url, example, analysis) + VALUES ('https://youtu.be/auJzb1D-fag', '{}'::jsonb, '{}'::jsonb)$$ +); + +SELECT pg_temp.must_reject( + 'a duplicate training example for the same video', + $$INSERT INTO training_examples (video_url, example, analysis) + VALUES ('https://youtu.be/auJzb1D-fag', '{}'::jsonb, '{}'::jsonb)$$ +); + +ROLLBACK; diff --git a/apps/web/src/app/api/__tests__/dashboard-route.test.ts b/apps/web/src/app/api/__tests__/dashboard-route.test.ts index 9c3555c07..4773f08f0 100644 --- a/apps/web/src/app/api/__tests__/dashboard-route.test.ts +++ b/apps/web/src/app/api/__tests__/dashboard-route.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { GET, POST } from '@/app/api/dashboard/route'; function jsonResponse(data: unknown, ok = true, status = 200): Response { @@ -10,9 +10,24 @@ function jsonResponse(data: unknown, ok = true, status = 200): Response { } as unknown as Response; } +/** + * These tests previously passed without configuring any backend URL at all. + * That worked only because the route fell back to a hardcoded + * `http://localhost:8000` placeholder, so "backend healthy" and "no backend + * configured" were indistinguishable — the exact production bug (audit finding + * F1/F2). The route now resolves per-request and reports `unconfigured` + * honestly, so each test states which world it is in. + */ +const TEST_BACKEND = 'https://backend.test.internal'; + +beforeEach(() => { + vi.stubEnv('BACKEND_URL', TEST_BACKEND); +}); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); describe('GET /api/dashboard', () => { @@ -35,6 +50,22 @@ describe('GET /api/dashboard', () => { expect(body.status).toBe('degraded'); expect(body.metrics.activeWorkflows).toBe(0); }); + + it('reports "unconfigured" — not "degraded" — when no backend is set, without any fetch', async () => { + // The regression guard for F2. A missing backend must be distinguishable + // from an unhealthy one, and must never trigger a request to a placeholder + // localhost URL. + vi.stubEnv('BACKEND_URL', ''); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const res = await GET(); + const body = await res.json(); + + expect(body.status).toBe('unconfigured'); + expect(body.reason).toBeTruthy(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); describe('POST /api/dashboard', () => { @@ -65,4 +96,24 @@ describe('POST /api/dashboard', () => { const body = await res.json(); expect(body.error).toMatch(/Failed to retrieve/); }); + + it('returns 503 with a reason when no backend is configured', async () => { + // Distinct from the 500 above: an unconfigured backend is a deployment + // problem the operator can fix, not a backend failure. + vi.stubEnv('BACKEND_URL', ''); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const req = new Request('http://localhost/api/dashboard', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + const res = await POST(req); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toMatch(/NEXT_PUBLIC_BACKEND_URL/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/api/__tests__/pipeline-route.test.ts b/apps/web/src/app/api/__tests__/pipeline-route.test.ts index 2bba74956..c904f0587 100644 --- a/apps/web/src/app/api/__tests__/pipeline-route.test.ts +++ b/apps/web/src/app/api/__tests__/pipeline-route.test.ts @@ -94,6 +94,7 @@ describe('POST /api/pipeline', () => { configured: true, available: true, host: 'api.uvai.io', + source: 'BACKEND_URL', }); vi.mocked(hasGeminiKey).mockReturnValue(true); vi.mocked(analyzeVideoWithGemini).mockResolvedValue({ @@ -140,6 +141,7 @@ describe('POST /api/pipeline', () => { configured: true, available: false, host: 'api.uvai.io', + source: 'BACKEND_URL', reason: 'Backend health returned 503', }); vi.mocked(hasGeminiKey).mockReturnValue(true); @@ -177,7 +179,8 @@ describe('POST /api/pipeline', () => { configured: false, available: false, host: null, - reason: 'BACKEND_URL is not configured', + source: null, + reason: 'No backend URL configured', }); vi.mocked(hasGeminiKey).mockReturnValue(false); diff --git a/apps/web/src/app/api/agents/dispatch/route.ts b/apps/web/src/app/api/agents/dispatch/route.ts index 29c940733..9e1902c5f 100644 --- a/apps/web/src/app/api/agents/dispatch/route.ts +++ b/apps/web/src/app/api/agents/dispatch/route.ts @@ -2,12 +2,7 @@ import { NextResponse } from 'next/server'; import { resolveTrustedBillingEmail } from '@/lib/billing/billing-context'; import { isProSubscriber } from '@/lib/billing/entitlement-store'; import { kaizenObserve } from '@/lib/billing/kaizen-trace'; - -/** Resolve the FastAPI backend base URL, or null if not configured. */ -function backendBaseUrl(): string | null { - const raw = process.env.BACKEND_URL || ''; - return raw.startsWith('http') ? raw : null; -} +import { backendHeaders, resolveBackendCapability } from '@/lib/backend/capability'; /** * GET /api/agents/dispatch @@ -17,7 +12,14 @@ function backendBaseUrl(): string | null { * exists when a FastAPI server is reachable (Vercel has none by default). */ export async function GET() { - return NextResponse.json({ available: backendBaseUrl() !== null }); + const capability = resolveBackendCapability(); + return NextResponse.json({ + available: capability.configured, + // Surfacing the source lets the UI (and an operator reading the network + // tab) see which env var was used, instead of a bare false. + source: capability.source, + reason: capability.reason, + }); } /** @@ -50,21 +52,27 @@ export async function POST(request: Request) { decision: `email=${billingEmail}`, }); - const base = backendBaseUrl(); - if (!base) { + const capability = resolveBackendCapability(); + if (!capability.configured || !capability.url) { return NextResponse.json( - { error: 'Agent backend not configured. Deploy the FastAPI backend and set BACKEND_URL.' }, + { + error: + 'Agent backend not configured. Set BACKEND_URL (or NEXT_PUBLIC_BACKEND_URL) to the FastAPI service.', + reason: capability.reason, + }, { status: 503 }, ); } + const base = capability.url; try { const res = await fetch(`${base}/api/v1/agents/dispatch`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(process.env.EVENTRELAY_API_KEY ? { 'X-API-Key': process.env.EVENTRELAY_API_KEY } : {}), - }, + // Use the shared header builder: it trims EVENTRELAY_API_KEY, which the + // inline version here did not. An API key stored in Secret Manager + // commonly carries a trailing newline, and an untrimmed header value + // makes the backend reject the request as unauthorized. + headers: backendHeaders(), body: JSON.stringify({ job_id: body.job_id, events: body.events ?? [], diff --git a/apps/web/src/app/api/agents/status/route.ts b/apps/web/src/app/api/agents/status/route.ts index c142bf352..619d19e97 100644 --- a/apps/web/src/app/api/agents/status/route.ts +++ b/apps/web/src/app/api/agents/status/route.ts @@ -1,11 +1,5 @@ import { NextResponse } from 'next/server'; -import { backendHeaders } from '@/lib/pipeline-backend'; - -/** Resolve the FastAPI backend base URL, or null if not configured. */ -function backendBaseUrl(): string | null { - const raw = process.env.BACKEND_URL || ''; - return raw.startsWith('http') ? raw : null; -} +import { backendHeaders, resolveBackendCapability } from '@/lib/backend/capability'; /** * GET /api/agents/status?agentId=... @@ -14,10 +8,14 @@ function backendBaseUrl(): string | null { * dispatched agent's progress without exposing the backend URL to the browser. */ export async function GET(request: Request) { - const base = backendBaseUrl(); - if (!base) { - return NextResponse.json({ error: 'Agent backend not configured.' }, { status: 503 }); + const capability = resolveBackendCapability(); + if (!capability.configured || !capability.url) { + return NextResponse.json( + { error: 'Agent backend not configured.', reason: capability.reason }, + { status: 503 }, + ); } + const base = capability.url; const agentId = new URL(request.url).searchParams.get('agentId'); if (!agentId) { diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts index f078f5a9a..ec9b19ae9 100644 --- a/apps/web/src/app/api/chat/route.ts +++ b/apps/web/src/app/api/chat/route.ts @@ -7,12 +7,13 @@ import { grokChatCompletion } from '@/lib/billing/grok-client'; import { FREE_CHAT_DAILY_LIMIT, resolvePaidTierRouting } from '@/lib/billing/paid-tier-model'; import { kaizenObserve } from '@/lib/billing/kaizen-trace'; import { aiGateway, GATEWAY_CHAT_MODEL } from '@/lib/ai-gateway'; +import { backendHeaders, resolveLegacyBackend } from '@/lib/backend/capability'; type ChatHistoryMessage = { role: 'user' | 'assistant'; content: string }; -const rawBackendUrl = process.env.BACKEND_URL || ''; -const BACKEND_URL = rawBackendUrl.startsWith('http') ? rawBackendUrl : 'http://localhost:8000'; -const BACKEND_AVAILABLE = rawBackendUrl.startsWith('http'); +// Resolved through the shared capability resolver so this also picks up +// NEXT_PUBLIC_BACKEND_URL (audit finding F1). +const { url: BACKEND_URL, available: BACKEND_AVAILABLE } = resolveLegacyBackend(); function isValidChatHistoryMessage(message: unknown): message is ChatHistoryMessage { if (!message || typeof message !== 'object') { @@ -85,13 +86,13 @@ export async function POST(request: Request) { if (BACKEND_AVAILABLE) { const response = await fetch(`${BACKEND_URL}/api/v1/chat`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(process.env.EVENTRELAY_API_KEY ? { 'X-API-Key': process.env.EVENTRELAY_API_KEY } : {}), + // Shared builder trims EVENTRELAY_API_KEY; billing routing headers are + // passed through as extras. + headers: backendHeaders({ 'X-Billing-Plan': routing.plan, 'X-Lead-Model': routing.model, 'X-Lead-Runtime': routing.runtime, - }, + }), body: JSON.stringify({ message: body.query, video_url: body.video_url || '', @@ -124,7 +125,8 @@ export async function POST(request: Request) { if (!process.env.AI_GATEWAY_API_KEY && !process.env.VERCEL_AI_GATEWAY_API_KEY && !process.env.VERCEL_API_KEY) { return NextResponse.json( { - answer: 'Chat requires either BACKEND_URL or AI_GATEWAY_API_KEY to be configured.', + answer: + 'Chat requires a backend (BACKEND_URL or NEXT_PUBLIC_BACKEND_URL) or AI_GATEWAY_API_KEY to be configured.', routing, plan: routing.plan, }, @@ -159,4 +161,4 @@ export async function POST(request: Request) { { status: 502 }, ); } -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/dashboard/route.ts b/apps/web/src/app/api/dashboard/route.ts index cc054b0fb..37e7b38e1 100644 --- a/apps/web/src/app/api/dashboard/route.ts +++ b/apps/web/src/app/api/dashboard/route.ts @@ -1,13 +1,32 @@ import { NextResponse } from 'next/server'; +import { backendHeaders, resolveBackendCapability } from '@/lib/backend/capability'; -const rawBackendUrl = process.env.BACKEND_URL || ''; -const BACKEND_URL = rawBackendUrl.startsWith('http') ? rawBackendUrl : 'http://localhost:8000'; -const BACKEND_AVAILABLE = rawBackendUrl.startsWith('http'); +/** + * Dashboard metrics + Looker embed proxy. + * + * Previously this module computed a `BACKEND_AVAILABLE` flag at import time and + * then never read it, so both handlers fetched the `http://localhost:8000` + * placeholder on every production request. GET swallowed the connection error + * and reported `status: 'degraded'` — indistinguishable from a real backend + * outage — while POST returned a generic 500. Resolution now happens + * per-request through the shared capability resolver, and an unconfigured + * backend is reported as exactly that. + */ export async function GET() { + const capability = resolveBackendCapability(); + if (!capability.configured || !capability.url) { + return NextResponse.json({ + status: 'unconfigured', + timestamp: new Date().toISOString(), + reason: capability.reason, + metrics: { activeWorkflows: 0, totalProcessed: 0, errorRate: 0 }, + }); + } + try { - // Use the real backend health endpoint - const response = await fetch(`${BACKEND_URL}/api/v1/health`, { + const response = await fetch(`${capability.url}/api/v1/health`, { + headers: backendHeaders(), signal: AbortSignal.timeout(5000), }); @@ -28,34 +47,42 @@ export async function GET() { }); } catch (error) { console.error('Dashboard stats error:', error); - // Return honest fallback — backend is not reachable + // Honest fallback: the backend is configured but not answering right now. return NextResponse.json({ status: 'degraded', timestamp: new Date().toISOString(), - metrics: { - activeWorkflows: 0, - totalProcessed: 0, - errorRate: 0, - }, + reason: error instanceof Error ? error.message : String(error), + metrics: { activeWorkflows: 0, totalProcessed: 0, errorRate: 0 }, }); } } export async function POST(request: Request) { + const capability = resolveBackendCapability(); + if (!capability.configured || !capability.url) { + return NextResponse.json( + { + error: + 'Reporting backend not configured. Set BACKEND_URL (or NEXT_PUBLIC_BACKEND_URL).', + reason: capability.reason, + }, + { status: 503 }, + ); + } + try { const body = await request.json(); - - const response = await fetch(`${BACKEND_URL}/api/v1/reporting/embed/dashboard`, { + + const response = await fetch(`${capability.url}/api/v1/reporting/embed/dashboard`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(process.env.EVENTRELAY_API_KEY ? { 'X-API-Key': process.env.EVENTRELAY_API_KEY } : {}), - }, + // Shared builder trims EVENTRELAY_API_KEY; the previous inline header did + // not, so a Secret Manager newline produced a silent 401. + headers: backendHeaders(), body: JSON.stringify({ dashboard_id: body.dashboard_id || 'events_overview', tenant_id: body.tenant_id || 'tenant_default', user_id: body.user_id || 'user_demo', - user_email: body.user_email || 'demo@example.com' + user_email: body.user_email || 'demo@example.com', }), signal: AbortSignal.timeout(5000), }); @@ -65,13 +92,12 @@ export async function POST(request: Request) { throw new Error(`Backend Looker service failed: ${response.status} ${errText}`); } - const data = await response.json(); - return NextResponse.json(data); + return NextResponse.json(await response.json()); } catch (error) { console.error('Dashboard embed error:', error); return NextResponse.json( { error: 'Failed to retrieve dashboard embed URL' }, - { status: 500 } + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/jobs/[jobId]/route.ts b/apps/web/src/app/api/jobs/[jobId]/route.ts index 5c90255f2..59ce34fc4 100644 --- a/apps/web/src/app/api/jobs/[jobId]/route.ts +++ b/apps/web/src/app/api/jobs/[jobId]/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from 'next/server'; -import { backendHeaders } from '@/lib/pipeline-backend'; - -const rawBackendUrl = process.env.BACKEND_URL || ''; -const BACKEND_URL = rawBackendUrl.startsWith('http') ? rawBackendUrl : ''; +import { backendHeaders, resolveBackendCapability } from '@/lib/backend/capability'; export const runtime = 'nodejs'; @@ -21,12 +18,21 @@ export async function GET( return NextResponse.json({ error: 'jobId is required' }, { status: 400 }); } - if (!BACKEND_URL) { - return NextResponse.json({ error: 'BACKEND_URL is not configured' }, { status: 503 }); + // Resolved per-request through the shared resolver so this picks up + // NEXT_PUBLIC_BACKEND_URL as well as BACKEND_URL (audit finding F1). + const capability = resolveBackendCapability(); + if (!capability.configured || !capability.url) { + return NextResponse.json( + { + error: 'Backend is not configured. Set BACKEND_URL (or NEXT_PUBLIC_BACKEND_URL).', + reason: capability.reason, + }, + { status: 503 }, + ); } try { - const response = await fetch(`${BACKEND_URL}/api/v1/jobs/${encodeURIComponent(jobId)}`, { + const response = await fetch(`${capability.url}/api/v1/jobs/${encodeURIComponent(jobId)}`, { cache: 'no-store', headers: backendHeaders(), signal: AbortSignal.timeout(15_000), @@ -45,4 +51,4 @@ export async function GET( { status: 502 }, ); } -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/pipeline/stream/route.ts b/apps/web/src/app/api/pipeline/stream/route.ts index d034673f7..cf9826130 100644 --- a/apps/web/src/app/api/pipeline/stream/route.ts +++ b/apps/web/src/app/api/pipeline/stream/route.ts @@ -24,8 +24,13 @@ import { analyzeVideoWithGemini, type VideoAnalysisResult } from '@/lib/gemini-v import { hasGeminiKey } from '@/lib/gemini-client'; import { waitUntil } from '@vercel/functions'; import { publishEvent, EventTypes } from '@/lib/cloudevents'; -import { backendHeaders, resolveBackendStatusUrl } from '@/lib/pipeline-backend'; -import { checkBackendHealth, getBackendConfig } from '@/lib/pipeline-backend-health'; +import { + backendHeaders, + checkBackendHealth, + getBackendConfig, + resolveBackendStatusUrl, + unprobedHealth, +} from '@/lib/backend/capability'; import { saveTrainingExample, TUNING_THRESHOLD } from '@/lib/training-store'; import { PipelineDeadline } from '../route'; import { @@ -659,8 +664,8 @@ export async function POST(request: Request) { let streamMode: 'backend-ws' | 'gemini-sse' = 'gemini-sse'; try { const backendHealth = BACKEND_CONFIGURED - ? await checkBackendHealth(5_000) - : { configured: false, available: false, host: null as string | null }; + ? await checkBackendHealth({ timeoutMs: 5_000 }) + : unprobedHealth(); const useBackend = backendHealth.available; const backendUrl = CONFIGURED_BACKEND_URL; streamMode = useBackend ? 'backend-ws' : 'gemini-sse'; diff --git a/apps/web/src/app/api/runs/[runId]/approve/route.ts b/apps/web/src/app/api/runs/[runId]/approve/route.ts new file mode 100644 index 000000000..9861d9f42 --- /dev/null +++ b/apps/web/src/app/api/runs/[runId]/approve/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; +import { resumeHook } from 'workflow/api'; +import { getRunOwner, latestSpec, loadRun } from '@/lib/db/delivery-repo'; +import { resolveRunUserId } from '@/lib/run-identity'; +import { withWorldVercelFetch } from '@/lib/world-vercel-fetch'; +import { approvalToken } from '@/workflows/delivery-run'; + +export const runtime = 'nodejs'; + +/** + * POST /api/runs/:runId/approve — the human gate. + * + * The workflow is suspended on `approvalHook`; resuming it with the decision is + * the only way a run leaves `awaiting_approval`. Three things are checked + * before the hook is touched: + * + * 1. the caller owns the run (approval is an authorization decision); + * 2. the run is actually waiting (no re-approving a finished run); + * 3. a spec version exists (an approval must reference what was read). + * + * The row itself is written inside the workflow's approval step, so the + * decision and the phase transition succeed or fail together. + */ +export async function POST( + request: Request, + context: { params: Promise<{ runId: string }> }, +): Promise { + const userId = await resolveRunUserId(request); + if (!userId) { + return NextResponse.json({ error: 'Sign in required' }, { status: 401 }); + } + + const { runId } = await context.params; + const owner = await getRunOwner(runId); + if (!owner || owner !== userId) { + return NextResponse.json({ error: 'Run not found' }, { status: 404 }); + } + + let body: { approved?: unknown; note?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (typeof body.approved !== 'boolean') { + return NextResponse.json( + { error: 'approved (boolean) is required' }, + { status: 400 }, + ); + } + const note = typeof body.note === 'string' ? body.note.trim().slice(0, 2_000) : undefined; + + const run = await loadRun(runId); + if (!run) { + return NextResponse.json({ error: 'Run not found' }, { status: 404 }); + } + if (run.phase !== 'awaiting_approval') { + return NextResponse.json( + { error: `Run is ${run.phase}, not awaiting approval`, phase: run.phase }, + { status: 409 }, + ); + } + + const spec = await latestSpec(runId); + if (!spec) { + return NextResponse.json( + { error: 'No spec version to approve' }, + { status: 409 }, + ); + } + + try { + await withWorldVercelFetch(() => + resumeHook(approvalToken(runId), { + approved: body.approved as boolean, + decidedBy: userId, + note, + }), + ); + } catch (error) { + console.error('[api/runs/approve] resumeHook failed', error); + return NextResponse.json( + { error: 'Approval could not be delivered to the run', runId }, + { status: 502 }, + ); + } + + return NextResponse.json({ + ok: true, + runId, + specId: spec.id, + specVersion: spec.version, + decision: body.approved ? 'approved' : 'rejected', + decidedBy: userId, + }); +} diff --git a/apps/web/src/app/api/runs/[runId]/route.ts b/apps/web/src/app/api/runs/[runId]/route.ts new file mode 100644 index 000000000..c0409b9e4 --- /dev/null +++ b/apps/web/src/app/api/runs/[runId]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { getRunOwner, latestApproval, latestSpec, loadRun } from '@/lib/db/delivery-repo'; +import { resolveRunUserId } from '@/lib/run-identity'; + +export const runtime = 'nodejs'; + +/** + * GET /api/runs/:runId — full run state: phase, every gate with its evidence, + * the spec version under review, and the recorded approval. + * + * The gate list is the product's actual claim, so it is returned in full rather + * than reduced to a status string. A blocked run reads as blocked, with the + * gate that stopped it named. + */ +export async function GET( + request: Request, + context: { params: Promise<{ runId: string }> }, +): Promise { + const userId = await resolveRunUserId(request); + if (!userId) { + return NextResponse.json({ error: 'Sign in required' }, { status: 401 }); + } + + const { runId } = await context.params; + const owner = await getRunOwner(runId); + // 404 rather than 403 for someone else's run: a wrong guess should not + // confirm that the id exists. + if (!owner || owner !== userId) { + return NextResponse.json({ error: 'Run not found' }, { status: 404 }); + } + + const run = await loadRun(runId); + if (!run) { + return NextResponse.json({ error: 'Run not found' }, { status: 404 }); + } + + const [spec, approval] = await Promise.all([latestSpec(runId), latestApproval(runId)]); + + return NextResponse.json({ + run, + spec: spec + ? { + id: spec.id, + version: spec.version, + requirements: spec.requirements, + plan: spec.plan, + createdAt: spec.createdAt.toISOString(), + } + : null, + approval, + awaitingApproval: run.phase === 'awaiting_approval', + }); +} diff --git a/apps/web/src/app/api/runs/[runId]/stream/route.ts b/apps/web/src/app/api/runs/[runId]/stream/route.ts new file mode 100644 index 000000000..1dd006d6a --- /dev/null +++ b/apps/web/src/app/api/runs/[runId]/stream/route.ts @@ -0,0 +1,112 @@ +import { getRunOwner, loadRun } from '@/lib/db/delivery-repo'; +import { resolveRunUserId } from '@/lib/run-identity'; +import { isTerminalPhase } from '@/lib/delivery-lifecycle'; + +export const runtime = 'nodejs'; +/** Long-lived SSE connection; the client reconnects when it ends. */ +export const maxDuration = 300; + +const POLL_MS = 2_000; +const HEARTBEAT_MS = 15_000; + +/** + * GET /api/runs/:runId/stream — server-sent events for one run. + * + * The durable state lives in Postgres, so this streams by polling that state + * rather than by holding workflow state in memory: a reconnect after a + * serverless instance dies resumes with the same truth, and nothing is lost + * because the connection dropped. + * + * Only changed snapshots are emitted, so an idle run costs one heartbeat. + */ +export async function GET( + request: Request, + context: { params: Promise<{ runId: string }> }, +): Promise { + const userId = await resolveRunUserId(request); + if (!userId) { + return new Response('Sign in required', { status: 401 }); + } + + const { runId } = await context.params; + const owner = await getRunOwner(runId); + if (!owner || owner !== userId) { + return new Response('Run not found', { status: 404 }); + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + let closed = false; + let lastSnapshot = ''; + let lastSendAt = 0; + + const send = (event: string, data: unknown) => { + if (closed) return; + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + lastSendAt = Date.now(); + }; + + const close = () => { + if (closed) return; + closed = true; + clearInterval(timer); + try { + controller.close(); + } catch { + // already closed by the client + } + }; + + request.signal.addEventListener('abort', close); + + const tick = async () => { + if (closed) return; + try { + const run = await loadRun(runId); + if (!run) { + send('error', { message: 'Run disappeared' }); + close(); + return; + } + + const snapshot = JSON.stringify(run); + if (snapshot !== lastSnapshot) { + lastSnapshot = snapshot; + send('run', run); + } else if (Date.now() - lastSendAt >= HEARTBEAT_MS) { + send('ping', { at: new Date().toISOString() }); + } + + // `blocked` ends the stream too: it is resumable, but only by an + // operator, so there is nothing further to watch until they act. + if (isTerminalPhase(run.phase) || run.phase === 'blocked') { + send('done', { + phase: run.phase, + reason: run.blockedReason ?? run.error ?? null, + }); + close(); + } + } catch (error) { + console.error('[api/runs/stream]', error); + send('error', { message: 'Run state is temporarily unreadable' }); + } + }; + + const timer = setInterval(tick, POLL_MS); + await tick(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/apps/web/src/app/api/runs/__tests__/route.test.ts b/apps/web/src/app/api/runs/__tests__/route.test.ts new file mode 100644 index 000000000..ee19ceb45 --- /dev/null +++ b/apps/web/src/app/api/runs/__tests__/route.test.ts @@ -0,0 +1,213 @@ +/** + * Regression tests for the run API guards. + * + * Two of these encode audit findings directly: + * - a failed `start()` must leave a *blocked* run, never a run stuck in + * `sourcing` with no worker attached (silent loss); + * - approval must be refused unless the run is actually waiting, so a + * replayed request cannot re-approve a finished run. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const start = vi.fn(); +const resumeHook = vi.fn(); +const createRun = vi.fn(); +const blockRun = vi.fn(); +const listRuns = vi.fn(); +const loadRun = vi.fn(); +const latestSpec = vi.fn(); +const latestApproval = vi.fn(); +const getRunOwner = vi.fn(); +const resolveRunUserId = vi.fn(); + +vi.mock('workflow/api', () => ({ + start: (...args: unknown[]) => start(...args), + resumeHook: (...args: unknown[]) => resumeHook(...args), +})); + +vi.mock('@/workflows/delivery-run', () => ({ + deliveryRunWorkflow: async () => ({}), + approvalToken: (runId: string) => `delivery-approval:${runId}`, +})); + +vi.mock('@/lib/db/delivery-repo', () => ({ + createRun: (...args: unknown[]) => createRun(...args), + blockRun: (...args: unknown[]) => blockRun(...args), + listRuns: (...args: unknown[]) => listRuns(...args), + loadRun: (...args: unknown[]) => loadRun(...args), + latestSpec: (...args: unknown[]) => latestSpec(...args), + latestApproval: (...args: unknown[]) => latestApproval(...args), + getRunOwner: (...args: unknown[]) => getRunOwner(...args), +})); + +vi.mock('@/lib/run-identity', () => ({ + LOCAL_DEV_USER: 'local-dev@eventrelay.invalid', + resolveRunUserId: (...args: unknown[]) => resolveRunUserId(...args), +})); + +const VIDEO = 'https://www.youtube.com/watch?v=auJzb1D-fag'; +const OWNER = 'owner@example.com'; +const RUN_ID = '11111111-1111-4111-8111-111111111111'; + +function postRuns(body: unknown): Request { + return new Request('https://uvai.io/api/runs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetModules(); + for (const fn of [ + start, + resumeHook, + createRun, + blockRun, + listRuns, + loadRun, + latestSpec, + latestApproval, + getRunOwner, + resolveRunUserId, + ]) { + fn.mockReset(); + } + resolveRunUserId.mockResolvedValue(OWNER); +}); + +describe('POST /api/runs', () => { + it('rejects an anonymous caller', async () => { + resolveRunUserId.mockResolvedValue(null); + const { POST } = await import('../route'); + const res = await POST(postRuns({ sourceUrl: VIDEO })); + expect(res.status).toBe(401); + expect(createRun).not.toHaveBeenCalled(); + }); + + it('rejects a non-YouTube source instead of fetching it', async () => { + const { POST } = await import('../route'); + const res = await POST(postRuns({ sourceUrl: 'http://169.254.169.254/latest/meta-data' })); + expect(res.status).toBe(400); + expect(createRun).not.toHaveBeenCalled(); + }); + + it('requires either a source URL or an idea', async () => { + const { POST } = await import('../route'); + const res = await POST(postRuns({})); + expect(res.status).toBe(400); + }); + + it('starts the workflow and returns the run id', async () => { + createRun.mockResolvedValue(RUN_ID); + start.mockResolvedValue({ runId: 'wf_1' }); + + const { POST } = await import('../route'); + const res = await POST(postRuns({ sourceUrl: VIDEO })); + const json = (await res.json()) as Record; + + expect(res.status).toBe(202); + expect(json.runId).toBe(RUN_ID); + expect(json.streamUrl).toBe(`/api/runs/${RUN_ID}/stream`); + expect(start).toHaveBeenCalledOnce(); + expect(blockRun).not.toHaveBeenCalled(); + }); + + it('blocks the run when workflow dispatch fails, never leaving it silently queued', async () => { + createRun.mockResolvedValue(RUN_ID); + start.mockRejectedValue(new Error('world not configured')); + blockRun.mockResolvedValue(undefined); + + const { POST } = await import('../route'); + const res = await POST(postRuns({ idea: 'a delivery engine for internal ops teams' })); + + expect(res.status).toBe(500); + expect(blockRun).toHaveBeenCalledWith( + RUN_ID, + 'sourcing', + expect.stringContaining('workflow dispatch failed'), + ); + }); + + it('reports 503 rather than 500 when run storage is unavailable', async () => { + createRun.mockRejectedValue(new Error('NEON_DATABASE_URL is not set')); + const { POST } = await import('../route'); + const res = await POST(postRuns({ sourceUrl: VIDEO })); + expect(res.status).toBe(503); + expect(start).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/runs/:runId/approve', () => { + const params = { params: Promise.resolve({ runId: RUN_ID }) }; + + function approveRequest(body: unknown): Request { + return new Request(`https://uvai.io/api/runs/${RUN_ID}/approve`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + it("returns 404 for another user's run rather than confirming it exists", async () => { + getRunOwner.mockResolvedValue('someone-else@example.com'); + const { POST } = await import('../[runId]/approve/route'); + const res = await POST(approveRequest({ approved: true }), params); + expect(res.status).toBe(404); + expect(resumeHook).not.toHaveBeenCalled(); + }); + + it('refuses to approve a run that is not awaiting approval', async () => { + getRunOwner.mockResolvedValue(OWNER); + loadRun.mockResolvedValue({ id: RUN_ID, phase: 'delivered' }); + + const { POST } = await import('../[runId]/approve/route'); + const res = await POST(approveRequest({ approved: true }), params); + + expect(res.status).toBe(409); + expect(resumeHook).not.toHaveBeenCalled(); + }); + + it('refuses approval when no spec version was persisted', async () => { + getRunOwner.mockResolvedValue(OWNER); + loadRun.mockResolvedValue({ id: RUN_ID, phase: 'awaiting_approval' }); + latestSpec.mockResolvedValue(null); + + const { POST } = await import('../[runId]/approve/route'); + const res = await POST(approveRequest({ approved: true }), params); + + expect(res.status).toBe(409); + expect(resumeHook).not.toHaveBeenCalled(); + }); + + it('resumes the hook with the session identity, not a client-supplied one', async () => { + getRunOwner.mockResolvedValue(OWNER); + loadRun.mockResolvedValue({ id: RUN_ID, phase: 'awaiting_approval' }); + latestSpec.mockResolvedValue({ id: 'spec-1', version: 2 }); + resumeHook.mockResolvedValue({ runId: 'wf_1' }); + + const { POST } = await import('../[runId]/approve/route'); + const res = await POST( + approveRequest({ approved: true, decidedBy: 'attacker@example.com', note: 'ship it' }), + params, + ); + const json = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(json.decidedBy).toBe(OWNER); + expect(json.specVersion).toBe(2); + expect(resumeHook).toHaveBeenCalledWith(`delivery-approval:${RUN_ID}`, { + approved: true, + decidedBy: OWNER, + note: 'ship it', + }); + }); + + it('requires an explicit boolean decision', async () => { + getRunOwner.mockResolvedValue(OWNER); + const { POST } = await import('../[runId]/approve/route'); + const res = await POST(approveRequest({ note: 'looks fine' }), params); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/web/src/app/api/runs/route.ts b/apps/web/src/app/api/runs/route.ts new file mode 100644 index 000000000..e275c1510 --- /dev/null +++ b/apps/web/src/app/api/runs/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from 'next/server'; +import { start } from 'workflow/api'; +import { extractYouTubeId } from '@/lib/timestamp'; +import { createRun, blockRun, listRuns } from '@/lib/db/delivery-repo'; +import { resolveRunUserId } from '@/lib/run-identity'; +import { workflowStartErrorBody } from '@/lib/sentry-server-integrations'; +import { withWorldVercelFetch } from '@/lib/world-vercel-fetch'; +import { deliveryRunWorkflow } from '@/workflows/delivery-run'; + +export const runtime = 'nodejs'; +/** `start()` returns as soon as the run is enqueued; the work is durable. */ +export const maxDuration = 60; + +const MAX_IDEA_CHARS = 8_000; + +/** + * POST /api/runs — open a delivery run and start the durable workflow. + * + * The row is created *before* the workflow starts so a failed `start()` leaves + * a visible blocked run instead of nothing at all. Silent loss is the failure + * mode this whole pipeline exists to eliminate, and it applies to its own + * dispatch path too. + */ +export async function POST(request: Request): Promise { + const userId = await resolveRunUserId(request); + if (!userId) { + return NextResponse.json({ error: 'Sign in required' }, { status: 401 }); + } + + let body: { sourceUrl?: unknown; idea?: unknown; title?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const sourceUrl = typeof body.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; + const idea = typeof body.idea === 'string' ? body.idea.trim().slice(0, MAX_IDEA_CHARS) : ''; + + if (!sourceUrl && !idea) { + return NextResponse.json( + { error: 'Provide either sourceUrl (YouTube) or idea text' }, + { status: 400 }, + ); + } + + // Same restriction as the video workflow: only an extractable YouTube id is + // accepted, which removes the user-controlled server-fetch target entirely + // rather than relying on a hostname denylist. + if (sourceUrl && !extractYouTubeId(sourceUrl)) { + return NextResponse.json( + { error: 'sourceUrl must be a valid YouTube watch, share, embed, shorts, or live URL' }, + { status: 400 }, + ); + } + + const title = + (typeof body.title === 'string' && body.title.trim().slice(0, 200)) || + (sourceUrl ? `Delivery from ${sourceUrl}` : idea.slice(0, 80)); + + let runId: string; + try { + runId = await createRun({ + userId, + title, + sourceKind: sourceUrl ? 'video' : 'idea', + sourceUrl: sourceUrl || undefined, + }); + } catch (error) { + console.error('[api/runs] createRun failed', error); + return NextResponse.json( + { error: 'Run storage is unavailable — set NEON_DATABASE_URL to start runs' }, + { status: 503 }, + ); + } + + try { + const run = await withWorldVercelFetch(() => + start(deliveryRunWorkflow, [ + { runId, userId, sourceUrl: sourceUrl || undefined, idea: idea || undefined }, + ]), + ); + + return NextResponse.json( + { + ok: true, + runId, + workflowRunId: run.runId, + phase: 'sourcing', + statusUrl: `/api/runs/${runId}`, + streamUrl: `/api/runs/${runId}/stream`, + approveUrl: `/api/runs/${runId}/approve`, + }, + { status: 202 }, + ); + } catch (error) { + console.error('[api/runs] workflow start failed', error); + const reason = error instanceof Error ? error.message : String(error); + // The run exists; make its state honest rather than leaving it stuck in + // `sourcing` forever with no worker attached. + await blockRun(runId, 'sourcing', `workflow dispatch failed: ${reason}`).catch(() => {}); + return NextResponse.json( + { ok: false, runId, ...workflowStartErrorBody(error) }, + { status: 500 }, + ); + } +} + +/** GET /api/runs — this user's runs, newest first. */ +export async function GET(request: Request): Promise { + const userId = await resolveRunUserId(request); + if (!userId) { + return NextResponse.json({ error: 'Sign in required' }, { status: 401 }); + } + + const limitParam = Number(new URL(request.url).searchParams.get('limit')); + const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 50; + + const runs = await listRuns(userId, limit); + return NextResponse.json({ runs }); +} diff --git a/apps/web/src/app/api/video/route.ts b/apps/web/src/app/api/video/route.ts index 36814ba97..d928de3d1 100644 --- a/apps/web/src/app/api/video/route.ts +++ b/apps/web/src/app/api/video/route.ts @@ -15,11 +15,13 @@ import { parseVerifiedBackendTranscript, type TranscriptionResult, } from '@/lib/transcription-service'; +import { backendHeaders, resolveLegacyBackend } from '@/lib/backend/capability'; -// Backend URL with validation - skip if not a valid URL -const rawBackendUrl = process.env.BACKEND_URL || ''; -const BACKEND_URL = rawBackendUrl.startsWith('http') ? rawBackendUrl : 'http://localhost:8000'; -const BACKEND_AVAILABLE = rawBackendUrl.startsWith('http'); +// Resolved through the shared capability resolver so this also picks up +// NEXT_PUBLIC_BACKEND_URL (audit finding F1). Previously BACKEND_AVAILABLE was +// always false in production, so Strategy 1 (the full backend pipeline) was +// skipped on every request and this route silently ran frontend-only. +const { url: BACKEND_URL, available: BACKEND_AVAILABLE } = resolveLegacyBackend(); export const runtime = 'nodejs'; export const maxDuration = 120; @@ -107,7 +109,9 @@ export async function POST(request: Request) { try { response = await fetch(`${BACKEND_URL}/api/v1/transcript-action`, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...(process.env.EVENTRELAY_API_KEY ? { 'X-API-Key': process.env.EVENTRELAY_API_KEY } : {}) }, + // Shared builder trims EVENTRELAY_API_KEY (Secret Manager values + // commonly carry a trailing newline, which yields a silent 401). + headers: backendHeaders(), body: JSON.stringify({ video_url: url, language: 'en' }), signal: controller.signal, }); diff --git a/apps/web/src/app/studio/page.tsx b/apps/web/src/app/studio/page.tsx index 3403dec01..369ba334d 100644 --- a/apps/web/src/app/studio/page.tsx +++ b/apps/web/src/app/studio/page.tsx @@ -1,5 +1,17 @@ -import VideoWorkflowStudio from '@/components/VideoWorkflowStudio'; +import type { Metadata } from 'next'; +import RunConsole from '@/components/run/RunConsole'; +export const metadata: Metadata = { + title: 'Delivery run — EventRelay', + description: + 'Source to shipped product with a gate report: requirements, human approval, build, tests, and a live URL that answered a request.', +}; + +/** + * `/studio` is the single run surface. The former 1200-line video workflow + * component still exists for the analysis-only path; this route is the + * verified delivery pipeline. + */ export default function StudioPage() { - return ; -} \ No newline at end of file + return ; +} diff --git a/apps/web/src/components/run/DeliveryCard.tsx b/apps/web/src/components/run/DeliveryCard.tsx new file mode 100644 index 000000000..067f95c3e --- /dev/null +++ b/apps/web/src/components/run/DeliveryCard.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { ExternalLink, GitBranch, OctagonAlert, ShieldCheck } from 'lucide-react'; +import type { DeliveryRun } from '@/lib/delivery-lifecycle'; + +/** + * The outcome, stated plainly. + * + * A blocked run gets the same prominence as a delivered one and names the gate + * that stopped it. Presenting a partial result as a success is the exact + * failure this pipeline was built to prevent, so the UI refuses to do it. + */ + +export interface DeliveryCardProps { + run: DeliveryRun; +} + +export default function DeliveryCard({ run }: DeliveryCardProps) { + const { repoUrl, deploymentUrl, testsPassedAt } = run.evidence; + + if (run.phase === 'blocked' || run.phase === 'failed' || run.phase === 'cancelled') { + return ( +
+
+
+

+ {run.blockedReason || run.error || 'The run stopped without recording a reason.'} +

+ {run.blockedFrom && ( +

+ stopped in: {run.blockedFrom} +

+ )} +
+ ); + } + + if (run.phase !== 'delivered') { + return ( +

+ The repository, test evidence, and live URL appear here when every gate has passed. +

+ ); + } + + return ( +
+
+
+ +
+ {deploymentUrl && ( + + + {deploymentUrl} + + + )} + {repoUrl && ( + + + + + )} + {testsPassedAt && ( + + + {new Date(testsPassedAt).toLocaleString()} + + + )} +
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/run/GateReport.tsx b/apps/web/src/components/run/GateReport.tsx new file mode 100644 index 000000000..838e9b578 --- /dev/null +++ b/apps/web/src/components/run/GateReport.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { CircleCheck, CircleMinus, CircleX } from 'lucide-react'; +import type { DeliveryGate } from '@/lib/delivery-lifecycle'; + +/** + * The centrepiece of the surface: every gate, its verdict, and the proof. + * + * The product's claim is not "a run finished" but "a run finished and here is + * why you can believe it". Evidence is therefore rendered verbatim — exit + * codes, counts, status codes, commit SHAs — instead of being summarised into + * a badge that would be indistinguishable from a fabricated one. + */ + +const GATE_LABELS: Record = { + source_evidence: 'Source evidence', + requirements_complete: 'Requirements complete', + plan_executable: 'Plan executable', + human_approved: 'Human approved', + build_succeeded: 'Build succeeded', + tests_passed: 'Tests passed', + deployment_live: 'Deployment live', +}; + +export interface GateReportProps { + gates: DeliveryGate[]; +} + +export default function GateReport({ gates }: GateReportProps) { + if (gates.length === 0) { + return ( +

+ No gates have been evaluated yet. Every claim this run makes will appear here with + its evidence. +

+ ); + } + + return ( +
    + {gates.map((gate, index) => ( +
  • +
    +
    + + + {GATE_LABELS[gate.kind] ?? gate.kind} + +
    + +
    + +
    + {Object.entries(gate.evidence).map(([key, value]) => ( +
    +
    + {key} +
    +
    + {format(value)} +
    +
    + ))} +
    +
  • + ))} +
+ ); +} + +function GateIcon({ result }: { result: DeliveryGate['result'] }) { + if (result === 'pass') { + return ( + + ); + } + if (result === 'fail') { + return ; + } + return ( + + ); +} + +function format(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} diff --git a/apps/web/src/components/run/RunConsole.tsx b/apps/web/src/components/run/RunConsole.tsx new file mode 100644 index 000000000..edfd3459b --- /dev/null +++ b/apps/web/src/components/run/RunConsole.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { Radio, RotateCcw } from 'lucide-react'; +import { useRun } from '@/hooks/use-run'; +import DeliveryCard from './DeliveryCard'; +import GateReport from './GateReport'; +import RunTimeline from './RunTimeline'; +import SourceStep from './SourceStep'; +import SpecReview from './SpecReview'; + +/** + * The single run surface. + * + * Composition only: every piece of state comes from `useRun`, which mirrors the + * persisted run. Audit finding F6 was a 1200-line component that mixed + * fetching, state, and every phase of UI; the phases are separate presentational + * components here so a change to one cannot silently alter another. + */ + +export default function RunConsole() { + const [runId, setRunId] = useState(null); + const [starting, setStarting] = useState(false); + const [startError, setStartError] = useState(null); + const { run, spec, approval, loading, error, live, submitting, approve } = useRun(runId); + + const start = useCallback(async (input: { sourceUrl?: string; idea?: string }) => { + setStarting(true); + setStartError(null); + try { + const response = await fetch('/api/runs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + const body = (await response.json().catch(() => ({}))) as { + runId?: string; + error?: string; + }; + if (!response.ok || !body.runId) { + throw new Error(body.error || `Could not start the run (${response.status})`); + } + setRunId(body.runId); + } catch (e: unknown) { + setStartError(e instanceof Error ? e.message : String(e)); + } finally { + setStarting(false); + } + }, []); + + return ( +
+
+
+
+

+ Delivery run +

+
+ {live && ( + + + )} + {runId && ( + + )} +
+
+

+ Source to shipped product, one gate at a time. Nothing is reported as delivered + without a repository, a passing build, and a live URL that answered a request. +

+ {runId && ( +

+ run {runId} +

+ )} +
+ + {!runId && ( + + + + )} + + {runId && loading && !run && ( +

+ Loading run… +

+ )} + + {error && ( +

+ {error} +

+ )} + + {run && ( + <> + + + + + + + + + + + + + + + + + )} +
+
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} diff --git a/apps/web/src/components/run/RunTimeline.tsx b/apps/web/src/components/run/RunTimeline.tsx new file mode 100644 index 000000000..1286e0c5f --- /dev/null +++ b/apps/web/src/components/run/RunTimeline.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { Check, CircleDashed, LoaderCircle, OctagonAlert } from 'lucide-react'; +import type { DeliveryPhase } from '@/lib/delivery-lifecycle'; + +/** + * The spine of the run surface: the ordered phases and where the run stopped. + * + * `blocked` is rendered *in place* — on the phase it stopped at — rather than + * as an extra step at the end. A run that failed to deploy should read as "got + * to deploying, stopped there", not as a run that quietly ended. + */ + +const ORDER: readonly DeliveryPhase[] = [ + 'sourcing', + 'requirements', + 'planning', + 'awaiting_approval', + 'building', + 'verifying', + 'deploying', + 'delivered', +] as const; + +const LABELS: Record = { + sourcing: 'Source', + requirements: 'Requirements', + planning: 'Plan', + awaiting_approval: 'Approval', + building: 'Build', + verifying: 'Verify', + deploying: 'Deploy', + delivered: 'Delivered', + blocked: 'Blocked', + failed: 'Failed', + cancelled: 'Cancelled', +}; + +type StepState = 'done' | 'active' | 'stopped' | 'pending'; + +function stateFor( + step: DeliveryPhase, + phase: DeliveryPhase, + stoppedAt: DeliveryPhase | null, +): StepState { + if (stoppedAt) { + const stoppedIndex = ORDER.indexOf(stoppedAt); + const index = ORDER.indexOf(step); + if (index < stoppedIndex) return 'done'; + if (index === stoppedIndex) return 'stopped'; + return 'pending'; + } + if (phase === 'delivered') return 'done'; + const current = ORDER.indexOf(phase); + const index = ORDER.indexOf(step); + if (index < current) return 'done'; + if (index === current) return 'active'; + return 'pending'; +} + +export interface RunTimelineProps { + phase: DeliveryPhase; + /** Phase the run was in when it blocked, if any. */ + blockedFrom?: DeliveryPhase; + live?: boolean; +} + +export default function RunTimeline({ phase, blockedFrom, live }: RunTimelineProps) { + const halted = phase === 'blocked' || phase === 'failed' || phase === 'cancelled'; + const stoppedAt: DeliveryPhase | null = halted ? (blockedFrom ?? 'sourcing') : null; + + return ( +
    + {ORDER.map((step, index) => { + const state = stateFor(step, phase, stoppedAt); + return ( +
  1. +
    + + + {LABELS[step]} + +
    + {index < ORDER.length - 1 && ( +
  2. + ); + })} +
+ ); +} + +function StepIcon({ state, live }: { state: StepState; live: boolean }) { + if (state === 'done') { + return