diff --git a/CLAUDE.md b/CLAUDE.md index 269010d77..8530f6750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,12 @@ -# CLAUDE.md +# UVAI — Claude Code Context -This file provides context for Claude Code when working in the EventRelay repository. +Read [AGENTS.md](AGENTS.md) first. Its locked product facts and scope take precedence over historical plans. The current execution boundary is [docs/NEXT-PHASE.md](docs/NEXT-PHASE.md); the evidence-based build-out plan is [docs/MASTER_ROADMAP.md](docs/MASTER_ROADMAP.md). ## Project Overview -EventRelay is an AI-powered video automation platform that transforms YouTube videos into actionable workflows. It captures transcripts, extracts events, dispatches them to MCP (Model Context Protocol) agents, and builds a RAG-based knowledge store. The backend is Python/FastAPI and the frontend is a Next.js/React/TypeScript monorepo. +UVAI (Universal Video Action Intelligence) turns a YouTube URL into a hashed Video Pack and grounded build rails. The public entry is `/`; `OneLoopStudio` at `/studio` is the canonical workbench, and legacy `/dashboard` skins redirect there. EventRelay is the internal Python/FastAPI runtime and repository name, not the public product. The web app uses Next.js App Router, React, and TypeScript. + +Video Pack extraction uses Gemini 3.8 Flash via Vercel AI Gateway. Production pack persistence is Upstash REST only. An App Builder workspace export is not proof of app recreation or deployment. Origin G.A.T.E. is the only authorized next cut; do not start adjacent product work or reopen held `asRecord` / claim work without the required authority. ## Repository Structure @@ -54,22 +56,18 @@ mypy src/ # Type check ### Frontend (Next.js) ```bash -# Install all workspace dependencies -npm install - -# Build (all workspaces via Turbo) -turbo run build -# or: npm run build - -# Dev server -turbo run dev -# or: npm run dev - -# Lint -turbo run lint - -# Test -turbo run test +# Node.js >=22; packageManager is npm@10.8.0; root lockfile only +npm ci + +# Root scripts invoke the local Turbo binary +npm run build +npm run dev +npm run lint +npm run test + +# Focus on the public web app +npm run dev:web +npm --workspace=apps/web run type-check ``` ## Code Style @@ -79,7 +77,7 @@ turbo run test - **Import sorting**: isort (profile: black) - **Linter**: Ruff (E, W, F, I, B, C4, UP rules; E501 ignored) - **Type checking**: mypy strict mode (`disallow_untyped_defs = true`) -- Target Python 3.9+ +- Target Python 3.10+ (`pyproject.toml` is authoritative) - Config in `pyproject.toml` ### TypeScript/JavaScript @@ -104,11 +102,12 @@ turbo run test - **Event-driven**: Events follow `..` naming (e.g. `youtube.video.captured`) - **Dependency injection**: Service container pattern in `backend/containers/` -- **Multi-provider AI**: Routes to Gemini, OpenAI, Anthropic, or Grok -- **MCP integration**: Agent orchestration via Model Context Protocol -- **Database**: SQLite (dev), PostgreSQL (prod); migrations via Alembic -- **Auth**: NextAuth.js (frontend), python-jose (backend) -- **Monorepo**: Turbo for JS workspaces (`apps/*`, `packages/*`, `mcp-servers/*`) +- **Product AI**: Video Pack extraction uses Gemini 3.8 Flash through Vercel AI Gateway; internal runtime paths also contain other provider integrations +- **MCP integration**: Internal agent orchestration via Model Context Protocol +- **Video Pack store**: Upstash REST only; never substitute a Redis TCP client or backend SQL store +- **Other runtime data**: SQLAlchemy / Alembic and auxiliary stores exist; inspect the owning service before changing one +- **Auth**: Existing NextAuth.js web configuration; backend auth has its own middleware policy. Do not infer one from the other +- **Monorepo**: Turbo; root npm workspace membership is `apps/*`. Shared packages and MCP directories are not automatically npm workspaces ## Key Policies diff --git a/GEMINI.md b/GEMINI.md index a9d62b54c..dbcc8773e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,18 +1,16 @@ -# EventRelay — Gemini CLI Context +# UVAI — Gemini CLI Context -This file provides project context for Gemini CLI when working in the EventRelay repository. +Read [AGENTS.md](AGENTS.md) first; its locked facts and scope override historical plans. Use [docs/NEXT-PHASE.md](docs/NEXT-PHASE.md) for the next authorized cut and [docs/MASTER_ROADMAP.md](docs/MASTER_ROADMAP.md) for the remaining build-out. ## Project Overview -EventRelay is an AI-powered video automation platform that transforms YouTube videos into -actionable workflows. It captures transcripts, extracts events, dispatches them to MCP -(Model Context Protocol) agents, and builds a RAG-based knowledge store. The backend is -Python/FastAPI and the frontend is a Next.js/React/TypeScript monorepo. +UVAI (Universal Video Action Intelligence) turns a YouTube URL into a hashed Video Pack and grounded build rails. EventRelay is the internal Python/FastAPI runtime and repository name, not a public product. The web app uses Next.js App Router, React, and TypeScript. `/` is the entry page; `OneLoopStudio` at `/studio` is the workbench. Legacy `/dashboard` skins redirect there. ## Single Workflow -**EventRelay has ONE workflow:** YouTube link → transcript → events → agents → outputs. -Never introduce alternative flows or manual triggers that bypass this pipeline. +**One product loop:** YouTube URL → Video Pack → inspect evidence and build rails → export / act / attempt to ship. Do not introduce a parallel product or bypass evidence gating. A generated evidence workspace is not proof of recreating or deploying the source app. + +Origin G.A.T.E. is the only authorized next cut. Mission Workspace, Agent Factory, ExperienceOS, and held `asRecord` / claim work require the authority described in `AGENTS.md`. ## Repository Structure @@ -75,11 +73,13 @@ mypy src/ ### Frontend (Next.js / Turbo monorepo) ```bash -npm install # install all workspace deps -turbo run build # build all workspaces -turbo run dev # dev servers -turbo run lint -turbo run test +# Node.js >=22; packageManager is npm@10.8.0; root lockfile only +npm ci +npm run build +npm run dev +npm run lint +npm run test +npm --workspace=apps/web run type-check ``` ## Code Style @@ -88,7 +88,7 @@ turbo run test - **Formatter**: Black, 88-char line length - **Linter**: Ruff (E, W, F, I, B, C4, UP; E501 ignored) - **Type checking**: mypy strict (`disallow_untyped_defs = true`) -- Target Python 3.9+; config in `pyproject.toml` +- Target Python 3.10+; `pyproject.toml` is authoritative ### TypeScript - Strict mode TypeScript (`apps/web/tsconfig.json`) @@ -106,11 +106,12 @@ turbo run test - **Event-driven**: events follow `..` (e.g. `youtube.video.captured`) - **Dependency injection**: service container pattern in `backend/containers/` -- **Multi-provider AI**: Gemini (primary), OpenAI, Anthropic, Grok -- **MCP integration**: agent orchestration via Model Context Protocol -- **Database**: SQLite (dev), PostgreSQL (prod) via SQLAlchemy / Alembic -- **Auth**: NextAuth.js (frontend), python-jose (backend) -- **Monorepo**: Turbo for JS workspaces (`apps/*`, `packages/*`, `mcp-servers/*`) +- **Product AI**: Gemini 3.8 Flash via Vercel AI Gateway for Video Pack extraction; other providers remain in internal runtime paths +- **MCP integration**: internal orchestration via Model Context Protocol +- **Video Pack store**: Upstash REST only; Redis TCP and backend SQL are not the pack store +- **Other runtime data**: inspect the owning service before modifying SQLAlchemy / Alembic or auxiliary integrations +- **Auth**: existing NextAuth.js web configuration; backend middleware has a separate policy +- **Monorepo**: Turbo; root npm workspaces are `apps/*`, not every shared-code or MCP directory ## Key Policies @@ -120,13 +121,10 @@ turbo run test - **Type safety**: mypy strict (Python), TypeScript strict (frontend) - **Minimal changes**: make surgical, precise modifications; never delete working code without justification -## Environment Variables (required) +## Configuration boundaries -```bash -GEMINI_API_KEY=... # Google Gemini API -OPENAI_API_KEY=... # OpenAI API -YOUTUBE_API_KEY=... # YouTube Data API v3 -DATABASE_URL=sqlite:///./.runtime/app.db -GITHUB_TOKEN=... # for github MCP server -STITCH_ACCESS_TOKEN=... # for stitch MCP server (optional) -``` +- Video Pack AI uses Vercel AI Gateway; inspect the current extractor and runtime configuration instead of requiring every legacy provider key. +- Production packs require `KV_REST_API_URL` + `KV_REST_API_TOKEN`, or the equivalent `UPSTASH_REDIS_REST_*` pair. +- Backend-dependent actions use `BACKEND_URL` plus the backend's own configured credentials. Missing configuration is not evidence of a successful action. +- Auth, billing, and CLI/MCP credentials belong to their existing integrations. Never print secrets or commit environment files. +- Inspect `.gemini/settings.json` and run `/mcp` before assuming an optional tooling service is connected. See [README.md](README.md) for the current development path. diff --git a/README.md b/README.md index 2b6c10ab1..4962cdd0a 100644 --- a/README.md +++ b/README.md @@ -1,213 +1,119 @@ -# 🎯 EventRelay — AI Video Processing & Event Extraction Platform +# UVAI — Universal Video Action Intelligence [![CI](https://github.com/groupthinking/EventRelay/actions/workflows/ci.yml/badge.svg)](https://github.com/groupthinking/EventRelay/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![Node >= 20](https://img.shields.io/badge/Node-%3E%3D20-green) -![Python >= 3.11](https://img.shields.io/badge/Python-%3E%3D3.11-blue) -AI-powered video transcript capture, structured event extraction, and agent execution for YouTube content. Paste a URL → get a word-for-word transcript, typed events, actionable tasks, workflow packages, and deployable next steps. +Paste a YouTube URL → inspect a hashed **Video Pack** → export grounded build rails → attempt to ship with evidence. The product is **UVAI** at [uvai.io](https://uvai.io); **EventRelay** is the internal runtime and repository name, not a second public product. The differentiator is action and shipping, not transcription. -## Architecture +## Start here -``` -┌──────────────────────────────────────────────────────────┐ -│ Next.js Frontend (apps/web) localhost:3000 │ -│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ -│ │ Dashboard │ │ /api/video │ │ /api/extract- │ │ -│ │ (React + │──│ (proxy to │──│ events │ │ -│ │ Zustand) │ │ backend) │ │ (OpenAI │ │ -│ └─────────────┘ └──────┬───────┘ │ Responses API) │ │ -│ │ └────────────────┘ │ -│ ┌────────────────┐ │ │ -│ │ /api/transcribe │ │ OpenAI STT fallback │ -│ └────────────────┘ │ │ -└──────────────────────────┼──────────────────────────────┘ - │ -┌──────────────────────────┼──────────────────────────────┐ -│ FastAPI Backend (src/) localhost:8000 │ -│ │ │ -│ ┌───────────────────────▼─────────────────────────┐ │ -│ │ /api/v1/transcript-action │ │ -│ │ YouTube transcript → 3 Gemini agents: │ │ -│ │ • transcript_action (summary + tasks) │ │ -│ │ • personality_agent (intent analysis) │ │ -│ │ • strategy_agent (strategic insights) │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ /api/v1/health /api/v1/capabilities /api/v1/videos │ -│ /api/v1/events /api/v1/agents /api/v1/chat │ -└─────────────────────────────────────────────────────────┘ -``` - -**Hybrid AI:** Gemini handles deep analysis (personality, strategy), OpenAI Responses API handles structured event/action extraction with JSON Schema strict mode, and OpenAI STT provides transcription fallback when YouTube captions are unavailable. - -## Quick Start - -### Prerequisites - -- Python >= 3.11 -- Node.js >= 20 -- API keys: `GEMINI_API_KEY` and `OPENAI_API_KEY` +- [AGENTS.md](AGENTS.md): locked product, pricing, storage, and scope rules. +- [Repository map](docs/REPO_MAP.md): current entry points and where code lives. +- [Build-out roadmap](docs/MASTER_ROADMAP.md): inspected capabilities, remaining work, and acceptance criteria. +- [Next authorized cut](docs/NEXT-PHASE.md): Origin G.A.T.E.; later product work remains held. +- [G.A.T.E. contract](docs/gate-transition-contract.md): PASS, HOLD, REJECT, and ESCALATE. -### Setup +## Current product path -```bash -# Clone -git clone https://github.com/groupthinking/EventRelay.git -cd EventRelay - -# Backend — include the youtube extra for full video path (yt-dlp + youtube-transcript-api) -python3 -m venv .venv && source .venv/bin/activate -pip install -e ".[dev,youtube]" - -# Frontend -npm install - -# API keys (add to shell profile or .env) -export GEMINI_API_KEY="your-key" -export OPENAI_API_KEY="your-key" - -# Local auth is opt-in: leave EVENTRELAY_API_KEY unset for open local dev. -# To require auth locally, set a shared key (frontend Next also needs this): -# export EVENTRELAY_API_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" -# Production still fails closed with HTTP 503 if neither EVENTRELAY_API_KEY nor -# ALLOW_UNAUTHENTICATED=1 is configured. +```text +/ — URL entry and Get Pro + → /studio — OneLoopStudio + → hashed Video Pack — Gemini 3.8 Flash via Vercel AI Gateway + → Upstash REST pack persistence + → inspect transcript, visual evidence, SOP, and build rails + → export / App Builder workspace / optional workflow actions + → Attempt deploy → visible G.A.T.E. decision ``` -### Run - -```bash -# Terminal 1: Backend -./scripts/dev_backend.sh -# Or manually: -# PYTHONPATH=src python3 -m uvicorn youtube_extension.main:app --port 8000 - -# Terminal 2: Frontend -cd apps/web && BACKEND_URL=http://localhost:8000 npx next dev --port 3000 -``` +`/dashboard` and its retired skins redirect to `/studio`; they are not separate products. App Builder's deterministic sandbox emitter produces a workspace displaying pack evidence. Exporting that workspace is not proof that the app shown in a video has been recreated, installed, tested, or deployed. -Check readiness: `curl -s localhost:8000/health` should show `"auth_mode":"open_dev"` (or `"api_key"`) and include dependency fields like `"yt_dlp_executable_ready"` / `"ffmpeg_ready"` / `"ffprobe_ready"`. +The existing Studio gate checks a returned HTTPS URL with a hostname before showing a live result. It does **not** independently probe the deployment or establish provider-backed ownership. See the [contract boundary](docs/gate-transition-contract.md#current-evidence-boundary) before making a live-success claim. -Open http://localhost:3000 — paste a YouTube URL and run the studio workflow. The older dashboard remains available at http://localhost:3000/dashboard. +## Locked offers -## How It Works +| Offer | Price | Boundary | +| --- | --- | --- | +| Workflow Pro | $39/mo or $390/yr | Existing Get Pro checkout | +| Maintain | $199/mo per live product | Do not infer checkout availability | +| Ship | Per-job quote | Do not infer checkout availability | -1. **Paste URL** → Dashboard sends to `/api/video` -2. **Transcribe** → Backend fetches YouTube transcript (falls back to OpenAI STT if unavailable) -3. **Analyze** → 3 Gemini agents run: summary, personality mapping, strategy -4. **Extract** → OpenAI Responses API returns structured events, actions, topics via strict JSON Schema -5. **Display** → Dashboard shows everything in tabs: insights, transcript, events, agents +Do not restore the retired EventRelay Pro catalog or invent additional products or prices. -## UVAI Studio + Realtime Voice +## Development -The public first screen is `apps/web/src/components/VideoWorkflowStudio.tsx`: a lightweight video-to-workflow studio with YouTube preview, frame proof, outcome chips, workflow progress, safety gating, and result actions for preview/export/deploy/save. +Requirements come from [package.json](package.json) and [pyproject.toml](pyproject.toml): Node.js 22+, npm 10.8.0 as the pinned package manager, and Python 3.10+ for the internal backend. -Optional voice input is hidden behind a small toggle. The browser creates an `RTCPeerConnection`, sends microphone audio, receives model audio, and opens an `oai-events` data channel. The server endpoint `POST /api/realtime/session` accepts raw SDP and uses `OPENAI_API_KEY` to post multipart `FormData` fields named `sdp` and `session` to OpenAI `/v1/realtime/calls` with `gpt-realtime-2`. The client registers a sample `check_calendar(date, time)` function tool with `session.update`. +From the repository root: -Do not use this app to clone or synthesize a YouTube speaker's voice without explicit consent. Speaker audio should be used for transcript, diarization, tone/context, and source-reference playback only. +```bash +npm ci +npm run dev:web +``` -## API Endpoints +Use the root npm lockfile; do not create an `apps/web/package-lock.json`. `/` is the public entry page and `/studio` is the workbench. -### Frontend Routes (Next.js) +When the selected workflow needs the Python backend: -| Method | Route | Description | -|--------|-------|-------------| -| POST | `/api/video` | Process YouTube URL → transcript + AI analysis | -| POST | `/api/extract-events` | Structured event/action extraction (OpenAI) | -| POST | `/api/transcribe` | Transcription with YouTube/OpenAI STT fallback | -| POST | `/api/chat` | Chat with AI about video content | -| GET | `/api/dashboard` | Backend health check proxy | -| POST | `/api/realtime/session` | Realtime 2 WebRTC SDP exchange for optional voice input | +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e '.[dev,youtube]' +PYTHONPATH=src python3 -m uvicorn youtube_extension.main:app --reload --port 8000 +``` -### Backend Routes (FastAPI) +Set the web runtime's `BACKEND_URL` to the intended backend. A missing backend must produce an honest unavailable/handoff result, not a fabricated deployment receipt. Provider credentials and auth configuration stay in the environment, never in Git. -| Method | Route | Description | -|--------|-------|-------------| -| POST | `/api/v1/transcript-action` | Core pipeline: transcript → agents → results | -| GET | `/api/v1/health` | Service health check | -| GET | `/api/v1/capabilities` | Available features and providers | -| POST | `/api/v1/videos/process` | Async video processing job | -| GET | `/api/v1/videos/{job_id}/status` | Job status polling | -| POST | `/api/v1/events/extract` | Backend event extraction | -| POST | `/api/v1/agents/dispatch` | Dispatch agent execution | -| POST | `/api/v1/chat` | Conversational AI about videos | +### Configuration boundaries -Full API docs at http://localhost:8000/docs (Swagger UI). +| Area | Configuration / source of truth | +| --- | --- | +| Video Pack extraction | Vercel AI Gateway; model defined in `apps/web/src/lib/video-pack-extractor.ts` | +| Production Video Pack storage | `KV_REST_API_URL` + `KV_REST_API_TOKEN`, or `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` | +| Web authentication | Existing NextAuth configuration and `apps/web/src/lib/auth-paths.ts`; do not replace the auth stack as cleanup | +| Backend-dependent workflows | `BACKEND_URL` and the backend's own auth/provider configuration | +| Billing | Existing Stripe setup under `apps/web/src/lib/billing/`; keep server-side catalog validation | -## Project Structure +Redis TCP URLs are **not** the Video Pack store. Backend SQL stores and auxiliary integrations serve other entities; they do not replace the locked Upstash REST pack contract. Direct provider keys used by legacy runtime paths are not a new requirement for every Studio user. -``` -EventRelay/ -├── apps/web/ # Next.js frontend -│ └── src/ -│ ├── app/ -│ │ ├── dashboard/page.tsx # Main dashboard UI -│ │ └── api/ # API routes (video, extract-events, transcribe, chat) -│ ├── components/ # TranscriptViewer, EventList, AgentDashboard, ResultsViewer -│ ├── store/ # Zustand state management -│ └── lib/ # API client, services, types -├── src/youtube_extension/ # FastAPI backend -│ ├── main.py # App entry point -│ └── backend/ -│ ├── api/v1/ # Router + Pydantic models -│ └── services/ai/ # Gemini service, health monitoring -├── tests/unit/ # Python unit tests -├── docs/ # Documentation -├── .github/ # CI workflows, Copilot agent configs -├── Dockerfile # Production container -└── package.json # Monorepo root (npm workspaces) -``` +## Verification -## Testing +Run focused tests for the changed surface, then the broader checks required by that change: ```bash -# Python unit tests (15 tests) -PYTHONPATH=src python3 -m pytest tests/unit/test_api_v1_models.py -v --override-ini="addopts=" - -# Frontend build check +npm exec --workspace=apps/web -- vitest run src/lib/__tests__/gate-transition.test.ts src/lib/__tests__/emit-app-builder-sandbox.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/video-pack-store.test.ts src/app/api/video/sandbox/__tests__/route.test.ts +npm --workspace=apps/web run type-check +npm --workspace=apps/web run lint npm run build:web - -# Lint -npm --prefix apps/web run lint ``` -## Environment Variables +For backend model changes: -| Variable | Required | Description | -|----------|----------|-------------| -| `GEMINI_API_KEY` | Yes | Google AI Studio key for Gemini agents | -| `OPENAI_API_KEY` | Yes | OpenAI key for event extraction, STT, and Realtime voice sessions | -| `AI_GATEWAY_API_KEY` | No | Vercel AI Gateway key for frontend chat fallback, embeddings, and Veo video generation | -| `OPENAI_SAFETY_IDENTIFIER` | No | Optional stable end-user or tenant identifier for OpenAI safety monitoring | -| `BACKEND_URL` | No | Backend URL (default: `http://localhost:8000`) | -| `YOUTUBE_API_KEY` | No | YouTube Data API for enhanced metadata | -| `VERCEL_TOKEN` | No | Vercel access token for MCP / deployment automation | -| `VERCEL_TEAM_ID` | No | Team scope for the Vercel MCP server | +```bash +PYTHONPATH=src python3 -m pytest tests/unit/test_api_v1_models.py -v --override-ini='addopts=' +``` -## Deployment +Default video fixture: `auJzb1D-fag`. Preserve separately documented historical fixtures when checking their original cuts. A passing unit suite, generated bundle, or old smoke receipt is not a fresh production end-to-end result. -```bash -# Docker -docker build -t eventrelay . -docker run -p 8000:8000 -e GEMINI_API_KEY=... -e OPENAI_API_KEY=... eventrelay +## Repository layout -# Vercel (frontend) -vercel deploy --prod -``` +| Path | Responsibility | +| --- | --- | +| `apps/web/` | Next.js App Router product and server routes | +| `src/youtube_extension/` | Internal Python/FastAPI runtime | +| `src/agents/`, `mcp-servers/`, `tools/mcp/` | Internal orchestration and tooling | +| `sdk/` | Client SDKs; keep Python response types aligned with backend models | +| `packages/` | Shared code; root npm workspace membership is defined in `package.json` | +| `tests/`, `apps/web/src/**/__tests__/` | Backend and web verification | +| `docs/`, `scripts/`, `.github/` | Documentation, operational helpers, and CI | -For AI Gateway fallback and experimental video generation in deployed environments, -add `AI_GATEWAY_API_KEY` to the Vercel project environment variables (dashboard -or `vercel env add AI_GATEWAY_API_KEY`). For agent tooling, also configure -`VERCEL_TOKEN` and `VERCEL_TEAM_ID`. See [docs/vercel-ai-setup.md](docs/vercel-ai-setup.md). +## Contributing and deployment -## Contributing +Use a feature branch and reviewable Conventional Commits. Verify the actual diff, preserve unrelated state, and do not delete working code or historical evidence solely because it is old. Production promotion requires current receipts and operator authorization; this README is not a deployment approval. -- Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, etc. -- Run tests before opening PRs -- See [CONTRIBUTING.md](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md) for detailed guidelines +See [CONTRIBUTING.md](CONTRIBUTING.md), the [production runbook](docs/deployment/VERCEL_PRODUCTION_RUNBOOK.md), and [AGENTS.md](AGENTS.md). Historical architecture documents are design records, not proof of today's implementation or service health. ## License -MIT — see [LICENSE](LICENSE) +MIT — see [LICENSE](LICENSE). diff --git a/apps/web/src/__tests__/test-env-isolation.test.ts b/apps/web/src/__tests__/test-env-isolation.test.ts index 5ad679754..413affa5d 100644 --- a/apps/web/src/__tests__/test-env-isolation.test.ts +++ b/apps/web/src/__tests__/test-env-isolation.test.ts @@ -1,5 +1,32 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +describe('sandbox development environment policy', () => { + const config = JSON.parse(readFileSync(new URL('../../../../turbo.json', import.meta.url), 'utf8')); + const requiredNames = [ + 'NEXTAUTH_SECRET', 'NEXTAUTH_URL', + 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET', + 'AUTH_ALLOWED_EMAIL_DOMAIN', + 'KV_REST_API_URL', 'KV_REST_API_TOKEN', 'UPSTASH_REDIS_REST_URL', 'UPSTASH_REDIS_REST_TOKEN', + 'V0_SANDBOX_URL', 'V0_RUNTIME_URL', 'V0_BUILD_URL', + ]; + + it('passes only the explicitly required names to the uncached development task', () => { + expect(config.tasks.dev).toEqual({ cache: false, persistent: true, passThroughEnv: requiredNames }); + }); + + it('does not expand global, build, or test environment access', () => { + expect(config.globalPassThroughEnv ?? []).toEqual([]); + expect(config.globalEnv ?? []).toEqual([]); + for (const task of ['build', 'test', 'lint']) { + expect(config.tasks[task].passThroughEnv ?? []).toEqual([]); + expect(config.tasks[task].env ?? []).toEqual([]); + } + const manifest = JSON.parse(readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8')); + expect(manifest.scripts.dev).toBe('turbo run dev'); + }); +}); + /** * Hermeticity guard for the test environment. * diff --git a/apps/web/src/app/api/gate/transitions/__tests__/route.test.ts b/apps/web/src/app/api/gate/transitions/__tests__/route.test.ts new file mode 100644 index 000000000..c5f141871 --- /dev/null +++ b/apps/web/src/app/api/gate/transitions/__tests__/route.test.ts @@ -0,0 +1,83 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const { getToken, decide } = vi.hoisted(() => ({ getToken: vi.fn(), decide: vi.fn() })); +vi.mock('next-auth/jwt', () => ({ getToken })); +vi.mock('@/lib/origin-gate-store', () => ({ decideOriginGate: decide })); +import { POST } from '../route'; + +function request(body: unknown = {}, origin = 'https://uvai.io') { + return new NextRequest('https://uvai.io/api/gate/transitions', { method: 'POST', headers: { origin, 'content-type': 'application/json' }, body: JSON.stringify(body) }); +} +describe('POST /api/gate/transitions', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('NEXTAUTH_URL', 'https://uvai.io'); + for (const key of ['V0_SANDBOX_URL', 'V0_RUNTIME_URL', 'V0_BUILD_URL']) vi.stubEnv(key, ''); + vi.stubEnv('NEXTAUTH_SECRET', 'unit-test-secret-with-at-least-32-characters'); + getToken.mockResolvedValue({ sub: 'session-owner' }); + }); + afterEach(() => vi.unstubAllEnvs()); + + function previewRequest(body: unknown = {}, cookie?: string) { + return new NextRequest('http://localhost:3000/api/gate/transitions', { + method: 'POST', + headers: { origin: 'https://preview.example.test', 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + }); + } + + it('reaches authentication for an approved proxied development origin without evaluating anonymous requests', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('V0_SANDBOX_URL', 'https://preview.example.test/studio'); + getToken.mockResolvedValue(null); + const res = await POST(previewRequest()); + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: 'authentication_required' }); + expect(getToken).toHaveBeenCalledTimes(1); + expect(decide).not.toHaveBeenCalled(); + }); + + it('uses the verified offline session subject, never the preview body identity', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('V0_RUNTIME_URL', 'https://preview.example.test'); + const jwt = await vi.importActual('next-auth/jwt'); + const token = await jwt.encode({ secret: process.env.NEXTAUTH_SECRET!, token: { sub: 'verified-preview-owner' } }); + getToken.mockImplementationOnce(jwt.getToken); + decide.mockResolvedValue({ decision: 'HOLD', reason: 'Missing evidence' }); + const body = { transitionId: 'test-transition', subject: 'forged-owner' }; + const res = await POST(previewRequest(body, `__Secure-next-auth.session-token=${token}`)); + expect(res.status).toBe(409); + expect(decide).toHaveBeenCalledWith(body, 'verified-preview-owner'); + }); + + it.each(['development', 'production', 'test'])('blocks untrusted preview submissions before auth or evaluation in %s', async (mode) => { + vi.stubEnv('NODE_ENV', mode); + vi.stubEnv('V0_SANDBOX_URL', mode === 'development' ? 'https://different.example.test' : 'https://preview.example.test'); + expect((await POST(previewRequest())).status).toBe(403); + expect(getToken).not.toHaveBeenCalled(); + expect(decide).not.toHaveBeenCalled(); + }); + it('does not evaluate unauthenticated or cross-origin submissions', async () => { + getToken.mockResolvedValue(null); + expect((await POST(request())).status).toBe(401); + getToken.mockResolvedValue({ sub: 'session-owner' }); + expect((await POST(request({}, 'https://evil.example'))).status).toBe(403); + expect(decide).not.toHaveBeenCalled(); + }); + it.each(['PASS', 'HOLD', 'REJECT', 'ESCALATE'])('returns the exact server %s without initiating any build', async (decision) => { + const gate = { decision, reason: 'Server decision', receipt: { id: 'test-receipt' } }; + decide.mockResolvedValue(gate); + const res = await POST(request({ transitionId: 'test-transition' })); + expect(res.status).toBe(decision === 'PASS' ? 200 : 409); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(await res.json()).toEqual({ ok: decision === 'PASS', gate }); + expect(decide).toHaveBeenCalledWith({ transitionId: 'test-transition' }, 'session-owner'); + }); + it('does not expose internal errors', async () => { + decide.mockRejectedValue(new Error('secret infrastructure details')); + const res = await POST(request()); + expect(res.status).toBe(503); + expect(await res.text()).not.toContain('secret infrastructure details'); + }); +}); diff --git a/apps/web/src/app/api/gate/transitions/route.ts b/apps/web/src/app/api/gate/transitions/route.ts new file mode 100644 index 000000000..6d80be9eb --- /dev/null +++ b/apps/web/src/app/api/gate/transitions/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { decideOriginGate } from '@/lib/origin-gate-store'; +import { StudioError } from '@/lib/studio/errors'; +import { readStudioJson, requireStudioMutationOrigin, requireStudioOwner } from '@/lib/studio/security'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest): Promise { + const headers = { 'Cache-Control': 'no-store' }; + try { + requireStudioMutationOrigin(request); + const owner = await requireStudioOwner(request); + const input = await readStudioJson(request); + const gate = await decideOriginGate(input, owner.subject); + return NextResponse.json({ ok: gate.decision === 'PASS', gate }, { status: gate.decision === 'PASS' ? 200 : 409, headers }); + } catch (error) { + if (error instanceof StudioError) return NextResponse.json({ ok: false, error: error.message, code: error.code }, { status: error.status, headers }); + return NextResponse.json({ ok: false, error: 'G.A.T.E. is unavailable. No transition is permitted.' }, { status: 503, headers }); + } +} diff --git a/apps/web/src/app/api/workflows/studio-deploy/__tests__/route.test.ts b/apps/web/src/app/api/workflows/studio-deploy/__tests__/route.test.ts index 25045bc36..03a570307 100644 --- a/apps/web/src/app/api/workflows/studio-deploy/__tests__/route.test.ts +++ b/apps/web/src/app/api/workflows/studio-deploy/__tests__/route.test.ts @@ -1,116 +1,85 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const start = vi.fn(); -const DEMO_URL = 'https://www.youtube.com/watch?v=auJzb1D-fag'; +const { start, getToken, decide } = vi.hoisted(() => ({ start: vi.fn(), getToken: vi.fn(), decide: vi.fn() })); +vi.mock('workflow/api', () => ({ start })); +vi.mock('@/workflows/studio-deploy', () => ({ studioDeployWorkflow: async () => ({}) })); +vi.mock('next-auth/jwt', () => ({ getToken })); +vi.mock('@/lib/origin-gate-store', () => ({ decideOriginGate: decide })); +import { POST } from '../route'; -vi.mock('server-only', () => ({})); +function request(body: unknown, origin = 'https://uvai.io') { + return new NextRequest('https://uvai.io/api/workflows/studio-deploy', { method: 'POST', headers: { 'content-type': 'application/json', origin }, body: JSON.stringify(body) }); +} +const fixture = { url: 'https://www.youtube.com/watch?v=auJzb1D-fag' }; -vi.mock('workflow/api', () => ({ - start: (...args: unknown[]) => start(...args), -})); +describe('Studio deployment preflight', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('NEXTAUTH_URL', 'https://uvai.io'); + for (const key of ['V0_SANDBOX_URL', 'V0_RUNTIME_URL', 'V0_BUILD_URL']) vi.stubEnv(key, ''); + vi.stubEnv('NEXTAUTH_SECRET', 'unit-test-secret-with-at-least-32-characters'); + getToken.mockResolvedValue({ sub: 'owner-test' }); + decide.mockResolvedValue({ decision: 'HOLD', reason_code: 'GATE_HOLD_MISSING_EVIDENCE', reason: 'Artifact-bound receipts are missing.', receipt: { version: 'eventrelay.gate-receipt.v2' } }); + }); + afterEach(() => vi.unstubAllEnvs()); -vi.mock('@/workflows/studio-deploy', () => ({ - studioDeployWorkflow: async () => ({}), -})); + function previewRequest() { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('V0_BUILD_URL', 'https://preview.example.test'); + return new NextRequest('http://localhost:3000/api/workflows/studio-deploy', { + method: 'POST', + headers: { origin: 'https://preview.example.test', 'content-type': 'application/json' }, + body: JSON.stringify(fixture), + }); + } -describe('POST /api/workflows/studio-deploy', () => { - beforeEach(() => { - start.mockReset(); + it('requires authentication for an approved development preview origin', async () => { + getToken.mockResolvedValue(null); + const res = await POST(previewRequest()); + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ code: 'authentication_required' }); + expect(decide).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); }); - it('rejects a missing url', async () => { - const { POST } = await import('../route'); - const res = await POST( - new Request('https://uvai.io/api/workflows/studio-deploy', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}), - }), - ); - expect(res.status).toBe(400); + it('keeps the approved preview preflight held and never starts a deployment', async () => { + const res = await POST(previewRequest()); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ ok: false, gate: { decision: 'HOLD' } }); + expect(decide).toHaveBeenCalledWith(expect.objectContaining({ kind: 'studio.deploy' }), 'owner-test'); expect(start).not.toHaveBeenCalled(); }); - it('rejects a localhost url', async () => { - const { POST } = await import('../route'); - const res = await POST( - new Request('https://uvai.io/api/workflows/studio-deploy', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ url: 'http://127.0.0.1:3000/steal' }), - }), - ); - expect(res.status).toBe(400); + it('requires a real server session before any side effect', async () => { + getToken.mockResolvedValue(null); + expect((await POST(request(fixture))).status).toBe(401); expect(start).not.toHaveBeenCalled(); + expect(decide).not.toHaveBeenCalled(); }); - it('rejects IPv6 loopback spelled with brackets', async () => { - const { POST } = await import('../route'); - const res = await POST( - new Request('https://uvai.io/api/workflows/studio-deploy', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ url: 'http://[::1]:8000/x' }), - }), - ); - expect(res.status).toBe(400); + it('denies cross-origin submissions', async () => { + expect((await POST(request(fixture, 'https://untrusted.example'))).status).toBe(403); expect(start).not.toHaveBeenCalled(); }); - it('rejects link-local metadata addresses', async () => { - const { POST } = await import('../route'); - const res = await POST( - new Request('https://uvai.io/api/workflows/studio-deploy', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ url: 'http://169.254.169.254/latest/meta-data/' }), - }), - ); - expect(res.status).toBe(400); + it.each([{}, { url: 'http://127.0.0.1:3000/x' }, { url: 'http://[::1]:8000/x' }, { url: 'http://169.254.169.254/latest/meta-data/' }])('rejects invalid or private source URLs', async (body) => { + expect((await POST(request(body))).status).toBe(400); expect(start).not.toHaveBeenCalled(); }); - it('returns runId when start() succeeds', async () => { - start.mockResolvedValue({ runId: 'wrun_c1' }); - const { POST } = await import('../route'); - const res = await POST( - new Request('https://uvai.io/api/workflows/studio-deploy', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ url: DEMO_URL }), - }), - ); - const json = (await res.json()) as Record; - expect(res.status).toBe(200); - expect(json.ok).toBe(true); - expect(json.runId).toBe('wrun_c1'); - expect(String(json.statusUrl)).toContain('wrun_c1'); + it('returns the server gate receipt, not a newly started deployment', async () => { + const res = await POST(request({ ...fixture, authority: { actor: 'system' }, artifactHash: 'a'.repeat(64) })); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ ok: false, gate: { decision: 'HOLD', receipt: { version: 'eventrelay.gate-receipt.v2' } } }); + expect(decide).toHaveBeenCalledWith(expect.objectContaining({ kind: 'studio.deploy', fromState: 'proposed', toState: 'live' }), 'owner-test'); + expect(start).not.toHaveBeenCalled(); }); - it('forwards a ready transcript into the workflow start payload', async () => { - start.mockResolvedValue({ runId: 'wrun_01M2ACYVYXBHM0YVMX1WHMQ1PJ' }); - const { POST } = await import('../route'); - const transcript = - 'Studio Video Pack for XYMcBrFSJ4c already has a usable transcript ready for deploy.'; - const res = await POST( - new Request('https://uvai.io/api/workflows/studio-deploy', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - url: 'https://www.youtube.com/watch?v=XYMcBrFSJ4c', - transcript, - }), - }), - ); - expect(res.status).toBe(200); - expect(start).toHaveBeenCalledWith( - expect.anything(), - [ - expect.objectContaining({ - url: 'https://www.youtube.com/watch?v=XYMcBrFSJ4c', - transcript, - }), - ], - ); + it('rejects an oversized body before invoking the gate', async () => { + expect((await POST(request({ ...fixture, transcript: 'x'.repeat(33_000) }))).status).toBe(413); + expect(start).not.toHaveBeenCalled(); + expect(decide).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/api/workflows/studio-deploy/route.ts b/apps/web/src/app/api/workflows/studio-deploy/route.ts index 4bbb35a07..16eff4391 100644 --- a/apps/web/src/app/api/workflows/studio-deploy/route.ts +++ b/apps/web/src/app/api/workflows/studio-deploy/route.ts @@ -1,61 +1,37 @@ -import { NextResponse } from 'next/server'; -import { start } from 'workflow/api'; -import { workflowStartErrorBody } from '@/lib/sentry-server-integrations'; +import { randomUUID } from 'node:crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { decideOriginGate } from '@/lib/origin-gate-store'; import { assertPublicHttpUrl } from '@/lib/ssrf-guard'; -import { usableProvidedTranscript } from '@/lib/video-to-actions-input'; -import { withWorldVercelFetch } from '@/lib/world-vercel-fetch'; -import { studioDeployWorkflow } from '@/workflows/studio-deploy'; +import { StudioError } from '@/lib/studio/errors'; +import { readStudioJson, requireStudioMutationOrigin, requireStudioOwner } from '@/lib/studio/security'; export const runtime = 'nodejs'; export const maxDuration = 60; -/** - * POST /api/workflows/studio-deploy - * - * Durable Studio deploy (WDK C): kickoff FastAPI async job + poll. - * Returns immediately with { runId }. - */ -export async function POST(request: Request): Promise { - let body: { url?: unknown; projectType?: unknown; outcome?: unknown; transcript?: unknown }; +export async function POST(request: NextRequest): Promise { + const headers = { 'Cache-Control': 'no-store' }; try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - const url = typeof body.url === 'string' ? body.url.trim() : ''; - if (!url || !/^https?:\/\//i.test(url)) { - return NextResponse.json( - { error: 'url (http/https string) is required' }, - { status: 400 }, - ); - } - - try { - await assertPublicHttpUrl(url); - } catch { - return NextResponse.json({ error: 'url host is not allowed' }, { status: 400 }); - } - - const projectType = - typeof body.projectType === 'string' ? body.projectType.slice(0, 40) : undefined; - const outcome = - typeof body.outcome === 'string' ? body.outcome.slice(0, 80) : undefined; - const transcript = - typeof body.transcript === 'string' ? usableProvidedTranscript(body.transcript) : undefined; - - try { - const run = await withWorldVercelFetch(() => - start(studioDeployWorkflow, [{ url, projectType, outcome, transcript }]), - ); - return NextResponse.json({ - ok: true, - runId: run.runId, - statusUrl: `/api/workflows/studio-deploy/${encodeURIComponent(run.runId)}`, - message: 'Durable Studio deploy started. Poll statusUrl.', - }); - } catch (err) { - console.error('[api/workflows/studio-deploy]', err); - return NextResponse.json({ ok: false, ...workflowStartErrorBody(err) }, { status: 500 }); + requireStudioMutationOrigin(request); + const owner = await requireStudioOwner(request); + const body = await readStudioJson(request); + const url = body && typeof body === 'object' && 'url' in body && typeof body.url === 'string' ? body.url.trim() : ''; + if (!url || !/^https?:\/\//i.test(url)) return NextResponse.json({ error: 'url (http/https string) is required' }, { status: 400, headers }); + try { + await assertPublicHttpUrl(url); + } catch { + return NextResponse.json({ error: 'url host is not allowed' }, { status: 400, headers }); + } + // The legacy video-to-job workflow cannot execute the exact approved artifact. + // A PASS from the acceptance endpoint must never authorize regenerating different bytes. + const gate = await decideOriginGate({ + transitionId: `attempt:${randomUUID()}`, + kind: 'studio.deploy', + fromState: 'proposed', + toState: 'live', + }, owner.subject); + return NextResponse.json({ ok: false, gate, message: gate.reason }, { status: 409, headers }); + } catch (error) { + if (error instanceof StudioError) return NextResponse.json({ ok: false, error: error.message, code: error.code }, { status: error.status, headers }); + return NextResponse.json({ ok: false, error: 'G.A.T.E. preflight is unavailable. No deployment was started.' }, { status: 503, headers }); } } diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index f2e9408fb..835414b50 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -308,6 +308,7 @@ export default function OneLoopStudio({ setApprovedSpecIds([]); setDeployReceiptUrl(null); setDeployReceiptVideoId(null); + setGateReceipt(null); }, [selectedVideoId]); useEffect(() => { @@ -616,14 +617,17 @@ export default function OneLoopStudio({ setDeployReceiptVideoId(attemptVideoId); setGateReceipt(null); try { - const started = await startStudioDeploy({ - url: next, - transcript: usableProvidedTranscript(selected?.transcript), - }); + const started = await startStudioDeploy({ url: next }); + if (useDashboardStore.getState().selectedVideoId !== attemptVideoId) return; if (started.status === 401 || started.status === 403) { window.location.href = `/login?callbackUrl=${encodeURIComponent(CANONICAL_STUDIO_PATH)}`; return; } + if (started.gate) { + setGateReceipt(started.gate); + setMessage(started.gate.reason); + return; + } if (!started.ok || !started.runId) { const backendReason = started.error || started.message || 'Deploy needs sign-in.'; const gated = evaluateStudioDeployTransition({ @@ -638,6 +642,7 @@ export default function OneLoopStudio({ } setDeployRunId(started.runId); const polled = await pollStudioDeploy(started.runId); + if (useDashboardStore.getState().selectedVideoId !== attemptVideoId) return; const backendReason = studioDeployPollResidual(polled); const gated = evaluateStudioDeployTransition({ transitionId: started.runId, @@ -665,6 +670,7 @@ export default function OneLoopStudio({ }), ); } catch (err) { + if (useDashboardStore.getState().selectedVideoId !== attemptVideoId) return; const backendReason = studioDeployOutcomeMessage({ error: err instanceof Error ? err.message : 'Deploy failed.', runStatus: 'failed', @@ -804,6 +810,18 @@ export default function OneLoopStudio({

