From 1c7f27795669cdf5c7531fa2c8a927d714ea107d Mon Sep 17 00:00:00 2001 From: Seva Date: Thu, 30 Jul 2026 17:38:16 +0000 Subject: [PATCH 1/3] chore: bootstrap V2 sandbox workstream --- AGENTS.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 07233a6..8060a47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,3 +60,73 @@ Report exact failures. Never update baselines or skip a gate automatically. - Never print `.env*` values or user/provider secrets. - Commit and push intentional completed work; deploy only after all release gates pass. +## V2 Sandbox Builder workstream + +These rules apply whenever work targets Project V2, the AI builder loop, or +persistent Vercel Sandbox previews. + +### Protected state and worktree + +- Before planning, inspect `git status -sb`, `git branch -vv`, `git worktree list`, recent commits, open PRs, and the latest default-branch CI. +- Work only in the isolated worktree on `codex/drops-studio-v2-sandbox-builder`, based on the verified current `origin/main`. Reuse it if it exists; never create competing suffix branches silently. +- `main`, the production deployment and aliases, historical PR #1 and PR #2, every non-owned PR branch, and every other worktree are read-only. +- Never reset, clean, stash, rebase, force-push, rewrite history, switch another worktree, merge to `main`, or change a production alias. +- Preserve V1 projects, all 12 recipes, `/p/{slug}`, ZIP export, current connections, checkpoints, and session-only BYOK behavior. Project V2 extends the canonical workspace through explicit adapters and migrations; it does not replace V1 in place. + +### Work-package ownership + +- The lead owns sequencing, shared contracts, cross-package integration, dependency manifests and lockfiles, final git operations, PR creation, and preview deployment. +- Project model owns versioned schemas, path and file validation, deterministic hashes, migration, templates, diffs, checkpoints, and persistence. +- Runtime owns `ProjectRuntimeAdapter`, Vercel Sandbox lifecycle, processes, ports, logs, limits, cleanup, network policy, and runtime audit evidence. +- Agent loop owns AI SDK orchestration, strict tool schemas, permissions, approvals, bounded outputs, repair limits, and request-only provider access. +- Studio owns the current unified workspace UI, real file/editor/preview/log/history states, Storybook, accessibility, and browser flows. It must not restore obsolete panels. +- Crypto integrations own typed DropsTab and Drops Bot proxies, provider evidence, fixtures, Telegram boundaries, and truthful unavailable or setup-required states. +- QA/release owns adversarial tests, CI parity, preview verification, and the final evidence table. It cannot waive a failing gate. +- Parallel agents receive disjoint files. Only the lead edits shared types, configs, manifests, and integration points. Subagents do not commit, push, deploy, or change branches. + +### Plugin-first workflow + +- Read every applicable `SKILL.md` completely before task actions. +- Use `agyb-essentials:concise-planning` for the implementation graph and context-mode for the long-running execution when callable. +- Use `vercel:vercel-sandbox`, `vercel:ai-sdk`, `vercel:ai-gateway`, `vercel:nextjs`, `vercel:deployments-cicd`, and `vercel:verification` for runtime, deployment, and agent work. +- Use `build-web-apps:frontend-app-builder`, `build-web-apps:frontend-testing-debugging`, `build-web-apps:react-best-practices`, `build-web-apps:shadcn`, and relevant `agyb-aas-web-app-builder` skills for Studio implementation. +- Use `product-design:index` and `product-design:audit` for visible UX; use Creative Production intake/produce for reference-driven final design QA. +- Use Playwright MCP or repository Playwright for every rendered or interactive change. Its global output directory must remain Drops-specific and must never point at a ColdMath workspace. +- Use `github:github`, `github:gh-fix-ci`, and `github:yeet` or verified `gh` fallback for repository operations. +- Use CodeRabbit after the bounded local gate with `coderabbit review --agent --base main`; verify every finding before editing and never weaken tests to satisfy it. +- Use OpenAI Developers only for OpenAI API work. Use Sites only for `.openai/hosting.json` or an explicitly requested secondary preview. Use Remotion only for actual video output and Visualize only when a diagram or data visualization materially helps. +- Do not invoke unrelated skills merely to claim plugin usage. + +### Runtime and security boundary + +- Multi-file source is canonical for Project V2; `GeneratedProjectSpec` remains product metadata. Preserve independently versioned store, workspace, spec, and provider-record envelopes. +- Untrusted build, install, test, server, and command execution occurs only in Vercel Sandbox. Browser-safe legacy preview may continue in its sandboxed iframe, and published deployments execute on their declared host. +- `run_command` is a policy-validated argv or declared-task tool, never a free-form host shell. `install_package` accepts exact public-registry versions, keeps lifecycle scripts disabled, and records an audit event. +- No provider or platform secret may enter generated files, Sandbox environment or filesystem, logs, checkpoints, Blob snapshots, ZIPs, prompts, or tool output. +- Every external or destructive tool has explicit approval, timeout, quota, audit record, bounded output, and idempotency behavior. +- DropsTab quota-bearing tests use fixtures and cache boundaries. Live Sandbox or provider smoke tests run only behind explicit flags and never use production user accounts implicitly. +- Never promote preview or browser telemetry into provider evidence. + +### Validation and preview-only release + +- Reproduce inherited default-branch failures before feature work. Fix their root cause without changing visual baselines or weakening thresholds. +- During implementation run narrow owner-specific tests. Run the full CI-equivalent gate once at the final boundary: + +```bash +npm audit --omit=dev --audit-level=high +npm run guardrails:ui +npm run lint +npm run typecheck +npm run build:vercel +npm run test:unit +npm run build-storybook +npm run test:storybook +npm run test:storybook:visual +npm run test:e2e:prepared +npm run test:lighthouse:prepared +npm run build +``` + +- Never update visual baselines, weaken thresholds, skip tests, or consume live DropsTab quota to make a gate pass. +- After green gates, the only authorized release is: commit the dedicated branch, push it, open one separate PR, and create a provider-confirmed preview deployment. +- Do not merge, promote, alias, or deploy to production. Record `main`, production, and protected PR state before and after release and prove they did not change. From 14efb857a763540da3488519363bb99f8ec0dd3c Mon Sep 17 00:00:00 2001 From: Seva Date: Thu, 30 Jul 2026 17:53:07 +0000 Subject: [PATCH 2/3] chore: grant Drops Studio full release authority --- AGENTS.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8060a47..18e35c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,13 +65,16 @@ Report exact failures. Never update baselines or skip a gate automatically. These rules apply whenever work targets Project V2, the AI builder loop, or persistent Vercel Sandbox previews. -### Protected state and worktree +### Full repository and release authority -- Before planning, inspect `git status -sb`, `git branch -vv`, `git worktree list`, recent commits, open PRs, and the latest default-branch CI. -- Work only in the isolated worktree on `codex/drops-studio-v2-sandbox-builder`, based on the verified current `origin/main`. Reuse it if it exists; never create competing suffix branches silently. -- `main`, the production deployment and aliases, historical PR #1 and PR #2, every non-owned PR branch, and every other worktree are read-only. -- Never reset, clean, stash, rebase, force-push, rewrite history, switch another worktree, merge to `main`, or change a production alias. -- Preserve V1 projects, all 12 recipes, `/p/{slug}`, ZIP export, current connections, checkpoints, and session-only BYOK behavior. Project V2 extends the canonical workspace through explicit adapters and migrations; it does not replace V1 in place. +- Before planning, inspect `git status -sb`, `git branch -vv`, `git worktree list`, recent commits, open PRs, and the latest default-branch CI so every mutation starts from verified current state. +- `codex/drops-studio-v2-sandbox-builder` is the prepared starting worktree, not an access boundary. The lead may inspect, create, switch, repair, or retire any Drops Studio branch or worktree needed to complete the product. +- The lead has full read/write/admin and release authority across the complete Drops Studio surface: `main`, every branch and worktree, historical and active PRs/issues, GitHub Actions and repository settings, Vercel project settings and environment variables, previews, production deployments, domains and aliases, Drops Studio VPS/runtime/services/storage, Sandbox resources, provider configuration, and product integrations. +- Commit, push, fetch, rebase, cherry-pick, merge, close or supersede PRs, update `main`, deploy, promote, roll back, and change the production alias when useful to finish or recover the product. These operations do not require another approval within the Drops Studio task. +- Historical PR #1 and PR #2 are normal repository history, not protected read-only state. Reuse, supersede, close, or modify their remaining branches when the verified current architecture benefits. +- Access includes reading and updating protected provider or deployment configuration through their proper interfaces. Never print secrets, place them in source/generated files/logs, or commit them. +- Preserve V1 projects, all 12 recipes, `/p/{slug}`, ZIP export, current connections, checkpoints, and session-only BYOK behavior as product outcomes. Migrate or replace internals when necessary, but verify compatibility before release. +- `/opt/coldmath`, ColdMath services, and the ColdMath repository remain outside this task because they are a separate product. Using the saved ColdMath VPS host as the task entry point does not authorize ColdMath changes. ### Work-package ownership @@ -82,7 +85,7 @@ persistent Vercel Sandbox previews. - Studio owns the current unified workspace UI, real file/editor/preview/log/history states, Storybook, accessibility, and browser flows. It must not restore obsolete panels. - Crypto integrations own typed DropsTab and Drops Bot proxies, provider evidence, fixtures, Telegram boundaries, and truthful unavailable or setup-required states. - QA/release owns adversarial tests, CI parity, preview verification, and the final evidence table. It cannot waive a failing gate. -- Parallel agents receive disjoint files. Only the lead edits shared types, configs, manifests, and integration points. Subagents do not commit, push, deploy, or change branches. +- Parallel agents normally receive disjoint files. The lead coordinates shared types, configs, manifests, integration points, and may explicitly delegate branch, commit, push, PR, deployment, or production operations when that accelerates delivery without creating conflicting ownership. ### Plugin-first workflow @@ -107,7 +110,7 @@ persistent Vercel Sandbox previews. - DropsTab quota-bearing tests use fixtures and cache boundaries. Live Sandbox or provider smoke tests run only behind explicit flags and never use production user accounts implicitly. - Never promote preview or browser telemetry into provider evidence. -### Validation and preview-only release +### Validation and production release - Reproduce inherited default-branch failures before feature work. Fix their root cause without changing visual baselines or weakening thresholds. - During implementation run narrow owner-specific tests. Run the full CI-equivalent gate once at the final boundary: @@ -127,6 +130,6 @@ npm run test:lighthouse:prepared npm run build ``` -- Never update visual baselines, weaken thresholds, skip tests, or consume live DropsTab quota to make a gate pass. -- After green gates, the only authorized release is: commit the dedicated branch, push it, open one separate PR, and create a provider-confirmed preview deployment. -- Do not merge, promote, alias, or deploy to production. Record `main`, production, and protected PR state before and after release and prove they did not change. +- Visual baselines, thresholds, fixtures, and test configuration are editable when an intentional product change requires it; document the reason and never use the change to conceal a regression. Do not consume live DropsTab quota in routine tests. +- After the bounded gates, choose and execute the complete release path needed for the task: commit and push, open/update/merge PRs, update `main`, deploy or promote the verified Vercel build, change the production alias, configure runtime resources, and verify the public product. +- Record the before/after Git, CI, deployment, alias, migration, and smoke-test evidence. Maintain a tested rollback path, but do not stop at a preview when a production release is required to complete the product. From 129af400a08b990a998cf7534998b5f09d7d5bee Mon Sep 17 00:00:00 2001 From: Seva Date: Thu, 30 Jul 2026 21:01:16 +0000 Subject: [PATCH 3/3] feat: ship Project V2 sandbox builder --- .env.example | 42 + DESIGN.md | 20 + app/api/access/route.ts | 17 +- app/api/agent/plan/route.ts | 106 +- app/api/builder/agent/route.ts | 194 +++ app/api/builder/cleanup/route.ts | 93 ++ app/api/builder/runtime/route.ts | 239 +++ app/api/builder/shared.ts | 278 ++++ app/api/deployments/vercel/route.ts | 366 +++++ app/api/dropsbot/events/route.ts | 15 + app/api/dropsbot/webhooks/route.ts | 27 + app/api/integrations/github/route.ts | 212 +++ app/api/project-data/route.ts | 233 +++ app/api/projects/publish/route.ts | 4 +- app/api/projects/v2/route.ts | 259 ++++ app/api/workspace/patch/route.ts | 5 +- app/styles/project-studio.responsive.css | 43 + components/drops-studio.tsx | 30 +- components/project-studio.tsx | 346 ++++- components/project-v2-code-editor.tsx | 75 + .../project-v2-studio-surface.module.css | 146 ++ components/project-v2-studio-surface.tsx | 1032 +++++++++++++ components/project-v2-workspace-model.ts | 209 +++ components/project-v2-workspace.module.css | 756 ++++++++++ components/project-v2-workspace.stories.tsx | 229 +++ components/project-v2-workspace.tsx | 1319 +++++++++++++++++ db/project-v2-release-receipts.ts | 239 +++ db/project-v2-snapshots.ts | 252 ++++ docs/BUILDER_V2.md | 372 +++++ docs/GITHUB_AND_DEPLOY_SETUP.md | 301 ++++ docs/SANDBOX_OPERATIONS.md | 347 +++++ docs/V2_COST_CONTROLS.md | 306 ++++ docs/V2_SECURITY_MODEL.md | 436 ++++++ .../design/v2-reference/01-project-studio.png | Bin 0 -> 1381816 bytes docs/design/v2-reference/02-homepage.png | Bin 0 -> 1386589 bytes docs/design/v2-reference/03-templates.png | Bin 0 -> 1562140 bytes docs/design/v2-reference/04-ai-build-plan.png | Bin 0 -> 1182598 bytes docs/design/v2-reference/05-integrations.png | Bin 0 -> 1369169 bytes docs/design/v2-reference/06-workflows.png | Bin 0 -> 1332877 bytes .../v2-reference/07-preview-testing.png | Bin 0 -> 1453250 bytes .../08-history-checkpoints-diff.png | Bin 0 -> 1364651 bytes .../design/v2-reference/09-publish-deploy.png | Bin 0 -> 1344840 bytes .../v2-reference/10-new-app-ai-builder.png | Bin 0 -> 1435273 bytes docs/design/v2-reference/README.md | 40 + e2e/contracts/member-project-cloud.spec.ts | 43 + e2e/contracts/project-v2-studio.spec.ts | 108 ++ e2e/fixtures/project-v2-ui-test.ts | 190 +++ e2e/products/free-prompt-game.spec.ts | 12 +- e2e/proofs/director-flow.spec.ts | 4 +- lib/access-tier.ts | 24 + lib/artifact-security.ts | 7 + lib/builder-agent/index.ts | 6 + lib/builder-agent/orchestrator.ts | 353 +++++ lib/builder-agent/policy.ts | 128 ++ lib/builder-agent/providers.ts | 326 ++++ lib/builder-agent/tools.ts | 441 ++++++ lib/builder-agent/types.ts | 249 ++++ lib/builder-agent/workspace.ts | 740 +++++++++ lib/drops-platform/dropsbot.ts | 248 ++++ lib/drops-platform/dropstab.ts | 185 +++ lib/drops-platform/index.ts | 5 + lib/drops-platform/primitives.ts | 131 ++ lib/drops-platform/rules-engine.ts | 182 +++ lib/drops-platform/testkit.ts | 37 + lib/github-integration.ts | 452 ++++++ lib/legacy-html-runtime-adapter.ts | 292 ++++ lib/project-checkpoint-v2.ts | 94 ++ lib/project-data/backend.ts | 129 ++ lib/project-data/capability.ts | 126 ++ lib/project-data/index.ts | 5 + lib/project-data/store.ts | 186 +++ lib/project-data/types.ts | 107 ++ lib/project-data/validation.ts | 129 ++ lib/project-file-diff.ts | 89 ++ lib/project-runtime-adapter.ts | 544 +++++++ lib/project-template-dropstab.ts | 302 ++++ lib/project-template-materializer.ts | 308 ++++ lib/project-template-ui.ts | 329 ++++ lib/project-types.ts | 2 + lib/project-v2-export.ts | 185 +++ lib/project-v2-files.ts | 252 ++++ lib/project-v2-hash.ts | 95 ++ lib/project-v2-migration.ts | 342 +++++ lib/project-v2-path.ts | 78 + lib/project-v2-sync-client.ts | 98 ++ lib/project-v2-types.ts | 221 +++ lib/project-v2-validator.ts | 402 +++++ lib/provider-response-boundary.ts | 57 + lib/request-rate-limit.ts | 2 +- lib/vercel-agent-browser-checker.ts | 476 ++++++ lib/vercel-deployment.ts | 394 +++++ lib/vercel-sandbox-runtime-adapter.ts | 1286 ++++++++++++++++ lib/workspace-ai-provider.ts | 4 +- package-lock.json | 437 ++++++ package.json | 12 +- tests/access-tier.test.mjs | 34 + tests/artifact-security.test.mjs | 4 + tests/builder-agent-repair-loop.test.mjs | 220 +++ tests/builder-agent-route.test.mjs | 289 ++++ tests/builder-agent-tools.test.mjs | 182 +++ tests/builder-live-flow.test.mjs | 140 ++ tests/builder-provider-security.test.mjs | 55 + tests/drops-platform-contracts.test.mjs | 171 +++ tests/dropsbot-webhook.test.mjs | 16 + ...ternal-integration-route-security.test.mjs | 532 +++++++ tests/github-integration.test.mjs | 202 +++ .../materialize-project-v2-starter.mjs | 76 + tests/platform-model-routing.test.mjs | 84 +- tests/product-routing.test.mjs | 3 + tests/project-checkpoint-v2.test.mjs | 85 ++ tests/project-data-v2.test.mjs | 249 ++++ tests/project-file-diff.test.mjs | 37 + tests/project-runtime-adapter.test.mjs | 169 +++ tests/project-template-materializer.test.mjs | 147 ++ tests/project-v2-export.test.mjs | 199 +++ tests/project-v2-files.test.mjs | 110 ++ tests/project-v2-hash.test.mjs | 29 + tests/project-v2-migration.test.mjs | 62 + tests/project-v2-path.test.mjs | 63 + tests/project-v2-snapshot-storage.test.mjs | 125 ++ tests/project-v2-validator.test.mjs | 87 ++ tests/project-v2-workspace.test.mjs | 118 ++ tests/vercel-agent-browser-checker.test.mjs | 234 +++ tests/vercel-deployment.test.mjs | 160 ++ tests/vercel-sandbox-runtime-cleanup.test.mjs | 232 +++ tests/vercel-sandbox-runtime.test.mjs | 394 +++++ tests/workspace-ai-provider.test.mjs | 19 + tests/workspace-ai-route.test.mjs | 20 + vercel.json | 8 +- 129 files changed, 23959 insertions(+), 38 deletions(-) create mode 100644 app/api/builder/agent/route.ts create mode 100644 app/api/builder/cleanup/route.ts create mode 100644 app/api/builder/runtime/route.ts create mode 100644 app/api/builder/shared.ts create mode 100644 app/api/deployments/vercel/route.ts create mode 100644 app/api/integrations/github/route.ts create mode 100644 app/api/project-data/route.ts create mode 100644 app/api/projects/v2/route.ts create mode 100644 components/project-v2-code-editor.tsx create mode 100644 components/project-v2-studio-surface.module.css create mode 100644 components/project-v2-studio-surface.tsx create mode 100644 components/project-v2-workspace-model.ts create mode 100644 components/project-v2-workspace.module.css create mode 100644 components/project-v2-workspace.stories.tsx create mode 100644 components/project-v2-workspace.tsx create mode 100644 db/project-v2-release-receipts.ts create mode 100644 db/project-v2-snapshots.ts create mode 100644 docs/BUILDER_V2.md create mode 100644 docs/GITHUB_AND_DEPLOY_SETUP.md create mode 100644 docs/SANDBOX_OPERATIONS.md create mode 100644 docs/V2_COST_CONTROLS.md create mode 100644 docs/V2_SECURITY_MODEL.md create mode 100644 docs/design/v2-reference/01-project-studio.png create mode 100644 docs/design/v2-reference/02-homepage.png create mode 100644 docs/design/v2-reference/03-templates.png create mode 100644 docs/design/v2-reference/04-ai-build-plan.png create mode 100644 docs/design/v2-reference/05-integrations.png create mode 100644 docs/design/v2-reference/06-workflows.png create mode 100644 docs/design/v2-reference/07-preview-testing.png create mode 100644 docs/design/v2-reference/08-history-checkpoints-diff.png create mode 100644 docs/design/v2-reference/09-publish-deploy.png create mode 100644 docs/design/v2-reference/10-new-app-ai-builder.png create mode 100644 docs/design/v2-reference/README.md create mode 100644 e2e/contracts/project-v2-studio.spec.ts create mode 100644 e2e/fixtures/project-v2-ui-test.ts create mode 100644 lib/builder-agent/index.ts create mode 100644 lib/builder-agent/orchestrator.ts create mode 100644 lib/builder-agent/policy.ts create mode 100644 lib/builder-agent/providers.ts create mode 100644 lib/builder-agent/tools.ts create mode 100644 lib/builder-agent/types.ts create mode 100644 lib/builder-agent/workspace.ts create mode 100644 lib/drops-platform/dropsbot.ts create mode 100644 lib/drops-platform/dropstab.ts create mode 100644 lib/drops-platform/index.ts create mode 100644 lib/drops-platform/primitives.ts create mode 100644 lib/drops-platform/rules-engine.ts create mode 100644 lib/drops-platform/testkit.ts create mode 100644 lib/github-integration.ts create mode 100644 lib/legacy-html-runtime-adapter.ts create mode 100644 lib/project-checkpoint-v2.ts create mode 100644 lib/project-data/backend.ts create mode 100644 lib/project-data/capability.ts create mode 100644 lib/project-data/index.ts create mode 100644 lib/project-data/store.ts create mode 100644 lib/project-data/types.ts create mode 100644 lib/project-data/validation.ts create mode 100644 lib/project-file-diff.ts create mode 100644 lib/project-runtime-adapter.ts create mode 100644 lib/project-template-dropstab.ts create mode 100644 lib/project-template-materializer.ts create mode 100644 lib/project-template-ui.ts create mode 100644 lib/project-v2-export.ts create mode 100644 lib/project-v2-files.ts create mode 100644 lib/project-v2-hash.ts create mode 100644 lib/project-v2-migration.ts create mode 100644 lib/project-v2-path.ts create mode 100644 lib/project-v2-sync-client.ts create mode 100644 lib/project-v2-types.ts create mode 100644 lib/project-v2-validator.ts create mode 100644 lib/provider-response-boundary.ts create mode 100644 lib/vercel-agent-browser-checker.ts create mode 100644 lib/vercel-deployment.ts create mode 100644 lib/vercel-sandbox-runtime-adapter.ts create mode 100644 tests/builder-agent-repair-loop.test.mjs create mode 100644 tests/builder-agent-route.test.mjs create mode 100644 tests/builder-agent-tools.test.mjs create mode 100644 tests/builder-live-flow.test.mjs create mode 100644 tests/builder-provider-security.test.mjs create mode 100644 tests/drops-platform-contracts.test.mjs create mode 100644 tests/external-integration-route-security.test.mjs create mode 100644 tests/github-integration.test.mjs create mode 100644 tests/helpers/materialize-project-v2-starter.mjs create mode 100644 tests/project-checkpoint-v2.test.mjs create mode 100644 tests/project-data-v2.test.mjs create mode 100644 tests/project-file-diff.test.mjs create mode 100644 tests/project-runtime-adapter.test.mjs create mode 100644 tests/project-template-materializer.test.mjs create mode 100644 tests/project-v2-export.test.mjs create mode 100644 tests/project-v2-files.test.mjs create mode 100644 tests/project-v2-hash.test.mjs create mode 100644 tests/project-v2-migration.test.mjs create mode 100644 tests/project-v2-path.test.mjs create mode 100644 tests/project-v2-snapshot-storage.test.mjs create mode 100644 tests/project-v2-validator.test.mjs create mode 100644 tests/project-v2-workspace.test.mjs create mode 100644 tests/vercel-agent-browser-checker.test.mjs create mode 100644 tests/vercel-deployment.test.mjs create mode 100644 tests/vercel-sandbox-runtime-cleanup.test.mjs create mode 100644 tests/vercel-sandbox-runtime.test.mjs diff --git a/.env.example b/.env.example index afad0dd..95ee98a 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,48 @@ VERCEL_OIDC_TOKEN= # AI Gateway is normally authenticated by Vercel OIDC. Set an explicit key only outside that flow. AI_GATEWAY_API_KEY= +# Project V2 private storage and project-data capability signing. +# PROJECT_DATA_CAPABILITY_SECRET must be 32+ bytes, but does not by itself enable Project Data: +# production still needs an injected durable adapter and trusted capability issuer. +PROJECT_DATA_CAPABILITY_SECRET= +# Set these to "1" only for non-production process-memory proof mode. +DROPS_STUDIO_LOCAL_PROJECT_STORE= +DROPS_STUDIO_LOCAL_PROJECT_DATA= + +# Production Sandbox authentication uses Vercel OIDC and needs none of these explicit values. +# For local Sandbox authentication only, set VERCEL_TOKEN, VERCEL_PROJECT_ID, and +# VERCEL_TEAM_ID together; never configure only part of that local credential trio. +# VERCEL_TEAM_ID has one additional production use: set it only when the server-owned +# generated-app deployment project below belongs to a Vercel team. Personal scope omits it. +VERCEL_TOKEN= +VERCEL_PROJECT_ID= +VERCEL_TEAM_ID= + +# Server-only prebuilt browser-check snapshot created with @agent-browser/sandbox. +AGENT_BROWSER_SNAPSHOT_ID= + +# Vercel Cron authenticates scheduled idle cleanup with this 32+ character server secret. +CRON_SECRET= +# Optional integer from 5 through 240; defaults to 20 minutes. +DROPS_STUDIO_SANDBOX_IDLE_MINUTES= +# Set to "1" only to opt into the live Sandbox contract test. +DROPS_STUDIO_LIVE_SANDBOX= +# Set to "1" only to opt into the full live install/build/preview/browser/checkpoint flow. +DROPS_STUDIO_LIVE_BUILDER= + +# GitHub App is preferred for least-privilege branch/commit/PR integration. +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= +GITHUB_APP_INSTALLATION_ID= +# Comma-separated exact owner/repository allowlist for platform App credentials. +GITHUB_APP_ALLOWED_REPOSITORIES= +# Visitor GitHub access tokens are tab-scoped and sent request-only; there is no token env name. + +# Generated-app preview deployment. Visitor tokens remain tab-scoped/request-only. +# These optional platform values never enter generated source or Sandbox environment. +VERCEL_DEPLOY_TOKEN= +VERCEL_GENERATED_PROJECT_ID= + # Stripe Billing and signed team collaboration. Values are configured only in the provider environment. STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/DESIGN.md b/DESIGN.md index 232dfb4..7c7e2b3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -34,6 +34,26 @@ The current rebuilt product architecture is the source of truth. Do not restore The older `docs/design/project-studio-spec.png` and `docs/screenshots/*` files are historical evidence only. They are explicitly forbidden as new visual baselines. Current-state references define architecture; the accessibility and readability rules below intentionally change their undersized text. +### Project V2 capability references + +The user-provided ten-screen pack under `docs/design/v2-reference/` is the +approved capability and visual-polish direction for the V2 builder. Its screen +map and SHA-256 manifest live in `docs/design/v2-reference/README.md`. + +- Apply its calm white/blue hierarchy, compact developer-tool affordances, + explicit build stages, real file tree, device preview, logs, checkpoint diff, + integration evidence, and release receipts to the current product. +- Treat every provider badge and status in the pack as a layout reference only. + Drops Studio may render `Live`, `Connected`, `Passed`, `Deployed`, or a real + URL only after the corresponding Sandbox, Drops, GitHub, or Vercel response + has been verified. +- The current prompt-first homepage and unified Project Studio architecture + still control layout. Do not recreate a permanent right Director column, + obsolete four-column shell, or permanent publish sidebar from a concept. +- Use DropsTab market intelligence, Drops Bot monitoring/delivery, the existing + twelve recipes, and honest setup-required states wherever a generic concept + uses interchangeable DeFi or third-party examples. + ## Drops Studio brand profile Brand tokens live only in `app/styles/drops-studio.tokens.css`. diff --git a/app/api/access/route.ts b/app/api/access/route.ts index 0e43e4c..add2143 100644 --- a/app/api/access/route.ts +++ b/app/api/access/route.ts @@ -14,14 +14,25 @@ import { readRequestLimitState } from "../../../lib/request-rate-limit.ts"; export const runtime = "nodejs"; +function requestOidcToken(request: NextRequest): string | undefined { + const value = request.headers.get("x-vercel-oidc-token")?.trim() ?? ""; + return value && value.length <= 4_096 && !/[\r\n\0]/.test(value) + ? value + : undefined; +} + export async function GET(request: NextRequest) { + const oidcToken = requestOidcToken(request); + const readinessEnvironment = oidcToken + ? { ...process.env, VERCEL_OIDC_TOKEN: oidcToken } + : process.env; const date = new Date().toISOString().slice(0, 10); const account = resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); if (account) { const fundedQuota = await resolveFundedBuildQuota({ kind: "account", account }); const memberTier = fundedQuota.tier; const memberLimit = fundedQuota.limit; - const readiness = platformAiReadiness("member"); + const readiness = platformAiReadiness("member", readinessEnvironment); const quota = readiness.available ? await readRequestLimitState({ identity: account.identity, @@ -37,7 +48,7 @@ export async function GET(request: NextRequest) { tier: platformAvailable ? memberTier : "fallback", used: quota.count ?? 0, account, - projectSyncAvailable: memberProjectSyncReadiness(), + projectSyncAvailable: memberProjectSyncReadiness(readinessEnvironment), platformLimit: memberLimit, }), quotaSigningConfigured: readiness.signingConfigured, @@ -50,7 +61,7 @@ export async function GET(request: NextRequest) { usageCookie: request.cookies.get(GUEST_USAGE_COOKIE)?.value, date, }); - const readiness = platformAiReadiness("guest"); + const readiness = platformAiReadiness("guest", readinessEnvironment); const access = accessMetadata({ tier: context.configured && readiness.available ? "guest" : "fallback", used: context.used, diff --git a/app/api/agent/plan/route.ts b/app/api/agent/plan/route.ts index b2669b7..b09cc1a 100644 --- a/app/api/agent/plan/route.ts +++ b/app/api/agent/plan/route.ts @@ -19,6 +19,13 @@ import { STUDIO_ACCOUNT_COOKIE, } from "../../../../lib/access-tier.ts"; import { consumeRequestLimitState, requestIdentity } from "../../../../lib/request-rate-limit.ts"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "../../../../lib/http-request-boundary.ts"; +import { secretFreeRuntimeMessage } from "../../../../lib/project-runtime-adapter.ts"; export const runtime = "nodejs"; @@ -34,6 +41,56 @@ export const PLATFORM_PLAN_MODELS = { ], } as const; const GUEST_IP_REQUEST_LIMIT = 12; +const PLAN_BODY_LIMIT_BYTES = 24_000; + +function requestOidcToken(request: NextRequest): string | undefined { + const value = request.headers.get("x-vercel-oidc-token")?.trim() ?? ""; + return value && value.length <= 4_096 && !/[\r\n\0]/.test(value) + ? value + : undefined; +} + +function requestCredential(request: NextRequest, name: string): string | undefined { + const value = request.headers.get(name)?.trim() ?? ""; + return value && value.length <= 4_096 && !/[\r\n\0]/.test(value) + ? value + : undefined; +} + +function sameOrigin(request: NextRequest): boolean { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false; + const origin = request.headers.get("origin"); + if (!origin) return process.env.NODE_ENV !== "production"; + try { + const host = request.headers.get("host")?.split(",")[0]?.trim(); + const protocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().replace(/:$/, "") + || request.nextUrl.protocol.replace(/:$/, ""); + const visibleOrigin = host ? `${protocol}://${host}` : request.nextUrl.origin; + const parsed = new URL(origin).origin; + return parsed === request.nextUrl.origin || parsed === visibleOrigin; + } catch { + return false; + } +} + +async function requestBody(request: NextRequest): Promise<{ + prompt?: string; + model?: string; + provider?: string; +} | null> { + try { + const raw = decodeUtf8Body(await readBoundedRequestBody(request, PLAN_BODY_LIMIT_BYTES)); + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as { prompt?: string; model?: string; provider?: string } + : null; + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + throw error; + } + return null; + } +} const presetIds = projectPresetIds as [PresetId, ...PresetId[]]; const planSchema = z.object({ @@ -350,8 +407,13 @@ async function runDirectProvider(prompt: string, key: string, provider: DirectPr return { ...planSchema.parse(parseObject(payload.choices?.[0]?.message?.content ?? "")), provider, model }; } -async function runGuestGateway(prompt: string, guestId: string, tier: "guest" | "member" = "guest") { - const gatewayToken = process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN; +async function runGuestGateway( + prompt: string, + guestId: string, + tier: "guest" | "member" = "guest", + requestGatewayToken?: string, +) { + const gatewayToken = requestGatewayToken || process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN; if (!gatewayToken) throw new Error("Platform AI Gateway is not configured."); const guestGateway = createGateway({ apiKey: gatewayToken }); const errors: string[] = []; @@ -394,14 +456,32 @@ async function runGuestGateway(prompt: string, guestId: string, tier: "guest" | } export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null) as { prompt?: string; model?: string; provider?: string } | null; + if (!sameOrigin(request)) { + return NextResponse.json({ error: "Cross-origin planning requests are not allowed." }, { status: 403 }); + } + if (!hasJsonMediaType(request)) { + return NextResponse.json({ error: "Planning requests require application/json." }, { status: 415 }); + } + let body: Awaited>; + try { + body = await requestBody(request); + } catch (error) { + if (error instanceof RequestBodyBoundaryError) { + return NextResponse.json({ error: "Planning request exceeds the bounded request size." }, { status: 413 }); + } + return NextResponse.json({ error: "Planning request is invalid." }, { status: 400 }); + } const prompt = body?.prompt?.trim() ?? ""; if (prompt.length < 3) return NextResponse.json({ error: "Describe what you want to build." }, { status: 400 }); if (prompt.length > 16_000) return NextResponse.json({ error: "Keep the product brief and edit context under 16,000 characters." }, { status: 400 }); + const gatewayToken = requestOidcToken(request); + const readinessEnvironment = gatewayToken + ? { ...process.env, VERCEL_OIDC_TOKEN: gatewayToken } + : process.env; const account = resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); - const openRouterKey = request.headers.get("x-openrouter-key")?.trim(); + const openRouterKey = requestCredential(request, "x-openrouter-key"); if (openRouterKey) { try { const model = body?.model?.trim() || "openrouter/free"; @@ -413,12 +493,14 @@ export async function POST(request: NextRequest) { access: accessMetadata({ tier: "byok", used: 0, account }), }, { headers: { "cache-control": "no-store" } }); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : "OpenRouter planning failed." }, { status: 502 }); + return NextResponse.json({ + error: secretFreeRuntimeMessage(error, "OpenRouter planning failed."), + }, { status: 502 }); } } const directProvider = ["openai", "anthropic", "kimi"].includes(body?.provider ?? "") ? body?.provider as DirectProvider : null; - const directKey = request.headers.get("x-provider-key")?.trim(); + const directKey = requestCredential(request, "x-provider-key"); if (directProvider && !directKey) { return NextResponse.json({ error: `Connect ${directProvider} with an API key before using it.` }, { status: 400 }); } @@ -438,12 +520,14 @@ export async function POST(request: NextRequest) { access: accessMetadata({ tier: "byok", used: 0, account }), }, { headers: { "cache-control": "no-store" } }); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : "Connected model planning failed." }, { status: 502 }); + return NextResponse.json({ + error: secretFreeRuntimeMessage(error, "Connected model planning failed."), + }, { status: 502 }); } } if (account) { - const readiness = platformAiReadiness("member"); + const readiness = platformAiReadiness("member", readinessEnvironment); if (!readiness.available) { const fallback = fallbackAgentPlan(prompt); return responseWithMemberQuota({ @@ -485,7 +569,7 @@ export async function POST(request: NextRequest) { }, account, 0); } try { - const result = await runGuestGateway(prompt, account.identity, "member"); + const result = await runGuestGateway(prompt, account.identity, "member", gatewayToken); const plan = alignPlanToRequestedOutput(result.plan, prompt); return responseWithMemberQuota({ plan, @@ -530,7 +614,7 @@ export async function POST(request: NextRequest) { }, guest, used, 429); } - const readiness = platformAiReadiness("guest"); + const readiness = platformAiReadiness("guest", readinessEnvironment); if (!guest.configured || !guest.identity || !readiness.available) { const fallback = fallbackAgentPlan(prompt); return responseWithQuota({ @@ -590,7 +674,7 @@ export async function POST(request: NextRequest) { const consumedUsed = Math.min(GUEST_DAILY_LIMIT, quota.count); try { - const result = await runGuestGateway(prompt, guest.identity); + const result = await runGuestGateway(prompt, guest.identity, "guest", gatewayToken); const plan = alignPlanToRequestedOutput(result.plan, prompt); return responseWithQuota({ plan, diff --git a/app/api/builder/agent/route.ts b/app/api/builder/agent/route.ts new file mode 100644 index 0000000..2a0bd76 --- /dev/null +++ b/app/api/builder/agent/route.ts @@ -0,0 +1,194 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest } from "next/server.js"; +import { z } from "zod"; +import { + writeProjectV2ReleaseReceipt, + type ProjectV2ReleaseReceipt, + type ProjectV2ReleaseReceiptDescriptor, +} from "../../../../db/project-v2-release-receipts.ts"; +import { + BuilderAgentSession, + materializedProjectDeterministicFallback, + runBuilderAgent, + type BuilderAgentAuditSink, + type BuilderAgentRunnerFactory, + type BuilderBrowserChecker, + type BuilderConnectionRequester, + type BuilderDeterministicFallback, + type BuilderModelResolver, + type BuilderProjectPublisher, + type BuilderProjectRepository, +} from "../../../../lib/builder-agent/index.ts"; +import { VercelSandboxRuntimeAdapter } from "../../../../lib/vercel-sandbox-runtime-adapter.ts"; +import { VercelAgentBrowserChecker } from "../../../../lib/vercel-agent-browser-checker.ts"; +import type { + ProjectRuntimeAdapter, + RuntimeAuditSink, +} from "../../../../lib/project-runtime-adapter.ts"; +import { + ServerBuilderAuditSink, + SnapshotBuilderProjectRepository, + BuilderRouteError, + builderActor, + builderCredentials, + builderJson, + builderRouteError, + consumeBuilderLimit, + readBuilderBody, + requireBuilderSameOrigin, +} from "../shared.ts"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 300; + +const requestSchema = z.object({ + projectId: z.string().regex(/^[a-z0-9][a-z0-9:._-]{0,127}$/i), + prompt: z.string().min(1).max(20_000), + mode: z.enum(["build", "edit", "repair"]), + provider: z.object({ + provider: z.enum(["free", "gateway", "openai", "anthropic", "openrouter", "kimi", "custom"]), + model: z.string().min(1).max(192).optional(), + baseUrl: z.string().url().max(2_000).optional(), + }).strict(), +}).strict(); + +const ALL_AGENT_PERMISSIONS = new Set([ + "files:read", + "files:write", + "runtime:execute", + "runtime:network", + "preview:start", + "browser:check", + "checkpoint:write", + "checkpoint:restore", + "connection:request", + "project:publish", +] as const); + +export interface BuilderAgentRouteDependencies { + repository?: BuilderProjectRepository; + runtime?: ProjectRuntimeAdapter; + audit?: BuilderAgentAuditSink & RuntimeAuditSink; + browser?: BuilderBrowserChecker; + connections?: BuilderConnectionRequester; + publisher?: BuilderProjectPublisher; + deterministicFallback?: BuilderDeterministicFallback; + modelResolver?: BuilderModelResolver; + runnerFactory?: BuilderAgentRunnerFactory; + writeReleaseReceipt?: ( + descriptor: ProjectV2ReleaseReceiptDescriptor, + ) => Promise; + resolveApprovedTools?: ( + request: NextRequest, + ) => Promise>; +} + +export async function handleBuilderAgentRequest( + request: NextRequest, + dependencies: BuilderAgentRouteDependencies = {}, +) { + try { + requireBuilderSameOrigin(request); + const actorId = builderActor(request); + await Promise.all([ + consumeBuilderLimit(actorId, "builder-agent-session", { + max: 20, + windowMs: 24 * 60 * 60_000, + retryAfter: 86_400, + }), + consumeBuilderLimit(actorId, "builder-agent-minute", { + max: 4, + windowMs: 60_000, + retryAfter: 60, + }), + ]); + const parsed = requestSchema.safeParse(await readBuilderBody(request)); + if (!parsed.success) { + return builderJson( + { + code: "BUILDER_INVALID_REQUEST", + error: "A valid bounded Project V2 builder request is required.", + }, + 400, + ); + } + const repository = dependencies.repository ?? new SnapshotBuilderProjectRepository(); + const project = await repository.loadAuthorized(actorId, parsed.data.projectId); + if (!project) { + return builderJson( + { code: "BUILDER_PROJECT_NOT_FOUND", error: "Project V2 was not found." }, + 404, + ); + } + const audit = dependencies.audit ?? new ServerBuilderAuditSink(); + const sandboxRuntime = + dependencies.runtime ?? new VercelSandboxRuntimeAdapter({ audit }); + const session = new BuilderAgentSession({ + actorId, + requestId: randomUUID(), + project, + repository, + runtime: sandboxRuntime, + permissions: ALL_AGENT_PERMISSIONS, + audit, + browser: dependencies.browser ?? new VercelAgentBrowserChecker(), + connections: dependencies.connections, + publisher: dependencies.publisher, + }); + // Approval evidence is resolved server-side. Tool names are intentionally + // absent from the public JSON body so a model cannot approve its own call. + const approvedTools = await (dependencies.resolveApprovedTools?.(request) ?? []); + const result = await runBuilderAgent( + { ...parsed.data, approvedTools: [...approvedTools] }, + { + services: session, + audit, + credentials: builderCredentials(request), + deterministicFallback: + dependencies.deterministicFallback ?? + materializedProjectDeterministicFallback, + modelResolver: dependencies.modelResolver, + runnerFactory: dependencies.runnerFactory, + }, + ); + if (result.releaseGate.ok) { + const checkpoint = result.project.checkpoints.at(-1); + if ( + !checkpoint + || !/^Verified (?:AI|deterministic) build$/.test(checkpoint.label) + || checkpoint.snapshot.revision !== result.project.revision + || checkpoint.snapshot.contentHash !== result.project.contentHash + ) { + throw new BuilderRouteError( + 503, + "BUILDER_RELEASE_RECEIPT_UNAVAILABLE", + "The verified build could not be bound to its server-side release receipt.", + ); + } + try { + await (dependencies.writeReleaseReceipt ?? writeProjectV2ReleaseReceipt)({ + actorId, + projectId: result.project.id, + revision: checkpoint.snapshot.revision, + contentHash: checkpoint.snapshot.contentHash, + checkpointId: checkpoint.id, + snapshotHash: checkpoint.snapshotHash, + }); + } catch { + throw new BuilderRouteError( + 503, + "BUILDER_RELEASE_RECEIPT_UNAVAILABLE", + "The build passed, but its private release receipt could not be persisted. Rebuild before deployment.", + ); + } + } + return builderJson({ result }, result.status === "blocked" ? 422 : 200); + } catch (error) { + return builderRouteError(error); + } +} + +export async function POST(request: NextRequest) { + return handleBuilderAgentRequest(request); +} diff --git a/app/api/builder/cleanup/route.ts b/app/api/builder/cleanup/route.ts new file mode 100644 index 0000000..27c0035 --- /dev/null +++ b/app/api/builder/cleanup/route.ts @@ -0,0 +1,93 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server.js"; + +import type { ProjectRuntimeAdapter } from "../../../../lib/project-runtime-adapter.ts"; +import { VercelSandboxRuntimeAdapter } from "../../../../lib/vercel-sandbox-runtime-adapter.ts"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 300; + +const HEADERS = { + "cache-control": "private, no-store, max-age=0", +}; + +interface CleanupRouteDependencies { + runtime?: Pick; + env?: Partial>; + now?: () => Date; +} + +function response(payload: Record, status = 200) { + return NextResponse.json(payload, { status, headers: HEADERS }); +} + +function matchesSecret(provided: string, expected: string): boolean { + const left = Buffer.from(provided, "utf8"); + const right = Buffer.from(expected, "utf8"); + return left.byteLength === right.byteLength && timingSafeEqual(left, right); +} + +function idleMinutes(env: CleanupRouteDependencies["env"]): number { + const value = Number(env?.DROPS_STUDIO_SANDBOX_IDLE_MINUTES ?? 20); + return Number.isSafeInteger(value) && value >= 5 && value <= 240 ? value : 20; +} + +export async function handleBuilderCleanupRequest( + request: NextRequest, + dependencies: CleanupRouteDependencies = {}, +) { + const env = dependencies.env ?? process.env; + const secret = env.CRON_SECRET?.trim() ?? ""; + if (!secret || (env.NODE_ENV === "production" && secret.length < 32)) { + return response( + { + code: "BUILDER_CLEANUP_UNAVAILABLE", + error: "Sandbox cleanup authorization is not configured.", + }, + 503, + ); + } + const authorization = request.headers.get("authorization") ?? ""; + if (!matchesSecret(authorization, `Bearer ${secret}`)) { + return response( + { code: "BUILDER_CLEANUP_UNAUTHORIZED", error: "Unauthorized." }, + 401, + ); + } + try { + const minutes = idleMinutes(env); + const now = dependencies.now?.() ?? new Date(); + const adapter = dependencies.runtime ?? new VercelSandboxRuntimeAdapter(); + const result = await adapter.cleanupIdle({ + idleBefore: new Date(now.getTime() - minutes * 60_000), + limit: 100, + }); + return response({ + idleMinutes: minutes, + inspected: result.inspected, + stopped: result.stopped, + failed: result.failed, + completedAt: now.toISOString(), + }); + } catch { + return response( + { + code: "BUILDER_CLEANUP_FAILED", + error: "Idle Sandbox cleanup could not be completed.", + }, + 503, + ); + } +} + +export async function GET(request: NextRequest) { + return handleBuilderCleanupRequest(request); +} + +export async function POST(request: NextRequest) { + return handleBuilderCleanupRequest(request); +} diff --git a/app/api/builder/runtime/route.ts b/app/api/builder/runtime/route.ts new file mode 100644 index 0000000..360cb51 --- /dev/null +++ b/app/api/builder/runtime/route.ts @@ -0,0 +1,239 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest } from "next/server.js"; +import { z } from "zod"; +import { + BuilderAgentSession, + type BuilderAgentAuditSink, + type BuilderProjectRepository, +} from "../../../../lib/builder-agent/index.ts"; +import type { + ProjectRuntimeAdapter, + RuntimeAuditSink, +} from "../../../../lib/project-runtime-adapter.ts"; +import { VercelSandboxRuntimeAdapter } from "../../../../lib/vercel-sandbox-runtime-adapter.ts"; +import { + ServerBuilderAuditSink, + SnapshotBuilderProjectRepository, + builderActor, + builderJson, + builderRouteError, + consumeBuilderLimit, + readBuilderBody, + requireBuilderSameOrigin, +} from "../shared.ts"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 300; + +const runtimeRequestSchema = z.object({ + projectId: z.string().regex(/^[a-z0-9][a-z0-9:._-]{0,127}$/i), + action: z.enum([ + "ensure", + "status", + "sync", + "install", + "run", + "typecheck", + "lint", + "tests", + "build", + "preview", + "logs", + "checkpoint", + "restore", + "stop", + "destroy", + ]), + taskId: z.string().min(1).max(64).optional(), + commandId: z.string().min(1).max(128).optional(), + checkpointId: z.string().min(1).max(128).optional(), + label: z.string().min(1).max(120).optional(), + port: z.union([z.literal(3000), z.literal(8080)]).optional(), + confirm: z.boolean().optional(), +}).strict(); + +const RUNTIME_PERMISSIONS = new Set([ + "files:read", + "files:write", + "runtime:execute", + "runtime:network", + "preview:start", + "checkpoint:write", + "checkpoint:restore", +] as const); + +export interface BuilderRuntimeRouteDependencies { + repository?: BuilderProjectRepository; + runtime?: ProjectRuntimeAdapter; + audit?: BuilderAgentAuditSink & RuntimeAuditSink; +} + +export async function handleBuilderRuntimeRequest( + request: NextRequest, + dependencies: BuilderRuntimeRouteDependencies = {}, +) { + try { + requireBuilderSameOrigin(request); + const actorId = builderActor(request); + await consumeBuilderLimit(actorId, "builder-runtime-action", { + max: 120, + windowMs: 60 * 60_000, + retryAfter: 3_600, + }); + const parsed = runtimeRequestSchema.safeParse(await readBuilderBody(request)); + if (!parsed.success) { + return builderJson( + { + code: "BUILDER_RUNTIME_INVALID_REQUEST", + error: "A valid bounded runtime action is required.", + }, + 400, + ); + } + const input = parsed.data; + if ( + ((input.action === "run" && !input.taskId) || + (input.action === "logs" && !input.commandId) || + (input.action === "restore" && !input.checkpointId)) + ) { + return builderJson( + { + code: "BUILDER_RUNTIME_MISSING_ARGUMENT", + error: "The selected runtime action is missing a required identifier.", + }, + 400, + ); + } + if ((input.action === "restore" || input.action === "destroy") && input.confirm !== true) { + return builderJson( + { + code: "BUILDER_RUNTIME_APPROVAL_REQUIRED", + error: `${input.action} requires explicit confirmation.`, + }, + 409, + ); + } + const repository = dependencies.repository ?? new SnapshotBuilderProjectRepository(); + const project = await repository.loadAuthorized(actorId, input.projectId); + if (!project) { + return builderJson( + { code: "BUILDER_PROJECT_NOT_FOUND", error: "Project V2 was not found." }, + 404, + ); + } + const audit = dependencies.audit ?? new ServerBuilderAuditSink(); + const runtimeAdapter = + dependencies.runtime ?? new VercelSandboxRuntimeAdapter({ audit }); + const session = new BuilderAgentSession({ + actorId, + requestId: randomUUID(), + project, + repository, + runtime: runtimeAdapter, + permissions: RUNTIME_PERMISSIONS, + audit, + }); + const existingOnly = new Set(["status", "logs", "stop", "destroy"]).has( + input.action, + ); + const existingHandle = existingOnly + ? await runtimeAdapter.resume(session.runtimeContext) + : null; + if (!existingHandle && existingOnly) { + if (input.action === "status") { + return builderJson({ + action: input.action, + result: { + provider: runtimeAdapter.provider, + status: "unavailable", + sandboxName: null, + sessionId: null, + vcpus: null, + memoryMb: null, + createdAt: null, + updatedAt: null, + expiresAt: null, + activeDurationMs: null, + previewUrl: null, + previewCommandId: null, + }, + }); + } + if (input.action === "stop" || input.action === "destroy") { + return builderJson({ + action: input.action, + result: input.action === "stop" ? { stopped: true } : { destroyed: true }, + }); + } + return builderJson( + { + code: "BUILDER_RUNTIME_NOT_FOUND", + error: "No active Sandbox command is available for this project.", + }, + 404, + ); + } + const handle = existingHandle ?? (await session.ensureRuntime()); + let result: unknown; + switch (input.action) { + case "ensure": + case "sync": + result = { handle, state: await runtimeAdapter.status(handle) }; + break; + case "status": + result = await runtimeAdapter.status(handle); + break; + case "install": + result = await runtimeAdapter.installDependencies(session.runtimeContext, handle); + break; + case "run": + result = await session.runTask(input.taskId!); + break; + case "typecheck": + result = await session.runTypecheck(); + break; + case "lint": + result = await session.runLint(); + break; + case "tests": + result = await session.runTests(); + break; + case "build": + result = await session.runBuild(); + break; + case "preview": { + const preview = await session.startPreview(undefined, input.port); + result = { ...preview, preview: session.project.preview }; + break; + } + case "logs": + result = await runtimeAdapter.readLogs(handle, { + commandId: input.commandId!, + limit: 256, + }); + break; + case "checkpoint": + result = await session.createCheckpoint(input.label ?? "Manual checkpoint"); + break; + case "restore": + result = await session.restoreCheckpoint(input.checkpointId!); + break; + case "stop": + await runtimeAdapter.stop(handle); + result = { stopped: true }; + break; + case "destroy": + await runtimeAdapter.destroy(handle); + result = { destroyed: true }; + break; + } + return builderJson({ action: input.action, result }); + } catch (error) { + return builderRouteError(error); + } +} + +export async function POST(request: NextRequest) { + return handleBuilderRuntimeRequest(request); +} diff --git a/app/api/builder/shared.ts b/app/api/builder/shared.ts new file mode 100644 index 0000000..b7ba6ab --- /dev/null +++ b/app/api/builder/shared.ts @@ -0,0 +1,278 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { + GUEST_IDENTITY_COOKIE, + resolveStudioProjectActor, + STUDIO_ACCOUNT_COOKIE, +} from "../../../lib/access-tier.ts"; +import { + ProjectRuntimeProviderError, + ProjectRuntimeUnavailableError, + ProjectRuntimeValidationError, + secretFreeRuntimeMessage, + type RuntimeAuditEvent, + type RuntimeAuditSink, +} from "../../../lib/project-runtime-adapter.ts"; +import { ProjectV2RevisionConflictError } from "../../../lib/project-v2-files.ts"; +import type { ProjectV2 } from "../../../lib/project-v2-types.ts"; +import { consumeRequestLimit } from "../../../lib/request-rate-limit.ts"; +import { + ProjectV2SnapshotStorageUnavailableError, + readProjectV2Snapshot, + writeProjectV2Snapshot, +} from "../../../db/project-v2-snapshots.ts"; +import type { + BuilderAgentAuditEvent, + BuilderAgentAuditSink, + BuilderProjectRepository, + BuilderProviderCredentials, +} from "../../../lib/builder-agent/types.ts"; +import { BuilderModelUnavailableError } from "../../../lib/builder-agent/providers.ts"; + +export const BUILDER_NO_STORE_HEADERS = { + "cache-control": "private, no-store, max-age=0", + vary: "Cookie", +}; +export const BUILDER_BODY_LIMIT_BYTES = 96_000; + +export class BuilderRouteError extends Error { + readonly status: number; + readonly code: string; + + constructor( + status: number, + code: string, + message: string, + ) { + super(message); + this.name = "BuilderRouteError"; + this.status = status; + this.code = code; + } +} + +export class SnapshotBuilderProjectRepository implements BuilderProjectRepository { + async loadAuthorized(actorId: string, projectId: string): Promise { + return (await readProjectV2Snapshot(actorId, projectId))?.project ?? null; + } + + async saveAuthorized( + actorId: string, + project: ProjectV2, + expectedRevision: number, + ): Promise { + const current = await readProjectV2Snapshot(actorId, project.id); + if (!current || current.project.revision !== expectedRevision) { + throw new ProjectV2RevisionConflictError( + current?.project.revision ?? 0, + expectedRevision, + ); + } + const result = await writeProjectV2Snapshot( + actorId, + project, + current.storageRevision, + ); + if (result.status === "conflict") { + throw new ProjectV2RevisionConflictError( + result.project.revision, + expectedRevision, + ); + } + if (result.status === "too-large") { + throw new BuilderRouteError( + 413, + "BUILDER_PROJECT_TOO_LARGE", + "Project V2 snapshot exceeds the private storage limit.", + ); + } + return result.project; + } +} + +export class ServerBuilderAuditSink + implements BuilderAgentAuditSink, RuntimeAuditSink +{ + async record(event: BuilderAgentAuditEvent | RuntimeAuditEvent): Promise { + // Vercel structured function logs are the durable operational audit stream. + // Event constructors hash actor identity and redact provider error details. + console.info(JSON.stringify({ source: "drops-studio-builder", ...event })); + } +} + +export function builderJson(payload: Record, status = 200) { + return NextResponse.json(payload, { + status, + headers: BUILDER_NO_STORE_HEADERS, + }); +} + +export function requireBuilderSameOrigin(request: NextRequest): void { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + throw new BuilderRouteError( + 403, + "BUILDER_CROSS_ORIGIN", + "Cross-origin builder execution is not allowed.", + ); + } + const origin = request.headers.get("origin"); + if (!origin && process.env.NODE_ENV !== "production") return; + try { + const host = request.headers.get("host")?.split(",")[0]?.trim(); + const protocol = + request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || + request.nextUrl.protocol.replace(/:$/, ""); + const visibleOrigin = host ? `${protocol}://${host}` : request.nextUrl.origin; + if (!origin || new URL(origin).origin !== visibleOrigin) throw new Error(); + } catch { + throw new BuilderRouteError( + 403, + "BUILDER_CROSS_ORIGIN", + "A same-origin builder request is required.", + ); + } +} + +export function builderActor(request: NextRequest): string { + const actor = resolveStudioProjectActor({ + accountCookie: request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + guestCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value, + }); + if (!actor) { + throw new BuilderRouteError( + 401, + "BUILDER_SESSION_REQUIRED", + "Start a signed Studio session before starting an isolated builder runtime.", + ); + } + return actor.identity; +} + +export async function consumeBuilderLimit( + actorId: string, + namespace: string, + options: { max: number; windowMs: number; retryAfter: number }, +): Promise { + const local = + process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && + !process.env.VERCEL; + const state = await consumeRequestLimit({ + identity: actorId, + namespace, + max: local ? Math.max(options.max, 1_000) : options.max, + windowMs: options.windowMs, + }).catch(() => "unavailable" as const); + if (state === "limited") { + throw new BuilderRouteError( + 429, + "BUILDER_RATE_LIMIT", + `Builder execution limit reached. Retry after ${options.retryAfter} seconds.`, + ); + } + if (state === "unavailable" && process.env.NODE_ENV === "production") { + throw new BuilderRouteError( + 503, + "BUILDER_RATE_LIMIT_UNAVAILABLE", + "Secure builder rate limiting is temporarily unavailable.", + ); + } +} + +export async function readBuilderBody(request: NextRequest): Promise { + if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) { + throw new BuilderRouteError( + 415, + "BUILDER_JSON_REQUIRED", + "Builder execution requires application/json.", + ); + } + const declared = Number(request.headers.get("content-length") ?? 0); + if (Number.isFinite(declared) && declared > BUILDER_BODY_LIMIT_BYTES) { + throw new BuilderRouteError( + 413, + "BUILDER_BODY_TOO_LARGE", + "Builder request exceeds the bounded request limit.", + ); + } + const bytes = new Uint8Array(await request.arrayBuffer()); + if (bytes.byteLength > BUILDER_BODY_LIMIT_BYTES) { + throw new BuilderRouteError( + 413, + "BUILDER_BODY_TOO_LARGE", + "Builder request exceeds the bounded request limit.", + ); + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new BuilderRouteError( + 400, + "BUILDER_INVALID_JSON", + "Builder request must contain valid UTF-8 JSON.", + ); + } +} + +function headerCredential(request: NextRequest, name: string): string | undefined { + const value = request.headers.get(name)?.trim() ?? ""; + if (!value) return undefined; + if (value.length > 4_096 || /[\r\n\0]/.test(value)) { + throw new BuilderRouteError( + 400, + "BUILDER_CREDENTIAL_INVALID", + "Request-only provider credential is invalid.", + ); + } + return value; +} + +export function builderCredentials(request: NextRequest): BuilderProviderCredentials { + return { + apiKey: headerCredential(request, "x-provider-key"), + openRouterKey: headerCredential(request, "x-openrouter-key"), + // Vercel Functions receive their short-lived OIDC credential on the + // request, not in process.env. Keep it request-scoped and pass it only to + // the Gateway model resolver; it is never persisted, audited, or returned. + gatewayToken: headerCredential(request, "x-vercel-oidc-token"), + }; +} + +export function builderRouteError(error: unknown) { + if (error instanceof BuilderRouteError) { + return builderJson({ code: error.code, error: error.message }, error.status); + } + if (error instanceof ProjectV2RevisionConflictError) { + return builderJson( + { + code: "BUILDER_REVISION_CONFLICT", + error: "Project V2 changed in another session. Reload before continuing.", + currentRevision: error.expectedRevision, + }, + 409, + ); + } + if ( + error instanceof ProjectRuntimeValidationError || + error instanceof BuilderModelUnavailableError + ) { + return builderJson({ code: "BUILDER_INVALID_REQUEST", error: error.message }, 400); + } + if ( + error instanceof ProjectRuntimeUnavailableError || + error instanceof ProjectV2SnapshotStorageUnavailableError + ) { + return builderJson({ code: "BUILDER_UNAVAILABLE", error: error.message }, 503); + } + if (error instanceof ProjectRuntimeProviderError) { + return builderJson({ code: "BUILDER_PROVIDER_FAILURE", error: error.message }, 502); + } + console.error("Unexpected Drops Studio builder route failure.", { + message: secretFreeRuntimeMessage(error, "Builder request failed."), + }); + return builderJson( + { + code: "BUILDER_UNAVAILABLE", + error: "Drops Studio builder is temporarily unavailable.", + }, + 503, + ); +} diff --git a/app/api/deployments/vercel/route.ts b/app/api/deployments/vercel/route.ts new file mode 100644 index 0000000..efba087 --- /dev/null +++ b/app/api/deployments/vercel/route.ts @@ -0,0 +1,366 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { + hasProjectV2ReleaseReceipt, + ProjectV2ReleaseReceiptStorageUnavailableError, +} from "@/db/project-v2-release-receipts"; +import { readProjectV2Snapshot } from "@/db/project-v2-snapshots"; +import { + GUEST_IDENTITY_COOKIE, + resolveStudioProjectActor, + STUDIO_ACCOUNT_COOKIE, + type StudioProjectActor, +} from "@/lib/access-tier"; +import { secretFreeRuntimeMessage } from "@/lib/project-runtime-adapter"; +import type { ProjectCheckpointV2, ProjectV2 } from "@/lib/project-v2-types"; +import { + cancelVercelDeployment, + createVercelPreviewDeployment, + getVercelDeployment, + getVercelDeploymentLogs, + VercelDeploymentError, + vercelDeploymentReadiness, + waitForVercelDeployment, + type VercelDeploymentCredentials, + type VercelDeploymentFile, +} from "@/lib/vercel-deployment"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; +import { consumeRequestLimit } from "@/lib/request-rate-limit"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 300; + +const MAX_BODY_BYTES = 3_900_000; +const NO_STORE = { "cache-control": "no-store, max-age=0" }; + +function json(payload: Record, status = 200, headers: Record = {}) { + return NextResponse.json(payload, { status, headers: { ...NO_STORE, ...headers } }); +} + +function sameOrigin(request: NextRequest): boolean { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false; + const origin = request.headers.get("origin"); + if (!origin) return process.env.NODE_ENV !== "production"; + try { + const host = request.headers.get("host")?.split(",")[0]?.trim(); + const protocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().replace(/:$/, "") + || request.nextUrl.protocol.replace(/:$/, ""); + const visibleOrigin = host ? `${protocol}://${host}` : request.nextUrl.origin; + const parsed = new URL(origin).origin; + return parsed === request.nextUrl.origin || parsed === visibleOrigin; + } catch { + return false; + } +} + +async function body(request: NextRequest): Promise> { + try { + const raw = decodeUtf8Body(await readBoundedRequestBody(request, MAX_BODY_BYTES)); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(); + return parsed as Record; + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + throw new VercelDeploymentError( + "The deployment request exceeds the bounded direct-upload size.", + 413, + "VERCEL_REQUEST_TOO_LARGE", + ); + } + throw new VercelDeploymentError("A valid JSON deployment request is required.", 400, "VERCEL_REQUEST_INVALID"); + } +} + +function headerCredential(request: NextRequest, name: string): string | undefined { + const value = request.headers.get(name)?.trim() ?? ""; + return value && value.length <= 512 && !/[\r\n\0]/.test(value) + ? value + : undefined; +} + +function actor(request: NextRequest): StudioProjectActor { + const resolved = resolveStudioProjectActor({ + accountCookie: request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + guestCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value, + }); + if (!resolved) { + throw new VercelDeploymentError( + "Start a signed Studio session before using Vercel deployment.", + 401, + "VERCEL_SESSION_REQUIRED", + ); + } + return resolved; +} + +function credentials( + request: NextRequest, + input: Record, + projectActor: StudioProjectActor, +): VercelDeploymentCredentials { + const sessionToken = headerCredential(request, "x-vercel-access-token"); + if (sessionToken) { + return { + accessToken: sessionToken, + teamId: typeof input.teamId === "string" ? input.teamId : undefined, + projectId: typeof input.projectId === "string" ? input.projectId : undefined, + }; + } + if (projectActor.kind !== "member") { + throw new VercelDeploymentError( + "Connect a session-only Vercel token before deploying as a guest.", + 403, + "VERCEL_CONNECTION_REQUIRED", + ); + } + const accessToken = process.env.VERCEL_DEPLOY_TOKEN?.trim() ?? ""; + const projectId = process.env.VERCEL_GENERATED_PROJECT_ID?.trim() ?? ""; + if (!accessToken || !projectId) { + throw new VercelDeploymentError( + "Platform Vercel deployment is not configured. Connect a session-only token.", + 503, + "VERCEL_CONFIGURATION_REQUIRED", + ); + } + // Platform credentials always use server-owned scope. Client-supplied team + // and project identifiers are accepted only with the visitor's own token. + return { + accessToken, + teamId: process.env.VERCEL_TEAM_ID, + projectId, + }; +} + +function deploymentId(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function sourceFiles(value: ProjectV2["files"]): VercelDeploymentFile[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + return Object.entries(value as Record).map(([path, item]) => { + if (typeof item === "string") return { path, content: item }; + if (item && typeof item === "object" && !Array.isArray(item)) { + const file = item as Record; + return { path, content: typeof file.content === "string" ? file.content : "" }; + } + return { path, content: "" }; + }); +} + +function studioProjectId(value: unknown): string { + if (typeof value !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(value)) { + throw new VercelDeploymentError( + "A valid Studio Project V2 id is required.", + 400, + "VERCEL_PROJECT_ID_INVALID", + ); + } + return value; +} + +async function authorizedProject(projectActor: StudioProjectActor, value: unknown): Promise { + const stored = await readProjectV2Snapshot(projectActor.identity, studioProjectId(value)); + if (!stored) { + throw new VercelDeploymentError( + "The authorized Project V2 snapshot was not found.", + 404, + "VERCEL_PROJECT_NOT_FOUND", + ); + } + return stored.project; +} + +function verifiedForDeployment(project: ProjectV2): ProjectCheckpointV2 | null { + const checkpoint = [...project.checkpoints] + .reverse() + .find((entry) => /^Verified (?:AI|deterministic) build$/.test(entry.label)); + if ( + !checkpoint || + checkpoint.snapshot.revision !== project.revision || + checkpoint.snapshot.contentHash !== project.contentHash || + project.preview?.status !== "ready" || + project.preview.projectRevision !== project.revision || + !project.preview.url?.startsWith("https://") + ) { + return null; + } + const requiredKinds = (["build", "typecheck", "lint", "test"] as const) + .filter((kind) => kind === "build" || Boolean(project.manifest.scripts[kind])); + const successfulTaskIds = new Set( + project.runs + .filter((run) => run.projectRevision === project.revision && run.status === "succeeded") + .map((run) => run.taskId), + ); + const checksPassed = requiredKinds.every((kind) => + project.tasks.some((task) => task.kind === kind && successfulTaskIds.has(task.id)), + ); + const browserChecked = project.logs.some((log) => { + if (log.stream !== "browser") return false; + return project.runs.some((run) => + run.id === log.runId && run.projectRevision === project.revision, + ); + }); + return checksPassed && browserChecked ? checkpoint : null; +} + +async function requireReleaseReceipt( + projectActor: StudioProjectActor, + project: ProjectV2, + checkpoint: ProjectCheckpointV2, +): Promise { + let found = false; + try { + found = await hasProjectV2ReleaseReceipt({ + actorId: projectActor.identity, + projectId: project.id, + revision: checkpoint.snapshot.revision, + contentHash: checkpoint.snapshot.contentHash, + checkpointId: checkpoint.id, + snapshotHash: checkpoint.snapshotHash, + }); + } catch (error) { + if (error instanceof ProjectV2ReleaseReceiptStorageUnavailableError) { + throw new VercelDeploymentError( + "Private release receipt storage is unavailable. Rebuild after storage recovers.", + 503, + "VERCEL_RELEASE_RECEIPT_UNAVAILABLE", + ); + } + throw error; + } + if (!found) { + throw new VercelDeploymentError( + "This source snapshot has no server-issued Sandbox release receipt. Rebuild it before deployment.", + 409, + "VERCEL_RELEASE_RECEIPT_REQUIRED", + ); + } +} + +function ownedDeploymentId(project: ProjectV2, value: unknown): string { + const requested = deploymentId(value); + if (!requested || project.deployment?.deploymentId !== requested) { + throw new VercelDeploymentError( + "The deployment receipt is not owned by this Project V2 snapshot.", + 404, + "VERCEL_DEPLOYMENT_NOT_FOUND", + ); + } + return requested; +} + +export async function GET() { + return json({ + provider: "vercel", + ...vercelDeploymentReadiness(), + sessionTokenSupported: true, + explicitApprovalRequired: true, + claims: "disabled-until-provider-confirms", + }); +} + +export async function POST(request: NextRequest) { + if (!sameOrigin(request)) { + return json({ error: "Cross-origin deployment requests are not allowed.", code: "VERCEL_CROSS_ORIGIN" }, 403); + } + if (!hasJsonMediaType(request)) { + return json({ error: "Deployment requests require application/json.", code: "VERCEL_CONTENT_TYPE" }, 415); + } + try { + const projectActor = actor(request); + const limit = await consumeRequestLimit({ + identity: projectActor.identity, + namespace: "vercel-project-deploy", + max: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !process.env.VERCEL ? 100 : 12, + windowMs: 24 * 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (limit === "limited") { + return json({ error: "The daily deployment limit has been reached.", code: "VERCEL_DEPLOY_LIMIT" }, 429, { "retry-after": "86400" }); + } + if (limit === "unavailable") { + return json({ error: "Durable deployment rate limiting is unavailable.", code: "VERCEL_RATE_LIMIT_UNAVAILABLE" }, 503); + } + const input = await body(request); + const action = typeof input.action === "string" ? input.action : "deploy"; + const project = await authorizedProject(projectActor, input.studioProjectId); + const auth = credentials(request, input, projectActor); + if ( + ["status", "logs", "cancel"].includes(action) && + !headerCredential(request, "x-vercel-access-token") + ) { + throw new VercelDeploymentError( + "Connect a session-only Vercel token to inspect or cancel an existing deployment.", + 403, + "VERCEL_CONNECTION_REQUIRED", + ); + } + if (action === "status") { + return json({ deployment: await getVercelDeployment({ credentials: auth, deploymentId: ownedDeploymentId(project, input.deploymentId) }) }); + } + if (action === "logs") { + return json({ logs: await getVercelDeploymentLogs({ credentials: auth, deploymentId: ownedDeploymentId(project, input.deploymentId) }) }); + } + if (action === "cancel") { + if (input.approved !== true) { + return json({ error: "Canceling an external deployment requires explicit approval.", code: "VERCEL_APPROVAL_REQUIRED" }, 409); + } + return json({ deployment: await cancelVercelDeployment({ credentials: auth, deploymentId: ownedDeploymentId(project, input.deploymentId) }) }); + } + if (action !== "deploy" && action !== "rollback") { + return json({ error: "Unsupported deployment action.", code: "VERCEL_ACTION_INVALID" }, 400); + } + if (input.approved !== true) { + return json({ error: "Creating or restoring an external deployment requires explicit approval.", code: "VERCEL_APPROVAL_REQUIRED" }, 409); + } + const checkpoint = action === "rollback" + ? project.checkpoints.find((entry) => entry.id === input.checkpointId) + : null; + if (action === "rollback" && !checkpoint) { + return json({ error: "Select an owned Project V2 checkpoint to redeploy.", code: "VERCEL_CHECKPOINT_REQUIRED" }, 404); + } + if (action === "rollback") { + await requireReleaseReceipt(projectActor, project, checkpoint!); + } else { + const verifiedCheckpoint = verifiedForDeployment(project); + if (!verifiedCheckpoint) { + return json({ + error: "This Project V2 revision has no complete verified Sandbox release gate. Rebuild it before deployment.", + code: "VERCEL_RELEASE_GATE_REQUIRED", + }, 409); + } + await requireReleaseReceipt(projectActor, project, verifiedCheckpoint); + } + const deploymentFiles = checkpoint?.snapshot.files ?? project.files; + const created = await createVercelPreviewDeployment({ + credentials: auth, + name: project.manifest.slug, + files: sourceFiles(deploymentFiles), + revisionHash: checkpoint?.snapshot.contentHash ?? project.contentHash, + }); + const deployment = input.wait === false + ? created + : await waitForVercelDeployment({ credentials: auth, deploymentId: created.id }); + const logs = deployment.readyState === "READY" + ? [] + : await getVercelDeploymentLogs({ credentials: auth, deploymentId: deployment.id }).catch(() => []); + return json({ + deployment, + logs, + confirmedReady: deployment.readyState === "READY" && Boolean(deployment.url), + operation: action === "rollback" ? "checkpoint-redeployment" : "preview-deployment", + }, deployment.readyState === "ERROR" ? 422 : 200); + } catch (error) { + const failure = error instanceof VercelDeploymentError + ? error + : new VercelDeploymentError("Vercel deployment failed."); + return json({ + error: secretFreeRuntimeMessage(failure, "Vercel deployment failed."), + code: failure.code, + }, failure.status); + } +} diff --git a/app/api/dropsbot/events/route.ts b/app/api/dropsbot/events/route.ts index f263df5..2627d00 100644 --- a/app/api/dropsbot/events/route.ts +++ b/app/api/dropsbot/events/route.ts @@ -13,6 +13,7 @@ import { resolveStudioAccount, STUDIO_ACCOUNT_COOKIE, } from "../../../../lib/access-tier.ts"; +import { consumeRequestLimit } from "../../../../lib/request-rate-limit.ts"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -56,6 +57,20 @@ export async function GET(request: NextRequest): Promise { } try { + const requestLimit = await consumeRequestLimit({ + identity: member.identity, + namespace: "dropsbot-webhook-events-read", + max: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !process.env.VERCEL + ? 1_000 + : 300, + windowMs: 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (requestLimit === "limited") { + return json({ code: "DROPSBOT_EVENTS_RATE_LIMITED", error: "Callback event read limit reached." }, 429); + } + if (requestLimit === "unavailable" && process.env.NODE_ENV === "production") { + return json({ code: "DROPSBOT_EVENTS_RATE_LIMIT_UNAVAILABLE", error: "Callback event protection is temporarily unavailable." }, 503); + } if (!dropsBotWebhookStorageConfigured()) { throw new DropsBotWebhookStorageUnavailableError(); } diff --git a/app/api/dropsbot/webhooks/route.ts b/app/api/dropsbot/webhooks/route.ts index 9a4cffb..56335c9 100644 --- a/app/api/dropsbot/webhooks/route.ts +++ b/app/api/dropsbot/webhooks/route.ts @@ -25,6 +25,7 @@ import { readDropsBotWebhookBody, } from "../../../../lib/dropsbot-webhook.ts"; import { hasJsonMediaType } from "../../../../lib/http-request-boundary.ts"; +import { consumeRequestLimit } from "../../../../lib/request-rate-limit.ts"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -149,6 +150,29 @@ function requireConsent(input: Record, action: string): void { } } +async function enforceMutationLimit(identity: string): Promise { + const status = await consumeRequestLimit({ + identity, + namespace: "dropsbot-webhook-mutation", + max: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !process.env.VERCEL + ? 1_000 + : 20, + windowMs: 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (status === "limited") { + throw new DropsBotWebhookResponseError(429, { + code: "DROPSBOT_WEBHOOK_RATE_LIMITED", + error: "Drops Bot callback mutation limit reached. Retry after the current window.", + }); + } + if (status === "unavailable" && process.env.NODE_ENV === "production") { + throw new DropsBotWebhookResponseError(503, { + code: "DROPSBOT_WEBHOOK_RATE_LIMIT_UNAVAILABLE", + error: "Drops Bot callback request protection is temporarily unavailable.", + }); + } +} + async function requireOwnedProject( member: StudioAccount, input: Record, @@ -217,6 +241,7 @@ export async function POST(request: NextRequest): Promise { try { const member = account(request); requireSameOrigin(request); + await enforceMutationLimit(member.identity); if (!dropsBotWebhookStorageConfigured()) { throw new DropsBotWebhookStorageUnavailableError(); } @@ -259,6 +284,7 @@ export async function PUT(request: NextRequest): Promise { try { const member = account(request); requireSameOrigin(request); + await enforceMutationLimit(member.identity); if (!dropsBotWebhookStorageConfigured()) { throw new DropsBotWebhookStorageUnavailableError(); } @@ -299,6 +325,7 @@ export async function DELETE(request: NextRequest): Promise { try { const member = account(request); requireSameOrigin(request); + await enforceMutationLimit(member.identity); if (!dropsBotWebhookStorageConfigured()) { throw new DropsBotWebhookStorageUnavailableError(); } diff --git a/app/api/integrations/github/route.ts b/app/api/integrations/github/route.ts new file mode 100644 index 0000000..3a54375 --- /dev/null +++ b/app/api/integrations/github/route.ts @@ -0,0 +1,212 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { readProjectV2Snapshot } from "@/db/project-v2-snapshots"; +import { + GUEST_IDENTITY_COOKIE, + resolveStudioProjectActor, + STUDIO_ACCOUNT_COOKIE, + type StudioProjectActor, +} from "@/lib/access-tier"; +import { + githubIntegrationReadiness, + GitHubIntegrationError, + importGitHubRepository, + inspectGitHubRepository, + publishProjectToGitHub, + type GitHubIntegrationCredentials, +} from "@/lib/github-integration"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; +import { secretFreeRuntimeMessage } from "@/lib/project-runtime-adapter"; +import { consumeRequestLimit } from "@/lib/request-rate-limit"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 120; + +const MAX_BODY_BYTES = 3_300_000; +const NO_STORE = { "cache-control": "no-store, max-age=0" }; + +function json(payload: Record, status = 200, headers: Record = {}) { + return NextResponse.json(payload, { status, headers: { ...NO_STORE, ...headers } }); +} + +function sameOrigin(request: NextRequest): boolean { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false; + const origin = request.headers.get("origin"); + if (!origin) return process.env.NODE_ENV !== "production"; + try { + const host = request.headers.get("host")?.split(",")[0]?.trim(); + const protocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().replace(/:$/, "") + || request.nextUrl.protocol.replace(/:$/, ""); + const visible = host ? `${protocol}://${host}` : request.nextUrl.origin; + const parsed = new URL(origin).origin; + return parsed === request.nextUrl.origin || parsed === visible; + } catch { + return false; + } +} + +async function parseBody(request: NextRequest): Promise> { + try { + const raw = decodeUtf8Body(await readBoundedRequestBody(request, MAX_BODY_BYTES)); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(); + return parsed as Record; + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + throw new GitHubIntegrationError("The GitHub request exceeds 3.3 MB.", 413, "GITHUB_REQUEST_TOO_LARGE"); + } + throw new GitHubIntegrationError("A valid JSON GitHub request is required.", 400, "GITHUB_REQUEST_INVALID"); + } +} + +function headerCredential(request: NextRequest, name: string): string | undefined { + const value = request.headers.get(name)?.trim() ?? ""; + return value && value.length <= 512 && !/[\r\n\0]/.test(value) + ? value + : undefined; +} + +function actor(request: NextRequest): StudioProjectActor { + const resolved = resolveStudioProjectActor({ + accountCookie: request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + guestCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value, + }); + if (!resolved) { + throw new GitHubIntegrationError( + "Start a signed Studio session before using GitHub integration.", + 401, + "GITHUB_SESSION_REQUIRED", + ); + } + return resolved; +} + +function allowedPlatformRepository(owner: string, repo: string): boolean { + const target = `${owner}/${repo}`.toLowerCase(); + return (process.env.GITHUB_APP_ALLOWED_REPOSITORIES ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .some((value) => value === target); +} + +function credentials( + request: NextRequest, + projectActor: StudioProjectActor, + owner: string, + repo: string, +): GitHubIntegrationCredentials { + const accessToken = headerCredential(request, "x-github-access-token"); + if (accessToken) return { accessToken }; + if (projectActor.kind !== "member" || !allowedPlatformRepository(owner, repo)) { + throw new GitHubIntegrationError( + "Connect a session-only GitHub token for this repository.", + 403, + "GITHUB_CONNECTION_REQUIRED", + ); + } + return { + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY, + installationId: process.env.GITHUB_APP_INSTALLATION_ID, + }; +} + +function text(input: Record, key: string): string { + return typeof input[key] === "string" ? input[key] as string : ""; +} + +function studioProjectId(value: unknown): string { + if (typeof value !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(value)) { + throw new GitHubIntegrationError( + "A valid Studio Project V2 id is required.", + 400, + "GITHUB_PROJECT_ID_INVALID", + ); + } + return value; +} + +export async function GET() { + return json({ + provider: "github", + ...githubIntegrationReadiness(), + sessionTokenSupported: true, + explicitApprovalRequired: ["branch", "commit", "pull-request"], + }); +} + +export async function POST(request: NextRequest) { + if (!sameOrigin(request)) { + return json({ error: "Cross-origin GitHub requests are not allowed.", code: "GITHUB_CROSS_ORIGIN" }, 403); + } + if (!hasJsonMediaType(request)) { + return json({ error: "GitHub requests require application/json.", code: "GITHUB_CONTENT_TYPE" }, 415); + } + try { + const projectActor = actor(request); + const limit = await consumeRequestLimit({ + identity: projectActor.identity, + namespace: "github-project-sync", + max: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !process.env.VERCEL ? 100 : 20, + windowMs: 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (limit === "limited") return json({ error: "GitHub request limit reached.", code: "GITHUB_RATE_LIMIT" }, 429, { "retry-after": "3600" }); + if (limit === "unavailable") return json({ error: "Durable GitHub rate limiting is unavailable.", code: "GITHUB_RATE_LIMIT_UNAVAILABLE" }, 503); + const input = await parseBody(request); + const action = text(input, "action") || "inspect"; + const owner = text(input, "owner"); + const repo = text(input, "repo"); + const common = { + credentials: credentials(request, projectActor, owner, repo), + owner, + repo, + }; + if (action === "inspect") { + return json({ repository: await inspectGitHubRepository(common) }); + } + if (action === "import") { + if (input.approved !== true) { + return json({ error: "Importing repository source requires explicit approval.", code: "GITHUB_APPROVAL_REQUIRED" }, 409); + } + return json(await importGitHubRepository({ ...common, branch: text(input, "branch") || undefined })); + } + if (action === "publish") { + if (input.approved !== true) { + return json({ error: "Creating a branch, commit and pull request requires explicit approval.", code: "GITHUB_APPROVAL_REQUIRED" }, 409); + } + const stored = await readProjectV2Snapshot( + projectActor.identity, + studioProjectId(input.studioProjectId), + ); + if (!stored) { + return json({ error: "The authorized Project V2 snapshot was not found.", code: "GITHUB_PROJECT_NOT_FOUND" }, 404); + } + const result = await publishProjectToGitHub({ + ...common, + files: Object.values(stored.project.files).map((file) => ({ + path: file.path, + content: file.content, + })), + conversationId: text(input, "conversationId"), + title: stored.project.manifest.name, + description: text(input, "description") || "Review this generated project before merge.", + baseBranch: text(input, "baseBranch") || undefined, + }); + return json({ result, confirmed: true }); + } + return json({ error: "Unsupported GitHub action.", code: "GITHUB_ACTION_INVALID" }, 400); + } catch (error) { + const failure = error instanceof GitHubIntegrationError + ? error + : new GitHubIntegrationError("GitHub integration failed."); + return json({ + error: secretFreeRuntimeMessage(failure, "GitHub integration failed."), + code: failure.code, + }, failure.status); + } +} diff --git a/app/api/project-data/route.ts b/app/api/project-data/route.ts new file mode 100644 index 0000000..28d59f2 --- /dev/null +++ b/app/api/project-data/route.ts @@ -0,0 +1,233 @@ +import { createHash } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server.js"; + +import { + MemoryProjectDataBackend, + ProjectDataError, + ProjectDataStore, + authorizeProjectDataCapability, + verifyProjectDataCapability, + type ProjectDataCapabilityPayload, + type ProjectDataPermission, +} from "../../../lib/project-data/index.ts"; +import { consumeRequestLimit } from "../../../lib/request-rate-limit.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const BODY_LIMIT_BYTES = 72 * 1_024; +const NO_STORE_HEADERS = { + "cache-control": "private, no-store, max-age=0", + vary: "Authorization", +}; + +function json(payload: Record, status = 200): NextResponse { + return NextResponse.json(payload, { status, headers: NO_STORE_HEADERS }); +} + +function backend() { + if (globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__) { + return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__; + } + if (process.env.DROPS_STUDIO_LOCAL_PROJECT_DATA !== "1") { + throw new ProjectDataError( + "storage_unavailable", + "Project data storage is not configured. The generated app can continue with its labelled browser-local fallback.", + ); + } + globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = new MemoryProjectDataBackend(); + return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__; +} + +function store(): ProjectDataStore { + return new ProjectDataStore(backend()); +} + +function bearer(request: NextRequest): string { + const authorization = request.headers.get("authorization") ?? ""; + const match = authorization.match(/^Bearer ([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/); + if (!match) throw new ProjectDataError("unauthorized", "A project data capability is required."); + return match[1]; +} + +function capability(request: NextRequest): ProjectDataCapabilityPayload { + const secret = process.env.PROJECT_DATA_CAPABILITY_SECRET; + if (!secret) { + throw new ProjectDataError("storage_unavailable", "Project data capability signing is not configured."); + } + const verified = verifyProjectDataCapability(bearer(request), secret); + if (!verified) throw new ProjectDataError("unauthorized", "Project data capability is invalid or expired."); + return verified; +} + +async function enforceRateLimit( + authorization: ProjectDataCapabilityPayload, + mode: "read" | "write", +): Promise { + const identity = createHash("sha256") + .update( + `project-data:v1:${authorization.subject}:${authorization.projectId}:${authorization.nonce}`, + "utf8", + ) + .digest("hex"); + const status = await consumeRequestLimit({ + identity, + namespace: `project-data-${mode}`, + max: mode === "read" ? 600 : 240, + windowMs: 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (status === "limited") { + throw new ProjectDataError( + "rate_limited", + "Project data request limit reached. Retry after the current window.", + ); + } + if (status === "unavailable" && process.env.NODE_ENV === "production") { + throw new ProjectDataError( + "storage_unavailable", + "Project data request protection is temporarily unavailable.", + ); + } +} + +function requireSameOrigin(request: NextRequest): void { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + throw new ProjectDataError("forbidden", "Cross-origin project data mutation rejected."); + } + const origin = request.headers.get("origin"); + if (!origin) throw new ProjectDataError("forbidden", "A same-origin project data mutation is required."); + try { + if (new URL(origin).origin !== request.nextUrl.origin) throw new Error("origin mismatch"); + } catch { + throw new ProjectDataError("forbidden", "Cross-origin project data mutation rejected."); + } +} + +async function requestBody(request: NextRequest): Promise> { + if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) { + throw new ProjectDataError("invalid_request", "Project data mutations require application/json.", { status: 415 }); + } + const declaredLength = Number(request.headers.get("content-length") ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > BODY_LIMIT_BYTES) { + throw new ProjectDataError("quota_exceeded", "Project data request body is too large."); + } + const raw = await request.text().catch(() => ""); + if (new TextEncoder().encode(raw).byteLength > BODY_LIMIT_BYTES) { + throw new ProjectDataError("quota_exceeded", "Project data request body is too large."); + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid object"); + return parsed as Record; + } catch { + throw new ProjectDataError("invalid_request", "Project data request body must be a JSON object."); + } +} + +function exactFields(input: Record, allowed: readonly string[]): void { + const allowedSet = new Set(allowed); + const unsupported = Object.keys(input).filter((field) => !allowedSet.has(field)); + if (unsupported.length) { + throw new ProjectDataError("invalid_request", `Unsupported project data fields: ${unsupported.join(", ")}.`); + } +} + +function scope( + authorization: ProjectDataCapabilityPayload, + input: Record, + permission: ProjectDataPermission, +): { projectId: string; namespace: string } { + if (typeof input.projectId !== "string" || typeof input.namespace !== "string") { + throw new ProjectDataError("invalid_request", "projectId and namespace are required."); + } + authorizeProjectDataCapability(authorization, { + projectId: input.projectId, + namespace: input.namespace, + permission, + }); + return { projectId: input.projectId, namespace: input.namespace }; +} + +function responseError(error: unknown): NextResponse { + if (error instanceof ProjectDataError) { + return json({ + code: error.code, + error: error.message, + ...(error.currentRevision !== undefined ? { currentRevision: error.currentRevision } : {}), + }, error.status); + } + return json({ + code: "storage_unavailable", + error: "Project data is temporarily unavailable. The browser-local fallback remains available.", + }, 503); +} + +export async function GET(request: NextRequest): Promise { + try { + const authorization = capability(request); + await enforceRateLimit(authorization, "read"); + const query = Object.fromEntries(request.nextUrl.searchParams.entries()); + exactFields(query, ["projectId", "namespace", "id"]); + const { projectId, namespace } = scope(authorization, query, "read"); + if (query.id) { + const document = await store().get(projectId, namespace, query.id); + if (!document) throw new ProjectDataError("not_found", "Project data document was not found."); + return json({ document, persistence: backend().kind }); + } + const documents = await store().list(projectId, namespace); + return json({ documents, persistence: backend().kind }); + } catch (error) { + return responseError(error); + } +} + +export async function POST(request: NextRequest): Promise { + try { + requireSameOrigin(request); + const authorization = capability(request); + await enforceRateLimit(authorization, "write"); + const input = await requestBody(request); + exactFields(input, ["projectId", "namespace", "id", "data"]); + const { projectId, namespace } = scope(authorization, input, "write"); + const document = await store().create({ projectId, namespace, id: input.id, data: input.data }); + return json({ document, persistence: backend().kind }, 201); + } catch (error) { + return responseError(error); + } +} + +export async function PUT(request: NextRequest): Promise { + try { + requireSameOrigin(request); + const authorization = capability(request); + await enforceRateLimit(authorization, "write"); + const input = await requestBody(request); + exactFields(input, ["projectId", "namespace", "id", "expectedRevision", "data"]); + const { projectId, namespace } = scope(authorization, input, "write"); + const document = await store().update({ + projectId, + namespace, + id: input.id, + expectedRevision: input.expectedRevision, + data: input.data, + }); + return json({ document, persistence: backend().kind }); + } catch (error) { + return responseError(error); + } +} + +export async function DELETE(request: NextRequest): Promise { + try { + requireSameOrigin(request); + const authorization = capability(request); + await enforceRateLimit(authorization, "write"); + const input = await requestBody(request); + exactFields(input, ["projectId", "namespace", "id", "expectedRevision"]); + const { projectId, namespace } = scope(authorization, input, "delete"); + await store().delete(projectId, namespace, input.id, input.expectedRevision); + return json({ deleted: true, persistence: backend().kind }); + } catch (error) { + return responseError(error); + } +} diff --git a/app/api/projects/publish/route.ts b/app/api/projects/publish/route.ts index 8368270..324f74f 100644 --- a/app/api/projects/publish/route.ts +++ b/app/api/projects/publish/route.ts @@ -242,8 +242,10 @@ async function publishLimit(request: NextRequest): Promise const localProofStore = process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !process.env.VERCEL; + const identity = requestIdentity(request) + ?? (localProofStore ? "session:local-publish-proof" : null); const limit = await consumeRequestLimit({ - identity: requestIdentity(request), + identity, namespace: "project-publish", // Parallel browser proofs publish all category-native products against the // explicit local-only store. Production keeps the conservative ceiling. diff --git a/app/api/projects/v2/route.ts b/app/api/projects/v2/route.ts new file mode 100644 index 0000000..90c1540 --- /dev/null +++ b/app/api/projects/v2/route.ts @@ -0,0 +1,259 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server.js"; + +import { + deleteProjectV2ReleaseReceipts, + ProjectV2ReleaseReceiptStorageUnavailableError, +} from "../../../../db/project-v2-release-receipts.ts"; +import { + deleteProjectV2Snapshot, + PROJECT_V2_SNAPSHOT_LIMIT_BYTES, + projectV2SnapshotStorageConfigured, + ProjectV2SnapshotStorageUnavailableError, + readProjectV2Snapshot, + writeProjectV2Snapshot, +} from "../../../../db/project-v2-snapshots.ts"; +import { + GUEST_IDENTITY_COOKIE, + resolveStudioProjectActor, + STUDIO_ACCOUNT_COOKIE, +} from "../../../../lib/access-tier.ts"; +import { ArtifactSecretError } from "../../../../lib/artifact-security.ts"; +import type { ProjectRuntimeAdapter } from "../../../../lib/project-runtime-adapter.ts"; +import { consumeRequestLimit } from "../../../../lib/request-rate-limit.ts"; +import { VercelSandboxRuntimeAdapter } from "../../../../lib/vercel-sandbox-runtime-adapter.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HEADERS = { + "cache-control": "private, no-store, max-age=0", + vary: "Cookie", +}; + +class RouteError extends Error { + readonly status: number; + readonly payload: Record; + + constructor( + status: number, + payload: Record, + ) { + super(String(payload.error ?? "Project V2 request failed.")); + this.name = "ProjectV2RouteError"; + this.status = status; + this.payload = payload; + } +} + +function json(payload: Record, status = 200) { + return NextResponse.json(payload, { status, headers: HEADERS }); +} + +function actor(request: NextRequest) { + const resolved = resolveStudioProjectActor({ + accountCookie: request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + guestCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value, + }); + if (!resolved) { + throw new RouteError(401, { + code: "STUDIO_SESSION_REQUIRED", + error: "Start a signed Studio session before syncing Project V2 files.", + }); + } + return resolved; +} + +function projectId(value: string | null): string { + if (!value || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(value)) { + throw new RouteError(400, { error: "Project V2 id is invalid." }); + } + return value; +} + +function requireStorage(): void { + if (!projectV2SnapshotStorageConfigured()) { + throw new RouteError(503, { + code: "PROJECT_V2_STORAGE_UNAVAILABLE", + error: "Private Project V2 storage is not configured. The browser project remains available.", + }); + } +} + +function requireSameOrigin(request: NextRequest): void { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + throw new RouteError(403, { error: "Cross-origin Project V2 mutation rejected." }); + } + const origin = request.headers.get("origin"); + if (!origin && process.env.NODE_ENV !== "production") return; + try { + if (!origin || new URL(origin).origin !== request.nextUrl.origin) throw new Error(); + } catch { + throw new RouteError(403, { error: "A same-origin Project V2 mutation is required." }); + } +} + +async function enforceLimit(identity: string, mode: "read" | "write"): Promise { + const state = await consumeRequestLimit({ + identity, + namespace: `project-v2-${mode}`, + max: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !process.env.VERCEL + ? 5_000 + : 600, + windowMs: 60 * 60 * 1_000, + }); + if (state === "limited") { + throw new RouteError(429, { error: "Project V2 sync is receiving too many requests. Retry shortly." }); + } + if (state === "unavailable" && process.env.NODE_ENV === "production") { + throw new RouteError(503, { error: "Project V2 sync protection is temporarily unavailable." }); + } +} + +async function requestBody(request: NextRequest): Promise> { + if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) { + throw new RouteError(415, { error: "Project V2 sync requires application/json." }); + } + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (Number.isFinite(contentLength) && contentLength > PROJECT_V2_SNAPSHOT_LIMIT_BYTES) { + throw new RouteError(413, { error: "Project V2 snapshot exceeds the private storage limit." }); + } + const raw = await request.text().catch(() => ""); + if (new TextEncoder().encode(raw).byteLength > PROJECT_V2_SNAPSHOT_LIMIT_BYTES) { + throw new RouteError(413, { error: "Project V2 snapshot exceeds the private storage limit." }); + } + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch { + throw new RouteError(400, { error: "Project V2 sync requires a valid JSON object." }); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new RouteError(400, { error: "Project V2 sync requires a JSON object." }); + } + return value as Record; +} + +function responseError(error: unknown) { + if (error instanceof RouteError) return json(error.payload, error.status); + if (error instanceof ArtifactSecretError) { + return json({ + code: "PROJECT_SECRET_REJECTED", + error: "Project V2 sync stopped because source or checkpoint data contains credential-like material.", + }, 400); + } + if (error instanceof ProjectV2SnapshotStorageUnavailableError) { + return json({ error: error.message }, 503); + } + if (error instanceof ProjectV2ReleaseReceiptStorageUnavailableError) { + return json({ + code: "PROJECT_V2_RELEASE_RECEIPT_CLEANUP_FAILED", + error: error.message, + }, 503); + } + console.error("Unexpected Project V2 sync failure.", error); + return json({ error: "Project V2 sync is temporarily unavailable. The browser project remains available." }, 503); +} + +export async function GET(request: NextRequest) { + try { + const account = actor(request); + requireStorage(); + await enforceLimit(account.identity, "read"); + const stored = await readProjectV2Snapshot( + account.identity, + projectId(request.nextUrl.searchParams.get("id")), + ); + if (!stored) return json({ error: "Project V2 snapshot was not found." }, 404); + return json(stored); + } catch (error) { + return responseError(error); + } +} + +export async function PUT(request: NextRequest) { + try { + requireSameOrigin(request); + const account = actor(request); + requireStorage(); + await enforceLimit(account.identity, "write"); + const input = await requestBody(request); + if (Object.keys(input).some((field) => !["project", "expectedStorageRevision"].includes(field))) { + throw new RouteError(400, { error: "Project V2 sync contains unsupported fields." }); + } + const expected = input.expectedStorageRevision; + if (!Number.isSafeInteger(expected) || Number(expected) < 0) { + throw new RouteError(400, { error: "expectedStorageRevision must be non-negative." }); + } + const result = await writeProjectV2Snapshot( + account.identity, + input.project, + Number(expected), + ); + if (result.status === "conflict") { + return json({ + code: "PROJECT_V2_REVISION_CONFLICT", + error: "Project V2 changed in another session.", + storageRevision: result.storageRevision, + project: result.project, + }, 409); + } + if (result.status === "too-large") { + return json({ error: "Project V2 snapshot and checkpoints exceed the private storage limit." }, 413); + } + return json(result); + } catch (error) { + return responseError(error); + } +} + +export interface DeleteProjectV2RouteDependencies { + runtime?: ProjectRuntimeAdapter; +} + +export async function handleDeleteProjectV2( + request: NextRequest, + dependencies: DeleteProjectV2RouteDependencies = {}, +) { + try { + requireSameOrigin(request); + const account = actor(request); + requireStorage(); + await enforceLimit(account.identity, "write"); + const id = projectId(request.nextUrl.searchParams.get("id")); + const stored = await readProjectV2Snapshot(account.identity, id); + let sandboxDestroyed = false; + if (stored) { + const runtimeAdapter = dependencies.runtime ?? new VercelSandboxRuntimeAdapter(); + try { + const context = { + actorId: account.identity, + project: stored.project, + requestId: randomUUID(), + }; + const handle = await runtimeAdapter.resume(context); + if (handle) { + await runtimeAdapter.destroy(handle); + sandboxDestroyed = true; + } + } catch { + throw new RouteError(503, { + code: "PROJECT_V2_SANDBOX_CLEANUP_FAILED", + error: "Sandbox cleanup could not be confirmed, so the project was not deleted. Retry shortly.", + }); + } + } + await deleteProjectV2ReleaseReceipts(account.identity, id); + await deleteProjectV2Snapshot( + account.identity, + id, + ); + return json({ deleted: true, sandboxDestroyed }); + } catch (error) { + return responseError(error); + } +} + +export async function DELETE(request: NextRequest) { + return handleDeleteProjectV2(request); +} diff --git a/app/api/workspace/patch/route.ts b/app/api/workspace/patch/route.ts index ac10d5b..a59ca91 100644 --- a/app/api/workspace/patch/route.ts +++ b/app/api/workspace/patch/route.ts @@ -134,7 +134,9 @@ async function requestBody(request: NextRequest): Promise { function headerCredential(request: NextRequest, name: string): string | undefined { const value = request.headers.get(name)?.trim() ?? ""; - return value || undefined; + return value && value.length <= 4_096 && !/[\r\n\0]/.test(value) + ? value + : undefined; } function directCredentialError( @@ -256,6 +258,7 @@ export async function handleWorkspaceAiPatchRequest( identity: identity ?? undefined, openRouterKey: headerCredential(request, "x-openrouter-key"), providerKey: headerCredential(request, "x-provider-key"), + gatewayToken: headerCredential(request, "x-vercel-oidc-token"), }; const connectionError = directCredentialError(parsed, credentials); if (connectionError) { diff --git a/app/styles/project-studio.responsive.css b/app/styles/project-studio.responsive.css index 61bb736..828245b 100644 --- a/app/styles/project-studio.responsive.css +++ b/app/styles/project-studio.responsive.css @@ -333,3 +333,46 @@ box-shadow: none; } } + +/* Native Project V2 owns the canvas only while Builder is selected. The + * approved rail stays available, and no permanent fourth column is added. */ +.project-studio-layout.v2-builder-active { + grid-template-areas: "rail builder"; + grid-template-columns: 68px minmax(0, 1fr); +} + +.project-v2-studio-host { + grid-area: builder; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.project-studio-layout.v2-builder-active > :is( + .studio-inspector, + .runtime-stage, + .assistant-panel +) { + display: none !important; +} + +@media (max-width: 1280px) and (min-width: 921px) { + .project-studio-layout.v2-builder-active { + grid-template-columns: 62px minmax(0, 1fr); + } +} + +@media (max-width: 920px) { + .project-studio-layout.v2-builder-active { + display: flex; + flex-direction: column; + min-height: calc(100dvh - 239px); + } + + .project-studio-layout.v2-builder-active .project-v2-studio-host { + display: block; + min-height: calc(100dvh - 239px); + order: 1; + overflow: visible; + } +} diff --git a/components/drops-studio.tsx b/components/drops-studio.tsx index 72594dc..d418190 100644 --- a/components/drops-studio.tsx +++ b/components/drops-studio.tsx @@ -59,6 +59,7 @@ import { materializeMemberProject, saveMemberProjectToCloud, } from "@/lib/member-project-sync-client"; +import { saveProjectV2ToCloud } from "@/lib/project-v2-sync-client"; import { customProductPreset, defaultPresetId, getProjectPreset, presets, type PresetId } from "@/lib/presets"; import { isModelProviderId, @@ -1520,10 +1521,15 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { spec = validateProjectSpec(buildPayload.spec); serverBuildWarning = buildPayload.warning || ""; - const [{ compileProject }, { evaluateProjectQuality }] = + const [ + { compileProject }, + { evaluateProjectQuality }, + { materializeProjectV2Template }, + ] = await Promise.all([ import("@/lib/project-compiler"), import("@/lib/project-quality"), + import("@/lib/project-template-materializer"), ]); const html = compileProject(spec); setActivity( @@ -1565,10 +1571,17 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { "Static contract passed · sandbox smoke continues in Studio", ); const now = new Date().toISOString(); + const projectId = crypto.randomUUID(); + const projectV2 = await materializeProjectV2Template({ + id: projectId, + spec, + now, + }); const project: GeneratedProject = { - id: crypto.randomUUID(), + id: projectId, spec, html, + projectV2, quality, createdAt: now, updatedAt: now, @@ -1586,11 +1599,20 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { "A project with this identity was created in another tab. Build again to keep both versions.", ); } + let builderSnapshotSaved = false; + if (projectSyncAvailable) { + try { + await saveProjectV2ToCloud(projectV2, 0); + builderSnapshotSaved = true; + } catch { + builderSnapshotSaved = false; + } + } let cloudSaved = false; if (projectSyncAvailable) { try { await saveMemberProjectToCloud(project, 0); - cloudSaved = true; + cloudSaved = builderSnapshotSaved; } catch { cloudSaved = false; } @@ -1602,6 +1624,8 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { ? `${serverBuildWarning} Opening your editable live project…` : cloudSaved ? "Project built and saved to your account. Opening the live workspace…" + : builderSnapshotSaved + ? "Project V2 saved for its isolated build. Opening the live workspace…" : projectSyncAvailable ? "Project built and saved in this browser. Cloud sync will retry in Studio…" : "Opening your editable live project…", diff --git a/components/project-studio.tsx b/components/project-studio.tsx index 2f34177..d42d770 100644 --- a/components/project-studio.tsx +++ b/components/project-studio.tsx @@ -60,6 +60,7 @@ import { TelegramChannelWizard } from "@/components/telegram-channel-wizard"; import { DropsBotWebhookConnection } from "@/components/dropsbot-webhook-connection"; import { StudioAccountTeamPanel } from "@/components/studio-account-team-panel"; import { DropsBrand } from "@/components/drops-brand"; +import { ProjectV2StudioSurface } from "@/components/project-v2-studio-surface"; import { ProjectWorkspaceDialog, type WorkspaceAiEvidenceView, @@ -73,6 +74,12 @@ import { type DirectorProposal, } from "@/lib/project-director"; import { createProjectArchive } from "@/lib/project-export"; +import { + createProjectV2ArchiveBlob, + projectV2ArchiveFilename, +} from "@/lib/project-v2-export"; +import type { ProjectV2 } from "@/lib/project-v2-types"; +import type { BuilderAgentResult } from "@/lib/builder-agent/types"; import { applyAgentPlan, type AgentProductPlan } from "@/lib/product-blueprint"; import { evaluateProjectQuality } from "@/lib/project-quality"; import { @@ -108,6 +115,11 @@ import { MemberProjectSyncError, saveMemberProjectToCloud, } from "@/lib/member-project-sync-client"; +import { + loadProjectV2FromCloud, + ProjectV2SyncError, + saveProjectV2ToCloud, +} from "@/lib/project-v2-sync-client"; import { validateEditableRuntimeHtml } from "@/lib/source-workspace"; import { addWorkspaceFile, @@ -522,6 +534,7 @@ export function ProjectStudio() { const saveQueueRef = useRef>(Promise.resolve(true)); const cloudSyncAvailableRef = useRef(false); const cloudRevisionRef = useRef(null); + const projectV2CloudRevisionRef = useRef(null); const [project, setProject] = useState(null); const [runtimeProject, setRuntimeProject] = useState(null); @@ -610,6 +623,13 @@ export function ProjectStudio() { cloudRevisionRef.current ?? 0, ); cloudRevisionRef.current = record.revision; + if (next.projectV2) { + const v2Record = await saveProjectV2ToCloud( + next.projectV2, + projectV2CloudRevisionRef.current ?? 0, + ); + projectV2CloudRevisionRef.current = v2Record.storageRevision; + } setProjectSyncStatus("synced"); } catch (error) { if ( @@ -621,6 +641,17 @@ export function ProjectStudio() { setToast( "This project changed in another signed-in session. Your browser copy is safe; reload to review the cloud version.", ); + } else if ( + error instanceof ProjectV2SyncError && + error.code === "PROJECT_V2_REVISION_CONFLICT" + ) { + if (error.storageRevision !== undefined) { + projectV2CloudRevisionRef.current = error.storageRevision; + } + setProjectSyncStatus("conflict"); + setToast( + "The Project V2 filesystem changed in another session. Your browser copy is safe; reload before writing files.", + ); } else { setProjectSyncStatus("local"); setToast( @@ -680,9 +711,21 @@ export function ProjectStudio() { }); if (stored.status === "saved") found = materialized; } + const cloudProjectV2 = await loadProjectV2FromCloud(params.id); + projectV2CloudRevisionRef.current = cloudProjectV2?.storageRevision ?? 0; + if ( + cloudProjectV2 && + found && + (!found.projectV2 || + Date.parse(cloudProjectV2.project.updatedAt) >= + Date.parse(found.projectV2.updatedAt)) + ) { + found = { ...found, projectV2: cloudProjectV2.project }; + } setProjectSyncStatus("synced"); } else { cloudRevisionRef.current = 0; + projectV2CloudRevisionRef.current = 0; setProjectSyncStatus(found ? "local" : "synced"); } } else { @@ -720,11 +763,34 @@ export function ProjectStudio() { html: storedHtml, }); const html = compileWorkspaceRuntime(spec, workspace); + const projectV2Source = { + ...found, + spec, + html, + workspace, + }; + let projectV2: import("@/lib/project-v2-types").ProjectV2; + try { + projectV2 = found.projectV2 + ? await import("@/lib/project-v2-validator").then(({ validateProjectV2 }) => + validateProjectV2(found.projectV2), + ) + : await import("@/lib/project-v2-migration").then( + ({ migrateGeneratedProjectToV2 }) => + migrateGeneratedProjectToV2(projectV2Source), + ); + } catch { + projectV2 = await import("@/lib/project-v2-migration").then( + ({ migrateGeneratedProjectToV2 }) => + migrateGeneratedProjectToV2(projectV2Source), + ); + } const migrated: GeneratedProject = { ...found, spec, html, workspace, + projectV2, sourceEditedAt: storedSourceIsValid ? found.sourceEditedAt : undefined, @@ -761,6 +827,9 @@ export function ProjectStudio() { setRuntimeSmoke(null); setProject(migrated); setRuntimeProject(migrated); + if (projectV2.manifest.framework.name === "nextjs") { + setTab("code"); + } setRuntimeRevision((revision) => revision + 1); setDirty( Boolean( @@ -1113,6 +1182,52 @@ export function ProjectStudio() { setDirty(true); }, []); + const adoptProjectV2 = useCallback( + (nextProjectV2: ProjectV2, storageRevision?: number) => { + const current = projectRef.current; + if (!current || current.id !== nextProjectV2.id) return; + if (storageRevision !== undefined) { + projectV2CloudRevisionRef.current = storageRevision; + } + const next: GeneratedProject = { + ...current, + projectV2: nextProjectV2, + updatedAt: nextProjectV2.updatedAt, + }; + projectRef.current = next; + committedProjectRef.current = next; + setProject(next); + setDirty(true); + setProjectSyncStatus(storageRevision !== undefined ? "synced" : "local"); + const save = () => + saveProjectSafely(next, { + expectedUpdatedAt: current.updatedAt, + }) + .then((result) => { + if (result.status === "conflict") { + setProjectSyncStatus("conflict"); + setToast( + "A newer browser revision exists. Reload before continuing Project V2 edits.", + ); + return false; + } + return true; + }) + .catch(() => { + setProjectSyncStatus("error"); + setToast( + storageRevision !== undefined + ? "Project V2 is safe in private cloud storage, but the browser copy could not be updated." + : "This Project V2 change could not be saved in this browser. Free storage, then retry.", + ); + return false; + }); + const queued = saveQueueRef.current.then(save, save); + saveQueueRef.current = queued; + }, + [], + ); + const replaceProject = useCallback( (next: GeneratedProject) => { const current = projectRef.current; @@ -1472,6 +1587,166 @@ export function ProjectStudio() { }; projectRef.current = conversationDraft; setProject(conversationDraft); + if (activeProject.projectV2 && cloudSyncAvailableRef.current) { + try { + await fetch("/api/access", { + credentials: "same-origin", + cache: "no-store", + headers: { accept: "application/json" }, + }); + let snapshot = await loadProjectV2FromCloud(activeProject.id); + if (!snapshot) { + snapshot = await saveProjectV2ToCloud(activeProject.projectV2, 0); + } + projectV2CloudRevisionRef.current = snapshot.storageRevision; + const provider = activeProvider; + const headers: Record = { + accept: "application/json", + "content-type": "application/json", + }; + const key = + provider === "free" || provider === "gateway" + ? null + : window.sessionStorage.getItem(`drops-studio:${provider}`); + if (provider === "openrouter" && key) { + headers["x-openrouter-key"] = key; + } else if (key) { + headers["x-provider-key"] = key; + } + const model = + window.sessionStorage.getItem( + provider === "custom" + ? "drops-studio:custom-model" + : `drops-studio:${provider}:model`, + ) || undefined; + const response = await fetch("/api/builder/agent", { + method: "POST", + credentials: "same-origin", + headers, + signal: AbortSignal.timeout(120_000), + body: JSON.stringify({ + projectId: activeProject.id, + prompt: instruction, + mode: "edit", + provider: { + provider, + ...(model ? { model } : {}), + ...(provider === "custom" + ? { + baseUrl: + window.sessionStorage.getItem( + "drops-studio:custom-endpoint", + ) || undefined, + } + : {}), + }, + }), + }); + const payload = (await response.json().catch(() => ({}))) as { + result?: BuilderAgentResult; + error?: string; + }; + if (!payload.result) { + throw new Error(payload.error || "The Project V2 agent returned no verified result."); + } + const remote = await loadProjectV2FromCloud(activeProject.id); + const projectV2 = remote?.project ?? payload.result.project; + if (remote) projectV2CloudRevisionRef.current = remote.storageRevision; + const changedFiles = Array.from( + new Set([ + ...Object.keys(snapshot.project.files), + ...Object.keys(projectV2.files), + ]), + ).filter( + (path) => + snapshot.project.files[path]?.hash !== projectV2.files[path]?.hash, + ).length; + const assistant: ProjectChatMessage = { + id: nowId("assistant"), + role: "assistant", + createdAt: new Date().toISOString(), + content: payload.result.releaseGate.ok + ? `${payload.result.providerMode === "deterministic-fallback" ? "Free Auto fallback" : "AI agent"} changed ${changedFiles} real file${changedFiles === 1 ? "" : "s"}, passed the release gate, refreshed preview and created a checkpoint. Open Builder to inspect the diff and evidence.` + : `${payload.result.summary} The changed Project V2 files and exact blocking checks are available in Builder; no deployment was claimed.`, + }; + const next: GeneratedProject = { + ...conversationDraft, + projectV2, + conversation: [...baseConversation, assistant], + updatedAt: projectV2.updatedAt, + }; + projectRef.current = next; + committedProjectRef.current = next; + setProject(next); + setProjectSyncStatus(remote ? "synced" : "local"); + setTab("code"); + const save = () => + saveProjectSafely(next, { + expectedUpdatedAt: activeProject.updatedAt, + }); + const queued = saveQueueRef.current.then(save, save); + saveQueueRef.current = queued.then( + () => true, + () => false, + ); + const saved = await queued; + if (saved.status === "conflict") { + setProjectSyncStatus("conflict"); + setToast( + "Another tab saved a newer browser version. Reload before continuing Project V2 edits.", + ); + return; + } + setToast( + remote + ? payload.result.releaseGate.ok + ? "Project V2 files changed and verified in Sandbox" + : "Project V2 edit saved with blocking check evidence" + : payload.result.releaseGate.ok + ? "Sandbox verification passed; the Project V2 update is saved in this browser because private cloud sync could not be confirmed." + : "Blocking check evidence is saved in this browser because private cloud sync could not be confirmed.", + ); + } catch (error) { + const assistant: ProjectChatMessage = { + id: nowId("assistant"), + role: "assistant", + createdAt: new Date().toISOString(), + content: `${error instanceof Error ? error.message : "The Project V2 agent is unavailable."} No deployment or external action was performed.`, + }; + const next: GeneratedProject = { + ...conversationDraft, + conversation: [...baseConversation, assistant], + }; + projectRef.current = next; + setProject(next); + const save = () => + saveProjectSafely(next, { + expectedUpdatedAt: activeProject.updatedAt, + }); + const queued = saveQueueRef.current.then(save, save); + saveQueueRef.current = queued.then( + () => true, + () => false, + ); + try { + const saved = await queued; + if (saved.status === "conflict") { + setProjectSyncStatus("conflict"); + setToast( + "The agent result could not be added because another tab saved a newer browser version. Reload to continue.", + ); + } + } catch { + setProjectSyncStatus("error"); + setToast( + "The agent result could not be saved in this browser. Free storage, then retry.", + ); + } + } finally { + setDirecting(false); + } + return; + } try { let proposal: DirectorProposal; const provider = activeProvider; @@ -2183,11 +2458,21 @@ export function ProjectStudio() { const currentProject = commitPendingSpec() ?? projectRef.current ?? project; if (!currentProject) return; - const bytes = await projectArchive(currentProject); - downloadBlob( - `${currentProject.spec.slug}-source.zip`, - new Blob([bytes.buffer as ArrayBuffer], { type: "application/zip" }), - ); + if ( + currentProject.projectV2 && + currentProject.projectV2.manifest.framework.name !== "legacy-html" + ) { + downloadBlob( + projectV2ArchiveFilename(currentProject.projectV2), + await createProjectV2ArchiveBlob(currentProject.projectV2), + ); + } else { + const bytes = await projectArchive(currentProject); + downloadBlob( + `${currentProject.spec.slug}-source.zip`, + new Blob([bytes.buffer as ArrayBuffer], { type: "application/zip" }), + ); + } setToast( nextHost ? `Git-ready deployment package created for ${nextHost}` @@ -2631,7 +2916,14 @@ export function ProjectStudio() { { id: "logic", label: "Logic", icon: Blocks }, { id: "connections", label: "Connect", icon: KeyRound }, { id: "quality", label: "Tests", icon: ShieldCheck }, - { id: "code", label: "Code", icon: Code2 }, + { + id: "code", + label: + project.projectV2?.manifest.framework.name === "nextjs" + ? "Builder" + : "Code", + icon: Code2, + }, { id: "history", label: "Versions", icon: History }, ]; const game = project.spec.gameDirection; @@ -2713,10 +3005,23 @@ export function ProjectStudio() { + + + + mutateFiles( + [{ type: "write", path, content: defaultFile(path), provenance: "manual" }], + `Before creating ${path}`, + ).then(() => { + setSelectedPath(path); + setDrafts((current) => clearProjectV2Draft(current, path)); + })} + onDeleteFile={(path) => mutateFiles( + [{ type: "delete", path }], + `Before deleting ${path}`, + ).then((next) => { + const replacement = next.manifest.entrypoints[0] ?? Object.keys(next.files)[0] ?? null; + setDrafts((current) => clearProjectV2Draft(current, path)); + setSelectedPath(replacement); + })} + onDraftChange={(content) => { + if (!selectedPath) return; + setDrafts((current) => updateProjectV2DraftMap( + current, + selectedPath, + content, + project.files[selectedPath]?.content ?? "", + )); + }} + onOperationError={(error) => onNotify?.(message(error, "Project operation failed."))} + onRefreshPreview={() => runBuilder( + "repair", + "Rebuild the current files, repair any verified error, rerun all checks, and refresh the real preview.", + )} + onRenameFile={(from, to) => { + const pendingDraft = Object.hasOwn(drafts, from) ? drafts[from] : undefined; + return mutateFiles( + [{ type: "rename", from, to, provenance: "manual" }], + `Before renaming ${from}`, + ).then((next) => { + setDrafts((current) => { + const cleared = clearProjectV2Draft(current, from); + return pendingDraft === undefined + ? cleared + : updateProjectV2DraftMap( + cleared, + to, + pendingDraft, + next.files[to]?.content ?? "", + ); + }); + setSelectedPath(to); + }); + }} + onRequestDeployment={requestDeployment} + onRestoreCheckpoint={restoreCheckpoint} + onRevertFile={async (path) => { + const comparison = comparisonFiles?.[path]; + if (!comparison) throw new Error("No comparison source is available for this file."); + await mutateFiles( + [{ type: "write", path, content: comparison.content, provenance: "manual" }], + `Before reverting ${path}`, + ); + setDrafts((current) => clearProjectV2Draft(current, path)); + }} + onRunTask={runTask} + onSaveFile={async (path, content) => { + await mutateFiles( + [{ type: "write", path, content, provenance: "manual" }], + `Before editing ${path}`, + ); + setDrafts((current) => clearProjectV2Draft(current, path)); + }} + onSelectFile={(path) => { + setSelectedPath(path); + }} + onStopSandbox={stopSandbox} + project={project} + releaseReadiness={release} + sandboxState={sandbox} + selectedPath={selectedPath} + /> + + ); +} diff --git a/components/project-v2-workspace-model.ts b/components/project-v2-workspace-model.ts new file mode 100644 index 0000000..01ea82b --- /dev/null +++ b/components/project-v2-workspace-model.ts @@ -0,0 +1,209 @@ +import type { + ProjectFileV2, + ProjectPreviewStateV2, +} from "../lib/project-v2-types.ts"; + +export type ProjectV2DraftMap = Record; + +export function resolveProjectV2DraftContent( + drafts: ProjectV2DraftMap, + path: string | null, + files: Readonly>, +): string { + if (!path) return ""; + return Object.hasOwn(drafts, path) + ? drafts[path] + : files[path]?.content ?? ""; +} + +export function updateProjectV2DraftMap( + drafts: ProjectV2DraftMap, + path: string, + content: string, + savedContent: string, +): ProjectV2DraftMap { + if (content === savedContent) { + if (!Object.hasOwn(drafts, path)) return drafts; + const next = { ...drafts }; + delete next[path]; + return next; + } + if (drafts[path] === content) return drafts; + return { ...drafts, [path]: content }; +} + +export function clearProjectV2Draft( + drafts: ProjectV2DraftMap, + path: string, +): ProjectV2DraftMap { + if (!Object.hasOwn(drafts, path)) return drafts; + const next = { ...drafts }; + delete next[path]; + return next; +} + +export interface ProjectV2FileTreeNode { + kind: "directory" | "file"; + name: string; + path: string; + children: ProjectV2FileTreeNode[]; +} + +function compareTreeNodes(left: ProjectV2FileTreeNode, right: ProjectV2FileTreeNode): number { + if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; + return left.name.localeCompare(right.name, "en", { numeric: true, sensitivity: "base" }); +} + +export function buildProjectV2FileTree(paths: readonly string[]): ProjectV2FileTreeNode[] { + interface MutableNode { + kind: "directory" | "file"; + name: string; + path: string; + children: Map; + } + const roots = new Map(); + for (const path of [...new Set(paths)].sort()) { + const parts = path.split("/").filter(Boolean); + if (!parts.length) continue; + let level: Map = roots; + let currentPath = ""; + for (let index = 0; index < parts.length; index += 1) { + const name = parts[index]; + currentPath = currentPath ? `${currentPath}/${name}` : name; + const kind = index === parts.length - 1 ? "file" : "directory"; + let node = level.get(name); + if (!node) { + node = { kind, name, path: currentPath, children: new Map() }; + level.set(name, node); + } + if (kind === "directory") level = node.children; + } + } + + function materialize(nodes: Map): ProjectV2FileTreeNode[] { + return [...nodes.values()].map((node) => { + return { + kind: node.kind, + name: node.name, + path: node.path, + children: materialize(node.children), + }; + }).sort(compareTreeNodes); + } + + return materialize(roots); +} + +export function filterProjectV2FileTree( + nodes: readonly ProjectV2FileTreeNode[], + query: string, +): ProjectV2FileTreeNode[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return nodes.map((node) => ({ ...node, children: filterProjectV2FileTree(node.children, "") })); + return nodes.flatMap((node) => { + const matchesSelf = node.path.toLowerCase().includes(normalized); + const children = matchesSelf + ? node.children.map((child) => ({ ...child, children: filterProjectV2FileTree(child.children, "") })) + : filterProjectV2FileTree(node.children, normalized); + if (!matchesSelf && !children.length) return []; + return [{ ...node, children }]; + }); +} + +export interface ProjectV2LineDiff { + kind: "context" | "added" | "removed"; + content: string; + oldLine?: number; + newLine?: number; +} + +function fallbackLineDiff(before: string[], after: string[]): ProjectV2LineDiff[] { + let prefix = 0; + while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix += 1; + let suffix = 0; + while ( + suffix < before.length - prefix + && suffix < after.length - prefix + && before[before.length - 1 - suffix] === after[after.length - 1 - suffix] + ) suffix += 1; + const result: ProjectV2LineDiff[] = []; + for (let index = 0; index < prefix; index += 1) { + result.push({ kind: "context", content: before[index], oldLine: index + 1, newLine: index + 1 }); + } + for (let index = prefix; index < before.length - suffix; index += 1) { + result.push({ kind: "removed", content: before[index], oldLine: index + 1 }); + } + for (let index = prefix; index < after.length - suffix; index += 1) { + result.push({ kind: "added", content: after[index], newLine: index + 1 }); + } + for (let offset = suffix; offset > 0; offset -= 1) { + const oldIndex = before.length - offset; + const newIndex = after.length - offset; + result.push({ kind: "context", content: before[oldIndex], oldLine: oldIndex + 1, newLine: newIndex + 1 }); + } + return result; +} + +export function createProjectV2LineDiff(beforeText: string, afterText: string): ProjectV2LineDiff[] { + const before = beforeText.split("\n"); + const after = afterText.split("\n"); + if (before.length > 600 || after.length > 600) return fallbackLineDiff(before, after); + const width = after.length + 1; + const table = new Uint16Array((before.length + 1) * width); + for (let oldIndex = before.length - 1; oldIndex >= 0; oldIndex -= 1) { + for (let newIndex = after.length - 1; newIndex >= 0; newIndex -= 1) { + const offset = oldIndex * width + newIndex; + table[offset] = before[oldIndex] === after[newIndex] + ? table[(oldIndex + 1) * width + newIndex + 1] + 1 + : Math.max(table[(oldIndex + 1) * width + newIndex], table[oldIndex * width + newIndex + 1]); + } + } + const result: ProjectV2LineDiff[] = []; + let oldIndex = 0; + let newIndex = 0; + while (oldIndex < before.length && newIndex < after.length) { + if (before[oldIndex] === after[newIndex]) { + result.push({ kind: "context", content: before[oldIndex], oldLine: oldIndex + 1, newLine: newIndex + 1 }); + oldIndex += 1; + newIndex += 1; + } else if (table[(oldIndex + 1) * width + newIndex] >= table[oldIndex * width + newIndex + 1]) { + result.push({ kind: "removed", content: before[oldIndex], oldLine: oldIndex + 1 }); + oldIndex += 1; + } else { + result.push({ kind: "added", content: after[newIndex], newLine: newIndex + 1 }); + newIndex += 1; + } + } + while (oldIndex < before.length) { + result.push({ kind: "removed", content: before[oldIndex], oldLine: oldIndex + 1 }); + oldIndex += 1; + } + while (newIndex < after.length) { + result.push({ kind: "added", content: after[newIndex], newLine: newIndex + 1 }); + newIndex += 1; + } + return result; +} + +export function verifiedProjectV2PreviewUrl(preview: ProjectPreviewStateV2 | undefined): string | null { + if (preview?.status !== "ready" || !preview.url) return null; + try { + const url = new URL(preview.url); + return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null; + } catch { + return null; + } +} + +export function formatProjectV2Duration(startedAt: string | undefined, now: number): string { + if (!startedAt) return "—"; + const started = Date.parse(startedAt); + if (!Number.isFinite(started)) return "—"; + const totalSeconds = Math.max(0, Math.floor((now - started) / 1_000)); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + return hours + ? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} diff --git a/components/project-v2-workspace.module.css b/components/project-v2-workspace.module.css new file mode 100644 index 0000000..b6d819a --- /dev/null +++ b/components/project-v2-workspace.module.css @@ -0,0 +1,756 @@ +.workspace { + --v2-blue: var(--ps-blue, #316cff); + --v2-blue-text: #245acb; + --v2-ink: var(--ps-ink, #0b1730); + --v2-muted: #596980; + --v2-line: var(--ps-line, #dce5f2); + --v2-soft: #f5f7fb; + --v2-panel: #ffffff; + background: var(--v2-panel); + border: 1px solid var(--v2-line); + border-radius: 16px; + color: var(--v2-ink); + display: flex; + flex-direction: column; + font-family: var(--font-geist-sans, Inter), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; + inline-size: 100%; + max-inline-size: 100%; + min-block-size: 720px; + min-inline-size: 0; + overflow: hidden; +} + +.workspace *, +.workspace *::before, +.workspace *::after { box-sizing: border-box; } + +.workspace button, +.workspace input { font: inherit; } + +.workspaceHeader { + align-items: center; + background: linear-gradient(180deg, #ffffff, #fbfcff); + border-bottom: 1px solid var(--v2-line); + display: flex; + gap: 20px; + justify-content: space-between; + min-block-size: 72px; + padding: 12px 18px; +} + +.projectIdentity, +.headerStatus, +.projectIdentity > div, +.sandboxStrip > div, +.surfaceHeading > div, +.logsToolbar > div:first-child, +.editorFileMeta, +.checkpointRow > div, +.deploymentCard > div:first-child { min-inline-size: 0; } + +.projectIdentity { + align-items: center; + display: flex; + gap: 12px; +} + +.projectMark { + align-items: center; + background: linear-gradient(145deg, #41d9ff, var(--v2-blue)); + border-radius: 11px; + box-shadow: 0 8px 20px rgba(49, 108, 255, .22); + color: white; + display: flex; + flex: 0 0 auto; + height: 44px; + justify-content: center; + width: 44px; +} + +.projectMark svg { height: 20px; width: 20px; } + +.projectIdentity > div, +.editorFileMeta, +.logsToolbar > div:first-child { + display: grid; + gap: 3px; +} + +.projectIdentity strong, +.editorFileMeta strong { + font-size: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.projectIdentity span, +.editorFileMeta span, +.logsToolbar span, +.surfaceHeading span, +.runRow span, +.checkpointRow span, +.deploymentCard span, +.metaText { + color: var(--v2-muted); + font-size: 12px; + line-height: 1.45; +} + +.headerStatus { + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.statusBadge { + font-size: 12px; + max-inline-size: 100%; + text-transform: capitalize; +} + +.statusPositive { background: #eaf8f2; border-color: #b9e9d3; color: #08734a; } +.statusNegative { background: #fff0f1; border-color: #ffc9ce; color: #b42333; } +.statusProgress { background: #edf3ff; border-color: #c5d6ff; color: #245acb; } +.statusNeutral { background: #f3f5f8; border-color: #dce2eb; color: #596980; } + +.workspaceNav { + align-items: center; + background: #fbfcfe; + border-bottom: 1px solid var(--v2-line); + display: flex; + flex: 0 0 auto; + gap: 4px; + max-inline-size: 100%; + min-block-size: 58px; + overflow-x: auto; + overscroll-behavior-inline: contain; + padding: 7px 12px; + scrollbar-width: thin; +} + +.navButton, +.navButtonActive { + color: #53647c; + flex: 0 0 auto; + font-size: 14px; +} + +.navButtonActive { + background: #eaf1ff; + color: #174fbf; +} + +.readOnlyNotice, +.providerMessage { + align-items: center; + background: #fff9e9; + border-bottom: 1px solid #f0dfae; + color: #715516; + display: flex; + font-size: 14px; + gap: 9px; + line-height: 1.5; + margin: 0; + min-block-size: 44px; + padding: 9px 16px; +} + +.readOnlyNotice svg, +.providerMessage svg { flex: 0 0 auto; height: 18px; width: 18px; } + +.workspaceBody { + background: var(--v2-soft); + display: flex; + flex: 1; + min-block-size: 0; + min-inline-size: 0; + overflow: hidden; +} + +.filesLayout { + block-size: 100%; + display: grid; + grid-template-columns: minmax(230px, 290px) minmax(0, 1fr); + inline-size: 100%; + min-block-size: 0; + min-inline-size: 0; +} + +.filePanel, +.editorPanel { + background: var(--v2-panel); + min-block-size: 0; + min-inline-size: 0; +} + +.filePanel { + border-right: 1px solid var(--v2-line); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panelHeading, +.editorToolbar, +.previewToolbar, +.logsToolbar, +.surfaceHeading { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.panelHeading { + min-block-size: 62px; + padding: 8px 10px 8px 16px; +} + +.panelHeading > div:first-child, +.surfaceHeading > div, +.sandboxStrip span { + display: grid; + gap: 3px; +} + +.panelHeading span, +.surfaceHeading span { + color: var(--v2-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .06em; +} + +.panelHeading strong, +.surfaceHeading strong, +.logsToolbar strong { font-size: 14px; } + +.iconActions, +.toolbarActions, +.previewActions, +.deviceSwitch, +.modeSwitch, +.logFilters { + align-items: center; + display: flex; + gap: 4px; +} + +.searchField { + align-items: center; + display: flex; + margin: 0 12px 10px; + position: relative; +} + +.searchField > svg { + color: #7c8ba0; + height: 17px; + left: 12px; + pointer-events: none; + position: absolute; + width: 17px; + z-index: 1; +} + +.searchField input { padding-left: 38px; } + +.fileTreeScroll { + flex: 1; + min-block-size: 0; + overflow: auto; + padding: 0 8px 14px; +} + +.fileTreeList { + list-style: none; + margin: 0; + padding: 0 0 0 12px; +} + +.fileTreeScroll > .fileTreeList { padding-left: 0; } + +.treeRow { + align-items: center; + background: transparent; + border: 0; + border-radius: 9px; + color: #43536b; + display: grid; + font-size: 14px; + gap: 7px; + grid-template-columns: 18px 18px minmax(0, 1fr); + inline-size: 100%; + min-block-size: 44px; + padding: 5px 9px; + text-align: left; +} + +.treeRow:hover { background: #f3f6fb; } +.treeRow:focus-visible { outline: 3px solid rgba(49, 108, 255, .24); outline-offset: -2px; } +.treeRowSelected { background: #eaf1ff; color: #174fbf; font-weight: 700; } +.treeRow svg { height: 16px; width: 16px; } +.treeRow span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.treeSpacer { display: block; height: 18px; width: 18px; } + +.fileActionForm { + background: #f7f9fd; + border-block: 1px solid var(--v2-line); + display: grid; + gap: 10px; + padding: 12px; +} + +.fileActionForm > div { + align-items: center; + display: flex; + justify-content: space-between; +} + +.fileActionForm strong { font-size: 14px; } +.fileActionForm p { color: #52627a; font-size: 14px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.fileActionForm code { font-size: 12px; } + +.editorPanel { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editorToolbar { + border-bottom: 1px solid var(--v2-line); + min-block-size: 66px; + padding: 8px 12px 8px 16px; +} + +.toolbarActions { flex: 0 1 auto; flex-wrap: wrap; justify-content: flex-end; } +.modeSwitch { background: #f0f3f8; border-radius: 10px; padding: 2px; } +.modeSwitch button[aria-pressed="true"], +.deviceSwitch button[aria-pressed="true"], +.logFilters button[aria-pressed="true"] { background: white; box-shadow: 0 1px 4px rgba(20, 42, 80, .11); color: var(--v2-blue-text); } + +.codeEditorWrap { + flex: 1; + min-block-size: 0; + min-inline-size: 0; + overflow: hidden; +} + +.codeEditorWrap :global(.cm-editor) { font-size: 14px; height: 100%; } +.codeEditorWrap :global(.cm-scroller) { font-family: var(--font-mono), "SFMono-Regular", Consolas, monospace; line-height: 1.6; overflow: auto; } +.codeEditorWrap :global(.cm-content) { min-inline-size: max-content; padding-block: 10px; } +.codeEditorWrap :global(.cm-gutters) { background: #f7f9fc; border-right-color: var(--v2-line); color: #8190a5; } + +.editorLoading { + align-items: center; + color: var(--v2-muted); + display: flex; + font-size: 14px; + gap: 10px; + height: 100%; + justify-content: center; +} + +.editorFooter, +.previewFooter { + align-items: center; + background: #fbfcfe; + border-top: 1px solid var(--v2-line); + color: var(--v2-muted); + display: flex; + font-family: var(--font-mono), monospace; + font-size: 12px; + gap: 16px; + justify-content: space-between; + min-block-size: 44px; + padding: 8px 14px; +} + +.diffPanel { display: flex; flex: 1; flex-direction: column; min-block-size: 0; min-inline-size: 0; overflow: hidden; } +.diffPanel > header { align-items: center; background: #fbfcff; border-bottom: 1px solid var(--v2-line); display: flex; font-size: 14px; justify-content: space-between; min-block-size: 44px; padding: 8px 14px; } +.diffPanel > header span { color: var(--v2-muted); font-size: 12px; } +.diffCode { flex: 1; font-family: var(--font-mono), monospace; font-size: 13px; overflow: auto; padding-block: 8px; } +.diffCode > div { display: grid; grid-template-columns: 48px 48px 24px minmax(max-content, 1fr); min-block-size: 24px; } +.diffCode > div > span { border-right: 1px solid rgba(130, 145, 170, .18); color: #8794a7; padding: 2px 8px; text-align: right; } +.diffCode > div > b { font-weight: 700; padding: 2px 8px; text-align: center; } +.diffCode code { padding: 2px 12px; white-space: pre; } +.diff_context { background: white; } +.diff_added { background: #edfbf4; color: #07643f; } +.diff_removed { background: #fff1f2; color: #a52331; } + +.previewView, +.logsView, +.historyView, +.deployView, +.surfaceGrid { + inline-size: 100%; + min-inline-size: 0; + overflow: auto; + padding: 18px; +} + +.previewView { display: flex; flex-direction: column; } +.previewToolbar { margin-bottom: 12px; } +.deviceSwitch { background: #e9edf4; border-radius: 11px; padding: 3px; } + +.sandboxStrip { + background: white; + border: 1px solid var(--v2-line); + border-radius: 12px; + display: grid; + gap: 10px; + grid-template-columns: minmax(170px, 1fr) auto minmax(150px, .6fr) minmax(160px, .6fr); + margin-bottom: 12px; + padding: 10px 12px; +} + +.sandboxStrip > div { align-items: center; display: flex; gap: 9px; } +.sandboxStrip svg { color: var(--v2-blue); flex: 0 0 auto; height: 18px; width: 18px; } +.sandboxStrip strong { font-size: 12px; } +.sandboxStrip span span, +.sandboxStrip span:not(:has(strong)) { color: var(--v2-muted); font-size: 12px; overflow-wrap: anywhere; } + +.providerMessage { border: 1px solid #f0dfae; border-radius: 10px; margin: 0 0 12px; } + +.previewFrameShell { + align-items: center; + background: #e9eef6; + border: 1px solid #d4deeb; + border-radius: 14px; + display: flex; + flex: 1; + justify-content: center; + min-block-size: 550px; + min-inline-size: 0; + overflow: auto; + padding: 18px; +} + +.previewFrameShell iframe { + background: white; + border: 1px solid #ccd8e7; + border-radius: 10px; + box-shadow: 0 14px 36px rgba(28, 48, 80, .13); + display: block; + height: 100%; + min-height: 510px; + width: 100%; +} + +.device_desktop iframe { max-width: 1240px; } +.device_tablet iframe { max-width: 768px; } +.device_mobile iframe { max-width: 390px; } +.previewFooter { border: 0; border-top: 1px solid var(--v2-line); margin-top: 12px; } +.previewFooter span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.surfaceGrid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.surfaceCard, +.releaseCard { + background: white; + border: 1px solid var(--v2-line); + border-radius: 14px; + box-shadow: 0 8px 24px rgba(28, 48, 80, .05); + min-inline-size: 0; + overflow: hidden; + padding: 16px; +} + +.wideCard { grid-column: 1 / -1; } +.surfaceHeading { border-bottom: 1px solid #e7edf5; margin: -2px -2px 14px; padding: 2px 2px 14px; } +.surfaceHeading > svg { color: var(--v2-blue); flex: 0 0 auto; height: 20px; width: 20px; } + +.checkList, +.errorList, +.runList, +.checkpointList, +.deploymentList { display: grid; gap: 8px; } + +.checkRow { + align-items: center; + border: 1px solid #e3e9f2; + border-radius: 11px; + display: grid; + gap: 10px; + grid-template-columns: 36px minmax(0, 1fr) auto auto; + min-inline-size: 0; + padding: 10px; +} + +.checkIcon { + align-items: center; + border: 1px solid currentColor; + border-radius: 10px; + display: flex; + height: 36px; + justify-content: center; + width: 36px; +} + +.checkIcon svg { height: 17px; width: 17px; } +.checkRow > div:nth-child(2) { display: grid; gap: 3px; min-inline-size: 0; } +.checkRow strong, +.runRow strong, +.checkpointRow strong, +.deploymentCard strong, +.integrationCard > strong { font-size: 14px; } +.checkRow > div:nth-child(2) span { color: var(--v2-muted); font-size: 12px; line-height: 1.45; } +.checkAside { align-items: flex-end; display: grid; gap: 4px; justify-items: end; } +.checkAside > span:last-child { color: var(--v2-muted); font-size: 12px; } + +.errorRow { + align-items: flex-start; + background: #fff7f7; + border: 1px solid #ffd6da; + border-radius: 11px; + display: flex; + gap: 10px; + padding: 12px; +} + +.errorRow > svg { color: #c52f40; flex: 0 0 auto; height: 19px; width: 19px; } +.errorRow > div { display: grid; gap: 4px; min-inline-size: 0; } +.errorRow strong { font-size: 12px; text-transform: uppercase; } +.errorRow p { font-size: 14px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.errorRow span { color: var(--v2-muted); font-size: 12px; overflow-wrap: anywhere; } + +.runRow { + align-items: center; + background: #fbfcfe; + border: 1px solid #e4eaf3; + border-radius: 10px; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 11px 12px; +} + +.runRow > div { display: grid; gap: 3px; min-inline-size: 0; } + +.logsView { display: flex; flex-direction: column; gap: 12px; } +.logsToolbar { background: white; border: 1px solid var(--v2-line); border-radius: 12px; padding: 10px 12px 10px 16px; } +.logFilters { flex-wrap: wrap; justify-content: flex-end; } +.logEntries { display: grid; gap: 10px; } +.logEntry { background: #111827; border: 1px solid #26344a; border-radius: 12px; color: #dce7f7; overflow: hidden; } +.logEntry header { align-items: center; border-bottom: 1px solid #27364d; display: flex; flex-wrap: wrap; font-size: 12px; gap: 8px 12px; min-block-size: 44px; padding: 8px 12px; } +.logEntry header > span:first-child, +.logEntry header > span:nth-child(3) { color: #9eacc1; } +.logEntry pre { font-family: var(--font-mono), monospace; font-size: 13px; line-height: 1.6; margin: 0; max-block-size: 400px; overflow: auto; padding: 14px; white-space: pre-wrap; word-break: break-word; } + +.integrationGrid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); } +.integrationCard { background: #fbfcff; border: 1px solid #e0e7f2; border-radius: 12px; display: flex; flex-direction: column; gap: 9px; min-inline-size: 0; padding: 14px; } +.integrationCard header { align-items: center; display: flex; justify-content: space-between; } +.integrationCard header > span:first-child { align-items: center; background: #eaf1ff; border-radius: 9px; color: var(--v2-blue); display: flex; height: 36px; justify-content: center; width: 36px; } +.integrationCard header svg { height: 17px; width: 17px; } +.integrationCard > strong { text-transform: capitalize; } +.integrationCard p { color: #53637a; font-size: 14px; line-height: 1.5; margin: 0; } +.integrationCard > div { display: flex; flex-wrap: wrap; gap: 5px; } +.integrationCard code { background: #f0f3f8; border-radius: 7px; color: #43526a; font-size: 12px; overflow-wrap: anywhere; padding: 7px; } + +.manifestList, +.definitionList, +.githubLayout { display: grid; gap: 10px; } + +.manifestList > div { + align-items: center; + background: #f7f9fc; + border: 1px solid #e3e9f2; + border-radius: 10px; + display: grid; + gap: 10px; + grid-template-columns: minmax(110px, .55fr) minmax(0, 1.45fr); + min-block-size: 48px; + padding: 8px 11px; +} + +.manifestList span, +.definitionList span { color: var(--v2-muted); font-size: 12px; line-height: 1.45; } +.manifestList code, +.manifestList strong, +.definitionList code { font-size: 12px; overflow-wrap: anywhere; } +.manifestList code { color: #174fbf; } + +.definitionList article { + align-items: center; + border: 1px solid #e3e9f2; + border-radius: 10px; + display: grid; + gap: 10px; + grid-template-columns: minmax(170px, 1.35fr) minmax(120px, .8fr) auto; + min-inline-size: 0; + padding: 10px 11px; +} + +.definitionList article > div { display: grid; gap: 3px; min-inline-size: 0; } +.definitionList strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.definitionList code { color: #52627a; } +.truthfulNote, +.githubHelper, +.githubMessage { color: #53637a; font-size: 14px; line-height: 1.55; margin: 12px 0 0; } +.capabilityList { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } + +.manifestColumns { display: grid; gap: 14px; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.manifestColumns > div { background: #f7f9fc; border: 1px solid #e3e9f2; border-radius: 10px; display: grid; gap: 7px; min-inline-size: 0; padding: 12px; } +.manifestColumns strong { font-size: 14px; } +.manifestColumns code { color: #52627a; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } + +.githubConnection { background: #f7f9fc; border: 1px solid #e1e8f2; border-radius: 11px; padding: 12px; } +.githubStatusLine { align-items: center; display: flex; flex-wrap: wrap; gap: 8px 12px; } +.githubStatusLine > span:last-child { color: var(--v2-muted); font-size: 12px; overflow-wrap: anywhere; } +.githubTokenRow { align-items: end; display: grid; gap: 8px; grid-template-columns: minmax(220px, 1fr) auto auto; margin-top: 12px; } +.githubTokenRow label, +.githubRepositoryForm label { display: grid; gap: 6px; min-inline-size: 0; } +.githubTokenRow label > span, +.githubRepositoryForm label > span { color: #53637a; font-size: 12px; font-weight: 700; } +.githubRepositoryForm { display: grid; gap: 10px; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.githubActions { display: flex; flex-wrap: wrap; gap: 8px; } +.githubMessage { margin: 0; min-block-size: 22px; } +.githubReceipt { align-items: center; background: #fbfcff; border: 1px solid #e1e8f2; border-radius: 10px; display: flex; flex-wrap: wrap; gap: 8px 14px; padding: 10px 12px; } +.githubReceipt strong { font-size: 14px; } +.githubReceipt span, +.githubReceipt code { color: var(--v2-muted); font-size: 12px; } +.githubReceipt a { align-items: center; color: #174fbf; display: inline-flex; font-size: 13px; min-block-size: 44px; padding-inline: 4px; } +.githubReceipt a:focus-visible { outline: 3px solid rgba(49, 108, 255, .24); outline-offset: 2px; } + +.environmentTable { border: 1px solid #e2e8f1; border-radius: 11px; overflow: hidden; } +.environmentRow { align-items: center; border-bottom: 1px solid #e7ecf3; display: grid; font-size: 14px; gap: 10px; grid-template-columns: minmax(150px, .8fr) minmax(220px, 1.6fr) auto minmax(120px, .6fr); min-block-size: 58px; padding: 8px 12px; } +.environmentRow:last-child { border-bottom: 0; } +.environmentRow code { color: #174fbf; font-size: 13px; overflow-wrap: anywhere; } +.environmentRow > span:nth-child(2) { color: #52627a; line-height: 1.45; } +.environmentRow > span:last-child { color: var(--v2-muted); font-size: 12px; text-align: right; } + +.historyView { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); } +.checkpointRow { align-items: center; border: 1px solid #e2e8f1; border-radius: 11px; display: grid; gap: 12px; grid-template-columns: 42px minmax(0, 1fr) auto; padding: 11px; } +.checkpointMark { align-items: center; background: #edf3ff; border-radius: 11px; color: var(--v2-blue); display: flex; height: 42px; justify-content: center; width: 42px; } +.checkpointMark svg { height: 18px; width: 18px; } +.checkpointRow > div { display: grid; gap: 3px; } +.checkpointRow code { color: #64748a; font-size: 12px; } +.historySummary { display: grid; gap: 16px; } +.historySummary p { color: #53637a; font-size: 14px; line-height: 1.55; margin: 0; } +.historySummary dl { display: grid; gap: 8px; grid-template-columns: repeat(2, 1fr); margin: 0; } +.historySummary dl > div { background: #f6f8fc; border-radius: 10px; padding: 12px; } +.historySummary dt { color: var(--v2-muted); font-size: 12px; } +.historySummary dd { font-size: 22px; font-weight: 750; margin: 5px 0 0; } + +.deployView { display: grid; gap: 16px; grid-template-columns: minmax(260px, .7fr) minmax(0, 1.3fr); } +.releaseCard { display: flex; flex-direction: column; gap: 14px; } +.readinessBody { display: grid; gap: 12px; } +.readinessBody ul { display: grid; gap: 7px; list-style: none; margin: 0; padding: 0; } +.readinessBody li { align-items: flex-start; color: #38536b; display: flex; font-size: 14px; gap: 8px; line-height: 1.45; } +.readinessBody li svg { color: #0a9a64; flex: 0 0 auto; height: 17px; margin-top: 2px; width: 17px; } +.readinessBody .blockerList li { color: #922d39; } +.readinessBody .blockerList svg { color: #c33748; } +.approvalNote { color: var(--v2-muted); font-size: 12px; line-height: 1.5; margin: 0; } +.deploymentHistory { grid-column: 1 / -1; } +.deploymentHistoryRow { align-items: center; border: 1px solid #e2e8f1; border-radius: 12px; display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr) auto; padding: 10px; } +.deploymentCard { align-items: center; display: grid; gap: 10px; grid-template-columns: minmax(160px, 1fr) auto minmax(130px, .7fr) minmax(180px, .9fr); min-inline-size: 0; } +.deploymentCard > div:first-child { display: grid; gap: 3px; } +.deploymentCard code { color: #52627a; font-size: 12px; overflow-wrap: anywhere; } +.deploymentCard a { align-items: center; border: 1px solid #cbd8eb; border-radius: 9px; color: #174fbf; display: inline-flex; font-size: 14px; justify-content: center; min-block-size: 44px; padding: 8px 12px; text-decoration: none; } +.deploymentCard a:focus-visible { outline: 3px solid rgba(49, 108, 255, .24); outline-offset: 2px; } + +.emptyState { + align-items: center; + color: var(--v2-muted); + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + min-block-size: 220px; + padding: 28px 20px; + text-align: center; +} + +.emptyState > svg { color: #9eacc0; height: 28px; margin-bottom: 10px; width: 28px; } +.emptyState strong { color: #34445b; font-size: 16px; } +.emptyState p { font-size: 14px; line-height: 1.55; margin: 7px 0 0; max-width: 520px; } + +.spin { animation: v2-spin .8s linear infinite; } +@keyframes v2-spin { to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + .spin { animation: none; } +} + +@media (max-width: 1024px) { + .filesLayout { grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); } + .surfaceGrid, + .historyView, + .deployView { grid-template-columns: minmax(0, 1fr); } + .wideCard, + .deploymentHistory { grid-column: auto; } + .sandboxStrip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .environmentRow { grid-template-columns: minmax(140px, .8fr) minmax(180px, 1.4fr) auto; } + .environmentRow > span:last-child { grid-column: 1 / -1; text-align: left; } + .deploymentCard { grid-template-columns: minmax(150px, 1fr) auto; } + .deploymentCard > code, + .deploymentCard > a, + .deploymentCard > .metaText { grid-column: 1 / -1; } +} + +@media (max-width: 720px) { + .workspace { border-radius: 12px; min-block-size: 680px; } + .workspaceHeader { align-items: flex-start; flex-direction: column; gap: 10px; padding: 12px; } + .headerStatus { flex-wrap: wrap; } + .workspaceNav { padding-inline: 8px; } + .filesLayout { block-size: auto; display: flex; flex-direction: column; } + .filePanel { border-bottom: 1px solid var(--v2-line); border-right: 0; flex: 0 0 auto; max-block-size: 340px; min-block-size: 270px; } + .editorPanel { flex: 1; min-block-size: 580px; } + .codeEditorWrap { min-block-size: 510px; } + .editorToolbar, + .previewToolbar, + .logsToolbar { align-items: stretch; flex-direction: column; } + .toolbarActions, + .previewActions, + .deviceSwitch, + .logFilters { inline-size: 100%; justify-content: flex-start; overflow-x: auto; } + .toolbarActions > button, + .previewActions > button { flex: 1 0 auto; } + .modeSwitch { flex: 1 0 auto; } + .previewView, + .logsView, + .historyView, + .deployView, + .surfaceGrid { padding: 12px; } + .sandboxStrip { grid-template-columns: minmax(0, 1fr); } + .previewFrameShell { min-block-size: 500px; padding: 10px; } + .previewFrameShell iframe { min-height: 470px; } + .checkRow { align-items: start; grid-template-columns: 36px minmax(0, 1fr); } + .checkAside { align-items: start; grid-column: 2; justify-items: start; } + .checkRow > button { grid-column: 1 / -1; } + .environmentTable { overflow-x: auto; } + .environmentRow { grid-template-columns: minmax(140px, .8fr) minmax(220px, 1.4fr) auto; min-inline-size: 620px; } + .checkpointRow { grid-template-columns: 42px minmax(0, 1fr); } + .checkpointRow > button { grid-column: 1 / -1; } + .deploymentHistoryRow { grid-template-columns: minmax(0, 1fr); } + .deploymentCard { grid-template-columns: minmax(0, 1fr); } + .deploymentCard > code, + .deploymentCard > a, + .deploymentCard > .metaText { grid-column: auto; } + .definitionList article { align-items: start; grid-template-columns: minmax(0, 1fr); } + .manifestColumns, + .githubRepositoryForm, + .githubTokenRow { grid-template-columns: minmax(0, 1fr); } + .editorFooter, + .previewFooter { align-items: flex-start; flex-direction: column; gap: 4px; } +} + +@media (max-width: 420px) { + .projectIdentity { align-items: flex-start; } + .projectIdentity > div span { white-space: normal; } + .panelHeading { align-items: flex-start; } + .iconActions { flex-wrap: wrap; justify-content: flex-end; } + .workspaceNav button { padding-inline: 10px; } + .surfaceCard, + .releaseCard { padding: 13px; } + .integrationGrid { grid-template-columns: minmax(0, 1fr); } + .historySummary dl { grid-template-columns: minmax(0, 1fr); } + .previewFrameShell { padding: 6px; } +} diff --git a/components/project-v2-workspace.stories.tsx b/components/project-v2-workspace.stories.tsx new file mode 100644 index 0000000..7283614 --- /dev/null +++ b/components/project-v2-workspace.stories.tsx @@ -0,0 +1,229 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import { fn } from "storybook/test"; + +import { + ProjectV2Workspace, + type ProjectV2PreviewDevice, + type ProjectV2WorkspaceProps, + type ProjectV2WorkspaceView, +} from "@/components/project-v2-workspace"; +import type { ProjectFileV2, ProjectV2 } from "@/lib/project-v2-types"; + +const createdAt = "2026-07-30T12:00:00.000Z"; + +const files: Record = { + "app/page.tsx": { + kind: "file", + path: "app/page.tsx", + content: `export default function Page() {\n return
Whale intelligence
;\n}\n`, + language: "tsx", + role: "entry", + provenance: "generated", + editable: true, + bytes: 83, + hash: "1111111111111111111111111111111111111111111111111111111111111111", + createdAt, + updatedAt: createdAt, + }, + "components/market-table.tsx": { + kind: "file", + path: "components/market-table.tsx", + content: `export function MarketTable() {\n return
Market context
;\n}\n`, + language: "tsx", + role: "component", + provenance: "ai", + editable: true, + bytes: 82, + hash: "2222222222222222222222222222222222222222222222222222222222222222", + createdAt, + updatedAt: createdAt, + }, + "package.json": { + kind: "file", + path: "package.json", + content: JSON.stringify({ private: true, scripts: { build: "next build", test: "node --test" } }, null, 2), + language: "json", + role: "manifest", + provenance: "generated", + editable: true, + bytes: 112, + hash: "3333333333333333333333333333333333333333333333333333333333333333", + createdAt, + updatedAt: createdAt, + }, +}; + +const productSpec = {} as ProjectV2["productSpec"]; + +const project: ProjectV2 = { + schemaVersion: 2, + id: "whale-intelligence-v2", + revision: 4, + contentHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifest: { + schemaVersion: 2, + name: "Whale Intelligence", + slug: "whale-intelligence", + packageManager: "npm", + framework: { name: "nextjs", version: "16.2.12" }, + runtime: { name: "nodejs", version: "24" }, + scripts: { build: "next build", test: "node --test", dev: "next dev" }, + dependencies: { next: "16.2.12", react: "19.2.4", "react-dom": "19.2.4" }, + devDependencies: { typescript: "5.9.3" }, + entrypoints: ["app/page.tsx"], + legacyFallback: { + supported: true, + adapter: "legacy-html", + reason: "V1 compatibility remains available.", + sourceSchemaVersion: 1, + }, + }, + files, + productSpec, + integrations: [ + { + id: "dropstab", + kind: "dropstab", + status: "setup-required", + capabilities: ["coins", "unlocks", "funding"], + proxyPath: "/api/public-data", + providerEvidenceRequired: true, + }, + { + id: "project-data", + kind: "project-data", + status: "demo", + capabilities: ["documents", "event-inbox"], + proxyPath: "/api/project-data", + providerEvidenceRequired: false, + }, + ], + environment: [ + { + name: "DROPSTAB_API_KEY", + description: "Server-side DropsTab connection for live market enrichment.", + required: false, + secret: true, + scope: "runtime", + }, + ], + permissions: [ + { + id: "telegram-delivery", + capability: "telegram.publish", + effect: "approval-required", + destructive: false, + external: true, + }, + ], + tasks: [ + { id: "typecheck", label: "Typecheck", kind: "typecheck", command: "npm", args: ["run", "typecheck"], cwd: ".", timeoutMs: 120_000, approvalRequired: false }, + { id: "build", label: "Production build", kind: "build", command: "npm", args: ["run", "build"], cwd: ".", timeoutMs: 300_000, approvalRequired: false }, + ], + runs: [], + logs: [], + checkpoints: [], + preview: { status: "idle", projectRevision: 4 }, + deployment: { status: "none", provider: "vercel" }, + migration: { + sourceSchemaVersion: 2, + sourceKind: "project-v2-template", + sourceFidelity: "native", + adapter: "native-v2", + migratedAt: createdAt, + }, + createdAt, + updatedAt: createdAt, +}; + +function WorkspaceStory(props: ProjectV2WorkspaceProps) { + const [selectedPath, setSelectedPath] = useState(props.selectedPath); + const [draft, setDraft] = useState(props.draftContent); + const [view, setView] = useState(props.activeView ?? "files"); + const [device, setDevice] = useState(props.previewDevice ?? "desktop"); + + function selectFile(path: string) { + setSelectedPath(path); + setDraft(props.project.files[path]?.content ?? ""); + props.onSelectFile(path); + } + + return ( +
+ +
+ ); +} + +const meta = { + title: "Project Studio/V2 Workspace", + component: ProjectV2Workspace, + tags: ["autodocs"], + parameters: { layout: "fullscreen" }, + args: { + project, + selectedPath: "app/page.tsx", + draftContent: files["app/page.tsx"].content, + comparisonFiles: { + ...files, + "app/page.tsx": { + ...files["app/page.tsx"], + content: `export default function Page() {\n return
Wallet monitor
;\n}\n`, + }, + }, + comparisonLabel: "Checkpoint before Director edit", + checks: [], + logEntries: [], + browserErrors: [], + deploymentHistory: [], + releaseReadiness: { status: "unknown", evidence: [], blockers: [] }, + onSelectFile: fn(), + onDraftChange: fn(), + onSaveFile: fn(), + onRevertFile: fn(), + onCreateFile: fn(), + onRenameFile: fn(), + onDeleteFile: fn(), + onRunTask: fn(), + onRefreshPreview: fn(), + onStopSandbox: fn(), + onRestoreCheckpoint: fn(), + onRequestDeployment: fn(), + onRequestRollback: fn(), + onOperationError: fn(), + }, + render: (args) => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ReadyToEdit: Story = {}; + +export const RuntimeUnavailable: Story = { + args: { + activeView: "preview", + sandboxState: { + status: "unavailable", + message: "Vercel Sandbox credentials are not configured for this environment.", + }, + }, +}; + +export const ReadOnlyFallback: Story = { + args: { + readOnly: true, + activeView: "integrations", + }, +}; diff --git a/components/project-v2-workspace.tsx b/components/project-v2-workspace.tsx new file mode 100644 index 0000000..6f05e11 --- /dev/null +++ b/components/project-v2-workspace.tsx @@ -0,0 +1,1319 @@ +"use client"; + +import { + AlertCircle, + ArchiveRestore, + Box, + Braces, + Check, + ChevronDown, + ChevronRight, + CircleStop, + Clock3, + CloudUpload, + Code2, + Database, + File, + FileDiff, + FilePlus2, + Files, + Folder, + FolderOpen, + GitBranch, + GitPullRequest, + History, + KeyRound, + Laptop, + ListChecks, + LoaderCircle, + Logs, + Monitor, + Pencil, + Play, + PlugZap, + RefreshCw, + RotateCcw, + Save, + Search, + Server, + Settings2, + ShieldAlert, + Smartphone, + Tablet, + Trash2, + TriangleAlert, + X, + type LucideIcon, +} from "lucide-react"; +import { + Suspense, + lazy, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useState, +} from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { + BuilderRunV2, + ProjectDeploymentStateV2, + ProjectFileV2, + ProjectIntegrationManifestV2, + ProjectV2, +} from "@/lib/project-v2-types"; + +import { + buildProjectV2FileTree, + createProjectV2LineDiff, + filterProjectV2FileTree, + formatProjectV2Duration, + verifiedProjectV2PreviewUrl, + type ProjectV2FileTreeNode, +} from "./project-v2-workspace-model"; +import styles from "./project-v2-workspace.module.css"; + +const LazyCodeEditor = lazy(() => import("./project-v2-code-editor")); + +export type ProjectV2WorkspaceView = + | "files" + | "preview" + | "data" + | "logic" + | "checks" + | "logs" + | "integrations" + | "history" + | "deploy"; + +export type ProjectV2PreviewDevice = "desktop" | "tablet" | "mobile"; + +export type ProjectV2WorkspaceAction = + | `save:${string}` + | `revert:${string}` + | `create:${string}` + | `rename:${string}` + | `delete:${string}` + | `task:${string}` + | `checkpoint:${string}` + | `rollback:${string}` + | "refresh-preview" + | "stop-sandbox" + | "github-inspect" + | "github-import" + | "github-publish" + | "request-deployment"; + +export interface ProjectV2GitHubFile { + path: string; + content: string; +} + +export interface ProjectV2SandboxViewState { + status: "unavailable" | "creating" | "running" | "stopping" | "stopped" | "failed"; + sandboxId?: string; + startedAt?: string; + idleDeadlineAt?: string; + message?: string; +} + +export interface ProjectV2CheckView { + id: string; + label: string; + kind: "build" | "browser" | "lint" | "security" | "test" | "typecheck"; + status: "idle" | "running" | "passed" | "failed" | "unavailable"; + message?: string; + runId?: string; + durationMs?: number; + taskId?: string; +} + +export interface ProjectV2LogEntryView { + id: string; + runId: string; + stream: "audit" | "browser" | "stderr" | "stdout"; + timestamp: string; + text: string; + truncated?: boolean; +} + +export interface ProjectV2BrowserErrorView { + id: string; + kind: "console" | "network" | "page"; + message: string; + timestamp: string; + url?: string; +} + +export interface ProjectV2DeploymentReceiptView extends ProjectDeploymentStateV2 { + id: string; + projectRevision: number; + finishedAt?: string; + commitSha?: string; + error?: string; +} + +export interface ProjectV2ReleaseReadiness { + status: "unknown" | "blocked" | "ready"; + evidence: string[]; + blockers: string[]; +} + +export interface ProjectV2WorkspaceProps { + project: ProjectV2; + selectedPath: string | null; + draftContent: string; + activeView?: ProjectV2WorkspaceView; + previewDevice?: ProjectV2PreviewDevice; + readOnly?: boolean; + busyAction?: ProjectV2WorkspaceAction | null; + comparisonFiles?: Record; + comparisonLabel?: string; + sandboxState?: ProjectV2SandboxViewState; + checks?: readonly ProjectV2CheckView[]; + logEntries?: readonly ProjectV2LogEntryView[]; + browserErrors?: readonly ProjectV2BrowserErrorView[]; + deploymentHistory?: readonly ProjectV2DeploymentReceiptView[]; + releaseReadiness?: ProjectV2ReleaseReadiness; + className?: string; + onActiveViewChange?: (view: ProjectV2WorkspaceView) => void; + onPreviewDeviceChange?: (device: ProjectV2PreviewDevice) => void; + onSelectFile: (path: string) => void; + onDraftChange: (content: string) => void; + onSaveFile?: (path: string, content: string) => void | Promise; + onRevertFile?: (path: string) => void | Promise; + onCreateFile?: (path: string) => void | Promise; + onRenameFile?: (from: string, to: string) => void | Promise; + onDeleteFile?: (path: string) => void | Promise; + onRunTask?: (taskId: string) => void | Promise; + onRefreshPreview?: () => void | Promise; + onStopSandbox?: () => void | Promise; + onRestoreCheckpoint?: (checkpointId: string) => void | Promise; + onRequestDeployment?: () => void | Promise; + onRequestRollback?: (deploymentId: string) => void | Promise; + onImportGitHubFiles?: (files: readonly ProjectV2GitHubFile[]) => void | Promise; + onOperationError?: (error: unknown, action: ProjectV2WorkspaceAction) => void; +} + +const EMPTY_CHECKS: readonly ProjectV2CheckView[] = []; +const EMPTY_LOGS: readonly ProjectV2LogEntryView[] = []; +const EMPTY_BROWSER_ERRORS: readonly ProjectV2BrowserErrorView[] = []; +const EMPTY_DEPLOYMENTS: readonly ProjectV2DeploymentReceiptView[] = []; + +const workspaceViews: Array<{ + id: ProjectV2WorkspaceView; + label: string; + icon: LucideIcon; +}> = [ + { id: "files", label: "Files", icon: Files }, + { id: "preview", label: "Preview", icon: Monitor }, + { id: "integrations", label: "Integrations", icon: PlugZap }, + { id: "data", label: "Data", icon: Database }, + { id: "logic", label: "Logic", icon: Braces }, + { id: "checks", label: "Test", icon: ListChecks }, + { id: "logs", label: "Logs", icon: Logs }, + { id: "history", label: "History", icon: History }, + { id: "deploy", label: "Deploy", icon: CloudUpload }, +]; + +function displayTime(value: string | undefined): string { + if (!value) return "Unavailable"; + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds)) return "Unavailable"; + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + timeStyle: "short", + }).format(milliseconds); +} + +function displayDuration(milliseconds: number | undefined): string { + if (milliseconds === undefined || !Number.isFinite(milliseconds)) return "Duration unavailable"; + return milliseconds < 1_000 + ? `${Math.max(0, Math.round(milliseconds))} ms` + : `${(milliseconds / 1_000).toFixed(1)} s`; +} + +function safeExternalUrl(value: string | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null; + } catch { + return null; + } +} + +function statusClass(status: string): string { + if (["available", "passed", "ready", "running", "succeeded"].includes(status)) return styles.statusPositive; + if (["failed", "blocked"].includes(status)) return styles.statusNegative; + if (["building", "creating", "pending", "queued", "starting", "stopping", "running"].includes(status)) return styles.statusProgress; + return styles.statusNeutral; +} + +function StatusBadge({ status, label }: { status: string; label?: string }) { + return ( + + {label ?? status.replaceAll("-", " ")} + + ); +} + +function FileTreeBranch({ + nodes, + expanded, + selectedPath, + searchActive, + onSelect, + onToggle, +}: { + nodes: readonly ProjectV2FileTreeNode[]; + expanded: ReadonlySet; + selectedPath: string | null; + searchActive: boolean; + onSelect: (path: string) => void; + onToggle: (path: string) => void; +}) { + return ( +
    + {nodes.map((node) => { + const open = searchActive || expanded.has(node.path); + if (node.kind === "directory") { + return ( +
  • + + {open ? ( + + ) : null} +
  • + ); + } + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function EmptyState({ + icon: Icon = Box, + title, + children, +}: { + icon?: LucideIcon; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ ); +} + +function RunStatus({ run }: { run: BuilderRunV2 }) { + return ( +
+
+ {run.taskId} + Revision {run.projectRevision} · {displayTime(run.startedAt)} +
+ +
+ ); +} + +interface GitHubReadinessPayload { + configured?: boolean; + mode?: "github-app" | "session-token-required"; + permissions?: string[]; + sessionTokenSupported?: boolean; + explicitApprovalRequired?: string[]; + error?: string; +} + +interface GitHubRepositoryView { + owner: string; + repo: string; + defaultBranch: string; + private: boolean; + url: string; +} + +interface GitHubPublishView { + branch: string; + commitSha: string; + commitUrl: string; + pullRequestNumber: number; + pullRequestUrl: string; + status: "pull-request-open"; +} + +interface GitHubActionPayload extends GitHubReadinessPayload { + repository?: GitHubRepositoryView; + files?: ProjectV2GitHubFile[]; + result?: GitHubPublishView; + confirmed?: boolean; +} + +const GITHUB_TOKEN_STORAGE_KEY = "drops-studio:github-access-token"; + +function actionError(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +function GitHubIntegrationPanel({ + project, + releaseReadiness, + readOnly, + onImportFiles, + onOperationError, +}: { + project: ProjectV2; + releaseReadiness?: ProjectV2ReleaseReadiness; + readOnly: boolean; + onImportFiles?: (files: readonly ProjectV2GitHubFile[]) => void | Promise; + onOperationError?: (error: unknown, action: ProjectV2WorkspaceAction) => void; +}) { + const [readiness, setReadiness] = useState(null); + const [readinessError, setReadinessError] = useState(null); + const [tokenDraft, setTokenDraft] = useState(""); + const [tokenConnected, setTokenConnected] = useState(false); + const [owner, setOwner] = useState(""); + const [repo, setRepo] = useState(""); + const [branch, setBranch] = useState(""); + const [repository, setRepository] = useState(null); + const [publishReceipt, setPublishReceipt] = useState(null); + const [busy, setBusy] = useState<"inspect" | "import" | "publish" | null>(null); + const [statusMessage, setStatusMessage] = useState( + "Check configuration, then inspect a repository before importing or publishing.", + ); + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setTokenConnected(Boolean(window.sessionStorage.getItem(GITHUB_TOKEN_STORAGE_KEY))); + void fetch("/api/integrations/github", { + credentials: "same-origin", + cache: "no-store", + headers: { accept: "application/json" }, + signal: controller.signal, + }).then(async (response) => { + const payload = await response.json().catch(() => ({})) as GitHubReadinessPayload; + if (!response.ok) throw new Error(payload.error ?? "GitHub readiness is unavailable."); + setReadiness(payload); + setReadinessError(null); + }).catch((error) => { + if (controller.signal.aborted) return; + setReadinessError(actionError(error, "GitHub readiness is unavailable.")); + }); + }, 0); + return () => { + controller.abort(); + window.clearTimeout(timer); + }; + }, []); + + const credentialsReady = Boolean(readiness?.configured || tokenConnected); + const repositoryInputReady = Boolean(owner.trim() && repo.trim()); + const canInspect = Boolean(readiness && !readinessError && credentialsReady && repositoryInputReady); + const externalDisabled = readOnly || Boolean(busy) || !canInspect; + + function requestHeaders(): Record { + const headers: Record = { + accept: "application/json", + "content-type": "application/json", + }; + const token = window.sessionStorage.getItem(GITHUB_TOKEN_STORAGE_KEY); + if (token) headers["x-github-access-token"] = token; + return headers; + } + + async function requestGitHub( + action: "inspect" | "import" | "publish", + values: Record, + ): Promise { + const response = await fetch("/api/integrations/github", { + method: "POST", + credentials: "same-origin", + headers: requestHeaders(), + body: JSON.stringify({ action, owner: owner.trim(), repo: repo.trim(), ...values }), + }); + const payload = await response.json().catch(() => ({})) as GitHubActionPayload; + if (!response.ok) throw new Error(payload.error ?? `GitHub ${action} failed.`); + return payload; + } + + async function runGitHubAction( + action: "inspect" | "import" | "publish", + operation: () => Promise, + ) { + if (busy) return; + setBusy(action); + setStatusMessage(`${action.charAt(0).toUpperCase()}${action.slice(1)} in progress…`); + try { + await operation(); + } catch (error) { + const failure = actionError(error, `GitHub ${action} failed.`); + setStatusMessage(failure); + onOperationError?.(error, `github-${action}`); + } finally { + setBusy(null); + } + } + + function connectToken() { + const token = tokenDraft.trim(); + if (token.length < 20 || token.length > 512 || /\s/.test(token)) { + setStatusMessage("Enter a valid GitHub token. It is kept only in this browser session."); + return; + } + window.sessionStorage.setItem(GITHUB_TOKEN_STORAGE_KEY, token); + setTokenDraft(""); + setTokenConnected(true); + setStatusMessage("Session-only GitHub token connected. It is not stored in project files."); + } + + function disconnectToken() { + window.sessionStorage.removeItem(GITHUB_TOKEN_STORAGE_KEY); + setTokenDraft(""); + setTokenConnected(false); + setStatusMessage("Session-only GitHub token removed."); + } + + const repositoryUrl = safeExternalUrl(repository?.url); + const commitUrl = safeExternalUrl(publishReceipt?.commitUrl); + const pullRequestUrl = safeExternalUrl(publishReceipt?.pullRequestUrl); + + return ( +
+
+
GitHubImport source or open a reviewable pull request
+
+
+
+
+ + {readiness?.permissions?.join(" · ") ?? "Least-privilege readiness not received"} +
+ {!readiness?.configured ? ( +
+ + + +
+ ) : ( +

The server-selected GitHub App installation is used. No installation or repository credential is accepted from project source.

+ )} +
+ +
+ + + +
+ +
+ + + +
+ +

{readinessError ?? statusMessage}

+ {repository ? ( +
+ {repository.owner}/{repository.repo} + {repository.private ? "Private" : "Public"} · default {repository.defaultBranch} + {repositoryUrl ? View repository : null} +
+ ) : null} + {publishReceipt ? ( +
+ {publishReceipt.branch} + {publishReceipt.commitSha.slice(0, 12)} + {commitUrl ? View commit : null} + {pullRequestUrl ? Open pull request #{publishReceipt.pullRequestNumber} : null} +
+ ) : null} + {releaseReadiness?.status !== "ready" ?

Opening a pull request stays disabled until the current revision has verified release-gate evidence.

: null} +
+
+ ); +} + +export function ProjectV2Workspace({ + project, + selectedPath, + draftContent, + activeView, + previewDevice, + readOnly = false, + busyAction = null, + comparisonFiles, + comparisonLabel = "Comparison snapshot", + sandboxState, + checks = EMPTY_CHECKS, + logEntries = EMPTY_LOGS, + browserErrors = EMPTY_BROWSER_ERRORS, + deploymentHistory = EMPTY_DEPLOYMENTS, + releaseReadiness, + className, + onActiveViewChange, + onPreviewDeviceChange, + onSelectFile, + onDraftChange, + onSaveFile, + onRevertFile, + onCreateFile, + onRenameFile, + onDeleteFile, + onRunTask, + onRefreshPreview, + onStopSandbox, + onRestoreCheckpoint, + onRequestDeployment, + onRequestRollback, + onImportGitHubFiles, + onOperationError, +}: ProjectV2WorkspaceProps) { + const [localView, setLocalView] = useState(activeView ?? "files"); + const [localDevice, setLocalDevice] = useState(previewDevice ?? "desktop"); + const [searchQuery, setSearchQuery] = useState(""); + const deferredSearch = useDeferredValue(searchQuery); + const [expanded, setExpanded] = useState>(() => new Set(["app", "components", "lib", "src", "tests"])); + const [internalAction, setInternalAction] = useState(null); + const [fileAction, setFileAction] = useState<"create" | "rename" | "delete" | null>(null); + const [fileActionPath, setFileActionPath] = useState(""); + const [sourceMode, setSourceMode] = useState<"editor" | "diff">("editor"); + const [logFilter, setLogFilter] = useState<"all" | ProjectV2LogEntryView["stream"]>("all"); + const [now, setNow] = useState(() => Date.now()); + + const view = activeView ?? localView; + const device = previewDevice ?? localDevice; + const pendingAction = busyAction ?? internalAction; + const selectedFile = selectedPath ? project.files[selectedPath] : undefined; + const unsaved = Boolean(selectedFile && draftContent !== selectedFile.content); + const previewUrl = sandboxState?.status === "running" + ? verifiedProjectV2PreviewUrl(project.preview) + : null; + const fileTree = useMemo( + () => buildProjectV2FileTree(Object.keys(project.files)), + [project.files], + ); + const filteredTree = useMemo( + () => filterProjectV2FileTree(fileTree, deferredSearch), + [deferredSearch, fileTree], + ); + const comparisonFile = selectedPath ? comparisonFiles?.[selectedPath] : undefined; + const diffLines = useMemo( + () => comparisonFile ? createProjectV2LineDiff(comparisonFile.content, draftContent) : [], + [comparisonFile, draftContent], + ); + const visibleLogs = useMemo( + () => logFilter === "all" ? logEntries : logEntries.filter((entry) => entry.stream === logFilter), + [logEntries, logFilter], + ); + const latestRuns = useMemo( + () => [...project.runs].sort((left, right) => right.startedAt.localeCompare(left.startedAt)).slice(0, 8), + [project.runs], + ); + const projectDataIntegration = useMemo( + () => project.integrations.find((integration) => integration.kind === "project-data"), + [project.integrations], + ); + const dataDefinitionFiles = useMemo( + () => Object.values(project.files).filter((file) => + file.role === "config" + || file.role === "integration" + || /(?:schema|data|project)\.(?:json|ts|tsx)$/i.test(file.path), + ), + [project.files], + ); + + useEffect(() => { + const active = sandboxState?.status === "running" || sandboxState?.status === "creating"; + if (!active) return undefined; + const timer = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, [sandboxState?.status]); + + const setView = useCallback((next: ProjectV2WorkspaceView) => { + setLocalView(next); + onActiveViewChange?.(next); + }, [onActiveViewChange]); + + const setDevice = useCallback((next: ProjectV2PreviewDevice) => { + setLocalDevice(next); + onPreviewDeviceChange?.(next); + }, [onPreviewDeviceChange]); + + const runAction = useCallback(async ( + action: ProjectV2WorkspaceAction, + operation: (() => void | Promise) | undefined, + ) => { + if (!operation || pendingAction) return; + setInternalAction(action); + try { + await operation(); + } catch (error) { + onOperationError?.(error, action); + } finally { + setInternalAction(null); + } + }, [onOperationError, pendingAction]); + + function toggleDirectory(path: string) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + } + + function openFileAction(action: "create" | "rename" | "delete") { + setFileAction(action); + setFileActionPath(action === "rename" ? selectedPath ?? "" : ""); + } + + async function submitFileAction() { + const path = fileActionPath.trim(); + if (fileAction === "create" && path) { + await runAction(`create:${path}`, () => onCreateFile?.(path)); + } else if (fileAction === "rename" && selectedPath && path && path !== selectedPath) { + await runAction(`rename:${selectedPath}`, () => onRenameFile?.(selectedPath, path)); + } else if (fileAction === "delete" && selectedPath) { + await runAction(`delete:${selectedPath}`, () => onDeleteFile?.(selectedPath)); + } else { + return; + } + setFileAction(null); + setFileActionPath(""); + } + + return ( +
+
+
+ +
+ {project.manifest.name} + Revision {project.revision} · {project.manifest.framework.name} · Node {project.manifest.runtime.version} +
+
+
+ {unsaved ? : } + {readOnly ? : null} +
+
+ + + + {readOnly ? ( +
+
+ ) : null} + +
+ {view === "files" ? ( +
+ + +
+ {selectedFile ? ( + <> +
+
+ {selectedFile.path} + {selectedFile.language} · {selectedFile.provenance} · {selectedFile.bytes.toLocaleString()} bytes +
+
+
+ + +
+ + + +
+
+ {sourceMode === "diff" && comparisonFile ? ( +
+
{comparisonLabel}− old · + current draft
+
+ {diffLines.map((line, index) => ( +
+ {line.oldLine ?? ""} + {line.newLine ?? ""} + {line.kind === "added" ? "+" : line.kind === "removed" ? "−" : " "} + {line.content || " "} +
+ ))} +
+
+ ) : ( +
+
}> + + +
+ )} +
+ {unsaved ? "Draft differs from revision" : "Matches saved revision"} + SHA-256 {selectedFile.hash.slice(0, 12)} +
+ + ) : ( + Choose a file from the tree to inspect or edit its canonical content. + )} +
+
+ ) : null} + + {view === "preview" ? ( +
+
+
+ {([ + ["desktop", Laptop, "Desktop"], + ["tablet", Tablet, "Tablet"], + ["mobile", Smartphone, "Mobile"], + ] as const).map(([id, Icon, label]) => ( + + ))} +
+
+ + +
+
+
+
+ +
+
+
+ {sandboxState?.message ?

{sandboxState.message}

: null} +
+ {previewUrl ? ( +