{gateReceipt.reason}

+

+ {gateReceipt.version === 'eventrelay.gate-receipt.v2' + ? 'Server decision. Later stages require separate Loop approval.' + : 'Local diagnostic only — not an authorization receipt.'} +

+ {gateReceipt.transitionId ? ( +

+ Transition: {gateReceipt.transitionId} + {' · '} + {gateReceipt.retained ? 'Receipt retained' : 'Receipt not retained'} +

+ ) : null} {scopedDeployReceipt ? (

void deploy()} disabled={deployBusy || !hasPayload || Boolean(holdReason)} title={holdReason || studioDeployEnabledHint(Boolean(scopedDeployReceipt))} + aria-describedby="studio-preflight-hint" className="inline-flex items-center gap-2 rounded-lg border border-white/15 px-4 py-2 text-sm disabled:opacity-40" > - {deployBusy ? 'Attempting deploy…' : studioDeployButtonLabel(Boolean(scopedDeployReceipt))} + {deployBusy ? 'Checking preflight…' : studioDeployButtonLabel(Boolean(scopedDeployReceipt))} +

+ {studioDeployEnabledHint(Boolean(scopedDeployReceipt))} +

{holdReason && (

{holdReason} diff --git a/apps/web/src/components/__tests__/OneLoopStudio.gate.test.tsx b/apps/web/src/components/__tests__/OneLoopStudio.gate.test.tsx new file mode 100644 index 000000000..68e9fdf4a --- /dev/null +++ b/apps/web/src/components/__tests__/OneLoopStudio.gate.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import OneLoopStudio from '@/components/OneLoopStudio'; +import { useDashboardStore, type Video } from '@/store/dashboard-store'; +import { startStudioDeploy } from '@/lib/studio-workflow'; + +vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams() })); +vi.mock('@/components/Nav', () => ({ default: () => null })); +vi.mock('@/app/studio/actions', () => ({ openGitHubPrsForApprovedSpecs: vi.fn() })); +vi.mock('@/lib/use-youtube-player', () => ({ useYouTubePlayer: () => ({ containerRef: { current: null }, ready: false, failed: false, seekTo: vi.fn() }) })); +vi.mock('@/lib/studio-workflow', async (importOriginal) => ({ ...await importOriginal(), startStudioDeploy: vi.fn() })); + +const gate: NonNullable>['gate']> = { decision: 'HOLD', reason: 'Signed artifact evidence is required.', reason_code: 'GATE_HOLD_MISSING_EVIDENCE', receiptId: 'er:gate:v2:test', receiptHash: 'a'.repeat(64), version: 'eventrelay.gate-receipt.v2', transitionId: 'transition-test', retained: false }; +const video: Video = { id: 'selected-a', title: 'Gate fixture', url: 'https://www.youtube.com/watch?v=auJzb1D-fag', status: 'complete', progress: 100, transcript: 'Review the observed requirements and gather verified evidence before authorizing a transition.' }; + +beforeEach(() => { + vi.stubGlobal('React', React); + vi.spyOn(useDashboardStore.persist, 'rehydrate').mockImplementation(() => undefined); + useDashboardStore.setState({ videos: [video, { ...video, id: 'selected-b' }], selectedVideoId: video.id }); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe('Studio authoritative gate receipt', () => { + it('labels idle and pending actions as preflight only, with deployment unavailable', async () => { + let finish!: (result: Awaited>) => void; + vi.mocked(startStudioDeploy).mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + render(); + const button = screen.getByRole('button', { name: 'Check preflight' }); + expect(screen.getByText(/preflight only.*deployment is unavailable/i)).toBeTruthy(); + expect(button.getAttribute('aria-describedby')).toBe('studio-preflight-hint'); + fireEvent.click(button); + expect(screen.getByRole('button', { name: 'Checking preflight…' }).hasAttribute('disabled')).toBe(true); + await act(async () => finish({ ok: false, status: 409, gate })); + expect(screen.getByRole('button', { name: 'Check preflight' })).toBeTruthy(); + expect(screen.queryByText(/attempting deploy|attempt deploy/i)).toBeNull(); + }); + + it('displays a server HOLD as runtime evidence, not a deployment', async () => { + vi.mocked(startStudioDeploy).mockResolvedValue({ ok: false, status: 409, gate }); + render(); + fireEvent.click(screen.getByTestId('studio-deploy-button')); + const receipt = await screen.findByTestId('studio-gate-receipt'); + expect(receipt.textContent).toContain('HOLD'); + expect(receipt.textContent).toContain(gate.receiptId); + expect(receipt.textContent).toContain('Transition: transition-test'); + expect(receipt.textContent).toContain('Receipt not retained'); + expect(receipt.textContent).toContain('Server decision. Later stages require separate Loop approval.'); + expect(screen.queryByRole('link', { name: /live/i })).toBeNull(); + act(() => useDashboardStore.getState().selectVideo('selected-b')); + expect(screen.queryByTestId('studio-gate-receipt')).toBeNull(); + }); + + it('ignores a late receipt when another video is selected', async () => { + let finish!: (result: Awaited>) => void; + vi.mocked(startStudioDeploy).mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + render(); + fireEvent.click(screen.getByTestId('studio-deploy-button')); + act(() => useDashboardStore.getState().selectVideo('selected-b')); + await act(async () => finish({ ok: false, status: 409, gate })); + expect(screen.queryByTestId('studio-gate-receipt')).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/__tests__/gate-transition.test.ts b/apps/web/src/lib/__tests__/gate-transition.test.ts index 600c97f32..2c11dac1c 100644 --- a/apps/web/src/lib/__tests__/gate-transition.test.ts +++ b/apps/web/src/lib/__tests__/gate-transition.test.ts @@ -39,13 +39,13 @@ function baseRequest( } describe('G.A.T.E. transition contract', () => { - it('PASS when Zero-Sim is real, authority is known, and live evidence is verified', () => { + it('REJECTs self-asserted real evidence and a known actor label without trusted receipts', () => { const result = evaluateTransition(baseRequest()); - expect(result.decision).toBe('PASS'); - expect(result.reason_code).toBe('GATE_PASS'); - expect(result.reason).toMatch(/permit/i); + expect(result.decision).toBe('REJECT'); + expect(result.reason_code).toBe('GATE_REJECT_CLAIM_MISMATCH'); + expect(result.reason).toMatch(/artifact-bound/i); expect(result.receipt.version).toBe(GATE_RECEIPT_VERSION); - expect(result.receipt.decision).toBe('PASS'); + expect(result.receipt.decision).toBe('REJECT'); expect(result.receipt.receipt_hash).toMatch(/^[a-f0-9]{64}$/); }); @@ -133,7 +133,7 @@ describe('G.A.T.E. transition contract', () => { }); describe('evaluateStudioDeployTransition', () => { - it('PASS only for a verified https live URL with hostname', () => { + it('REJECTs a raw https URL without a trusted artifact-bound receipt', () => { const result = evaluateStudioDeployTransition({ transitionId: 'wrun_live', runId: 'wrun_live', @@ -143,8 +143,8 @@ describe('evaluateStudioDeployTransition', () => { authority: { actor: 'anonymous' }, issuedAt: ISSUED_AT, }); - expect(result.decision).toBe('PASS'); - expect(result.reason_code).toBe('GATE_PASS'); + expect(result.decision).toBe('REJECT'); + expect(result.reason_code).toBe('GATE_REJECT_CLAIM_MISMATCH'); }); it('HOLD when workflow completed without a live receipt (no Deploy completed claim)', () => { @@ -486,7 +486,7 @@ describe('evaluateStudioDeployTransition', () => { expect(view.receiptId).toBe('er:gate:v1:wrun_01M2AKRAVZ0SEBM670BGXEMCQZ'); }); - it('PASS when a verified live URL arrives after a timeout abort residual', () => { + it('REJECTs a raw live URL even after a timeout abort residual', () => { const result = evaluateStudioDeployTransition({ transitionId: 'wrun_01M2AKRAVZ0SEBM670BGXEMCQZ', runId: 'wrun_01M2AKRAVZ0SEBM670BGXEMCQZ', @@ -497,7 +497,7 @@ describe('evaluateStudioDeployTransition', () => { authority: { actor: 'anonymous' }, issuedAt: ISSUED_AT, }); - expect(result.decision).toBe('PASS'); + expect(result.decision).toBe('REJECT'); expect(result.receipt.id).toBe('er:gate:v1:wrun_01M2AKRAVZ0SEBM670BGXEMCQZ'); }); }); @@ -512,7 +512,7 @@ describe('studioGateReceiptView', () => { issuedAt: ISSUED_AT, }), ); - expect(pass.decision).toBe('PASS'); + expect(pass.decision).toBe('REJECT'); expect(pass.reason.length).toBeGreaterThan(0); expect(pass.receiptId).toBe('er:gate:v1:wrun_live'); expect(pass.receiptHash).toMatch(/^[a-f0-9]{64}$/); diff --git a/apps/web/src/lib/__tests__/origin-gate.test.ts b/apps/web/src/lib/__tests__/origin-gate.test.ts new file mode 100644 index 000000000..4e5b97e5d --- /dev/null +++ b/apps/web/src/lib/__tests__/origin-gate.test.ts @@ -0,0 +1,474 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient } from 'redis'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { canonicalGateJson, hashCanonical } from '@/lib/gate-transition'; +import { evaluateOriginGate, type OriginGateEvaluation, type OriginGateStore } from '@/lib/origin-gate'; +import { COMMIT_ORIGIN_GATE_SCRIPT, createOriginGateStore, ORIGIN_GATE_POLICY_KEY } from '@/lib/origin-gate-store'; + +const now = Date.parse('2026-09-13T12:00:00.000Z'); +const loop = generateKeyPairSync('ed25519'); +const verifier = generateKeyPairSync('ed25519'); +const policy = { + version: 1, + issuers: [ + { id: 'loop-test', role: 'loop', publicKey: loop.publicKey.export({ type: 'spki', format: 'pem' }).toString(), projectIds: ['prj_test'], revoked: false }, + { id: 'verifier-test', role: 'deployment-verifier', publicKey: verifier.publicKey.export({ type: 'spki', format: 'pem' }).toString(), projectIds: ['prj_test'], revoked: false }, + ], +}; +const binding = { + transitionId: 'transition-test', kind: 'studio.deploy', fromState: 'proposed', toState: 'live', + subject: 'owner-test', runId: 'run-test', artifactHash: 'a'.repeat(64), + target: { provider: 'vercel', projectId: 'prj_test', environment: 'preview', liveUrl: 'https://test.example.com' }, +}; +function attestation(type: 'approval' | 'deployment', changes = {}) { + const payload = { + version: 'origin.attestation.v1', type, issuer: type === 'approval' ? 'loop-test' : 'verifier-test', + nonce: `nonce-${type}`, issuedAt: new Date(now - 1000).toISOString(), expiresAt: new Date(now + 60_000).toISOString(), + binding, verdict: type === 'approval' ? 'allow' : 'real', + ...(type === 'deployment' ? { providerReceiptId: 'deployment-test', providerReceiptHash: 'b'.repeat(64) } : {}), ...changes, + }; + return { payload, signature: sign(null, Buffer.from(`origin.attestation.v1\n${canonicalGateJson(payload)}`), type === 'approval' ? loop.privateKey : verifier.privateKey).toString('base64url') }; +} +function input(changes = {}) { + const { subject: _subject, ...proposal } = binding; + return { ...proposal, approval: attestation('approval'), evidence: attestation('deployment'), ...changes }; +} +function setup(trust: unknown = policy) { + const receipts = new Map(); + const transitions = new Map(); + const nonces = new Map(); + const store: OriginGateStore = { + readPolicy: vi.fn(async () => trust), + commit: vi.fn(async ({ requestHash, transitionKey, nonceKeys, evaluation }) => { + if (receipts.get(requestHash)?.decision === 'PASS') return { status: 'existing', evaluation: receipts.get(requestHash) }; + if (evaluation.decision === 'PASS') { + if (transitions.has(transitionKey) || nonceKeys.some((key) => nonces.has(key))) return { status: 'conflict' }; + transitions.set(transitionKey, requestHash); + nonceKeys.forEach((key) => nonces.set(key, requestHash)); + } + receipts.set(requestHash, evaluation); + return { status: 'stored' }; + }), + }; + const context = { subject: 'owner-test', now, signingSecret: 'unit-test-signing-secret-at-least-32-characters', store }; + return { store, context }; +} + +describe('Origin G.A.T.E. signed server boundary', () => { + it('PASSes independently signed, exact-bound approval and evidence and retains a signed receipt', async () => { + const { context, store } = setup(); + const result = await evaluateOriginGate(input(), context); + expect(result.decision).toBe('PASS'); + expect(result.receipt).toMatchObject({ version: 'eventrelay.gate-receipt.v2', transition_id: binding.transitionId, artifact_hash: binding.artifactHash, run_id: binding.runId, retained: true }); + expect(result.receipt.signature).toMatch(/^[a-f0-9]{64}$/); + const { receipt_hash, signature: _signature, ...body } = result.receipt; + expect(receipt_hash).toBe(hashCanonical(canonicalGateJson(body))); + expect(store.commit).toHaveBeenCalledTimes(1); + }); + + it('ESCALATEs without an authenticated subject; browser actor labels are not authority', async () => { + const { context, store } = setup(); + expect((await evaluateOriginGate(input(), { ...context, subject: null })).decision).toBe('ESCALATE'); + expect(store.commit).not.toHaveBeenCalled(); + }); + + it('HOLDs an evidence workspace with no artifact and never manufactures a live receipt', async () => { + const { context } = setup(); + const result = await evaluateOriginGate({ transitionId: 'attempt-test', kind: 'studio.deploy', fromState: 'proposed', toState: 'live' }, context); + expect(result.decision).toBe('HOLD'); + expect(result.reason_code).toBe('GATE_HOLD_MISSING_EVIDENCE'); + expect(result.receipt.to_state).toBe('live'); + }); + + it('REJECTs a raw live claim without signed deployment evidence', async () => { + const { context } = setup(); + expect((await evaluateOriginGate(input({ evidence: undefined }), context)).decision).toBe('REJECT'); + }); + + it('HOLDs missing Loop approval even when deployment evidence is real', async () => { + const { context } = setup(); + expect((await evaluateOriginGate(input({ approval: undefined }), context)).decision).toBe('HOLD'); + }); + + it.each(['kind', 'fromState', 'toState'])('REJECTs unsupported %s', async (field) => { + const { context } = setup(); + expect((await evaluateOriginGate(input({ [field]: 'not-authorized' }), context)).decision).toBe('REJECT'); + }); + + it.each(['subject', 'runId', 'artifactHash', 'transitionId', 'target'])('REJECTs evidence bound to a different %s', async (field) => { + const { context } = setup(); + const mismatch = { ...binding, [field]: field === 'target' ? { ...binding.target, projectId: 'other-project' } : field === 'artifactHash' ? 'c'.repeat(64) : 'other-value' }; + expect((await evaluateOriginGate(input({ evidence: attestation('deployment', { binding: mismatch }) }), context)).decision).toBe('REJECT'); + }); + + it('REJECTs tampered signatures', async () => { + const { context } = setup(); + const evidence = attestation('deployment'); + evidence.payload.providerReceiptId = 'tampered'; + expect((await evaluateOriginGate(input({ evidence }), context)).decision).toBe('REJECT'); + }); + + it('REJECTs malformed artifact hashes and unknown request fields', async () => { + const { context } = setup(); + expect((await evaluateOriginGate(input({ artifactHash: 'not-a-hash' }), context)).decision).toBe('REJECT'); + expect((await evaluateOriginGate(input({ authority: { actor: 'system' } }), context)).decision).toBe('REJECT'); + }); + + it('ESCALATEs missing or invalid trust configuration', async () => { + for (const trust of [null, {}, { version: 1, issuers: [] }]) { + const { context } = setup(trust); + expect((await evaluateOriginGate(input(), context)).decision).toBe('ESCALATE'); + } + }); + + it('REJECTs revoked signers and project-scope mismatches', async () => { + for (const changes of [{ revoked: true }, { projectIds: ['other-project'] }]) { + const { context } = setup({ ...policy, issuers: [{ ...policy.issuers[0], ...changes }, policy.issuers[1]] }); + expect((await evaluateOriginGate(input(), context)).decision).toBe('REJECT'); + } + }); + + it.each(['unverified', 'unreal', 'unknown'])('preserves the signed Zero-Sim %s verdict', async (verdict) => { + const { context } = setup(); + expect((await evaluateOriginGate(input({ evidence: attestation('deployment', { verdict }) }), context)).decision).toBe(verdict === 'unverified' ? 'HOLD' : verdict === 'unreal' ? 'REJECT' : 'ESCALATE'); + }); + + it('HOLDs expired, future-dated, and overlong attestations', async () => { + for (const changes of [ + { expiresAt: new Date(now - 1).toISOString() }, + { issuedAt: new Date(now + 60_000).toISOString() }, + { expiresAt: new Date(now + 3_600_000).toISOString() }, + ]) { + const { context } = setup(); + expect((await evaluateOriginGate(input({ approval: attestation('approval', changes) }), context)).decision).toBe('HOLD'); + } + }); + + it('REJECTs one cryptographic key impersonating both independent roles', async () => { + const { context } = setup({ ...policy, issuers: [policy.issuers[0], { ...policy.issuers[1], publicKey: `${policy.issuers[0].publicKey}\n` }] }); + const evidence = attestation('deployment'); + evidence.signature = sign(null, Buffer.from(`origin.attestation.v1\n${canonicalGateJson(evidence.payload)}`), loop.privateKey).toString('base64url'); + expect((await evaluateOriginGate(input({ evidence }), context)).decision).toBe('REJECT'); + }); + + it('REJECTs reused nonces on a separately signed transition', async () => { + const { context } = setup(); + await evaluateOriginGate(input(), context); + const changedBinding = { ...binding, transitionId: 'other-transition' }; + const result = await evaluateOriginGate(input({ transitionId: changedBinding.transitionId, approval: attestation('approval', { binding: changedBinding }), evidence: attestation('deployment', { binding: changedBinding }) }), context); + expect(result.decision).toBe('REJECT'); + expect(result.reason_code).toBe('GATE_REJECT_REPLAY'); + }); + + it('HOLDs an altered retained receipt instead of trusting storage blindly', async () => { + const { context, store } = setup(); + const accepted = await evaluateOriginGate(input(), context); + vi.mocked(store.commit).mockResolvedValue({ status: 'existing', evaluation: { ...accepted, receipt: { ...accepted.receipt, artifact_hash: 'f'.repeat(64) } } }); + expect((await evaluateOriginGate(input(), context)).decision).toBe('HOLD'); + }); + + it('returns the identical receipt for concurrent identical retries', async () => { + const { context } = setup(); + const [first, second] = await Promise.all([evaluateOriginGate(input(), context), evaluateOriginGate(input(), { ...context, now: now + 1000 })]); + expect(first.decision).toBe('PASS'); + expect(second).toEqual(first); + }); + + it('REJECTs a second acceptance on the same transition, even with fresh nonces', async () => { + const { context } = setup(); + await evaluateOriginGate(input(), context); + const result = await evaluateOriginGate(input({ approval: attestation('approval', { nonce: 'fresh-approval' }), evidence: attestation('deployment', { nonce: 'fresh-evidence' }) }), context); + expect(result.decision).toBe('REJECT'); + expect(result.reason_code).toBe('GATE_REJECT_REPLAY'); + }); + + it('does not replay a previous PASS after issuer revocation', async () => { + const { context, store } = setup(); + expect((await evaluateOriginGate(input(), context)).decision).toBe('PASS'); + vi.mocked(store.readPolicy).mockResolvedValue({ ...policy, issuers: [{ ...policy.issuers[0], revoked: true }, policy.issuers[1]] }); + expect((await evaluateOriginGate(input(), context)).decision).toBe('REJECT'); + }); + + it('does not replay a previous PASS after attestation expiry', async () => { + const { context } = setup(); + expect((await evaluateOriginGate(input(), context)).decision).toBe('PASS'); + expect((await evaluateOriginGate(input(), { ...context, now: now + 60_001 })).decision).toBe('HOLD'); + }); + + it('does not claim retention when the per-user receipt quota is exhausted', async () => { + const { context, store } = setup(); + vi.mocked(store.commit).mockResolvedValue({ status: 'quota' }); + const result = await evaluateOriginGate({ transitionId: 'quota-test', kind: 'studio.deploy', fromState: 'proposed', toState: 'live' }, context); + expect(result).toMatchObject({ decision: 'HOLD', reason_code: 'GATE_HOLD_RETENTION_LIMIT', receipt: { retained: false } }); + }); + + it('HOLDs storage and signing failures instead of permitting a transition', async () => { + const { context, store } = setup(); + vi.mocked(store.commit).mockRejectedValue(new Error('offline')); + const result = await evaluateOriginGate(input(), context); + expect(result.decision).toBe('HOLD'); + expect(result.receipt.retained).toBe(false); + expect((await evaluateOriginGate(input(), { ...context, signingSecret: '' })).decision).toBe('HOLD'); + }); +}); + +// Opt-in executes the production Lua against a disposable Unix-socket Redis, never a configured store. +describe.runIf(process.env.ORIGIN_GATE_REDIS_TESTS === '1')('Origin G.A.T.E. isolated Redis atomic commits', () => { + let server: ChildProcess; + let directory: string; + let redis: ReturnType; + const receiptKey = (result: OriginGateEvaluation) => `er:gate:v2:receipt:${result.receipt.request_hash}`; + const pending = (transitionId: string) => ({ transitionId, kind: 'studio.deploy', fromState: 'proposed', toState: 'live' }); + const futureProposal = () => input({ + approval: attestation('approval', { issuedAt: new Date(now + 60_000).toISOString(), expiresAt: new Date(now + 180_000).toISOString() }), + evidence: attestation('deployment', { expiresAt: new Date(now + 180_000).toISOString() }), + }); + const evaluate = (proposal: unknown, time = now, subject = binding.subject) => evaluateOriginGate(proposal, { + subject, now: time, signingSecret: setup().context.signingSecret, store: createOriginGateStore(), + }); + const quotaKey = async () => { + const keys = await redis.keys('er:gate:v2:pending:*'); + expect(keys).toHaveLength(1); + return keys[0]; + }; + + beforeAll(async () => { + const binary = ['redis-server', 'redis6-server'].find((name) => spawnSync(name, ['--version']).status === 0); + if (!binary) throw new Error('ORIGIN_GATE_REDIS_TESTS requires redis-server or redis6-server; no external store is used.'); + directory = await mkdtemp(join(tmpdir(), 'origin-gate-test-')); + const socketPath = join(directory, 'redis.sock'); + server = spawn(binary, ['--port', '0', '--unixsocket', socketPath, '--unixsocketperm', '700', '--save', '', '--appendonly', 'no'], { + env: { PATH: process.env.PATH, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Isolated Redis did not become ready')), 5000); + server.once('error', (error) => { clearTimeout(timeout); reject(error); }); + server.once('exit', (code) => { clearTimeout(timeout); reject(new Error(`Isolated Redis exited: ${code}`)); }); + server.stdout!.on('data', (data: Buffer) => { + if (/ready to accept connections/i.test(data.toString())) { clearTimeout(timeout); resolve(); } + }); + }); + redis = createClient({ socket: { path: socketPath, reconnectStrategy: false } }); + await redis.connect(); + }); + afterAll(async () => { + if (redis?.isOpen) redis.destroy(); + if (server && server.exitCode === null) { + await new Promise((resolve) => { server.once('exit', () => resolve()); server.kill(); }); + } + if (directory) await rm(directory, { recursive: true, force: true }); + }); + beforeEach(async () => { + await redis.flushDb(); + await redis.set(ORIGIN_GATE_POLICY_KEY, JSON.stringify(policy)); + vi.stubEnv('KV_REST_API_URL', ''); + vi.stubEnv('KV_REST_API_TOKEN', ''); + vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://offline-gate.example.test'); + vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'offline-only'); + vi.stubGlobal('fetch', vi.fn(async (url: string, options: RequestInit) => { + expect(url).toBe('https://offline-gate.example.test'); + const args = JSON.parse(String(options.body)) as Array; + expect(['GET', 'EVAL']).toContain(args[0]); + const result = await redis.sendCommand(args.map(String)); + return { ok: true, json: async () => ({ result }) }; + })); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); + + it.each(['HOLD', 'REJECT', 'ESCALATE'])('bounds %s receipt and quota-index retention to 24 hours', async (decision) => { + const proposal = decision === 'HOLD' ? pending('pending-test') : decision === 'REJECT' ? input({ evidence: undefined }) : input({ approval: attestation('approval', { issuer: 'unknown-issuer' }) }); + const result = await evaluate(proposal); + expect(result).toMatchObject({ decision, receipt: { retained: true } }); + expect(await redis.ttl(receiptKey(result))).toBeGreaterThan(0); + expect(await redis.ttl(receiptKey(result))).toBeLessThanOrEqual(86400); + expect(await redis.zCard(await quotaKey())).toBe(1); + expect(await redis.ttl(await quotaKey())).toBeGreaterThan(0); + expect(await redis.ttl(await quotaKey())).toBeLessThanOrEqual(86400); + expect(await redis.keys('er:gate:v2:transition:*')).toEqual([]); + expect(await redis.keys('er:gate:v2:nonce:*')).toEqual([]); + }); + + it('atomically caps one subject at 100 pending receipts, including concurrent requests', async () => { + const results = await Promise.all(Array.from({ length: 105 }, (_, i) => evaluate(pending(`pending-${i}`)))); + expect(results.filter((result) => result.receipt.retained)).toHaveLength(100); + expect(results.filter((result) => result.reason_code === 'GATE_HOLD_RETENTION_LIMIT')).toHaveLength(5); + expect(await redis.keys('er:gate:v2:receipt:*')).toHaveLength(100); + expect(await redis.zCard(await quotaKey())).toBe(100); + expect(await quotaKey()).not.toContain(binding.subject); + const retained = results.find((result) => result.receipt.retained)!; + expect((await evaluate(pending(retained.receipt.transition_id))).receipt.retained).toBe(true); + expect(await redis.zCard(await quotaKey())).toBe(100); + expect((await evaluate(pending('other-owner'), now, 'owner-other')).receipt.retained).toBe(true); + expect((await evaluate(input())).decision).toBe('PASS'); + }); + + it('prunes expired quota members using storage time rather than caller time', async () => { + await Promise.all(Array.from({ length: 99 }, (_, i) => evaluate(pending(`pending-${i}`)))); + const key = await quotaKey(); + await redis.zAdd(key, { score: 0, value: 'expired-receipt' }); + expect(await redis.zCard(key)).toBe(100); + const result = await evaluate(pending('new-after-expiry'), now - 86400_000); + expect(result.receipt.retained).toBe(true); + expect(await redis.zCard(key)).toBe(100); + expect(await redis.zScore(key, 'expired-receipt')).toBeNull(); + }); + + it('re-evaluates a future-dated HOLD and atomically promotes it to one permanent PASS', async () => { + const proposal = futureProposal(); + const held = await evaluate(proposal); + expect(held).toMatchObject({ decision: 'HOLD', reason_code: 'GATE_HOLD_STALE_EVIDENCE', receipt: { retained: true } }); + const [first, retry] = await Promise.all([evaluate(proposal, now + 65_000), evaluate(proposal, now + 66_000)]); + expect(first.decision).toBe('PASS'); + expect(retry).toEqual(first); + expect(first.receipt.request_hash).toBe(held.receipt.request_hash); + expect(await redis.ttl(receiptKey(first))).toBe(-1); + expect(await redis.keys('er:gate:v2:pending:*')).toEqual([]); + for (const pattern of ['er:gate:v2:transition:*', 'er:gate:v2:nonce:*']) { + const keys = await redis.keys(pattern); + expect(keys).toHaveLength(pattern.includes('nonce') ? 2 : 1); + for (const key of keys) expect(await redis.ttl(key)).toBe(-1); + } + }); + + it('keeps accepted receipts immutable and replay markers permanent after a negative retry', async () => { + const accepted = await evaluate(input()); + const raw = await redis.get(receiptKey(accepted)); + const expired = await evaluate(input(), now + 60_001); + expect(expired.decision).toBe('HOLD'); + expect(expired.receipt.retained).toBe(false); + expect(await redis.get(receiptKey(accepted))).toBe(raw); + expect(await redis.ttl(receiptKey(accepted))).toBe(-1); + const replayed = await evaluate(input({ approval: attestation('approval', { nonce: 'fresh-approval' }), evidence: attestation('deployment', { nonce: 'fresh-evidence' }) })); + expect(replayed).toMatchObject({ decision: 'REJECT', reason_code: 'GATE_REJECT_REPLAY', receipt: { retained: false } }); + const changed = { ...binding, transitionId: 'another-transition' }; + expect(await evaluate(input({ transitionId: changed.transitionId, approval: attestation('approval', { binding: changed }), evidence: attestation('deployment', { binding: changed }) }))).toMatchObject({ decision: 'REJECT', reason_code: 'GATE_REJECT_REPLAY' }); + }); + + it('cannot promote a held receipt if the policy changes before commit', async () => { + const proposal = futureProposal(); + const held = await evaluate(proposal); + const raw = await redis.get(receiptKey(held)); + const evaluation = await evaluateOriginGate(proposal, { ...setup().context, now: now + 65_000 }); + expect(evaluation.decision).toBe('PASS'); + expect(evaluation.receipt.request_hash).toBe(held.receipt.request_hash); + const store = createOriginGateStore(); + await store.readPolicy(); + await redis.set(ORIGIN_GATE_POLICY_KEY, JSON.stringify({ ...policy, issuers: [] })); + await expect(store.commit({ evaluation, requestHash: evaluation.receipt.request_hash, transitionKey: 'transition-test', nonceKeys: ['approval', 'verifier'] })).rejects.toThrow(); + expect(await redis.get(receiptKey(held))).toBe(raw); + expect(await redis.zCard(await quotaKey())).toBe(1); + expect(await redis.keys('er:gate:v2:transition:*')).toEqual([]); + expect(await redis.keys('er:gate:v2:nonce:*')).toEqual([]); + }); + + it('does not promote a held request after another request accepts its transition', async () => { + const proposal = futureProposal(); + const held = await evaluate(proposal); + const raw = await redis.get(receiptKey(held)); + const accepted = await evaluate(input({ + approval: attestation('approval', { nonce: 'other-approval' }), + evidence: attestation('deployment', { nonce: 'other-evidence' }), + })); + expect(accepted.decision).toBe('PASS'); + expect(await evaluate(proposal, now + 65_000)).toMatchObject({ decision: 'REJECT', reason_code: 'GATE_REJECT_REPLAY', receipt: { retained: false } }); + expect(await redis.get(receiptKey(held))).toBe(raw); + expect(await redis.ttl(receiptKey(accepted))).toBe(-1); + expect(await redis.zCard(await quotaKey())).toBe(1); + }); + + it('bounds a legacy permanent non-PASS receipt when it is next re-evaluated', async () => { + const proposal = pending('legacy-hold'); + const held = await evaluate(proposal); + await redis.persist(receiptKey(held)); + await redis.del(await quotaKey()); + expect(await redis.ttl(receiptKey(held))).toBe(-1); + expect((await evaluate(proposal, now + 1000)).receipt.retained).toBe(true); + expect(await redis.ttl(receiptKey(held))).toBeGreaterThan(0); + expect(await redis.ttl(receiptKey(held))).toBeLessThanOrEqual(86400); + expect(await redis.zCard(await quotaKey())).toBe(1); + }); +}); + +describe('Origin G.A.T.E. Upstash REST adapter', () => { + const rawPolicy = JSON.stringify(policy); + let evaluation: OriginGateEvaluation; + const fetchMock = vi.fn(); + beforeEach(async () => { + evaluation = await evaluateOriginGate(input(), setup().context); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('KV_REST_API_URL', ''); + vi.stubEnv('KV_REST_API_TOKEN', ''); + vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.example.test'); + vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'test-rest-token'); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); + const commitInput = () => ({ requestHash: evaluation.receipt.request_hash, transitionKey: 'transition-hash', nonceKeys: ['approval-hash', 'verifier-hash'], evaluation }); + const response = (result: unknown) => ({ ok: true, json: async () => ({ result }) }); + + it('atomically binds a PASS to the exact policy snapshot read for verification', async () => { + fetchMock.mockResolvedValueOnce(response(rawPolicy)).mockResolvedValueOnce(response(['stored'])); + const store = createOriginGateStore(); + expect(await store.readPolicy()).toEqual(policy); + expect(await store.commit(commitInput())).toEqual({ status: 'stored' }); + const [url, options] = fetchMock.mock.calls[1]; + expect(url).toBe('https://redis.example.test'); + expect(options).toMatchObject({ method: 'POST', cache: 'no-store', redirect: 'error' }); + const args = JSON.parse(options.body); + expect(args[0]).toBe('EVAL'); + expect(args[1]).toBe(COMMIT_ORIGIN_GATE_SCRIPT); + expect(args).toContain(ORIGIN_GATE_POLICY_KEY); + expect(args.slice(-3)).toEqual([rawPolicy, 86400, 100]); + expect(args).toContain(`er:gate:v2:pending:${hashCanonical(binding.subject)}`); + }); + + it('refuses a PASS commit without a loaded policy snapshot', async () => { + fetchMock.mockResolvedValue(response(['stored'])); + await expect(createOriginGateStore().commit(commitInput())).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['existing', 'conflict', 'unexpected', 'policy_changed'])('handles the atomic result %s without fabricating acceptance', async (status) => { + const result = status === 'existing' ? [status, JSON.stringify(evaluation)] : [status]; + fetchMock.mockResolvedValueOnce(response(rawPolicy)).mockResolvedValueOnce(response(result)); + const store = createOriginGateStore(); + await store.readPolicy(); + if (status === 'existing') expect(await store.commit(commitInput())).toEqual({ status, evaluation }); + else if (status === 'conflict') expect(await store.commit(commitInput())).toEqual({ status }); + else await expect(store.commit(commitInput())).rejects.toThrow(); + }); + + it('uses the injected KV REST alias pair without a TCP client', async () => { + vi.stubEnv('UPSTASH_REDIS_REST_URL', ''); + vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', ''); + vi.stubEnv('KV_REST_API_URL', 'https://kv.example.test'); + vi.stubEnv('KV_REST_API_TOKEN', 'offline-kv-token'); + fetchMock.mockResolvedValue(response(rawPolicy)); + expect(await createOriginGateStore().readPolicy()).toEqual(policy); + expect(fetchMock).toHaveBeenCalledWith('https://kv.example.test', expect.objectContaining({ + method: 'POST', + headers: { authorization: 'Bearer offline-kv-token', 'content-type': 'application/json' }, + body: JSON.stringify(['GET', ORIGIN_GATE_POLICY_KEY]), + })); + }); + + it.each(['both', 'url', 'token'])('HOLDs without retained evidence when REST credentials are missing: %s', async (missing) => { + if (missing !== 'token') vi.stubEnv('UPSTASH_REDIS_REST_URL', ''); + if (missing !== 'url') vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', ''); + const result = await evaluateOriginGate(input(), { ...setup().context, store: createOriginGateStore() }); + expect(result).toMatchObject({ decision: 'HOLD', reason_code: 'GATE_HOLD_RUNTIME_UNAVAILABLE', receipt: { retained: false } }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fails closed on an unavailable REST runtime', async () => { + fetchMock.mockResolvedValue({ ok: false }); + await expect(createOriginGateStore().readPolicy()).rejects.toThrow(); + vi.stubEnv('UPSTASH_REDIS_REST_URL', 'http://redis.example.test'); + await expect(createOriginGateStore().readPolicy()).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts index c91d8abe8..797951e2f 100644 --- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts +++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts @@ -402,14 +402,17 @@ describe('studio-pipeline-status', () => { }), ).toBeNull(); - expect(studioDeployButtonLabel(false)).toBe('Attempt deploy'); - expect(studioDeployButtonLabel(true)).toBe('Attempt deploy'); - expect(studioDeployEnabledHint(false)).toMatch(/unknown checks are not a deploy receipt/i); + expect(studioDeployButtonLabel(false)).toBe('Check preflight'); + expect(studioDeployButtonLabel(true)).toBe('Check preflight'); + for (const hasReceipt of [false, true]) { + expect(studioDeployEnabledHint(hasReceipt)).toMatch(/preflight only/i); + expect(studioDeployEnabledHint(hasReceipt)).toMatch(/deployment is unavailable/i); + } const studio = readFileSync(join(process.cwd(), 'src/components/OneLoopStudio.tsx'), 'utf8'); expect(studio).toContain('studioDeployOutcomeMessage'); expect(studio).not.toMatch(/pollStudioDeploy\([^)]*attempts:\s*20\b/); - expect(studio).toMatch(/startStudioDeploy\(\{[\s\S]*transcript:/); + expect(studio).toContain('startStudioDeploy({ url: next })'); expect(studio).toContain('usableProvidedTranscript'); const workflow = readFileSync(join(process.cwd(), 'src/workflows/studio-deploy.ts'), 'utf8'); expect(workflow).toMatch(/kickoffAsyncVideoJob\(url,\s*\{\s*transcript/); @@ -557,4 +560,4 @@ describe('studio-pipeline-status', () => { expect(studio).not.toMatch(/code_snippets/); expect(studio).not.toMatch(/mapKeyframes|fakeEvents|invent.*events/i); }); -}); \ No newline at end of file +}); diff --git a/apps/web/src/lib/__tests__/studio-workflow.test.ts b/apps/web/src/lib/__tests__/studio-workflow.test.ts index e44835ef0..dfbd8c211 100644 --- a/apps/web/src/lib/__tests__/studio-workflow.test.ts +++ b/apps/web/src/lib/__tests__/studio-workflow.test.ts @@ -20,6 +20,19 @@ import { } from '@/lib/studio-pipeline-status'; describe('studio-workflow (WDK Product v1)', () => { + it('preserves the server gate receipt on a blocked deployment', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, status: 409, + json: async () => ({ ok: false, gate: { + decision: 'HOLD', reason: 'Artifact-bound evidence required.', reason_code: 'GATE_HOLD_MISSING_EVIDENCE', + receipt: { version: 'eventrelay.gate-receipt.v2', decision: 'HOLD', id: 'er:gate:v2:test', receipt_hash: 'a'.repeat(64), transition_id: 'transition-test', retained: false }, + } }), + })); + const result = await startStudioDeploy({ url: 'https://www.youtube.com/watch?v=auJzb1D-fag' }); + expect(result.ok).toBe(false); + expect(result.gate).toEqual({ decision: 'HOLD', reason: 'Artifact-bound evidence required.', reason_code: 'GATE_HOLD_MISSING_EVIDENCE', receiptId: 'er:gate:v2:test', receiptHash: 'a'.repeat(64), version: 'eventrelay.gate-receipt.v2', transitionId: 'transition-test', retained: false }); + }); + it('prefers a failed-run cause over a generic unread-return message', () => { const cause = new Error('Deploy job job_1 still complete'); const failed = new Error('Workflow run failed'); @@ -224,23 +237,18 @@ describe('studio-workflow (WDK Product v1)', () => { ); }); - it('startStudioDeploy sends a ready transcript so deploy can skip YouTube re-fetch', async () => { + it('keeps gate preflight bounded instead of resubmitting a large transcript', async () => { const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ ok: true, runId: 'wrun_01M2ACYVYXBHM0YVMX1WHMQ1PJ' }), + ok: false, + status: 409, + json: async () => ({ ok: false, message: 'Artifact-bound evidence required.' }), }); vi.stubGlobal('fetch', fetchMock); - const transcript = - 'Studio Video Pack for XYMcBrFSJ4c already has a usable transcript ready for deploy.'; - const started = await startStudioDeploy({ - url: 'https://www.youtube.com/watch?v=XYMcBrFSJ4c', - transcript, - }); - expect(started.ok).toBe(true); + const url = 'https://www.youtube.com/watch?v=auJzb1D-fag'; + const started = await startStudioDeploy({ url, transcript: 'x'.repeat(40_000) }); + expect(started.ok).toBe(false); const init = fetchMock.mock.calls[0]?.[1] as { body?: string }; - const body = JSON.parse(String(init.body)) as { transcript?: string }; - expect(body.transcript).toBe(transcript); + expect(JSON.parse(String(init.body))).toEqual({ url }); }); it('pollStudioDeploy returns on handoff result', async () => { diff --git a/apps/web/src/lib/app-builder-sandbox-PLAN.md b/apps/web/src/lib/app-builder-sandbox-PLAN.md index 36123ade9..0187f46fd 100644 --- a/apps/web/src/lib/app-builder-sandbox-PLAN.md +++ b/apps/web/src/lib/app-builder-sandbox-PLAN.md @@ -1,4 +1,6 @@ -# TASK: second-video→App Builder smoke (XYMcBrFSJ4c) +# Completed cut record: second-video→App Builder smoke (XYMcBrFSJ4c) + +> Historical execution receipt, classified 2026-09-13. The checked steps, VM paths, smoke results, and draft-PR statement below describe the original run; they have not been freshly reverified by the documentation review. Preserve this evidence and its locked fixtures. It is not the current execution plan, proof of recreating the demonstrated app, or a production deployment receipt. Current scope is in [AGENTS.md](../../../../AGENTS.md), [NEXT-PHASE.md](../../../../docs/NEXT-PHASE.md), and [MASTER_ROADMAP.md](../../../../docs/MASTER_ROADMAP.md). ## 1. Goal & Scope * **Objective:** Lock App Builder sandbox emit for live pack `XYMcBrFSJ4c` (`vp:v0:XYMcBrFSJ4c`, source_hash `b7d0ea083b1f8a6146fd6db818896ed09abfaa3f92babf976f5c209e07d3b959`). Payload is transcript + visual events + SOP only. PASS is fixture + tests, materialize from live pack/sandbox JSON, `npm run build` + `npm run typecheck`, and `0.0.0.0:8080` smoke on this VM. diff --git a/apps/web/src/lib/gate-transition.ts b/apps/web/src/lib/gate-transition.ts index dc6dd5117..df5323ea5 100644 --- a/apps/web/src/lib/gate-transition.ts +++ b/apps/web/src/lib/gate-transition.ts @@ -55,7 +55,7 @@ export interface GateTransitionRequest { } export interface GateReceipt { - version: typeof GATE_RECEIPT_VERSION; + version: typeof GATE_RECEIPT_VERSION | 'eventrelay.gate-receipt.v2'; id: string; kind: string; transition_id: string; @@ -97,7 +97,9 @@ export interface StudioGateReceiptView { reason_code: string; receiptId: string; receiptHash: string; - version: typeof GATE_RECEIPT_VERSION; + transitionId?: string; + retained?: boolean; + version: typeof GATE_RECEIPT_VERSION | 'eventrelay.gate-receipt.v2'; } const SHA256_HEX = /^[a-f0-9]{64}$/; @@ -340,14 +342,14 @@ export function evaluateTransition(request: GateTransitionRequest): GateEvaluati const liveRefs = request.evidenceRefs.filter((ref) => ref.kind === 'live_url'); const verifiedLive = liveRefs.map(liveRefValue).find((url): url is string => Boolean(url)); const claimedLiveWithoutReceipt = - toState === 'live' && liveRefs.some((ref) => presentedLiveValue(ref)) && !verifiedLive; + toState === 'live' && liveRefs.some((ref) => presentedLiveValue(ref)); if (claimedLiveWithoutReceipt) { return finish( request, 'REJECT', 'GATE_REJECT_CLAIM_MISMATCH', - 'Claimed live transition without a verified https live URL (hostname required).', + 'Claimed live transition without a trusted artifact-bound receipt. A URL is not deployment verification.', zeroSim, ); } @@ -427,7 +429,7 @@ export function evaluateStudioDeployTransition( } const zeroSim: ZeroSimResult | undefined = verified - ? { verdict: 'real', reason_code: 'ZERO_SIM_REAL' } + ? { verdict: 'unverified', reason_code: 'ZERO_SIM_UNVERIFIED' } : presented ? { verdict: 'unreal', reason_code: 'ZERO_SIM_UNREAL' } : undefined; diff --git a/apps/web/src/lib/origin-gate-store.ts b/apps/web/src/lib/origin-gate-store.ts new file mode 100644 index 000000000..98dc67870 --- /dev/null +++ b/apps/web/src/lib/origin-gate-store.ts @@ -0,0 +1,81 @@ +import 'server-only'; + +import { resolveUpstashRedisCredentials } from '@/lib/billing/redis-credentials'; +import { canonicalGateJson, hashCanonical } from '@/lib/gate-transition'; +import { evaluateOriginGate, type OriginGateStore } from '@/lib/origin-gate'; + +export const ORIGIN_GATE_POLICY_KEY = 'er:gate:v2:trusted-policy'; +const NON_PASS_RETENTION_SECONDS = 24 * 60 * 60; +const MAX_NON_PASS_RECEIPTS_PER_SUBJECT = 100; + +// Only PASS is immutable: re-evaluation of a negative receipt must not bypass replay or policy checks. +export const COMMIT_ORIGIN_GATE_SCRIPT = ` +if ARGV[2] == 'PASS' and redis.call('GET', KEYS[#KEYS]) ~= ARGV[4] then + return { 'policy_changed' } +end +local prior = redis.call('GET', KEYS[1]) +if prior and cjson.decode(prior).decision == 'PASS' then return { 'existing', prior } end +if ARGV[2] == 'PASS' then + for i = 3, #KEYS - 1 do + if redis.call('EXISTS', KEYS[i]) == 1 then return { 'conflict' } end + end + redis.call('ZREM', KEYS[2], KEYS[1]) + for i = 3, #KEYS - 1 do redis.call('SET', KEYS[i], ARGV[3]) end + redis.call('SET', KEYS[1], ARGV[1]) +else + local time = redis.call('TIME') + local now = tonumber(time[1]) * 1000 + math.ceil(tonumber(time[2]) / 1000) + redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now) + if not redis.call('ZSCORE', KEYS[2], KEYS[1]) and redis.call('ZCARD', KEYS[2]) >= tonumber(ARGV[6]) then + return { 'quota' } + end + redis.call('ZADD', KEYS[2], now + tonumber(ARGV[5]) * 1000, KEYS[1]) + redis.call('EXPIRE', KEYS[2], ARGV[5]) + redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[5]) +end +return { 'stored' } +`; + +export function createOriginGateStore(): OriginGateStore { + let policySnapshot: string | null = null; + const command = async (args: Array): Promise => { + const credentials = resolveUpstashRedisCredentials(); + if (!credentials || !credentials.url.startsWith('https://')) throw new Error('GATE runtime unavailable'); + const response = await fetch(credentials.url, { + method: 'POST', + headers: { authorization: `Bearer ${credentials.token}`, 'content-type': 'application/json' }, + body: JSON.stringify(args), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new Error('GATE runtime unavailable'); + const payload: unknown = await response.json(); + if (!payload || typeof payload !== 'object' || !('result' in payload) || 'error' in payload) throw new Error('Invalid GATE runtime response'); + return payload.result; + }; + return { + async readPolicy() { + const raw = await command(['GET', ORIGIN_GATE_POLICY_KEY]); + policySnapshot = typeof raw === 'string' ? raw : null; + return typeof raw === 'string' ? JSON.parse(raw) : raw; + }, + async commit({ requestHash, transitionKey, nonceKeys, evaluation }) { + if (evaluation.decision === 'PASS' && !policySnapshot) throw new Error('Missing trusted policy snapshot'); + const subject = evaluation.receipt.authority.claim; + if (!subject) throw new Error('Missing authenticated receipt subject'); + const keys = [`er:gate:v2:receipt:${requestHash}`, `er:gate:v2:pending:${hashCanonical(subject)}`, `er:gate:v2:transition:${transitionKey}`, ...nonceKeys.map((nonce) => `er:gate:v2:nonce:${nonce}`), ORIGIN_GATE_POLICY_KEY]; + const result = await command(['EVAL', COMMIT_ORIGIN_GATE_SCRIPT, keys.length, ...keys, canonicalGateJson(evaluation), evaluation.decision, requestHash, policySnapshot ?? '', NON_PASS_RETENTION_SECONDS, MAX_NON_PASS_RECEIPTS_PER_SUBJECT]); + if (!Array.isArray(result)) throw new Error('Invalid GATE commit response'); + if (result[0] === 'stored') return { status: 'stored' }; + if (result[0] === 'conflict') return { status: 'conflict' }; + if (result[0] === 'quota') return { status: 'quota' }; + if (result[0] === 'existing' && typeof result[1] === 'string') return { status: 'existing', evaluation: JSON.parse(result[1]) as unknown }; + throw new Error('Invalid GATE commit response'); + }, + }; +} + +export function decideOriginGate(input: unknown, subject: string) { + return evaluateOriginGate(input, { subject, signingSecret: process.env.NEXTAUTH_SECRET ?? '', store: createOriginGateStore() }); +} diff --git a/apps/web/src/lib/origin-gate.ts b/apps/web/src/lib/origin-gate.ts new file mode 100644 index 000000000..328ac588c --- /dev/null +++ b/apps/web/src/lib/origin-gate.ts @@ -0,0 +1,219 @@ +import 'server-only'; + +import { createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto'; +import { z } from 'zod'; +import { canonicalGateJson, hashCanonical, type GateDecision, type GateEvaluation, type GateReceipt, type ZeroSimVerdict } from '@/lib/gate-transition'; +import { studioVerifiedLiveUrl } from '@/lib/studio-pipeline-status'; + +const identifier = z.string().min(1).max(200).regex(/^[a-zA-Z0-9_.:-]+$/); +const digest = z.string().regex(/^[a-f0-9]{64}$/); +const targetSchema = z.object({ + provider: z.literal('vercel'), + projectId: identifier, + environment: z.enum(['preview', 'production']), + liveUrl: z.string().max(2048).refine((value) => studioVerifiedLiveUrl(value) === value), +}).strict(); +const bindingSchema = z.object({ + transitionId: identifier, + kind: z.literal('studio.deploy'), + fromState: z.literal('proposed'), + toState: z.literal('live'), + subject: z.string().min(1).max(512), + runId: identifier, + artifactHash: digest, + target: targetSchema, +}).strict(); +const attestationSchema = z.object({ + payload: z.object({ + version: z.literal('origin.attestation.v1'), + type: z.enum(['approval', 'deployment']), + issuer: identifier, + nonce: identifier, + issuedAt: z.string().datetime(), + expiresAt: z.string().datetime(), + binding: bindingSchema, + verdict: z.string().min(1).max(32), + providerReceiptId: identifier.optional(), + providerReceiptHash: digest.optional(), + }).strict(), + signature: z.string().regex(/^[A-Za-z0-9_-]{86}$/), +}).strict(); +const requestSchema = bindingSchema.omit({ subject: true }).extend({ + runId: identifier.optional(), + artifactHash: digest.optional(), + target: targetSchema.optional(), + approval: attestationSchema.optional(), + evidence: attestationSchema.optional(), +}).strict(); +const policySchema = z.object({ + version: z.literal(1), + issuers: z.array(z.object({ + id: identifier, + role: z.enum(['loop', 'deployment-verifier']), + publicKey: z.string().min(1).max(4096), + projectIds: z.array(identifier).min(1).max(100), + revoked: z.boolean(), + }).strict()).max(100), +}).strict().refine((policy) => new Set(policy.issuers.map((issuer) => issuer.id)).size === policy.issuers.length); + +type Proposal = z.infer; +type Attestation = z.infer; +type Policy = z.infer; +type Finding = { decision: GateDecision; code: string; reason: string; verdict?: ZeroSimVerdict }; + +export interface OriginGateReceipt extends GateReceipt { + version: 'eventrelay.gate-receipt.v2'; + request_hash: string; + artifact_hash: string | null; + run_id: string | null; + target: z.infer | null; + policy_hash: string | null; + retained: boolean; + signature: string | null; +} +export interface OriginGateEvaluation extends GateEvaluation { receipt: OriginGateReceipt } +export interface OriginGateStore { + readPolicy(): Promise; + commit(input: { + requestHash: string; + transitionKey: string; + nonceKeys: string[]; + evaluation: OriginGateEvaluation; + }): Promise<{ status: 'stored' | 'conflict' | 'quota' } | { status: 'existing'; evaluation: unknown }>; +} +export interface OriginGateContext { + subject: string | null; + signingSecret: string; + store: OriginGateStore; + now?: number; +} + +const missing: Finding = { decision: 'HOLD', code: 'GATE_HOLD_MISSING_EVIDENCE', reason: 'A run, artifact hash, permitted target, signed Loop approval, and independent verification receipt are required. An evidence workspace is not a verified deployment.' }; +const unavailable: Finding = { decision: 'HOLD', code: 'GATE_HOLD_RUNTIME_UNAVAILABLE', reason: 'The trusted signing or receipt-retention runtime is unavailable. No transition is permitted.' }; +const replay: Finding = { decision: 'REJECT', code: 'GATE_REJECT_REPLAY', reason: 'This transition or attestation nonce was already accepted for a different request.' }; + +function verifyAttestation(envelope: Attestation, type: 'approval' | 'deployment', binding: z.infer, policy: Policy, now: number): Finding | null { + const payload = envelope.payload; + const issuer = policy.issuers.find((candidate) => candidate.id === payload.issuer); + if (!issuer) return { decision: 'ESCALATE', code: 'GATE_ESCALATE_AUTHORITY_UNKNOWN', reason: 'The attestation issuer is not registered by the trusted runtime.' }; + const expectedRole = type === 'approval' ? 'loop' : 'deployment-verifier'; + if (issuer.revoked || issuer.role !== expectedRole || !issuer.projectIds.includes(binding.target.projectId)) { + return { decision: 'REJECT', code: 'GATE_REJECT_AUTHORITY_SCOPE', reason: 'The signer is revoked or lacks the required role and project scope.' }; + } + let valid = false; + try { + const key = createPublicKey(issuer.publicKey); + valid = key.asymmetricKeyType === 'ed25519' && verify(null, Buffer.from(`origin.attestation.v1\n${canonicalGateJson(payload)}`), key, Buffer.from(envelope.signature, 'base64url')); + } catch { + return { decision: 'ESCALATE', code: 'GATE_ESCALATE_AUTHORITY_UNKNOWN', reason: 'The registered verification key is invalid.' }; + } + if (!valid || payload.type !== type || canonicalGateJson(payload.binding) !== canonicalGateJson(binding)) { + return { decision: 'REJECT', code: 'GATE_REJECT_ATTESTATION_MISMATCH', reason: 'Signature or user/run/artifact/target/transition binding does not match.', verdict: 'unreal' }; + } + const issued = Date.parse(payload.issuedAt); + const expires = Date.parse(payload.expiresAt); + if (issued > now + 30_000 || expires <= now || expires <= issued || expires - issued > 900_000) { + return { decision: 'HOLD', code: 'GATE_HOLD_STALE_EVIDENCE', reason: 'The attestation is expired, future-dated, or exceeds the 15-minute validity window.' }; + } + if (type === 'approval') { + if (payload.verdict === 'deny') return { decision: 'REJECT', code: 'GATE_REJECT_AUTHORITY_DENIED', reason: 'Loop denied this exact transition.' }; + if (payload.verdict !== 'allow') return { decision: 'ESCALATE', code: 'GATE_ESCALATE_AUTHORITY_UNKNOWN', reason: 'The signed approval decision is unknown.' }; + return null; + } + if (!['real', 'unverified', 'unreal'].includes(payload.verdict)) return { decision: 'ESCALATE', code: 'GATE_ESCALATE_ZERO_SIM_UNKNOWN', reason: 'The signed Zero-Sim verdict is unknown.' }; + if (payload.verdict === 'unreal') return { decision: 'REJECT', code: 'GATE_REJECT_UNREAL_EVIDENCE', reason: 'The trusted verifier marked this evidence unreal.', verdict: 'unreal' }; + if (payload.verdict === 'unverified') return { decision: 'HOLD', code: 'GATE_HOLD_WEAK_EVIDENCE', reason: 'The trusted verifier has not verified this deployment.', verdict: 'unverified' }; + if (!payload.providerReceiptId || !payload.providerReceiptHash) return missing; + return null; +} + +function validStoredReceipt(value: unknown, expected: OriginGateEvaluation, secret: string): value is OriginGateEvaluation { + if (!value || typeof value !== 'object') return false; + const candidate = value as OriginGateEvaluation; + if (!candidate.receipt || candidate.decision !== expected.decision || candidate.receipt.request_hash !== expected.receipt.request_hash || candidate.receipt.retained !== true) return false; + const { signature, receipt_hash, ...body } = candidate.receipt; + if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/.test(signature)) return false; + if (receipt_hash !== hashCanonical(canonicalGateJson(body))) return false; + if (candidate.decision !== body.decision || candidate.reason !== body.reason || candidate.reason_code !== body.reason_code) return false; + const expectedSignature = createHmac('sha256', secret).update(`origin.gate-receipt.v2\n${receipt_hash}`).digest(); + return timingSafeEqual(Buffer.from(signature, 'hex'), expectedSignature); +} + +export async function evaluateOriginGate(input: unknown, context: OriginGateContext): Promise { + const now = context.now ?? Date.now(); + const parsed = requestSchema.safeParse(input); + const proposal: Partial = parsed.success ? parsed.data : {}; + let policyHash: string | null = null; + let requestHash = hashCanonical(canonicalGateJson({ input: parsed.success ? parsed.data : null, subject: context.subject })); + const canSign = context.signingSecret.length >= 32; + const issue = (finding: Finding, retained: boolean): OriginGateEvaluation => { + const body = { + version: 'eventrelay.gate-receipt.v2' as const, + id: `er:gate:v2:${requestHash}`, + kind: proposal.kind ?? 'studio.deploy', + transition_id: proposal.transitionId ?? 'invalid', + from_state: proposal.fromState ?? 'proposed', + to_state: proposal.toState ?? 'live', + decision: finding.decision, + reason_code: finding.code, + reason: finding.reason, + evidence_refs: [proposal.approval, proposal.evidence].flatMap((envelope) => envelope ? [{ kind: envelope.payload.type, id: `${envelope.payload.issuer}:${envelope.payload.nonce}`, hash: hashCanonical(canonicalGateJson(envelope)) }] : []), + authority: { actor: context.subject ? 'signed-in' : 'anonymous', claim: context.subject ?? '' }, + zero_sim: { verdict: finding.verdict ?? 'unverified' as ZeroSimVerdict, reason_code: finding.verdict === 'real' ? 'ZERO_SIM_REAL' : finding.verdict === 'unreal' ? 'ZERO_SIM_UNREAL' : 'ZERO_SIM_UNVERIFIED' }, + issued_at: new Date(now).toISOString(), + request_hash: requestHash, + artifact_hash: proposal.artifactHash ?? null, + run_id: proposal.runId ?? null, + target: proposal.target ?? null, + policy_hash: policyHash, + retained, + }; + const receipt_hash = hashCanonical(canonicalGateJson(body)); + const signature = canSign ? createHmac('sha256', context.signingSecret).update(`origin.gate-receipt.v2\n${receipt_hash}`).digest('hex') : null; + return { decision: finding.decision, reason: finding.reason, reason_code: finding.code, receipt: { ...body, receipt_hash, signature } }; + }; + const retain = async (finding: Finding): Promise => { + const evaluation = issue(finding, true); + try { + const result = await context.store.commit({ + requestHash, + transitionKey: hashCanonical(canonicalGateJson({ subject: context.subject, transitionId: proposal.transitionId })), + nonceKeys: finding.decision === 'PASS' ? [proposal.approval!, proposal.evidence!].map(({ payload }) => hashCanonical(`${payload.issuer}:${payload.nonce}`)) : [], + evaluation, + }); + if (result.status === 'conflict') return issue(replay, false); + if (result.status === 'quota') return issue({ decision: 'HOLD', code: 'GATE_HOLD_RETENTION_LIMIT', reason: 'Temporary receipt storage limit reached. No transition is permitted; retry after retained non-PASS receipts expire.' }, false); + if (result.status === 'existing') return validStoredReceipt(result.evaluation, evaluation, context.signingSecret) ? result.evaluation : issue(unavailable, false); + return evaluation; + } catch { + return issue(unavailable, false); + } + }; + + if (!context.subject) return issue({ decision: 'ESCALATE', code: 'GATE_ESCALATE_AUTHORITY_UNKNOWN', reason: 'A verified server session is required; browser actor labels do not authorize transitions.' }, false); + if (!parsed.success) return issue({ decision: 'REJECT', code: 'GATE_REJECT_INVALID_TRANSITION', reason: 'Only the strict studio.deploy proposed-to-live contract is accepted.' }, false); + if (!canSign) return issue(unavailable, false); + if (proposal.target?.liveUrl && !proposal.evidence) return retain({ decision: 'REJECT', code: 'GATE_REJECT_CLAIM_MISMATCH', reason: 'A live URL without a signed, artifact-bound verification receipt is not accepted.' }); + if (!proposal.runId || !proposal.artifactHash || !proposal.target || !proposal.approval || !proposal.evidence) return retain(missing); + let policy: Policy; + try { + const trusted = policySchema.safeParse(await context.store.readPolicy()); + if (!trusted.success || !trusted.data.issuers.length) return retain({ decision: 'ESCALATE', code: 'GATE_ESCALATE_AUTHORITY_UNKNOWN', reason: 'Loop and verifier public keys have not been registered in the trusted runtime.' }); + policy = trusted.data; + } catch { + return issue(unavailable, false); + } + policyHash = hashCanonical(canonicalGateJson(policy)); + requestHash = hashCanonical(canonicalGateJson({ input: parsed.data, subject: context.subject, policyHash })); + const binding = bindingSchema.parse({ transitionId: proposal.transitionId, kind: proposal.kind, fromState: proposal.fromState, toState: proposal.toState, subject: context.subject, runId: proposal.runId, artifactHash: proposal.artifactHash, target: proposal.target }); + const approvalFinding = verifyAttestation(proposal.approval, 'approval', binding, policy, now); + if (approvalFinding) return retain(approvalFinding); + const evidenceFinding = verifyAttestation(proposal.evidence, 'deployment', binding, policy, now); + if (evidenceFinding) return retain(evidenceFinding); + const approvalKey = createPublicKey(policy.issuers.find((issuer) => issuer.id === proposal.approval!.payload.issuer)!.publicKey).export({ type: 'spki', format: 'der' }); + const evidenceKey = createPublicKey(policy.issuers.find((issuer) => issuer.id === proposal.evidence!.payload.issuer)!.publicKey).export({ type: 'spki', format: 'der' }); + if (approvalKey.equals(evidenceKey)) { + return retain({ decision: 'REJECT', code: 'GATE_REJECT_AUTHORITY_SCOPE', reason: 'Loop approval and deployment verification require independent signing keys.' }); + } + return retain({ decision: 'PASS', code: 'GATE_PASS', reason: 'Trusted Loop approval and independent verification permit this exact artifact-bound transition. This receipt does not execute deployment or authorize a later phase.', verdict: 'real' }); +} diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index 60e2a8644..c0c1f1144 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -445,12 +445,12 @@ export function studioDeployOutcomeMessage(input: { } export function studioDeployButtonLabel(_hasReceipt: boolean): string { - return 'Attempt deploy'; + return 'Check preflight'; } export function studioDeployEnabledHint(hasReceipt: boolean): string { - if (hasReceipt) return 'A live URL was returned. That is the receipt — not the enabled button.'; - return 'Starts an attempt. UNKNOWN checks are not a deploy receipt.'; + const hint = 'Preflight only. Deployment is unavailable.'; + return hasReceipt ? `${hint} An existing receipt does not authorize another deployment.` : `${hint} No deployment will be started.`; } export type StudioPlayerPhase = 'empty' | 'loading' | 'ready' | 'error'; @@ -542,4 +542,4 @@ export function studioExportToastMessage(input: { }; } return { tone: 'success', text: `Export downloaded${fileBit}.` }; -} \ No newline at end of file +} diff --git a/apps/web/src/lib/studio-workflow.ts b/apps/web/src/lib/studio-workflow.ts index 97bdcdb9f..769a521a4 100644 --- a/apps/web/src/lib/studio-workflow.ts +++ b/apps/web/src/lib/studio-workflow.ts @@ -6,6 +6,7 @@ */ import type { AnalysisProvenance, EvidenceAssessment } from '@/lib/analysis-evidence'; +import type { StudioGateReceiptView } from '@/lib/gate-transition'; import type { VideoAnalysisResult } from '@/lib/gemini-video-analyzer'; import { extractBackendLiveUrl, @@ -187,13 +188,26 @@ export function isTransientWorkflowRunReadError(err: unknown): boolean { ); } -/** Start durable Studio deploy (WDK C). Returns immediately with runId. */ +/** Studio preflight returns a server gate decision; legacy runs remain pollable. */ export interface StudioDeployStart { ok: boolean; status: number; runId?: string; message?: string; error?: string; + gate?: StudioGateReceiptView; +} + +function serverGateView(value: unknown): StudioGateReceiptView | undefined { + if (!value || typeof value !== 'object') return undefined; + const gate = value as Record; + if (!gate.receipt || typeof gate.receipt !== 'object') return undefined; + const receipt = gate.receipt as Record; + const decision = gate.decision; + if (decision !== 'PASS' && decision !== 'HOLD' && decision !== 'REJECT' && decision !== 'ESCALATE') return undefined; + if (receipt.version !== 'eventrelay.gate-receipt.v2' || receipt.decision !== decision || typeof receipt.id !== 'string' || typeof receipt.receipt_hash !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.receipt_hash) || typeof gate.reason !== 'string' || typeof gate.reason_code !== 'string') return undefined; + if (decision === 'PASS' && (receipt.retained !== true || typeof receipt.signature !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.signature))) return undefined; + return { decision, reason: gate.reason, reason_code: gate.reason_code, receiptId: receipt.id, receiptHash: receipt.receipt_hash, version: receipt.version, transitionId: str(receipt.transition_id), retained: receipt.retained === true }; } export interface StudioDeployPoll { @@ -213,7 +227,7 @@ export interface StudioDeployPoll { message?: string; } -/** Start durable Studio deploy (WDK C). */ +/** Request gate preflight only; transcript generation is not deployment evidence. */ export async function startStudioDeploy(input: { url: string; projectType?: string; @@ -226,12 +240,7 @@ export async function startStudioDeploy(input: { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: input.url, - projectType: input.projectType, - outcome: input.outcome, - ...(input.transcript ? { transcript: input.transcript } : {}), - }), + body: JSON.stringify({ url: input.url }), signal: input.signal ?? AbortSignal.timeout(30_000), }); const payload = (await response.json().catch(() => ({}))) as Record; @@ -241,6 +250,7 @@ export async function startStudioDeploy(input: { runId: str(payload.runId), message: str(payload.message), error: str(payload.error), + gate: serverGateView(payload.gate), }; } catch (err) { if (isStudioDeployAbortTimeout(err)) { diff --git a/apps/web/src/lib/studio/__tests__/security.test.ts b/apps/web/src/lib/studio/__tests__/security.test.ts index 26482b026..22aed5e5f 100644 --- a/apps/web/src/lib/studio/__tests__/security.test.ts +++ b/apps/web/src/lib/studio/__tests__/security.test.ts @@ -5,8 +5,11 @@ import { readStudioJson, requireStudioOwner, requireStudioMutationOrigin } from const secret = 'offline-studio-test-secret-not-a-real-credential'; const endpoint = 'https://uvai.example/api/studio/chats'; +const previewUrlKeys = ['V0_SANDBOX_URL', 'V0_RUNTIME_URL', 'V0_BUILD_URL'] as const; +const previewOrigin = 'https://preview.example.test'; beforeEach(() => { + for (const key of previewUrlKeys) vi.stubEnv(key, ''); vi.stubEnv('NEXTAUTH_SECRET', secret); vi.stubEnv('NEXTAUTH_URL', 'https://uvai.example'); vi.stubEnv('NODE_ENV', 'production'); @@ -95,6 +98,65 @@ describe('Studio mutation origin checks', () => { }); }); +describe('Studio development preview origins', () => { + beforeEach(() => vi.stubEnv('NODE_ENV', 'development')); + + function proxiedRequest(origin: string | null = previewOrigin, extraHeaders: Record = {}) { + const headers = new Headers(extraHeaders); + if (origin !== null) headers.set('origin', origin); + return new NextRequest('http://localhost:3000/api/studio/chats', { method: 'POST', headers }); + } + + it.each(previewUrlKeys)('accepts only the exact HTTPS origin configured by %s', (key) => { + vi.stubEnv(key, `${previewOrigin}/preview/path?view=studio`); + expect(() => requireStudioMutationOrigin(proxiedRequest())).not.toThrow(); + }); + + it('preserves direct same-origin development requests without preview configuration', () => { + expect(() => requireStudioMutationOrigin(proxiedRequest('http://localhost:3000'))).not.toThrow(); + }); + + it.each(['production', 'test'])('ignores preview origins in %s mode', (mode) => { + vi.stubEnv('NODE_ENV', mode); + for (const key of previewUrlKeys) vi.stubEnv(key, previewOrigin); + expect(() => requireStudioMutationOrigin(proxiedRequest())) + .toThrow(expect.objectContaining({ status: 403, code: 'invalid_origin' })); + }); + + it.each(['', 'not a URL', 'null', 'http://preview.example.test', 'https://user:password@preview.example.test', 'javascript:alert(1)'])('adds no trust for invalid configuration %s', (configuredUrl) => { + for (const key of previewUrlKeys) vi.stubEnv(key, configuredUrl); + expect(() => requireStudioMutationOrigin(proxiedRequest())) + .toThrow(expect.objectContaining({ status: 403, code: 'invalid_origin' })); + }); + + it.each([ + null, 'null', 'https://other.example.test', 'https://preview.example.test.attacker.test', + 'http://preview.example.test', 'https://preview.example.test:444', 'https://preview.example.test:443', + 'https://preview.example.test/', 'https://preview.example.test/path', + 'https://preview.example.test?query=1', 'https://preview.example.test#fragment', + 'https://user@preview.example.test', 'https://preview.example.test https://attacker.test', + ])('rejects untrusted or non-serialized Origin %s', (origin) => { + vi.stubEnv('V0_SANDBOX_URL', previewOrigin); + expect(() => requireStudioMutationOrigin(proxiedRequest(origin))) + .toThrow(expect.objectContaining({ status: 403, code: 'invalid_origin' })); + }); + + it('does not let forwarded headers introduce a trusted preview origin', () => { + expect(() => requireStudioMutationOrigin(proxiedRequest(previewOrigin, { + host: 'preview.example.test', + forwarded: 'host=preview.example.test;proto=https', + 'x-forwarded-host': 'preview.example.test', + 'x-forwarded-proto': 'https', + }))).toThrow(expect.objectContaining({ status: 403 })); + }); + + it('rejects cross-site requests even for a configured preview origin', () => { + vi.stubEnv('V0_SANDBOX_URL', previewOrigin); + expect(() => requireStudioMutationOrigin(proxiedRequest(previewOrigin, { 'sec-fetch-site': 'cross-site' }))) + .toThrow(expect.objectContaining({ status: 403 })); + }); +}); + describe('bounded Studio JSON input', () => { function request(body: string, headers: Record = {}) { return new NextRequest(endpoint, { method: 'POST', body, headers: { 'content-type': 'application/json', ...headers } }); diff --git a/apps/web/src/lib/studio/security.ts b/apps/web/src/lib/studio/security.ts index 957861eaf..c1c679256 100644 --- a/apps/web/src/lib/studio/security.ts +++ b/apps/web/src/lib/studio/security.ts @@ -25,9 +25,26 @@ export async function requireStudioOwner(request: NextRequest): Promise { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && url.origin === origin; + } catch { + return false; + } + }); +} + export function requireStudioMutationOrigin(request: NextRequest): void { const origin = request.headers.get('origin'); - if (origin !== request.nextUrl.origin || request.headers.get('sec-fetch-site') === 'cross-site') { + if (request.headers.get('sec-fetch-site') === 'cross-site' || + (origin !== request.nextUrl.origin && !isDevelopmentPreviewOrigin(origin))) { throw new StudioError(403, 'invalid_origin', 'This request must originate from Studio.'); } } diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 996585eed..d51e3e3e4 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,103 +1,9 @@ -# UVAI▶ — Agent Instructions +# Documentation scope -Grok Build (`grok --cwd `) appends this file to the system prompt; keep locked UVAI facts here. -This is the live UVAI product repo (`uvai.io`, Vercel project `v0-uvai`). +Read [../AGENTS.md](../AGENTS.md) before working here. The repository-root file is the single authority for locked UVAI product facts, prices, storage, lineage, operating roles, and authorized scope; do not maintain a second copy in this directory. -**Do not invent prices, catalog SKUs, or stack.** If a fact is not locked here, look it up in the repo or leave it unset. - -## Product (locked) - -**UVAI▶** (Universal Video Action Intelligence). - -Paste a YouTube URL → hashed **Video Pack** → shipable architecture / build rails. - -The differentiator is **action / ship**, not transcription. Do not compete with Google on STT or models. Ride **Gemini 3.8 Flash via Vercel AI Gateway**. - -## Pricing (locked) - -| Offer | Price | -| --- | --- | -| Workflow Pro | **$39/mo** and **$390/yr** | -| Maintain | **$199/mo** per live product | -| Ship | **per-job quote** | - -Do **not** use **$19** / **$180** (dead EventRelay Pro catalog). - -Do not invent other prices or imply Maintain / Ship checkout exists unless the repo already implements it. - -## Pack store (locked) - -Video Pack persistence is **Upstash REST only** (`KV_REST_API_*`, or the equivalent `UPSTASH_REDIS_REST_*` pair the integration injects). - -**Official Redis TCP is not the pack store.** Do not add `redis://`, ioredis, or other TCP Redis clients for packs. - -## Already live (do not re-litigate) - -- Get Pro checkout -- Hashed Video Pack -- UI unify — single skin; `/dashboard` and sibling skins redirect into the canonical studio -- Pack-quality — architecture / artifacts / `stack.tools`; **no forced Shopify gate** - -## Lineage backbone (locked) - -``` -UVAI▶ - → Video Pack - → Mission Workspace - → Agent Factory / Slingshot - → EventRelay ← internal runtime, not a public brand - → Zero-Sim / G.A.T.E. ← Origin hardgate - → ExperienceOS -``` - -**FORGE / Workbench / Living Notebook / VIZUL** are UX patterns only — not product brands and not parallel products. - -## Operating model (locked) - -| Role | Owns | -| --- | --- | -| **UVAI Loop** | The standing ship | -| **Chief of Staff** | Orchestration only — no codebase diving | -| **Grok Build** | Repo-side checker (`grok --cwd`) | -| **Builder** | Stays off parallel UVAI cuts unless Loop asks | - -## Authorized next cut - -**Origin G.A.T.E.** only. - -`asRecord` / claim stay **held** unless that cut requires them. Do not start adjacent cuts (Mission Workspace, Agent Factory, ExperienceOS, FORGE-as-product, etc.) unless Loop asks. - -## G.A.T.E. vs Zero-Sim (locked) - -- **Zero-Sim:** is the evidence real? (`real` | `unverified` | `unreal`). Never invent evidence. -- **G.A.T.E.:** do verified evidence + authority permit this state transition? -- Every consequential transition resolves to exactly one of **PASS | HOLD | REJECT | ESCALATE**. -- Missing required evidence → **HOLD**. Weak/unverified evidence → **HOLD**. Unreal or a live claim without a verified receipt → **REJECT**. Unknown authority / unknown Zero-Sim verdict → [...] -- G.A.T.E. does not build the artifact and is not the project database. Plane stays **proposed** until config + receipts verify. SeeScriptShip lock ≠ install/run/deploy. -- Contract: `apps/web/src/lib/gate-transition.ts`. Spec: `docs/gate-transition-contract.md`. -- Gated today: Studio `studio.deploy` (`proposed` → `live`) in `OneLoopStudio` — no live success claim unless G.A.T.E. **PASS**es a verified `https://` hostname URL (#1707 / #1710). - -## Grok Build discovery - -- **Project rules:** this `AGENTS.md` (also `CLAUDE.md` if present). Grok walks repo root → cwd. Each file is capped at 10k characters. -- **Skills:** auto-discovered from `.grok/skills/` (repo or cwd). Extra `[skills] paths` belong in `~/.grok/config.toml`, not project config. -- **Project `.grok/config.toml`:** official docs honor **`[mcp_servers]` only** here. Do not add one unless sharing MCP servers. This repo has no committed project MCP config. -- **Existing Claude skills** in `.claude/skills/` load via Grok's Claude compatibility. Do not duplicate them under `.grok/skills/` unless a Grok-native override is required. -- **Workflows:** auto-discovered from `.grok/workflows/*.rhai` (already present). Treat as automation, not product lineage. -- Confirm what loaded: `grok inspect --cwd `. - -## Repo conventions - -- Verify before submit. If a feature has no test, add one. -- Conventional Commits, imperative mood, subject < 72 chars (`feat:`, `fix:`, `docs:`, …). -- Test fixtures: use `auJzb1D-fag`. Do **not** use `dQw4w9WgXcQ`. -- No secrets in git. No mock/hardcoded data from production AI paths. -- EventRelay code under `src/`, `mcp-servers/`, and related paths is the **internal runtime**. Do not rebrand it publicly as EventRelay. - -## Nightly audit (Jules) - -When running the nightly first-principles audit (status > 400 or latency > 200ms): analyze origin, remediate atomically, add a preventative guard. Manual dry-run: - -```bash -PYTHONPATH=src python3 scripts/nightly_audit_agent.py --dry-run -``` +- [MASTER_ROADMAP.md](MASTER_ROADMAP.md) separates source-inspected capability, verification evidence, and proposed work. +- [NEXT-PHASE.md](NEXT-PHASE.md) describes the next authorized Origin G.A.T.E. cut; later phases are not authorization to start them. +- [gate-transition-contract.md](gate-transition-contract.md) specifies the existing gate behavior and its evidence boundary. +- Historical plans and smoke receipts must retain their original dates and limitations. Never promote them to a fresh production PASS. +- Prefer updating these entry points over adding overlapping status documents. Preserve source fixtures, audit records, and operational evidence unless a verified cleanup reason requires removal. diff --git a/docs/ARCHITECTURE_DIAGRAM.md b/docs/ARCHITECTURE_DIAGRAM.md index bf15b6672..edbc985a9 100644 --- a/docs/ARCHITECTURE_DIAGRAM.md +++ b/docs/ARCHITECTURE_DIAGRAM.md @@ -1,4 +1,6 @@ -# EventRelay Architecture — Current State & Target State +# EventRelay architecture — historical runtime and issue map + +> Historical snapshot, marked 2026-09-13. “Current,” “healthy,” issue states, and target components below refer to the original analysis, not a fresh deployment check. The public product is UVAI; the current workbench is `/studio`, not a separate dashboard. Use [REPO_MAP.md](REPO_MAP.md) for current source locations, [MASTER_ROADMAP.md](MASTER_ROADMAP.md) for the build-out plan, and [../AGENTS.md](../AGENTS.md) for locked scope and storage policy. This record is retained for context, not as an execution queue. ## Color Legend diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 58e577b08..efaa13387 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -1,152 +1,5 @@ -# CLAUDE.md +# Claude Code — documentation scope -This file provides context for Claude Code when working in the EventRelay repository. +Read [../AGENTS.md](../AGENTS.md) and [../CLAUDE.md](../CLAUDE.md) before editing documentation. The root files own product policy and Claude-specific repository guidance; this file intentionally does not duplicate them. -## Project Overview - -EventRelay is an AI-powered video automation platform that transforms YouTube videos into actionable workflows. It captures transcripts, extracts events, dispatches them to MCP (Model Context Protocol) agents, and builds a RAG-based knowledge store. The backend is Python/FastAPI and the frontend is a Next.js/React/TypeScript monorepo. - -## Repository Structure - -``` -src/ # Python backend - youtube_extension/ - backend/ # FastAPI app (api/v1/, services/, models/, middleware/) - services/ # Orchestration (agents/, workflows/, ai/) - mcp/ # MCP ecosystem coordinator - main.py # FastAPI entry point -apps/ - web/ # Next.js frontend (port 3000) -packages/ # Shared monorepo packages (eslint-config, tsconfig, logger, etc.) -mcp-servers/ # MCP server implementations (litert-mcp, shared-state) -tests/ # Python tests (unit/, integration/, fixtures/, workflows/) -docs/ # Extended documentation -infrastructure/ # Kubernetes manifests, Terraform, database setup -.github/workflows/ # CI/CD (ci.yml, security.yml, deploy-cloud-run.yml, etc.) -``` - -## Common Commands - -### Python Backend - -```bash -# Install (editable with dev extras) -pip install -e .[dev,youtube,ml] - -# Run backend server (PYTHONPATH=src is required: the package uses absolute -# imports rooted at src/, so the `src.youtube_extension.main` form silently -# fails to load the API v1 router and event routes) -PYTHONPATH=src uvicorn youtube_extension.main:app --reload --port 8000 - -# Run tests -pytest tests/ -v -pytest tests/unit/ -v # Unit tests only -pytest tests/ -m "not slow" # Skip slow tests - -# Lint and format -ruff check src/ # Lint -ruff check src/ --fix # Auto-fix -black src/ # Format -isort src/ # Sort imports -mypy src/ # Type check -``` - -### Frontend (Next.js) - -```bash -# Install all workspace dependencies -npm install - -# Build (all workspaces via Turbo) -turbo run build -# or: npm run build - -# Dev server -turbo run dev -# or: npm run dev - -# Lint -turbo run lint - -# Test -turbo run test -``` - -## Code Style - -### Python -- **Formatter**: Black, 88-char line length -- **Import sorting**: isort (profile: black) -- **Linter**: Ruff (E, W, F, I, B, C4, UP rules; E501 ignored) -- **Type checking**: mypy strict mode (`disallow_untyped_defs = true`) -- Target Python 3.9+ -- Config in `pyproject.toml` - -### TypeScript/JavaScript -- **Strict mode** TypeScript (`apps/web/tsconfig.json`) -- **ESLint** with Next.js rules (shared config in `packages/eslint-config/`) -- **Tailwind CSS** for styling -- Path alias: `@/*` maps to `src/*` - -## Testing - -### Python (pytest) -- Config: `pyproject.toml` `[tool.pytest.ini_options]` -- `pythonpath = src`, `testpaths = tests` -- Async mode: `asyncio_mode = "auto"` -- Markers: `unit`, `integration`, `slow`, `asyncio`, `database`, `security`, `e2e`, `performance` -- Coverage target: 90% minimum, source: `src/youtube_extension` - -### Frontend -- Tests in `apps/web/src/components/__tests__/` and `apps/web/src/__tests__/` - -## Architecture Notes - -- **Event-driven**: Events follow `..` naming (e.g. `youtube.video.captured`) -- **Dependency injection**: Service container pattern in `backend/containers/` -- **Multi-provider AI**: Routes to Gemini, OpenAI, Anthropic, or Grok -- **MCP integration**: Agent orchestration via Model Context Protocol -- **Database**: SQLite (dev), PostgreSQL (prod); migrations via Alembic -- **Auth**: NextAuth.js (frontend), python-jose (backend) -- **Monorepo**: Turbo for JS workspaces (`apps/*`, `packages/*`, `mcp-servers/*`) - -## Key Policies - -- **REAL_MODE_ONLY**: No mock delays, fake data, or simulated responses in production code -- **No secrets in code**: All keys/credentials go in `.env` (gitignored) -- **Security**: Validate inputs via Pydantic; no `dangerouslySetInnerHTML` in React; sanitize subprocess args -- **Type safety enforced**: mypy strict (Python), TypeScript strict (frontend) -- **Vercel docs context**: Use `https://vercel.com/docs/llms-full.txt` when you need complete Vercel platform context for AI Gateway, Hosting, or MCP-related work. - -## SDK ↔ Backend Contract Alignment - -`sdk/python/eventrelay_sdk/types.py` must stay in sync with `src/youtube_extension/backend/api/v1/models.py`. - -**Rules:** -- The backend `models.py` is the source of truth — never infer SDK types from test behaviour or API docs alone. -- When a test fails with `ValidationError`, fix the test mock to match the real response shape; do not weaken the SDK type (e.g., making required fields `Optional`). -- Enum fields (e.g., `job_status: Optional[JobStatus]`) must use the SDK enum type, not `str`. -- After any change to backend response models, audit the corresponding SDK model for drift. - -**Verify alignment with:** - -```bash -# Backend source of truth -grep -A 30 "class TranscriptActionResponse" src/youtube_extension/backend/api/v1/models.py -# SDK model -grep -A 30 "class TranscriptActionResponse" sdk/python/eventrelay_sdk/types.py -``` - -## Anthropic SDK Version - -The project requires `anthropic>=0.105.0` (see `pyproject.toml`). This floor guarantees: -- `thinking={"type": "adaptive"}` (adaptive thinking, no `budget_tokens`) — introduced in 0.78.0 -- `output_config={"effort": "..."}` (GA effort control, no beta header) — introduced in 0.75.0 -- Current model string: `claude-opus-4-8` (no date suffix) - -Do not add `try/except TypeError` fallbacks around these parameters — if the SDK is too old the install constraint is wrong, not the call site. - -## Repo Hygiene - -- **History was rewritten**: `main` has been force-pushed (a secret-purge of committed credentials). Older branches are therefore *orphaned* — they share little real ancestry with current `main`. -- **Branch audits**: use the `branch-cleanup` skill (`.claude/skills/branch-cleanup/`) — it runs the 6-gate fail-test harness (`scripts/maintenance/branch-fail-test.sh`) and emits a decision matrix. +Follow [AGENTS.md](AGENTS.md) for documentation maintenance. Current planning lives in [MASTER_ROADMAP.md](MASTER_ROADMAP.md) and [NEXT-PHASE.md](NEXT-PHASE.md), not in historical architecture or completed-cut records. diff --git a/docs/GEMINI.md b/docs/GEMINI.md index a9d62b54c..a2c0dfee4 100644 --- a/docs/GEMINI.md +++ b/docs/GEMINI.md @@ -1,132 +1,5 @@ -# EventRelay — Gemini CLI Context +# Gemini CLI — documentation scope -This file provides project context for Gemini CLI when working in the EventRelay repository. +Read [../AGENTS.md](../AGENTS.md) and [../GEMINI.md](../GEMINI.md) before editing documentation. The root files own product policy and Gemini-specific repository guidance; this file intentionally does not duplicate them. -## Project Overview - -EventRelay is an AI-powered video automation platform that transforms YouTube videos into -actionable workflows. It captures transcripts, extracts events, dispatches them to MCP -(Model Context Protocol) agents, and builds a RAG-based knowledge store. The backend is -Python/FastAPI and the frontend is a Next.js/React/TypeScript monorepo. - -## Single Workflow - -**EventRelay has ONE workflow:** YouTube link → transcript → events → agents → outputs. -Never introduce alternative flows or manual triggers that bypass this pipeline. - -## Repository Structure - -``` -src/ # Python backend - youtube_extension/ - backend/ # FastAPI app (api/v1/, services/, models/, middleware/) - services/ # Orchestration (agents/, workflows/, ai/) - mcp/ # MCP ecosystem coordinator - main.py # FastAPI entry point -apps/ - web/ # Next.js frontend (port 3000) -packages/ # Shared monorepo packages -mcp-servers/ # MCP server implementations (langextract, vercel config) -tests/ # Python tests (unit/, integration/, fixtures/, workflows/) -docs/ # Extended documentation -infrastructure/ # Kubernetes manifests, Terraform, Cloud Run deploy scripts -.github/workflows/ # CI/CD pipelines -.gemini/settings.json # Gemini CLI MCP server configuration -``` - -## MCP Extensions (configured in .gemini/settings.json) - -The following MCP servers are pre-configured for Gemini CLI: - -| Server | Purpose | Trust | -|---|---|---| -| `github` | GitHub repo management via `@github/github-mcp-server` | `false` — external npm package | -| `git-workflow` | Safe git operations (status/add/commit/pull/push) | `true` — local project code | -| `stitch` | Google Stitch HTTP MCP endpoint | `false` — remote HTTP service | - -`trust: true` bypasses per-call confirmation prompts; use only for local project tools. -Environment variables (e.g. `$GITHUB_TOKEN`) are expanded by Gemini CLI from the shell environment. - -Run `/mcp` inside Gemini CLI to verify connected servers and available tools. - -## Common Commands - -### Python Backend - -```bash -# Install (editable with dev extras) -pip install -e .[dev,youtube,ml] - -# Run backend server (PYTHONPATH=src is required for absolute imports to resolve) -PYTHONPATH=src uvicorn youtube_extension.main:app --reload --port 8000 - -# Tests -pytest tests/ -v -pytest tests/unit/ -v # unit only -pytest tests/ -m "not slow" # skip slow tests - -# Lint / format -ruff check src/ --fix -black src/ -isort src/ -mypy src/ -``` - -### Frontend (Next.js / Turbo monorepo) - -```bash -npm install # install all workspace deps -turbo run build # build all workspaces -turbo run dev # dev servers -turbo run lint -turbo run test -``` - -## Code Style - -### Python -- **Formatter**: Black, 88-char line length -- **Linter**: Ruff (E, W, F, I, B, C4, UP; E501 ignored) -- **Type checking**: mypy strict (`disallow_untyped_defs = true`) -- Target Python 3.9+; config in `pyproject.toml` - -### TypeScript -- Strict mode TypeScript (`apps/web/tsconfig.json`) -- ESLint with Next.js rules (shared config in `packages/eslint-config/`) -- Tailwind CSS; path alias `@/*` → `src/*` - -## Testing - -- **pytest** — `pythonpath = src`, `testpaths = tests`, `asyncio_mode = auto` -- Coverage target: 90 % minimum enforced via `pytest.ini` on `backend`, `enhanced_video_processor`, and `enterprise_mcp_server` -- Default test video ID: `auJzb1D-fag` — **never** use `dQw4w9WgXcQ` (Rick Roll; causes flaky tests due to age-gating) -- Use real `tempfile`/`shutil` temp dirs; avoid `pyfakefs` - -## Architecture Notes - -- **Event-driven**: events follow `..` (e.g. `youtube.video.captured`) -- **Dependency injection**: service container pattern in `backend/containers/` -- **Multi-provider AI**: Gemini (primary), OpenAI, Anthropic, Grok -- **MCP integration**: agent orchestration via Model Context Protocol -- **Database**: SQLite (dev), PostgreSQL (prod) via SQLAlchemy / Alembic -- **Auth**: NextAuth.js (frontend), python-jose (backend) -- **Monorepo**: Turbo for JS workspaces (`apps/*`, `packages/*`, `mcp-servers/*`) - -## Key Policies - -- **REAL_MODE_ONLY**: no mock delays or fake data in production code -- **No secrets in code**: all keys in `.env` (gitignored) -- **Security**: Pydantic input validation; no `dangerouslySetInnerHTML`; sanitize subprocess args -- **Type safety**: mypy strict (Python), TypeScript strict (frontend) -- **Minimal changes**: make surgical, precise modifications; never delete working code without justification - -## Environment Variables (required) - -```bash -GEMINI_API_KEY=... # Google Gemini API -OPENAI_API_KEY=... # OpenAI API -YOUTUBE_API_KEY=... # YouTube Data API v3 -DATABASE_URL=sqlite:///./.runtime/app.db -GITHUB_TOKEN=... # for github MCP server -STITCH_ACCESS_TOKEN=... # for stitch MCP server (optional) -``` +Follow [AGENTS.md](AGENTS.md) for documentation maintenance. Current planning lives in [MASTER_ROADMAP.md](MASTER_ROADMAP.md) and [NEXT-PHASE.md](NEXT-PHASE.md). A historical design is not authorization to implement a new product or evidence that a deployment is live. diff --git a/docs/GOAL.md b/docs/GOAL.md index 0116356a8..014961bc0 100644 --- a/docs/GOAL.md +++ b/docs/GOAL.md @@ -1,11 +1,11 @@ -# /goal — one-loop P3 +# Goal — approved Origin G.A.T.E. slice -Paste this into the TUI (goal mode must be on): +First read [../AGENTS.md](../AGENTS.md), [NEXT-PHASE.md](NEXT-PHASE.md), and [gate-transition-contract.md](gate-transition-contract.md). UVAI Loop must confirm the owned transition boundary before this goal is executed; it does not reopen parked or adjacent work. -``` -/goal After Analyze on / , Act on the same run shows tool results on that page. Fixture auJzb1D-fag. Do not add ADK/LWP. Do not use dQw4w9WgXcQ. +```text +/goal Implement and verify only the Loop-approved Origin G.A.T.E. slice on the canonical /studio flow. Preserve PASS/HOLD/REJECT/ESCALATE and the existing receipt contract. Derive consequential authority from trusted context and accept only the evidence required by the approved transition. Missing or unverified receipts must not become live-success claims. Use auJzb1D-fag as the default fixture. Keep unrelated asRecord/claim, extractor expansion, and adjacent products held. Do not deploy or mutate production configuration as part of this goal. ``` -**Done only when** an independent check can reproduce: Analyze produced transcript or events (or honest empty), Act returned a `runId`, and Act results are visible on `/` without opening Dashboard as a second product. +**Done only when:** an independent check reproduces the approved accepted case and its failure cases; the resulting receipt identifies the actual run and evidence; the documentation states the remaining limits. Unit tests or a valid-looking URL alone do not prove production deployment. -**Budget:** default. Branch: `feat/one-loop-studio`. Map: [NEXT-PHASE.md](./NEXT-PHASE.md). +Work on a dedicated feature branch, not the historical `feat/one-loop-studio` branch. Keep the scope, changed files, verification commands, and current results with the review for that cut. See [MASTER_ROADMAP.md](MASTER_ROADMAP.md) for later approval-dependent work. diff --git a/docs/MASTER_ROADMAP.md b/docs/MASTER_ROADMAP.md index 3946c7286..cb441be4a 100644 --- a/docs/MASTER_ROADMAP.md +++ b/docs/MASTER_ROADMAP.md @@ -1,244 +1,120 @@ -# EventRelay / UVAI — Master Roadmap to a Full Working System +# UVAI — evidence-based build-out roadmap -**Last updated:** 2026-06-17 -**Repo baseline:** `feat/master-roadmap-phases` (implements Phases 1–6 code paths; Phase 1 provider keys remain dashboard ops) -**Prior landings:** PRs [#295](https://github.com/groupthinking/EventRelay/pull/295), [#303](https://github.com/groupthinking/EventRelay/pull/303) -**Live surface:** [uvai.io](https://uvai.io) · Vercel `garv1/v0-uvai` · Backend Cloud Run / Railway (misconfigured) +- **Reviewed:** 2026-09-13 +- **Inspected baseline:** `f531f34d` on `main` +- **Authority:** [AGENTS.md](../AGENTS.md) +- **Execution boundary:** Origin G.A.T.E. only; later phases below are proposals, not authorization. -This document merges: +This replaces the June execution board and its stale environment assertions. The [June roadmap remains in Git history](https://github.com/groupthinking/EventRelay/blob/f531f34d/docs/MASTER_ROADMAP.md). This review changes documentation only: it does not promote, deploy, migrate storage, or reopen adjacent product cuts. -- Remaining post-merge work (CodeRabbit, Sentry, production env) -- [`EVENTRELAY_UVAI_BASELINE_AUDIT_AND_PLAN.md`](../EVENTRELAY_UVAI_BASELINE_AUDIT_AND_PLAN.md) phased plan -- [`docs/EventRelay-Full-System-Breakdown.md`](./EventRelay-Full-System-Breakdown.md) consolidation targets -- [`~/Downloads/see-script-ship-conversation-export`](file:///Users/garvey/Downloads/see-script-ship-conversation-export) — **YouTube-to-Repo** MVP sequence +## Intended outcome ---- +A user starts with a YouTube URL, inspects a source-grounded Video Pack, approves the intended output, and receives a reproducible artifact with verification evidence. A live claim requires the applicable G.A.T.E. decision and verified receipts, not merely generated files or a completed workflow status. -## North star: “full working system” +The existing workspace export is a useful evidence handoff. It must not be sold as proof that the application demonstrated in a video has been recreated. -A user can complete this loop **without manual intervention** and get an honest outcome every time: +## What the current source supports -```mermaid -flowchart LR - A[YouTube URL or upload] --> B[Transcript + metadata] - B --> C[VideoPack / events / blueprint] - C --> D[Codegen or handoff artifact] - D --> E[Sandboxed tests] - E --> F[Deploy adapter] - F --> G[Live URL + audit trail] - G --> H[Observability: Sentry + logs] -``` - -**Definition of done (system-level):** - -| Gate | Criterion | -|------|-----------| -| **CI** | `test`, `build`, `dependency-review`, E2E green on `main` | -| **Preview** | Vercel preview deploy succeeds (monorepo-root `npm ci`) | -| **Production web** | `uvai.io` 200, security headers, `/api/pipeline` bounded JSON | -| **Production AI** | Gemini + OpenAI calls succeed (billing/quota fixed) | -| **Production backend** | `BACKEND_URL` health returns 200, not 404/503 | -| **Pipeline** | Approved test video → VideoPack or explicit handoff (never silent fail) | -| **Observability** | Frontend + backend errors in Sentry with DSN | -| **Trust** | Agent steps auditable; VERA cannot crash pipeline on gateway error | - ---- - -## What is already shipped (Phase 0 — complete) - -| Area | Status | Evidence | -|------|--------|----------| -| Test suite restored | Done | PR #295, `bin/run_tests_clean.sh` | -| Security dep upgrades (vite/vitest) | Done | PR #295 | -| `dependency-review` | Done | `0BSD` for rollup; Sentry FSL via purls | -| Vercel preview install path | Done | PR #303: root `npm ci`, removed stale `apps/web/package-lock.json` | -| Sentry (web, partial) | Done | `@sentry/nextjs`, `sentry.*.config.ts`, webpack upload gated on `SENTRY_AUTH_TOKEN` | -| Ralph-loop demo scripts | Done | `dca23c7e` content on `main` via #295 squash (`demo_agent_pipeline.sh`, `start_mcp_youtube.sh`, orchestrator hardening) | -| Live landing | Up | `curl uvai.io` → 200; `/api/pipeline` returns pipeline metadata | - ---- - -## Master phases (ordered by dependency) - -### Phase 1 — Production environment gates (P0) - -**Blocks real user value today.** Mostly Vercel / GCP / provider dashboard — not application code. - -Source: [`docs/deployment/VERCEL_PRODUCTION_RUNBOOK.md`](./deployment/VERCEL_PRODUCTION_RUNBOOK.md) +| Capability | Inspected evidence | Limit / remaining work | +| --- | --- | --- | +| One public product and canonical workbench | [`app/page.tsx`](../apps/web/src/app/page.tsx), [`studio/page.tsx`](../apps/web/src/app/studio/page.tsx), [`auth-paths.ts`](../apps/web/src/lib/auth-paths.ts) | `/` enters `/studio`; retired dashboard skins are not a new product | +| Hashed Video Pack extraction and persistence | [`video-pack.ts`](../apps/web/src/lib/video-pack.ts), [`video-pack-extractor.ts`](../apps/web/src/lib/video-pack-extractor.ts), [`video-pack-store.ts`](../apps/web/src/lib/video-pack-store.ts) | Gemini 3.8 Flash via AI Gateway; production pack store remains Upstash REST; stored fields are not automatically verified observations | +| Deterministic App Builder workspace emit | [`emit-app-builder-sandbox.ts`](../apps/web/src/lib/emit-app-builder-sandbox.ts), [`sandbox/route.ts`](../apps/web/src/app/api/video/sandbox/route.ts) | Displays transcript, visual events, and SOP; strips architecture/code snippets at this boundary; does not execute or deploy the bundle | +| Prior two-video emit work | [`app-builder-sandbox-PLAN.md`](../apps/web/src/lib/app-builder-sandbox-PLAN.md) and emitter fixtures/tests | Preserve original fixtures and receipts; old VM smoke and PR status are not freshly reverified here | +| Same-run actions and deployment attempts | [`OneLoopStudio.tsx`](../apps/web/src/components/OneLoopStudio.tsx), [`studio-workflow.ts`](../apps/web/src/lib/studio-workflow.ts) | Backend/provider configuration and authenticated workflows need their own current operational evidence | +| Four-way gate and canonical receipt hash | [`gate-transition.ts`](../apps/web/src/lib/gate-transition.ts), [contract](gate-transition-contract.md) | Client-callable policy; current Studio adapter passes `anonymous` authority and derives its evidence verdict from URL validation; not independent provider/ownership verification | +| Get Pro and locked offers | Root policy; [`app/page.tsx`](../apps/web/src/app/page.tsx) | Workflow Pro $39/mo or $390/yr; Maintain $199/mo per live product; Ship per-job quote. No new checkout promises | -| Item | Current state (2026-06-17) | Fix location | Verify | -|------|---------------------------|--------------|--------| -| `BACKEND_URL` | **Set** → `https://api.uvai.io` on Vercel | Redeploy production after env change | `curl -sS https://api.uvai.io/api/v1/health` | -| `api.uvai.io` | **200** healthy | Cloud Run min-instances=1 | `curl -sS https://api.uvai.io/api/v1/health` | -| `GEMINI_API_KEY` / billing | Live probes: no `BILLING_DISABLED` today | GCP billing if regressions return | POST `/api/pipeline` with test video | -| `OPENAI_API_KEY` | Live probes: transcribe **200** | OpenAI dashboard if regressions return | `/api/transcribe` | -| `SENTRY_DSN` (web) | **Set** on Vercel (`v0-uvai-web`) | Redeploy + client config PR pending | Deliberate smoke after deploy | -| `SENTRY_DSN` (backend) | **Set** on Cloud Run (`eventrelay-backend`) | — | Backend logs: Sentry initialized | -| `SENTRY_AUTH_TOKEN` | Optional — not set | Vercel env + Sentry auth token | Build log shows upload or clean skip | -| `GITHUB_TOKEN` | **Set** on Vercel | — | Pipeline deploy path (no "token not configured") | +### Verification performed for this review -**Smoke script (run after every prod promote):** +From the repository root: ```bash -curl -sSI https://uvai.io/ | grep -Ei 'content-security-policy|strict-transport-security' -curl -sS https://uvai.io/api/pipeline -curl -sS -X POST https://uvai.io/api/pipeline \ - -H 'content-type: application/json' \ - --data '{"url":"https://www.youtube.com/watch?v=jNQXAC9IVRw"}' +npm exec --workspace=apps/web --no -- vitest run src/lib/__tests__/gate-transition.test.ts src/lib/__tests__/emit-app-builder-sandbox.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/video-pack-store.test.ts src/app/api/video/sandbox/__tests__/route.test.ts ``` -**Exit criteria:** Pipeline POST returns progress or a **bounded** provider-outage message (not 500 stack trace). - ---- - -### Phase 2 — Reliability hardening (P0/P1, code) - -Source: CodeRabbit review threads on PR #295 (still valid on `main`). - -| ID | Issue | File(s) | Fix (minimal) | Test | -|----|-------|---------|---------------|------| -| **R-001** | VERA `decision` unbound if gateway raises | `src/agents/pipeline_orchestrator.py` | Initialize `decision = None`; guard `if decision is not None and not decision.allowed` | Unit test: mock gateway raise → pipeline continues | -| **R-002** | No aiohttp session timeout | `enhanced_video_processor.py` | `ClientTimeout(total=30, connect=10, sock_read=20)` on shared session | Mock slow response → timeout, not hang | -| **R-003** | `build_plan` / `extracted_info` dropped from return | `enhanced_video_processor.py` | Include in response dict | Assert keys in processor test | -| **R-004** | `self.results` not reset per run | `pipeline_orchestrator.py` | `self.results = {}` at `run_pipeline` start | Two sequential runs → no cross-contamination | -| **R-005** | Placeholder `_generate_build_plan` in production path | `enhanced_video_processor.py` | Replace with real builder or mark `handoff_only` in response metadata | Integration test with golden VideoPack | - -**Suggested PR stack:** `fix/vera-decision-guard` → `fix/processor-timeouts-and-payload` → `fix/orchestrator-state-reset` - -**Exit criteria:** CodeRabbit re-review clean on touched files; `pytest tests/unit/ -m "not slow"` green. - ---- - -### Phase 3 — Full observability (P1) - -| Layer | Current | Target | Action | -|-------|---------|--------|--------| -| **Next.js** | `withSentryConfig` + server/edge configs | DSN live in preview/prod | Set `SENTRY_DSN` in Vercel | -| **Python** | `sentry-sdk` in `pyproject.toml`, **no `init`** | FastAPI ASGI integration | Add `sentry_sdk.init` in `src/youtube_extension/main.py` (or `backend/main.py`) with `SENTRY_DSN`, `traces_sample_rate`, `environment` | -| **Cross-service** | Separate projects recommended | `v0-uvai-web` + `eventrelay-backend` | Align with [`docs/SENTRY_SETUP.md`](../SENTRY_SETUP.md) | -| **CI gate** | None | Optional marker | `.verification-gate-pass` or workflow step after Sentry smoke | - -**Exit criteria:** Deliberate `throw new Error("sentry-smoke")` on preview + `raise RuntimeError("sentry-smoke")` on backend both appear in Sentry within 60s. - ---- +Result: **5 files passed; 81 tests passed, 1 skipped**. This is local regression evidence for these five suites only. It is not a full build, complete test run, authenticated browser check, fresh provider verification, or production end-to-end receipt. The Vite config-loader warning does not fail this command and is not a reason to change dependencies in a documentation cleanup. -### Phase 4 — YouTube-to-Repo MVP (P1) +## Remaining build-out, in dependency order -Source: **see-script-ship** export (`~/Downloads/see-script-ship-conversation-export/conversation_visible_transcript.md`) — maps directly onto EventRelay’s existing architecture. +### 1. Origin G.A.T.E. — next authorized boundary -**MVP contract (from export):** +**Purpose:** close the gap between an outcome-display guard and independently trustworthy transition evidence without weakening the existing contract. -| Endpoint / capability | EventRelay today | Gap | -|----------------------|------------------|-----| -| `POST /video/analyze` | `/api/video`, `/api/pipeline`, backend `/api/v1/*` | Unify contract + SDK alignment | -| `POST /video/pack` | VideoPack artifacts in tests/fixtures | Persist + version VideoPacks | -| `POST /projects/blueprint` | `build_plan` models exist | Wire through API; stop dropping fields (R-003) | -| `POST /projects/generate` | `ai_code_generator.py` | AST validation before user sees output | -| `GET /jobs/{id}` | Async jobs in backend | Expose consistent job status schema | -| `WS /jobs/{id}/stream` | SSE `/api/pipeline/stream` | Align event shape with job lifecycle | -| Real transcript provider | YouTube captions + OpenAI STT | Document cloud IP limits; hosted fallback | -| Persistent storage | SQLite dev / PG prod | Migrations for packs, blueprints, jobs | -| GitHub App | Partial / scaffold | Repo create + push commits | -| Sandboxed tests | Missing | Container or subprocess runner before deploy | -| Deploy adapters | Vercel (web) | Add Netlify, Fly, Docker export per export spec | -| Repair loop | Not yet | **Only after** tests + logs + deploy telemetry (export rule) | +Before implementation, UVAI Loop confirms the exact owned slice and any parked live-URL work. Keep current PASS/HOLD/REJECT/ESCALATE semantics intact while specifying: -**Test video (canonical):** `https://youtu.be/vjdHAWvVCP4` (export) · CI default: `jNQXAC9IVRw` (E2E #292) +- Which server-side principal is allowed to request the consequential transition; a recognized actor string is not authentication or authorization. +- Which trusted receipt proves the artifact/run, provider result, target project, and permitted ownership. URL shape validation alone does not prove any of these. +- How a transition is bound to source/artifact identity, how retries avoid duplicate side effects, and how stale, replayed, mismatched, or missing evidence is handled. +- Who owns any receipt retention. G.A.T.E. itself stays a policy/receipt module, not a project database; do not turn the Video Pack store into a generic gate database by assumption. +- How the UI displays an unavailable backend, denied authority, incomplete run, and verified result without inventing a success message. -**Exit criteria:** One command (`scripts/demo_agent_pipeline.sh` or SDK) runs analyze → pack → blueprint → generate on test URL and produces a verifiable artifact directory + job audit log. +**Exit evidence:** scoped contract change; focused tests for all four decisions and hostile/missing evidence; route-level authorization tests for the approved boundary; a reproducible browser receipt for the approved user path. Do not claim independent deployment verification until it actually exists and has been exercised. ---- +**Held:** unrelated `asRecord` / claim changes, Mission Workspace, Agent Factory, ExperienceOS, or reopening the extractor simply to broaden this cut. Details: [NEXT-PHASE.md](NEXT-PHASE.md). -### Phase 5 — Product, SEO, and self-build (P2) +### 2. Source-grounded build specification — requires Loop approval -Source: [`EVENTRELAY_UVAI_BASELINE_AUDIT_AND_PLAN.md`](../EVENTRELAY_UVAI_BASELINE_AUDIT_AND_PLAN.md) Phases 1–2. +**Purpose:** distinguish what the video shows from a proposed implementation before using a pack as builder input. -| Theme | Actions | -|-------|---------| -| **SEO / a11y** | JSON-LD HowTo on templates, ARIA on emoji icons, meta/OG per workflow | -| **Audit trail (IETF-inspired)** | Named agents in SSE payloads; `/api/v1/audit` or dashboard trace panel | -| **Retention** | Auth + job history (NextAuth already in tree) | -| **Self-build** | Meta-template: “Improve UVAI landing” — platform analyzes its own README/site | -| **Perf CI** | Lighthouse ≥ 90 in GitHub Actions on `apps/web` | +- Attach source references/timestamps to actionable requirements and label inferred choices explicitly. +- Represent unknown behavior, missing credentials, and unsupported capabilities as unresolved requirements, not invented architecture or code. +- Define user acceptance criteria and the supported output class before selecting implementation rails. +- Preserve the emitter's architecture/code-snippet stripping tests; expanding that boundary requires its own evidence and approval. -**Exit criteria:** Lighthouse CI artifact; audit endpoint returns last N pipeline steps for a job id. +**Exit evidence:** a reviewed build-spec contract, positive and negative source-grounding fixtures, explicit unsupported cases, and no silent substitution of one video's content for another. Keep `auJzb1D-fag` as the default fixture and preserve the separately recorded emit fixtures. ---- +### 3. Reproducible artifact assembly — requires Loop approval -### Phase 6 — Architecture consolidation (P3, 90-day horizon) +**Purpose:** move beyond a pack viewer to a supported working artifact without free-form stack invention. -Source: [`docs/EventRelay-Full-System-Breakdown.md`](./EventRelay-Full-System-Breakdown.md), [`docs/development/ARCHITECTURAL_REFACTORING_ROADMAP.md`](./development/ARCHITECTURAL_REFACTORING_ROADMAP.md) +- First inventory existing generation/handoff code; reuse the valid path instead of adding a parallel builder. +- Resolve the approved spec against versioned, compatible implementation rails with declared inputs and outputs. +- Pin dependencies and emit a file/dependency manifest, source identity, and explicit configuration requirements. +- Keep secrets outside generated source and never mark an unsupported feature as implemented. -**Do not start until Phases 1–4 exit criteria pass** — consolidation without a working loop risks deleting the only working path. +**Exit evidence:** the approved non-viewer behavior runs; two approved source cases remain distinct; the same locked inputs reproduce the same assembly manifest; no accidental source or credential leakage. A generated directory alone does not pass. -| Target | Problem today | Direction | -|--------|---------------|-----------| -| Unified video processor | 5+ overlapping implementations | Single `VideoProcessorService` + strategy pattern | -| Unified MCP gateway | 17 servers, shared mutable `fabric.py` | Registry + gateway; remove shared mutable state | -| Coordinator merge | 4 orchestration entrypoints | `core/UnifiedCoordinator` + event bus | -| Dual DB writes | Firebase + Supabase without transactions | Pick authoritative store per entity | +### 4. Isolated execution and repair — requires Loop approval ---- +**Purpose:** turn artifact output into verifiable execution, not a claim based on generation. -## 14-day execution board (recommended order) +- Run install, type-check, relevant tests, build, and browser acceptance inside a bounded disposable environment. +- Limit network/credential access, subprocess permissions, time, output size, and retry budget. +- Bind exit codes, logs, screenshots, artifact identity, and configuration references into receipts. +- Allow repair only from actual failing evidence; stop on unresolved failures instead of fabricating a green run. -| Day | Track | Deliverable | -|-----|-------|-------------| -| 1 | Phase 1 | Fix Vercel `BACKEND_URL`, GCP billing, OpenAI quota — document values in runbook only (no secrets in repo) | -| 1 | Phase 1 | Run production smoke script; capture results in PR or issue | -| 2–3 | Phase 2 | PR: VERA `decision` guard (R-001) | -| 3–4 | Phase 2 | PR: aiohttp timeouts + build_plan payload (R-002, R-003) | -| 4 | Phase 2 | PR: orchestrator `self.results` reset (R-004) | -| 5–6 | Phase 3 | PR: Python `sentry_sdk.init` + env docs | -| 6 | Phase 3 | Set `SENTRY_DSN` in Vercel; smoke both surfaces | -| 7–10 | Phase 4 | PR: persist VideoPack + job status API alignment | -| 10–12 | Phase 4 | PR: sandbox test runner (minimal: `pytest` on generated tree) | -| 12–14 | Phase 4 | PR: deployment handoff artifact hardening (already started in dashboard-store) | +**Exit evidence:** a clean-environment replay plus a deliberate failing case that remains held; auditable cleanup and bounded retries. No deployment claim at this stage. ---- +### 5. Authorized shipping and operations — requires Loop approval -## Risk register +**Purpose:** connect a verified artifact to an authorized deployment and retain honest state through retries and failures. -| Risk | Mitigation | -|------|------------| -| Provider keys/billing block all AI paths | Phase 1 first; bounded error responses already in web API routes | -| Stale per-app lockfile breaks Vercel again | **Never** commit `apps/web/package-lock.json`; root lockfile only (enforced in #303) | -| CodeRabbit blocks merge | Dismiss or fix; repo rules require PR + review hygiene | -| Placeholder build_plan ships to users | R-005 + REAL_MODE_ONLY policy: explicit `handoff` status in response | -| see-script-ship scope creep | Phase 4 stops at sandboxed generate + Vercel adapter; repair loop deferred | +- Reuse the intended deployment adapter and require explicit target/credential authority. +- Bind provider receipts to the verified artifact and run; reconcile failed, pending, rolled-back, and successful results. +- If reachability checks are approved, constrain them against SSRF and distinguish health from ownership and application correctness. +- Require the approved G.A.T.E. policy before a live-success claim; provide rollback and observable failure paths. ---- +**Exit evidence:** an operator-authorized fresh deployment receipt, verification tied to the expected artifact, a rollback/failure exercise, and no live claim without the required evidence. No fabricated URL or stale smoke receipt qualifies. -## Source index +### 6. Operational acceptance, then held product expansion -| Document | Path | -|----------|------| -| Baseline audit + phased plan | `EVENTRELAY_UVAI_BASELINE_AUDIT_AND_PLAN.md` | -| Full system breakdown | `docs/EventRelay-Full-System-Breakdown.md` | -| Architectural refactoring | `docs/development/ARCHITECTURAL_REFACTORING_ROADMAP.md` | -| Vercel production runbook | `docs/deployment/VERCEL_PRODUCTION_RUNBOOK.md` | -| Sentry setup | `docs/SENTRY_SETUP.md` | -| Security remediation | `docs/analysis/REMEDIATION_PLAN.md` | -| see-script-ship MVP export | `~/Downloads/see-script-ship-conversation-export/` | +**Purpose:** make the approved loop supportable before expanding its product surface. ---- +- Exercise anonymous, signed-in, quota, billing, provider-outage, and retry paths against the actual configured environment. +- Check ownership isolation, bounded costs, observability, and the repeat-run experience on the canonical Studio surface. +- Reconcile plans against merged code and deployment receipts; retire superseded guidance rather than duplicating it. -## Implementation status (feat/master-roadmap-phases) +**Exit evidence:** fresh end-to-end results for the supported flow and explicit unresolved limits. Only a subsequent Loop decision may open Mission Workspace, Agent Factory / Slingshot, or ExperienceOS. FORGE / Workbench / Living Notebook / VIZUL remain UX patterns, not additional products. -| Phase | Code status | Operator action still required | -|-------|-------------|-------------------------------| -| **1** | `scripts/deployment/production_smoke.sh` added | Vercel/GCP/OpenAI env vars + billing | -| **2** | VERA guard, aiohttp timeout, build_plan payload, results reset | — | -| **3** | Python `sentry_sdk.init` in `main.py` | Set `SENTRY_DSN` in backend deploy env | -| **4** | `PipelineJobStore`, `/video/analyze`, `/jobs/{id}`, sandbox runner | GitHub App + repair loop deferred | -| **5** | `/api/v1/audit/pipeline/*`, JSON-LD HowTo on web | Lighthouse CI deferred | -| **6** | `VideoProcessorFacade` entry hook | Full processor merge deferred (90d plan) | +## File-management decisions -## Changelog (roadmap maintenance) +- Update the root overview, host guides, repository map, goal, and next-phase entry points to agree with locked policy and current source. +- Replace copied `docs/AGENTS.md`, `docs/CLAUDE.md`, and `docs/GEMINI.md` bodies with pointers to the authoritative root guides. +- Mark old architecture/issue maps and completed App Builder plans as historical; preserve their evidence rather than treating them as today's queue. +- Remove only the unreferenced `_writetest.md` containing `# test`; retain substantive exports, fixtures, audit ledgers, and operational records. +- Leave runtime code, manifests, lockfiles, secrets, integrations, deployments, and remote data unchanged in this maintenance cut. -| Date | Change | -|------|--------| -| 2026-06-17 | Initial master roadmap: merged post-#295/#303 state, CodeRabbit backlog, Sentry/env gaps, see-script-ship MVP track | -| 2026-06-17 | `feat/master-roadmap-phases`: Phases 2–6 code + Phase 1 smoke script | +## Decision rule -*Append a row when a phase exit criterion is met or scope shifts.* \ No newline at end of file +Every implementation cut needs a bounded owner-approved scope, a failure case, a reproducible acceptance check, and a receipt that identifies what actually ran. A plan, source inspection, passing unit suite, successful build, and live deployment are different evidence levels; never collapse them into a single “done.” diff --git a/docs/NEXT-PHASE.md b/docs/NEXT-PHASE.md index f51f32176..e6ac83c81 100644 --- a/docs/NEXT-PHASE.md +++ b/docs/NEXT-PHASE.md @@ -1,56 +1,54 @@ -# Next phase — P3 Act same run +# Next phase — Origin G.A.T.E. -**Branch:** `feat/one-loop-studio` -**Goal command:** see [GOAL.md](./GOAL.md) -**Live view:** `~/BrainVault/UVAI-EventRelay-SSOT/17-ONE-LOOP-LIVE.md` +- **Reviewed:** 2026-09-13 +- **Authority:** [../AGENTS.md](../AGENTS.md) +- **Plan:** [MASTER_ROADMAP.md](MASTER_ROADMAP.md) -P1 is done (`/` and `/studio` call live `/api/pipeline/stream`). -**P3 implement + verify passed** (`uvai-one-loop-next`, 2026-08-15). Remaining: optional OneLoopStudio `#act-results` test; P2 is audit-pass (no local secret). +The former P3 “Act on the same run” document described an earlier cut. Same-run actions already exist in `OneLoopStudio`; do not restart that work or use its historical `/` routing as current guidance. `/studio` is the canonical workbench. -## Goal +## Next owned slice -After Analyze, the same page can Act. Results render here. No second product, no ADK, no Rickroll. Fixture: `auJzb1D-fag`. +Origin G.A.T.E. is the only cut authorized by root policy. The requested implementation now covers the `studio.deploy` proposed-to-live acceptance boundary: session-derived identity, scoped independent Ed25519 attestations, exact artifact/run/target binding, signed retained receipts, and atomic replay protection. See the [current contract](gate-transition-contract.md). -``` -/goal After Analyze on / , Act on the same run shows tool results on that page. Fixture auJzb1D-fag. Do not add ADK/LWP. Do not use dQw4w9WgXcQ. -``` +**Goal:** make the approved consequential transition depend on trustworthy evidence and authority, without changing the fail-closed decision vocabulary or creating a second product. -## Agents and workflows +Studio distinguishes authoritative server decisions from local diagnostics. A valid HTTPS hostname is no longer upgraded into verified deployment evidence. The legacy deployment kickoff is held because regenerating from a video cannot guarantee the approved artifact bytes. -Run one agent at a time, or the orchestrator. +**Operational acceptance remains pending.** An authorized runtime owner must register real Loop/verifier public keys and supply fresh, actual artifact/provider evidence for an authenticated acceptance run. No production registry, issuer keys, deployment, or later-phase approval is created by the tests or this document. The gate verifies external verifier attestations; provider execution/health verification is not implemented by this cut. -| Agent | Workflow | Mode | Measurable pass | Fail | -|-------|----------|------|-----------------|------| -| **act-implementer** | `/uvai-p3-act` | read-write | `OneLoopStudio` Act uses the selected video’s transcript/events when present; results stay on `/` | Act only re-kicks URL with no on-page output | -| **act-verifier** | `/uvai-p3-verify` | read-only | Code + curl/browser evidence that Act start returns `runId` and the UI has an Act-results surface | No file read, or only a plan | -| **auth-auditor** | `/uvai-p2-auth` | read-only | `auth-paths.ts`: stream + video-to-actions public; studio-deploy gated; UI 401 → `/login` | Claims local 401 when `.env.local` has no `NEXTAUTH_SECRET` | -| **hygiene** | `/uvai-p4-hygiene` | read-write | Launch Board stale rows noted; GitHub synced PR DB **not** deleted | Deletes the synced PR database | -| **orchestrator** | `/uvai-one-loop-next` | gated | implement → verify (fail closed) → auth audit → hygiene | Continues after a failed verify | +## Proposed implementation sequence -## Order (do not skip) +1. **Lock the contract slice.** Name the exact transition, caller, required evidence, evidence verifier, and what stays proposed. Preserve compatibility with the existing [gate contract](gate-transition-contract.md). +2. **Bind authority and evidence at the owned server boundary.** Derive authority from the actual trusted context; tie accepted receipts to the expected run/artifact and permitted target. Do not trust browser actor strings as authorization. +3. **Handle retries and negative evidence.** Define idempotency and reject/hold/escalate behavior for stale, replayed, mismatched, unknown, missing, or unavailable results. Keep receipt retention with its approved runtime owner, not inside a new G.A.T.E. database. +4. **Verify the exact user path.** Run focused contract and route tests, then exercise Studio's receipt display, auth denial, missing-backend, pending, and accepted-result paths. Record what was actually verified. -``` -P3 implement → P3 verify → P2 auth audit → P4 hygiene -``` +No step authorizes deploying, adding credentials, mutating production data, or weakening the current gate. -P2 does **not** invent a local `NEXTAUTH_SECRET`. Production gate is already in `auth-paths.ts`. +## Acceptance -## Out of scope this phase +- Every scoped decision is exactly PASS, HOLD, REJECT, or ESCALATE. +- Missing/weak required evidence stays HOLD; unreal evidence or an invalid live claim is REJECT; unknown authority/verdict is ESCALATE. +- Known actor labels do not substitute for server authorization. +- A completed workflow or generated bundle without the required receipt never becomes a live-success claim. +- The visible receipt identifies the decision, reason, transition, and canonical hash; it is tied to the current run, not a stale selected video. +- Tests exercise failure paths as well as the accepted case. Any fresh operational PASS cites the actual verification run. -ADK, LWP, Ultron, HF AV-Skills training, merging `research/uvai-landscape`, WDK C extras, deleting Notion GitHub sync. +## Starting checks -## How to run +From repository root: -``` -/goal After Analyze on / , Act on the same run shows tool results on that page. Fixture auJzb1D-fag. Do not add ADK/LWP. Do not use dQw4w9WgXcQ. -/workflow uvai-one-loop-next +```bash +npm exec --workspace=apps/web --no -- vitest run src/lib/__tests__/origin-gate.test.ts src/lib/__tests__/gate-transition.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/studio-workflow.test.ts src/lib/__tests__/auth-paths.test.ts src/app/api/gate/transitions/__tests__/route.test.ts src/app/api/workflows/studio-deploy/__tests__/route.test.ts src/components/__tests__/OneLoopStudio.gate.test.tsx ``` -One agent: +Then add checks for the approved implementation boundary; this command alone does not prove server-side authorization or a live deployment. -``` -/uvai-p3-act -/uvai-p3-verify -/uvai-p2-auth -/uvai-p4-hygiene -``` +## Held work + +- `asRecord` / claim changes unless this authorized cut specifically requires them. +- Extractor expansion, new storage, or a broader assembly/build system without Loop approval. +- Mission Workspace, Agent Factory / Slingshot, ExperienceOS, or a new FORGE product. +- Replaying old App Builder VM/PR receipts as current evidence, deleting synced external records, or promoting production as “cleanup.” + +Use [GOAL.md](GOAL.md) only after the owned slice is confirmed. The later build-out sequence remains proposed in [MASTER_ROADMAP.md](MASTER_ROADMAP.md). diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index 9140e0763..23c9bcf99 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -1,107 +1,71 @@ -# EventRelay Repository Map +# UVAI repository map -A directory-level map of this repository, kept separate from -[`ARCHITECTURE_DIAGRAM.md`](./ARCHITECTURE_DIAGRAM.md), which documents the -runtime data flow (YouTube link → transcript → events → agents → outputs) -in detail. This file answers "where does X live?"; `ARCHITECTURE_DIAGRAM.md` -answers "how does data move through the system?". +UVAI is the public product; EventRelay is the internal runtime and repository name. This map answers **where the current code lives**, not whether a deployed service is healthy. Locked rules remain in [AGENTS.md](../AGENTS.md). -## Top-level layout - -``` -EventRelay/ -├── src/ # Python backend package (youtube_extension, agents, mcp, core, ...) -│ └── youtube_extension/ -│ ├── backend/ # FastAPI app: api/v1/, services/, models/, middleware/ -│ ├── services/ # Orchestration: agents/, workflows/, ai/ -│ ├── mcp/ # MCP ecosystem coordinator -│ └── main.py # FastAPI entry point -├── apps/ -│ ├── web/ # Next.js/React frontend (port 3000) -│ └── backend/ # Backend workspace app wrapper -├── packages/ # Shared monorepo packages (database, embeddings, etc.) -├── mcp-servers/ # Standalone MCP server implementations (langextract, vercel) -├── sdk/ # Published client SDKs -│ ├── python/ # eventrelay_sdk (must stay aligned with backend/api/v1/models.py) -│ └── typescript/ -├── tests/ # Python tests: unit/, integration/, e2e/, fixtures/, workflows/ -├── docs/ # Extended documentation (this file, architecture, audits, guides) -├── infrastructure/ # Kubernetes manifests, Terraform, Cloud Run, database setup -├── scripts/ # Operational and CI helper scripts (scripts/ci, scripts/maintenance, ...) -├── shared/ # Cross-cutting shared code/config -├── tools/mcp/ # Local MCP servers used by agent tooling (e.g. git_workflow_server.mjs) -├── config/ # Runtime configuration -├── data/, dataconnect/ # Data fixtures and Firebase Data Connect schema -├── supabase/ # Supabase functions/setup (auxiliary integration) -├── .github/ # CI/CD workflows, agent instructions, MCP server config -│ ├── workflows/ # GitHub Actions + gh-aw agentic workflows (see workflows/README.md) -│ ├── agents/ # Per-domain Copilot agent instruction files -│ ├── agent/ # Agent plans, rules, and task tracking -│ └── mcp-servers.json # MCP servers wired into the Copilot cloud agent host -├── .agents/skills/, .claude/skills/ # Agent Skills (SKILL.md folders), mirrored per host -└── SKILL.md, AGENTS.md, CLAUDE.md, GEMINI.md # Root-level agent/host instruction files -``` +## Current entry points -## Directory relationships +| Responsibility | Source | +| --- | --- | +| Public URL entry and Get Pro | [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx) | +| Canonical Studio route | [`apps/web/src/app/studio/page.tsx`](../apps/web/src/app/studio/page.tsx) | +| Workbench, same-run actions, deploy result | [`OneLoopStudio.tsx`](../apps/web/src/components/OneLoopStudio.tsx) | +| Public/authenticated API policy and dashboard redirect helpers | [`auth-paths.ts`](../apps/web/src/lib/auth-paths.ts) | +| Pack HTTP endpoint | [`api/video/pack/route.ts`](../apps/web/src/app/api/video/pack/route.ts) | +| Grounded model extraction | [`video-pack-extractor.ts`](../apps/web/src/lib/video-pack-extractor.ts) | +| Pack identity and format | [`video-pack.ts`](../apps/web/src/lib/video-pack.ts) | +| Upstash REST pack persistence | [`video-pack-store.ts`](../apps/web/src/lib/video-pack-store.ts) | +| Evidence-workspace emitter | [`emit-app-builder-sandbox.ts`](../apps/web/src/lib/emit-app-builder-sandbox.ts) | +| Sandbox HTTP endpoint | [`api/video/sandbox/route.ts`](../apps/web/src/app/api/video/sandbox/route.ts) | +| Four-way transition decision and hashed receipt | [`gate-transition.ts`](../apps/web/src/lib/gate-transition.ts) | +| Studio URL/result validation | [`studio-pipeline-status.ts`](../apps/web/src/lib/studio-pipeline-status.ts) | +| Workflow start/poll adapters | [`studio-workflow.ts`](../apps/web/src/lib/studio-workflow.ts) | -```mermaid -flowchart TB - subgraph Frontend["apps/web (Next.js)"] - UI["Dashboard UI"] - end +`/dashboard` is not a second workbench. G.A.T.E. currently gates Studio's displayed deployment outcome; it is not a builder, independent deployment probe, or project database. - subgraph Backend["src/youtube_extension (FastAPI)"] - API["api/v1 router"] - SVC["services/"] - end - - subgraph MCP["MCP ecosystem"] - MCPCoord["src/youtube_extension/mcp"] - MCPServers["mcp-servers/*"] - MCPTools["tools/mcp/*"] - end +## Top-level layout - subgraph Agents["Agent orchestration"] - AgentsDir["src/agents"] - AgentInstr[".github/agents/*.agent.md"] - Skills[".agents/skills, .claude/skills"] - end +```text +EventRelay/ +├── apps/web/ Next.js App Router product and server routes +├── apps/backend/ Backend workspace wrapper +├── src/youtube_extension/ Internal FastAPI backend and services +├── src/agents/ Internal agent implementations +├── mcp-servers/ Internal MCP implementations +├── tools/mcp/ Repository tooling servers +├── packages/ Shared code +├── sdk/ Python and TypeScript clients +├── tests/ Backend and integration tests +├── docs/ Current guidance plus dated historical records +├── scripts/ CI, development, deployment, maintenance helpers +├── infrastructure/ Deployment/infrastructure definitions +├── .github/ CI and host-specific agent configuration +├── .claude/skills/ Repository skills, also usable through Grok compatibility +├── .grok/workflows/ Repository automation, not product lineage +└── AGENTS.md, CLAUDE.md, + GEMINI.md Root policy and host-specific guidance +``` - subgraph Ops["CI / infra"] - Workflows[".github/workflows/*"] - Infra["infrastructure/*"] - Scripts["scripts/*"] - end +Directory presence does not establish an active deployment or npm workspace. Root [package.json](../package.json) currently declares `apps/*` as npm workspaces; use its scripts and root lockfile. Other stores and prototypes do not replace Upstash REST for Video Packs. - UI -->|REST| API - API --> SVC - SVC --> MCPCoord - MCPCoord --> MCPServers - MCPCoord --> MCPTools - SVC --> AgentsDir - AgentInstr -.->|guides| AgentsDir - Skills -.->|guides| AgentsDir - Workflows --> Infra - Workflows --> Scripts -``` +## Verification locations -## Dependency summary +- Web library tests: `apps/web/src/lib/__tests__/`. +- Web component tests: `apps/web/src/components/__tests__/`. +- Route tests: beside routes under `apps/web/src/app/api/`. +- Backend tests: `tests/`; API response models must stay aligned with `sdk/python/eventrelay_sdk/types.py`. +- Commands and requirements: [README.md](../README.md), [package.json](../package.json), [apps/web/package.json](../apps/web/package.json), and [pyproject.toml](../pyproject.toml). -- **Python** (`pyproject.toml`, `requirements.txt`, `uv.lock`): FastAPI, SQLAlchemy + - Alembic, google-genai / anthropic / openai clients, pytest + coverage. -- **JavaScript/TypeScript** (`package.json`, workspaces under `apps/*`): Turbo - monorepo, Next.js (`apps/web`), `@modelcontextprotocol/sdk` (used by - `tools/mcp/git_workflow_server.mjs`), Vitest. -- See `README.md` for the full setup/quickstart commands referenced by - `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`. +## Documentation authority and history -## Related documents +| Document | Use | +| --- | --- | +| [MASTER_ROADMAP.md](MASTER_ROADMAP.md) | Current inspected baseline, proposed build-out, and acceptance criteria | +| [NEXT-PHASE.md](NEXT-PHASE.md) | Bounded next cut; Origin G.A.T.E. only under root authorization | +| [GOAL.md](GOAL.md) | Goal template for that approved cut | +| [gate-transition-contract.md](gate-transition-contract.md) | Existing contract, tests, and limits | +| [ARCHITECTURE_DIAGRAM.md](ARCHITECTURE_DIAGRAM.md) | Historical runtime/issue map, not current health | +| [video_to_gtm_architecture.md](video_to_gtm_architecture.md) | Historical March architecture proposal | +| [App Builder cut record](../apps/web/src/lib/app-builder-sandbox-PLAN.md) | Recorded second-video emit smoke; not a current deployment receipt | +| [Workflow catalog](../.github/workflows/README.md) | CI and automation navigation; inspect current workflow definitions before execution | -- [`ARCHITECTURE_DIAGRAM.md`](./ARCHITECTURE_DIAGRAM.md) — detailed runtime - data-flow diagram (current vs. target state). -- [`AGENT_CAPABILITIES_CHECKLIST.md`](./AGENT_CAPABILITIES_CHECKLIST.md) — - checklist of agent/tooling capabilities (agents, tools, MCP, git operations, - issues, code, dependencies, database, actions, role assignment) mapped to - what is actually implemented in this repository. -- [`.github/workflows/README.md`](../.github/workflows/README.md) — catalog of - every GitHub Actions / gh-aw workflow. +When a dated record conflicts with locked policy, preserve the record as history and follow the current policy. Do not add another competing roadmap. diff --git a/docs/external/chatgpt-exports/2026-06-16-video-intelligence/_writetest.md b/docs/external/chatgpt-exports/2026-06-16-video-intelligence/_writetest.md deleted file mode 100644 index 83c831f0b..000000000 --- a/docs/external/chatgpt-exports/2026-06-16-video-intelligence/_writetest.md +++ /dev/null @@ -1 +0,0 @@ -# test diff --git a/docs/gate-transition-contract.md b/docs/gate-transition-contract.md index 685ace5b3..740d63ba1 100644 --- a/docs/gate-transition-contract.md +++ b/docs/gate-transition-contract.md @@ -1,77 +1,107 @@ # G.A.T.E. transition contract -Governed Acceptance & Transition Engine. This is the Origin hardgate in-repo — not a second product, not a project database, and not a builder. +Governed Acceptance & Transition Engine. Origin hardgate, not a second product, builder, or project database. ## Split (locked) -| Layer | Question | +| Layer | Question / owner | | --- | --- | -| **Zero-Sim** | Is the evidence real? | -| **G.A.T.E.** | Do verified evidence + authority permit this state transition? | -| **EventRelay** | Durable runtime / receipts (versioned, hashable, citable). | +| **Zero-Sim** | Is the evidence real (`real`, `unverified`, `unreal`)? | +| **G.A.T.E.** | Do verified evidence and authority permit this exact transition? | +| **EventRelay** | Internal runtime and retained, versioned receipts. | -G.A.T.E. does **not** build the artifact. It does **not** become Mission Workspace or the Outcome Graph. Flywheel center stays Mission Workspace + Outcome Graph + acceptance; EventRelay + Zero-Sim/G.A.T.E. are continuous, not post-build only. +Plane stays **proposed** until config and actual receipts verify. SeeScriptShip lock is not install/run/deploy. An evidence workspace, export, completed workflow, or valid URL is not deployment proof. -Plane = **proposed** until config + receipts are verified. SeeScriptShip lock ≠ install / run / deploy. +## Authorized scope -## Decisions +Only `studio.deploy`, `proposed` → `live`, is implemented here. This cut verifies attestations and retains acceptance decisions. It does not build, run, deploy, roll back, or unlock another phase. Grounded specification and every subsequent roadmap phase still require separate UVAI Loop approval. + +Authoritative modules: + +- `apps/web/src/lib/origin-gate.ts`: strict input, independent signatures, binding, policy, signed receipts. +- `apps/web/src/lib/origin-gate-store.ts`: existing Upstash REST runtime adapter, atomic receipt/nonce/transition writes. +- `POST /api/gate/transitions`: authenticated acceptance boundary; `200` only for PASS, `409` for other gate decisions. +- `POST /api/workflows/studio-deploy`: authenticated **preflight only**. It cannot start the legacy video-to-job workflow, which would regenerate bytes rather than deploy an approved artifact. No receipt supplied to this route enables execution. + +Both routes use the existing Studio owner/session check, same-origin mutation protection, bounded JSON reader, and `Cache-Control: no-store`. HTTP authentication/input failures may return `401`, `403`, `400`, or `413` before evaluation; unexpected boundary failures return `503`, never authorization. -Every consequential state transition resolves to exactly one of: +## Decisions | Decision | When | | --- | --- | -| **PASS** | Zero-Sim `real`, authority actor is known (`anonymous` \| `signed-in` \| `system`), and required evidence for the claimed `from→to` is present and verified. | -| **HOLD** | Required evidence is **missing**, or evidence is present but **weak / unverified**. The plane stays proposed. Caller may retry with a real receipt. | -| **REJECT** | Zero-Sim `unreal`, or the caller **claims** `to=live` with a live URL that fails the verified-receipt bar (https + hostname). | -| **ESCALATE** | Authority actor is unknown, or a supplied Zero-Sim verdict is not `real` \| `unverified` \| `unreal`. G.A.T.E. will not invent a decision. | +| **PASS** | Session subject, exact artifact/run/transition/target binding, scoped Loop approval, independent verifier evidence with Zero-Sim `real`, fresh valid signatures, unchanged trusted policy, and atomic retention all verify. | +| **HOLD** | Required evidence is missing, unverified, stale, or the signing/retention runtime is unavailable. | +| **REJECT** | Evidence is unreal, a live claim has no signed verification receipt, signatures/bindings are mismatched, authority is denied/revoked/out of scope, a nonce or accepted transition is reused, or the request violates the strict contract. | +| **ESCALATE** | Authority, configured verification key, or signed verdict is unknown. | -### Missing vs weak (documented) +Missing evidence is not invented. A registered role is not enough without its valid signature. Two different issuer names sharing one cryptographic key are not independent approval and verification. -- **Missing** (`GATE_HOLD_MISSING_EVIDENCE`): no required receipt. For `studio.deploy`, the required receipt is a verified live URL — a workflow `completed` status or run id is not enough. -- **Weak** (`GATE_HOLD_WEAK_EVIDENCE`): refs exist but Zero-Sim is `unverified`. G.A.T.E. does not upgrade that to `real`. -- **Unreal** (`GATE_REJECT_UNREAL_EVIDENCE`): Zero-Sim says the evidence is not real (malformed hash, invented/unparseable live URL treated as a live claim). +## Request and attestations -G.A.T.E. never invents evidence. If a fact is not in the request, it HOLDs or REJECTs; it does not fetch, simulate, or stub a receipt. +The acceptance request contains: -## Contract +- `transitionId`, `kind: "studio.deploy"`, `fromState: "proposed"`, `toState: "live"`. +- `runId`, lowercase SHA-256 `artifactHash`. +- `target: { provider: "vercel", projectId, environment: "preview" | "production", liveUrl }` with a normalized, validated HTTPS hostname URL. +- `approval` and `evidence`, each `{ payload, signature }`. -Module: `apps/web/src/lib/gate-transition.ts` +Missing artifact fields may produce a HOLD. Unknown fields, including browser `authority` or `subject`, are rejected. The subject is derived from the verified server session. +Each payload has `version: "origin.attestation.v1"`, `type: "approval" | "deployment"`, `issuer`, `nonce`, ISO `issuedAt` / `expiresAt`, `binding`, and `verdict`. The exact binding repeats the transition, authenticated `subject`, run, artifact hash, and complete target. Deployment evidence additionally requires `providerReceiptId` and lowercase SHA-256 `providerReceiptHash`. + +- Approval verdict: `allow` or `deny`; other values escalate. +- Deployment verdict: Zero-Sim `real`, `unverified`, or `unreal`; other values escalate. +- Both attestations expire within 15 minutes of issuance. Future issuance beyond 30 seconds and expired attestations HOLD. +- Signature: Ed25519, base64url, over the UTF-8 bytes of `origin.attestation.v1\n` followed by `canonicalGateJson(payload)` from `gate-transition.ts`. + +The trusted external deployment verifier is responsible for inspecting the actual provider receipt and matching its artifact, target, and deployment. G.A.T.E. authenticates that signed attestation; **this implementation does not independently call Vercel or establish deployment health**. An unsigned caller-supplied provider receipt ID/hash is not sufficient. + +## Trust configuration and activation + +The runtime reads JSON from `er:gate:v2:trusted-policy` in the **existing Upstash REST** resource: + +```typescript +{ + version: 1, + issuers: Array<{ + id: string; + role: 'loop' | 'deployment-verifier'; + publicKey: string; // Ed25519 SPKI PEM; public material only + projectIds: string[]; + revoked: boolean; + }>; +} ``` -evaluateTransition({ - transitionId, kind, fromState, toState, - evidenceRefs, // hashes / ids / uris — only what the caller has - authority, // { actor, claim? } - zeroSim?, // supplied result, or assessed fail-closed from refs -}) → { decision, reason, reason_code, receipt } -``` -Receipt (`eventrelay.gate-receipt.v1`) is EventRelay-shaped: versioned, canonical-JSON SHA-256 (`receipt_hash`), id `er:gate:v1:{transitionId}`. Suitable to store or cite later. This module does not persist it (Upstash remains the Video Pack store only). +Only an authorized runtime administrator registers or revokes these keys with Loop approval. There is deliberately no public policy-write endpoint, auto-enrollment, generated production approval, or private issuer key in this app. Protect the existing runtime credentials: anyone who can replace the trust registry is inside this trust boundary. + +Receipt authentication uses the existing server-only `NEXTAUTH_SECRET` (minimum 32 characters), domain-separated from session use. Retention uses `KV_REST_API_URL` / `KV_REST_API_TOKEN` or `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`. Redis TCP is not substituted. Missing credentials, unusable signing configuration, or runtime outage cannot PASS. No new integration or credential is implied by this contract. -Zero-Sim assessment (when the caller does not supply a verdict): +Activation requires an authorized registry, independently managed issuer keys, actual artifact/provider evidence, and a real authenticated acceptance run. Tests generate ephemeral keys and fixtures only; they do not activate policy or prove deployment. Production keys/policy/data must not be mutated merely to demonstrate a green result. -- empty refs → `unverified` / `ZERO_SIM_MISSING_EVIDENCE` -- malformed SHA-256 or a presented `live_url` that fails https+hostname → `unreal` -- otherwise → `unverified` (format-valid refs are **not** upgraded to `real`) +## v2 receipts and retries -The Studio adapter may assert Zero-Sim `real` only after `studioVerifiedLiveUrl` succeeds. That bar is locked by #1707 / #1710 — it is not a network probe that the deploy exists. +`eventrelay.gate-receipt.v2` includes the decision/reason, authenticated subject, transition, evidence references, run, artifact hash, target, policy hash, request hash, issue time, and `retained`. `receipt_hash` is the SHA-256 of its canonical body; `signature` is HMAC-SHA-256 over `origin.gate-receipt.v2\n{receipt_hash}`. This is runtime-authenticated evidence, not a portable public-key signature or a deploy bearer token. -## Gated transition (PR1) +The runtime owns `er:gate:v2:receipt:*`, `er:gate:v2:transition:*`, and `er:gate:v2:nonce:*`. It atomically compares the exact policy snapshot before PASS and reserves transition/nonce keys with the retained receipt. Identical valid retries return the original authenticated receipt; conflicting requests reject. Current policy and expiry are checked again on retry. Revocation cannot be bypassed by replaying a prior PASS. Altered stored receipts fail closed. -**`studio.deploy`**: `proposed` → `live` on the OneLoopStudio Deploy attempt. +Records have no automatic expiry: removing nonce/transition keys removes replay protection. Retention lifecycle changes need explicit ownership and approval. G.A.T.E. remains a policy engine; these are internal runtime records, not a new project database. Storage failure returns `retained: false`; it never pretends a receipt was saved. Signing-secret rotation invalidates prior receipt authentication and requires an owned operational procedure rather than implicit reacceptance. -- Anonymous Deploy remains an **attempt** (button: Attempt deploy). Enabled ≠ receipt. -- G.A.T.E. runs on every Attempt deploy that is not an auth redirect — including when `startStudioDeploy` fails (e.g. `BACKEND_URL is not configured`) — and **before** `studioDeployOutcomeMessage`. -- Studio **must** render a visible decision chip (`data-testid="studio-gate-receipt"`): **PASS | HOLD | REJECT | ESCALATE**, the short reason, and `receipt.id` / `receipt_hash` (`eventrelay.gate-receipt.v1`). Missing backend config is **HOLD** (`GATE_HOLD_MISSING_EVIDENCE`) plus the backend reason — not a silent/no-chip failure. -- **PASS** only with a verified `https://` live URL that has a hostname. -- Workflow `completed` without that URL → **HOLD**. Copy must not say “Deploy completed”. -- Presented live URL that fails the hostname bar → **REJECT**. -- Unknown authority actor → **ESCALATE**. +## Studio and v1 compatibility -Mission advance is not implemented in this repo; do not invent a Mission Workspace gate here. +`gate-transition.ts` retains its client-safe v1 diagnostic contract and decision vocabulary. v1 content-addressed receipts are **not** authorization. The Studio URL adapter no longer upgrades a syntactically valid URL into Zero-Sim `real`; a valid raw live claim stays unverified/REJECT without authoritative evidence. This supersedes the earlier #1707 / #1710 URL-only bar without accepting any weaker URL. -## Tests +Studio displays server v2 decisions, transition IDs, and receipt-retention status distinctly from local diagnostics, retains existing auth redirects, clears receipts on selection changes, and ignores late responses for another selected video. A decision chip or completed workflow never starts deployment. Mission advance is not implemented. + +## Verification + +From repository root: ```bash -cd apps/web && npx vitest run src/lib/__tests__/gate-transition.test.ts +npm exec --workspace=apps/web --no -- vitest run src/lib/__tests__/origin-gate.test.ts src/lib/__tests__/gate-transition.test.ts src/lib/__tests__/studio-workflow.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/lib/__tests__/auth-paths.test.ts src/app/api/gate/transitions/__tests__/route.test.ts src/app/api/workflows/studio-deploy/__tests__/route.test.ts src/components/__tests__/OneLoopStudio.gate.test.tsx +npm run type-check --workspace=apps/web ``` + +Coverage includes signed acceptance, missing/weak/unreal/unknown evidence, binding/signature tampering, scope/revocation, expiry, independent keys, retries/concurrency, altered receipts, runtime failure, protected API boundaries, no workflow kickoff, and selected-video receipt isolation. Browser acceptance and a real configured-runtime PASS are distinct checks; local fixtures cannot replace either. + +Sandbox verification on 2026-09-13: the focused suite plus `src/lib/studio/__tests__/security.test.ts` passed **178 tests across 9 files**, and the web type-check passed. `/studio` rendered at 758 × 752 in dark mode. Full authenticated browser acceptance remains blocked: browser submissions returned `403 invalid_origin`, and a direct same-origin local submission returned `503 authentication_unavailable`. The existing Upstash integration reports connected, but the sandbox-injected environment did not expose a recognized REST credential pair on repeated checks. These are sandbox observations, not evidence of a production outage. No production configuration was changed and no operational PASS or next-phase authorization is claimed. diff --git a/docs/video_to_gtm_architecture.md b/docs/video_to_gtm_architecture.md index 039ce1f71..292867cfc 100644 --- a/docs/video_to_gtm_architecture.md +++ b/docs/video_to_gtm_architecture.md @@ -1,6 +1,6 @@ -# Video → GTM Revenue Architecture v3.0 +# Video → GTM Revenue Architecture v3.0 — historical design -> This is the single editable source of truth for EventRelay's Video → GTM Revenue Architecture v3.0. Any duplicate copies should point here instead of being edited separately. +> Historical March 2026 design, marked 2026-09-13. This remains the canonical copy of that design; duplicate architecture stubs should continue pointing here. Its implementation, deployment, provider, and product claims are not a fresh verification of today's UVAI system. Current authority is [../AGENTS.md](../AGENTS.md); current source navigation and proposed work are [REPO_MAP.md](REPO_MAP.md) and [MASTER_ROADMAP.md](MASTER_ROADMAP.md). Do not execute this historical plan as a new authorized cut. ## Complete End-to-End Pipeline Design — Video In → Deployed Site Out ## Date: March 20, 2026 (Updated from v2.0 March 13, 2026) ## Author: Lead Engineer + viralnowsales diff --git a/turbo.json b/turbo.json index 8edbd0462..4aa0fab2d 100644 --- a/turbo.json +++ b/turbo.json @@ -20,7 +20,23 @@ "test": {}, "dev": { "cache": false, - "persistent": true + "persistent": true, + "passThroughEnv": [ + "NEXTAUTH_SECRET", + "NEXTAUTH_URL", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GOOGLE_OAUTH_CLIENT_ID", + "GOOGLE_OAUTH_CLIENT_SECRET", + "AUTH_ALLOWED_EMAIL_DOMAIN", + "KV_REST_API_URL", + "KV_REST_API_TOKEN", + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", + "V0_SANDBOX_URL", + "V0_RUNTIME_URL", + "V0_BUILD_URL" + ] } } -} \ No newline at end of file +}