diff --git a/.env.example b/.env.example index 834c5ba..afad0dd 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ VERCEL_OIDC_TOKEN= # AI Gateway is normally authenticated by Vercel OIDC. Set an explicit key only outside that flow. AI_GATEWAY_API_KEY= + +# Stripe Billing and signed team collaboration. Values are configured only in the provider environment. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRO_PRICE_ID= +DROPS_TEAM_INVITE_SECRET= diff --git a/.github/workflows/ui-quality.yml b/.github/workflows/ui-quality.yml index 65cd6c3..d31acd2 100644 --- a/.github/workflows/ui-quality.yml +++ b/.github/workflows/ui-quality.yml @@ -20,6 +20,7 @@ jobs: env: CI: "true" TZ: UTC + DROPSTAB_API_KEY: "" steps: - name: Check out repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 diff --git a/DESIGN.md b/DESIGN.md index 0d71fe7..232dfb4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -27,9 +27,10 @@ The current rebuilt product architecture is the source of truth. Do not restore - Native size: 1280 x 790 - SHA-256: `8781ecdb749449b31f48935a8918c852ebeeeb6f5e59686cfa91aa5df1ec26c6` - Captured from the current rebuilt editor on 2026-07-29 before the premium UI correction. -- Preserve: one unified left workspace navigation with its contextual inspector, the central live product canvas, the current Design Mode/direct element editing, the right AI Director conversation when space permits, top Run/Connections/Share/Publish actions, and the persistent status bar. -- Correct in place: unreadable microcopy, cramped controls, panel sizing, responsive collapse, proposal quality, category-specific editing depth, and publish/connection clarity. +- Preserve: one unified left workspace navigation with its contextual inspector, the central live product canvas, the current Design Mode/direct element editing, Director inside the existing left tool (or an intentional overlay/sheet), top Run/Connections/Share/Publish actions, and the persistent status bar. +- Correct in place: unreadable microcopy, cramped controls, contextual-surface sizing, responsive collapse, proposal quality, category-specific editing depth, and publish/connection clarity. - Do not restore the earlier separate fixed publish sidebar or the older Project/AI brain/Branding rail. Publish and connections remain current actions and contextual surfaces. +- Never render Director as a persistent right-side column at any viewport width. A wide viewport may enlarge the canvas, but it must not make the right-side panel reappear. 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. @@ -63,7 +64,7 @@ No 5–11 px text is permitted in source or computed styles. ### 1440+ - Start builder: two balanced columns, builder 56–60%, preview 40–44%, maximum content width 1500 px. -- Project Studio: one 72–400 px unified left navigation/inspector surface depending on the selected tool, flexible live canvas, and a 340–400 px AI Director panel. Publish is a current contextual dialog/sheet, not a permanently restored legacy column. +- Project Studio: one 72–540 px unified left navigation/context surface depending on the selected tool and a flexible live canvas. Director occupies that existing left tool or an intentional overlay/sheet; it is never a persistent right column. Publish remains a contextual dialog/sheet. - Never scale the entire UI down to fit. Panels scroll independently where appropriate. ### 1024 @@ -98,6 +99,20 @@ No 5–11 px text is permitted in source or computed styles. New components use Base UI 1.6 via shadcn CLI v4 and remain editable under `components/ui`. Use Lucide icons. Existing Radix Dialog, Select, and Switch may remain only until their bounded migration passes keyboard, Axe, and visual tests. +`app/globals.css` is an import-only manifest and must remain below 4 KiB. New +product surfaces belong in local React components using Tailwind CSS v4 and the +shared Base UI primitives; do not add another monolithic hand-written CSS file. +The release guardrail rejects any source declaration below 12 px and any manual +style block added back to `globals.css`. It also rejects any individual manual +stylesheet above 48 KiB so a new monolithic CSS surface cannot silently return. + +`app/styles/project-studio.runtime.css` is a bounded legacy exception for the +existing Studio runtime canvas and portalled publish dialog. Targeted +accessibility and regression fixes may update those existing selectors, but no +new product surface or component may be added there; new UI stays in local +Tailwind CSS v4 and Base UI components. The file remains subject to the 48 KiB +stylesheet cap and is migrated only in reviewed, browser-tested slices. + Storybook 10.5.5 must cover primitives and important product states: default, hover, focus, disabled, loading, error, connected, disconnected, empty, populated, desktop, and mobile. ## Motion diff --git a/README.md b/README.md index 8c433ec..885a723 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Drops Studio -Drops Studio turns a crypto idea into a real, editable and publishable product powered by DropsTab intelligence and Drops Bot automation. +Drops Studio turns a crypto idea into a real, editable and publishable product powered by DropsTab intelligence with explicit Drops Bot setup handoffs. The start page is a prompt-first recipe builder. Choose one of 12 products, tune its settings or describe something custom, then build. Drops Studio compiles a standalone application and opens its Project Studio, where the user can run it, change logic and branding, publish a public URL and download the runnable source. @@ -26,13 +26,30 @@ must not be used to restore an older editor layout. block variants/visibility, uploaded hero artwork and category-specific chat directions - explicit Plan and Build Now flows with visible compilation stages - editable Project Studio for project metadata, data, product logic, AI, - branding, validated source, release checks and checkpoints -- sandboxed live application preview + branding, a canonical multi-file source workspace with up to six bounded npm + packages, release checks and checkpoints +- bounded AI source patches with GPT-5.6 Sol first when the platform route is + configured, a free-model fallback, request-only BYOK and optimistic revisions +- browser live preview plus real root or package-scoped Check, Test, Build and + Start tasks in an ephemeral Vercel Sandbox Firecracker microVM, with + stdout/stderr/exit receipts - DropsTab Public API production adapter with a 15-minute shared cache, no generated-app polling, user-triggered BYOK snapshots and a clearly labelled public demo fallback -- Drops Bot alert, channel, Telegram and action handoffs +- owner-scoped webhook receivers for user-registered Drops Bot callbacks, with + one-time capabilities, redacted/idempotent events and provider-unverified receipts +- Telegram MTProto channel creation, bot administration and provider-confirmed + first-post delivery, plus the session-only existing-channel Bot API fallback +- Stripe-backed Pro checkout, billing portal and signed subscription webhooks, + with provider-confirmed 100-build and 100-sandbox-run daily allowances that + fail closed to Member +- revisioned team workspaces with one-time invites, validated canonical + multi-file source, owner/editor writes, and read-only viewer apply when Pro + billing and durable storage are configured; provider keys, runtime receipts, + terminal output and compiled HTML never enter the shared draft - one-click free public publishing to an anonymous `/p/{slug}` application URL - deterministic quality gate on every edit and before publishing -- runnable source ZIP with `index.html`, editable project config, integration manifest, quality report, smoke test and Vercel, Cloudflare, Netlify and GitHub Pages files +- runnable source ZIP with the complete editable workspace, exact dependency + manifest, tasks, `index.html`, project/integration manifests, quality report, + smoke test and Vercel, Cloudflare, Netlify and GitHub Pages files - local project persistence and automatic migration from the earlier blueprint prototype - responsive builder, Studio and standalone products @@ -67,9 +84,33 @@ change creates a restorable checkpoint. - A deployment can set `DROPSTAB_API_KEY` server-side so every published app uses the official DropsTab Public API without exposing the key. Without it, apps label the public demo feed as a fallback. - The platform-owned feed is cached for 15 minutes and targets one shared market request per warm runtime cache window; CDN caching and in-flight de-duplication suppress duplicate traffic. Serverless cold starts, regions and retries mean this is a budget policy, not a false global hard cap. Generated apps do not poll it. A visitor's own key is called only on an explicit connect or refresh action. - Generated products preserve DropsTab attribution, compatible market data and research links. -- Drops Bot actions continue through the official Telegram product; the app never claims an undocumented remote configuration succeeded. +- Drops Bot callback registration continues through the official `@drops` API + screen until its public documentation exposes a stable registration endpoint; + the app never claims that an undocumented remote configuration succeeded. - Trading-like actions are explicit research, paper-mode or official-product handoffs until the user approves an action in the connected product. -- Connected models return a validated JSON design object. They never author the executable runtime. +- The visual Director returns a validated design object. The source workspace + path may return only strict create/update/delete file operations; it cannot + directly invoke commands, install lifecycle scripts, add lockfiles, persist + secrets or escape through traversal paths. Validated manifest scripts become + explicit task buttons and run only after a user selects one in the isolated + sandbox. Every patch is compiled and validated before it becomes a revision. + Canonical HTML permits only the inert `projectSpec` JSON block and the exact + local CSS/runtime entries; extra scripts, active embeds, link loads, inline + handlers, script-scheme URLs and outbound form actions are rejected. +- A multi-package workspace is deliberately bounded: the root manifest may list + at most six explicit `packages/` directories (never globs or URLs), + every package manifest stays private, and dependencies/devDependencies use + exact registry versions. AI revisions allow 24 aggregate dependencies; the + isolated sandbox accepts at most 64. Canonical source and sandbox input share + a 1.5 MB total limit. +- Pro and team capabilities activate only from a signed Stripe webhook for the + exact configured Price. Missing billing, invite or durable-storage secrets + keep checkout and collaboration visibly unavailable instead of granting a + client-asserted tier. +- Public builds, root ZIP apps and the generated workspace server enforce + restrictive, same-origin CSP boundaries. Client ZIP export always records + provider evidence as `unverified`; browser iframe telemetry is never promoted + into a DropsTab provider claim. See [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md), [docs/ACCESS_TIERS.md](docs/ACCESS_TIERS.md), [docs/PREMIUM_RELEASE.md](docs/PREMIUM_RELEASE.md), [docs/COMPETITIVE-BENCHMARK.md](docs/COMPETITIVE-BENCHMARK.md) and [docs/ACCOUNTABILITY_REPORT_RU.md](docs/ACCOUNTABILITY_REPORT_RU.md) for the product, access-tier, competitor, security and process-correction contracts. @@ -113,6 +154,8 @@ Visual baselines are never updated without explicit approval. primitives, short Framer Motion transitions and Lucide icons - Cloudflare D1 on Sites or Vercel Blob on the public fallback for published-project persistence +- Vercel Sandbox for isolated Node 24 task execution with network-denied runtime + and registry-only dependency installation with install scripts disabled - Fflate for browser-side runnable source archives - Cloudflare Workers-compatible Sites runtime diff --git a/app/api/access/route.ts b/app/api/access/route.ts index 40aa3ac..0e43e4c 100644 --- a/app/api/access/route.ts +++ b/app/api/access/route.ts @@ -3,9 +3,9 @@ import { accessMetadata, GUEST_IDENTITY_COOKIE, GUEST_USAGE_COOKIE, - MEMBER_DAILY_LIMIT, memberProjectSyncReadiness, platformAiReadiness, + resolveFundedBuildQuota, resolveGuestAccess, resolveStudioAccount, STUDIO_ACCOUNT_COOKIE, @@ -18,12 +18,15 @@ export async function GET(request: NextRequest) { 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 quota = readiness.available ? await readRequestLimitState({ identity: account.identity, namespace: "member-ai-plan", - max: MEMBER_DAILY_LIMIT, + max: memberLimit, windowMs: 24 * 60 * 60 * 1_000, }) : { status: "unavailable" as const, count: null, remaining: null }; @@ -31,10 +34,11 @@ export async function GET(request: NextRequest) { return NextResponse.json( { access: accessMetadata({ - tier: platformAvailable ? "member" : "fallback", + tier: platformAvailable ? memberTier : "fallback", used: quota.count ?? 0, account, projectSyncAvailable: memberProjectSyncReadiness(), + platformLimit: memberLimit, }), quotaSigningConfigured: readiness.signingConfigured, }, diff --git a/app/api/agent/plan/route.ts b/app/api/agent/plan/route.ts index 9ffcfbb..b2669b7 100644 --- a/app/api/agent/plan/route.ts +++ b/app/api/agent/plan/route.ts @@ -10,9 +10,9 @@ import { GUEST_DAILY_LIMIT, GUEST_IDENTITY_COOKIE, GUEST_USAGE_COOKIE, - MEMBER_DAILY_LIMIT, MEMBER_USAGE_COOKIE, platformAiReadiness, + consumeFundedBuildQuota, resolveAccountCookieSecret, resolveGuestAccess, resolveStudioAccount, @@ -455,20 +455,23 @@ export async function POST(request: NextRequest) { warning: "Signed-in platform AI is not fully configured. The local product compiler created this build without consuming a model allowance.", }, account, 0); } - const quota = await consumeRequestLimitState({ - identity: account.identity, - namespace: "member-ai-plan", - max: MEMBER_DAILY_LIMIT, - windowMs: 24 * 60 * 60 * 1_000, - }); + const quota = await consumeFundedBuildQuota({ kind: "account", account }); + const memberTier = quota.tier; + const memberLimit = quota.limit; if (quota.status === "limited") { return responseWithMemberQuota({ error: "Signed-in AI build limit reached.", code: "MEMBER_LIMIT", remaining: 0, connect: "openrouter", - access: accessMetadata({ tier: "member", used: quota.count ?? MEMBER_DAILY_LIMIT, account }), - }, account, quota.count ?? MEMBER_DAILY_LIMIT, 429); + tier: memberTier, + access: accessMetadata({ + tier: memberTier, + used: quota.count ?? memberLimit, + account, + platformLimit: memberLimit, + }), + }, account, quota.count ?? memberLimit, 429); } if (quota.status === "unavailable" || quota.count === null) { const fallback = fallbackAgentPlan(prompt); @@ -486,11 +489,16 @@ export async function POST(request: NextRequest) { const plan = alignPlanToRequestedOutput(result.plan, prompt); return responseWithMemberQuota({ plan, - tier: "member", + tier: memberTier, model: result.model, usage: result.usage, remaining: quota.remaining, - access: accessMetadata({ tier: "member", used: quota.count, account }), + access: accessMetadata({ + tier: memberTier, + used: quota.count, + account, + platformLimit: memberLimit, + }), }, account, quota.count); } catch (error) { const fallback = fallbackAgentPlan(prompt); @@ -555,11 +563,9 @@ export async function POST(request: NextRequest) { }, guest, used); } - const quota = await consumeRequestLimitState({ + const quota = await consumeFundedBuildQuota({ + kind: "guest", identity: guest.identity, - namespace: "guest-ai-plan", - max: GUEST_DAILY_LIMIT, - windowMs: 24 * 60 * 60 * 1_000, }); if (quota.status === "limited") { return responseWithQuota({ diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..2388e16 --- /dev/null +++ b/app/api/billing/checkout/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + BillingUnavailableError, + BillingValidationError, + createProCheckout, + stripeBillingProvider, + stripeCheckoutConfiguration, +} from "@/lib/billing"; +import { + billingRepository, + billingStorageConfigured, + BillingStorageUnavailableError, +} from "@/db/billing"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; +import { consumeRequestLimit } from "@/lib/request-rate-limit"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HEADERS = { "cache-control": "private, no-store, max-age=0", vary: "Cookie" }; + +function json(error: string, status: number) { + return NextResponse.json({ error }, { status, headers: HEADERS }); +} + +function sameOrigin(request: NextRequest): boolean { + const origin = request.headers.get("origin"); + if (!origin || request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false; + try { + return new URL(origin).origin === request.nextUrl.origin; + } catch { + return false; + } +} + +export async function POST(request: NextRequest) { + const config = stripeCheckoutConfiguration(); + if (!config || !billingStorageConfigured()) { + return json("Billing is not configured or unavailable.", 503); + } + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!account) return json("A signed Studio member account is required.", 401); + if (!sameOrigin(request)) return json("Cross-origin billing request rejected.", 403); + if (!hasJsonMediaType(request)) { + return json("Billing checkout requires application/json.", 415); + } + let raw: string; + try { + raw = decodeUtf8Body(await readBoundedRequestBody(request, 2_048)); + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + return json("Billing checkout request is too large.", 413); + } + return json("Billing checkout request is invalid.", 400); + } + let consent = false; + try { + consent = (JSON.parse(raw) as { consent?: unknown }).consent === true; + } catch { + return json("Billing checkout request is invalid.", 400); + } + const limit = await consumeRequestLimit({ + identity: account.identity, + namespace: "billing-checkout", + max: 8, + windowMs: 60 * 60 * 1_000, + }); + if (limit === "limited") return json("Too many checkout requests. Try again later.", 429); + if (limit === "unavailable") return json("Billing request protection is unavailable.", 503); + try { + const receipt = await createProCheckout( + { accountIdentity: account.identity, origin: request.nextUrl.origin, consent }, + { + config, + repository: billingRepository, + provider: stripeBillingProvider(config.secretKey), + }, + ); + return NextResponse.json(receipt, { status: 201, headers: HEADERS }); + } catch (error) { + if (error instanceof BillingValidationError) return json(error.message, 400); + if ( + error instanceof BillingUnavailableError + || error instanceof BillingStorageUnavailableError + ) { + return json("Billing is not configured or unavailable.", 503); + } + return json("Stripe checkout could not be created.", 502); + } +} diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts new file mode 100644 index 0000000..80e174b --- /dev/null +++ b/app/api/billing/portal/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + BillingUnavailableError, + BillingValidationError, + createCustomerPortal, + stripeBillingProvider, + stripeCheckoutConfiguration, +} from "@/lib/billing"; +import { + billingRepository, + billingStorageConfigured, + BillingStorageUnavailableError, +} from "@/db/billing"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; +import { consumeRequestLimit } from "@/lib/request-rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HEADERS = { "cache-control": "private, no-store, max-age=0", vary: "Cookie" }; + +function json(error: string, status: number) { + return NextResponse.json({ error }, { status, headers: HEADERS }); +} + +function sameOrigin(request: NextRequest): boolean { + const origin = request.headers.get("origin"); + if (!origin || request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false; + try { + return new URL(origin).origin === request.nextUrl.origin; + } catch { + return false; + } +} + +export async function POST(request: NextRequest) { + const config = stripeCheckoutConfiguration(); + if (!config || !billingStorageConfigured()) { + return json("Billing is not configured or unavailable.", 503); + } + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!account) return json("A signed Studio member account is required.", 401); + if (!sameOrigin(request)) return json("Cross-origin billing request rejected.", 403); + const limit = await consumeRequestLimit({ + identity: account.identity, + namespace: "billing-portal", + max: 20, + windowMs: 60 * 60 * 1_000, + }); + if (limit === "limited") return json("Too many portal requests. Try again later.", 429); + if (limit === "unavailable") return json("Billing request protection is unavailable.", 503); + try { + const receipt = await createCustomerPortal( + { accountIdentity: account.identity, origin: request.nextUrl.origin }, + { + config, + repository: billingRepository, + provider: stripeBillingProvider(config.secretKey), + }, + ); + return NextResponse.json(receipt, { headers: HEADERS }); + } catch (error) { + if (error instanceof BillingValidationError) return json(error.message, 404); + if ( + error instanceof BillingUnavailableError + || error instanceof BillingStorageUnavailableError + ) { + return json("Billing is not configured or unavailable.", 503); + } + return json("Stripe customer portal could not be created.", 502); + } +} diff --git a/app/api/billing/status/route.ts b/app/api/billing/status/route.ts new file mode 100644 index 0000000..7169e95 --- /dev/null +++ b/app/api/billing/status/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + billingEntitlements, + billingTierForAccount, + stripeProPriceId, +} from "@/lib/billing"; +import { + billingStorageConfigured, + BillingStorageUnavailableError, + readBillingAccount, +} from "@/db/billing"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HEADERS = { "cache-control": "private, no-store, max-age=0", vary: "Cookie" }; + +function json(payload: Record, status: number) { + return NextResponse.json(payload, { status, headers: HEADERS }); +} + +export async function GET(request: NextRequest) { + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!account) return json({ error: "A signed Studio member account is required." }, 401); + if (!billingStorageConfigured()) { + return json({ error: "Billing status is not configured or unavailable." }, 503); + } + try { + const billing = await readBillingAccount(account.identity); + const tier = billingTierForAccount(billing, stripeProPriceId()); + return json({ + tier, + entitlements: billingEntitlements(tier), + billing: billing + ? { + status: billing.status, + cancelAtPeriodEnd: billing.cancelAtPeriodEnd, + currentPeriodEnd: billing.currentPeriodEnd, + } + : { + status: "none", + cancelAtPeriodEnd: false, + currentPeriodEnd: null, + }, + }, 200); + } catch (error) { + if (error instanceof BillingStorageUnavailableError) { + return json({ error: "Billing status is temporarily unavailable." }, 503); + } + return json({ error: "Billing status could not be read safely." }, 500); + } +} diff --git a/app/api/billing/webhook/route.ts b/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..9b4585b --- /dev/null +++ b/app/api/billing/webhook/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + BillingValidationError, + stripeWebhookConfiguration, + verifyStripeWebhook, +} from "@/lib/billing"; +import { + applyBillingWebhookEvent, + billingStorageConfigured, + BillingStorageUnavailableError, +} from "@/db/billing"; +import { + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_WEBHOOK_BYTES = 512 * 1_024; +const HEADERS = { "cache-control": "no-store, max-age=0" }; + +function json(payload: Record, status: number) { + return NextResponse.json(payload, { status, headers: HEADERS }); +} + +export async function POST(request: NextRequest) { + const config = stripeWebhookConfiguration(); + if (!config || !billingStorageConfigured()) { + return json({ error: "Billing webhook is not configured or unavailable." }, 503); + } + const signature = request.headers.get("stripe-signature")?.trim() ?? ""; + let raw: Uint8Array; + try { + raw = await readBoundedRequestBody(request, MAX_WEBHOOK_BYTES); + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + return json({ error: "Stripe webhook payload is too large." }, 413); + } + return json({ error: "Stripe webhook payload is unreadable." }, 400); + } + try { + const event = verifyStripeWebhook(raw, signature, config.webhookSecret); + const result = await applyBillingWebhookEvent(event); + return json({ received: true, duplicate: result.status === "duplicate" }, 200); + } catch (error) { + if (error instanceof BillingValidationError) { + return json({ error: "Stripe webhook signature verification failed." }, 400); + } + if (error instanceof BillingStorageUnavailableError) { + return json({ error: "Billing webhook storage is unavailable." }, 503); + } + return json({ error: "Billing webhook could not be processed safely." }, 500); + } +} diff --git a/app/api/dropsbot/events/route.ts b/app/api/dropsbot/events/route.ts new file mode 100644 index 0000000..f263df5 --- /dev/null +++ b/app/api/dropsbot/events/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + dropsBotWebhookStorageConfigured, + DropsBotWebhookStorageUnavailableError, + listDropsBotWebhookProject, +} from "../../../../db/dropsbot-webhooks.ts"; +import { + listMemberProjects, + MemberProjectStorageUnavailableError, +} from "../../../../db/member-projects.ts"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "../../../../lib/access-tier.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { + "cache-control": "private, no-store, max-age=0", + vary: "Cookie", +}; + +function json(payload: Record, status: number): NextResponse { + return NextResponse.json(payload, { status, headers: NO_STORE_HEADERS }); +} + +function projectId(value: string | null): string | null { + return value && /^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value) ? value : null; +} + +function eventLimit(value: string | null): number | null { + if (value === null || value === "") return 50; + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 100 ? parsed : null; +} + +export async function GET(request: NextRequest): Promise { + const member = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!member) { + return json({ + code: "DROPSBOT_ACCOUNT_REQUIRED", + error: "Connect a signed Studio account before reading callback events.", + }, 401); + } + const requestedProjectId = projectId(request.nextUrl.searchParams.get("projectId")); + const limit = eventLimit(request.nextUrl.searchParams.get("limit")); + if (!requestedProjectId || limit === null) { + return json({ + error: "A valid projectId and an optional limit from 1 to 100 are required.", + }, 400); + } + + try { + if (!dropsBotWebhookStorageConfigured()) { + throw new DropsBotWebhookStorageUnavailableError(); + } + const projects = await listMemberProjects(member.identity); + if (!projects.some((project) => project.id === requestedProjectId)) { + return json({ error: "Signed project not found." }, 404); + } + const project = await listDropsBotWebhookProject( + member.identity, + requestedProjectId, + ); + if (!project) { + return json({ error: "Drops Bot callback not found for this project." }, 404); + } + return json({ + connection: { + id: project.connectionId, + projectId: project.projectId, + createdAt: project.createdAt, + consentedAt: project.consentedAt, + }, + events: project.events.slice(0, limit), + callbackEvidence: project.callbackEvidence, + registration: { + mode: "manual-in-@drops", + officialSurface: "https://t.me/Drops", + claimedConfigured: false, + }, + }, 200); + } catch (error) { + if ( + error instanceof DropsBotWebhookStorageUnavailableError + || error instanceof MemberProjectStorageUnavailableError + ) { + return json({ + error: "Callback event storage is temporarily unavailable.", + }, 503); + } + console.error("Unexpected callback event list failure.", error); + return json({ + error: "Callback events are temporarily unavailable.", + }, 503); + } +} diff --git a/app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts b/app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts new file mode 100644 index 0000000..0904852 --- /dev/null +++ b/app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + acceptDropsBotWebhookEvent, + DropsBotWebhookCapacityError, + DropsBotWebhookStorageUnavailableError, +} from "../../../../../../db/dropsbot-webhooks.ts"; +import { + dropsBotWebhookEventId, + DropsBotWebhookValidationError, + hashDropsBotWebhookCapability, + hashDropsBotWebhookContent, + parseDropsBotWebhookPayload, + readDropsBotWebhookBody, + validDropsBotWebhookCapability, +} from "../../../../../../lib/dropsbot-webhook.ts"; +import { hasJsonMediaType } from "../../../../../../lib/http-request-boundary.ts"; +import { + consumeRequestLimit, + requestIdentity, +} from "../../../../../../lib/request-rate-limit.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { + "cache-control": "no-store, max-age=0", + "referrer-policy": "no-referrer", +}; + +function json(payload: Record, status: number): NextResponse { + return NextResponse.json(payload, { status, headers: NO_STORE_HEADERS }); +} + +function notFound(): NextResponse { + return json({ error: "Drops Bot callback not found." }, 404); +} + +function validConnectionId(value: string): boolean { + return /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ connectionId: string; capability: string }> }, +): Promise { + const { connectionId, capability } = await context.params; + if (!validConnectionId(connectionId) || !validDropsBotWebhookCapability(capability)) { + return notFound(); + } + if (!hasJsonMediaType(request)) { + return json({ + code: "DROPSBOT_WEBHOOK_CONTENT_TYPE", + error: "Drops Bot callbacks require application/json.", + }, 415); + } + + const limit = await consumeRequestLimit({ + identity: `${connectionId}:${requestIdentity(request)}`, + namespace: "dropsbot-callback", + max: 120, + windowMs: 60_000, + }).catch(() => "unavailable" as const); + if (limit === "limited") { + return NextResponse.json({ + code: "DROPSBOT_WEBHOOK_RATE_LIMITED", + error: "Too many Drops Bot callbacks. Retry after the current rate-limit window.", + }, { + status: 429, + headers: { ...NO_STORE_HEADERS, "retry-after": "60" }, + }); + } + if (limit === "unavailable") { + return NextResponse.json({ + code: "DROPSBOT_WEBHOOK_RATE_LIMIT_UNAVAILABLE", + error: "Drops Bot callback protection is temporarily unavailable.", + }, { + status: 503, + headers: { ...NO_STORE_HEADERS, "retry-after": "60" }, + }); + } + + try { + const raw = await readDropsBotWebhookBody(request); + const payload = parseDropsBotWebhookPayload(raw, [capability]); + const contentHash = hashDropsBotWebhookContent(raw); + const event = { + id: dropsBotWebhookEventId(connectionId, contentHash), + contentHash, + receivedAt: new Date().toISOString(), + payload, + }; + const result = await acceptDropsBotWebhookEvent({ + connectionId, + capabilityHash: hashDropsBotWebhookCapability(capability), + event, + }); + if (result.status === "not-found") return notFound(); + + const accepted = result.status === "accepted"; + return json({ + accepted, + duplicate: !accepted, + eventId: result.event.id, + contentHash: result.event.contentHash, + receivedAt: result.event.receivedAt, + callbackEvidence: result.callbackEvidence, + }, accepted ? 202 : 200); + } catch (error) { + if (error instanceof DropsBotWebhookValidationError) { + return json({ code: error.code, error: error.message }, error.status); + } + if (error instanceof DropsBotWebhookCapacityError) { + return NextResponse.json({ + error: "Drops Bot callback storage reached its safe MVP capacity.", + }, { + status: 503, + headers: { ...NO_STORE_HEADERS, "retry-after": "3600" }, + }); + } + if (error instanceof DropsBotWebhookStorageUnavailableError) { + return NextResponse.json({ + error: "Drops Bot callback storage is temporarily unavailable.", + }, { + status: 503, + headers: { ...NO_STORE_HEADERS, "retry-after": "60" }, + }); + } + console.error("Unexpected Drops Bot callback ingestion failure.", error); + return NextResponse.json({ + error: "Drops Bot callback ingestion is temporarily unavailable.", + }, { + status: 503, + headers: { ...NO_STORE_HEADERS, "retry-after": "60" }, + }); + } +} diff --git a/app/api/dropsbot/webhooks/route.ts b/app/api/dropsbot/webhooks/route.ts new file mode 100644 index 0000000..9a4cffb --- /dev/null +++ b/app/api/dropsbot/webhooks/route.ts @@ -0,0 +1,325 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server.js"; + +import { + createDropsBotWebhookConnection, + dropsBotWebhookStorageConfigured, + DropsBotWebhookCapacityError, + DropsBotWebhookStorageUnavailableError, + revokeDropsBotWebhookConnection, + rotateDropsBotWebhookConnection, +} from "../../../../db/dropsbot-webhooks.ts"; +import { + listMemberProjects, + MemberProjectStorageUnavailableError, +} from "../../../../db/member-projects.ts"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, + type StudioAccount, +} from "../../../../lib/access-tier.ts"; +import { + createDropsBotWebhookCapability, + DROPSBOT_WEBHOOK_CREATE_BODY_LIMIT_BYTES, + DropsBotWebhookValidationError, + readDropsBotWebhookBody, +} from "../../../../lib/dropsbot-webhook.ts"; +import { hasJsonMediaType } from "../../../../lib/http-request-boundary.ts"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { + "cache-control": "private, no-store, max-age=0", + "referrer-policy": "no-referrer", + vary: "Cookie", +}; + +class DropsBotWebhookResponseError extends Error { + readonly status: number; + readonly payload: Record; + + constructor(status: number, payload: Record) { + super(String(payload.error ?? "Drops Bot webhook request failed.")); + this.name = "DropsBotWebhookResponseError"; + this.status = status; + this.payload = payload; + } +} + +function json(payload: Record, status: number): NextResponse { + return NextResponse.json(payload, { status, headers: NO_STORE_HEADERS }); +} + +function account(request: NextRequest): StudioAccount { + const resolved = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!resolved) { + throw new DropsBotWebhookResponseError(401, { + code: "DROPSBOT_ACCOUNT_REQUIRED", + error: "Connect a signed Studio account before creating a Drops Bot callback.", + }); + } + return resolved; +} + +function requireSameOrigin(request: NextRequest): void { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + throw new DropsBotWebhookResponseError(403, { + error: "Cross-origin Drops Bot callback mutation rejected.", + }); + } + const origin = request.headers.get("origin"); + if (!origin) { + throw new DropsBotWebhookResponseError(403, { + error: "A same-origin Drops Bot callback mutation is required.", + }); + } + try { + const originUrl = new URL(origin); + 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 browserVisibleOrigin = host ? `${protocol}://${host}` : null; + if ( + originUrl.origin !== request.nextUrl.origin + && originUrl.origin !== browserVisibleOrigin + ) { + throw new Error(); + } + } catch { + throw new DropsBotWebhookResponseError(403, { + error: "Cross-origin Drops Bot callback mutation rejected.", + }); + } +} + +async function body(request: NextRequest): Promise> { + if (!hasJsonMediaType(request)) { + throw new DropsBotWebhookResponseError(415, { + error: "Drops Bot callback mutations require application/json.", + }); + } + const raw = await readDropsBotWebhookBody( + request, + DROPSBOT_WEBHOOK_CREATE_BODY_LIMIT_BYTES, + ); + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)) as unknown; + } catch { + throw new DropsBotWebhookResponseError(400, { + error: "Drops Bot callback mutations require a valid JSON body.", + }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new DropsBotWebhookResponseError(400, { + error: "Drops Bot callback mutations require a JSON object.", + }); + } + const input = parsed as Record; + const unsupported = Object.keys(input).filter((key) => !["projectId", "consent"].includes(key)); + if (unsupported.length) { + throw new DropsBotWebhookResponseError(400, { + error: `Drops Bot callback mutation contains unsupported fields: ${unsupported.join(", ")}.`, + }); + } + return input; +} + +function projectId(value: unknown): string { + if (typeof value !== "string" || !/^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value)) { + throw new DropsBotWebhookResponseError(400, { + error: "Drops Bot callback project id is invalid.", + }); + } + return value; +} + +function requireConsent(input: Record, action: string): void { + if (input.consent !== true) { + throw new DropsBotWebhookResponseError(400, { + code: "DROPSBOT_CONSENT_REQUIRED", + error: `Explicit consent is required before ${action} a secret callback URL.`, + }); + } +} + +async function requireOwnedProject( + member: StudioAccount, + input: Record, +): Promise { + const ownedProjectId = projectId(input.projectId); + const projects = await listMemberProjects(member.identity); + if (!projects.some((project) => project.id === ownedProjectId)) { + throw new DropsBotWebhookResponseError(404, { + error: "Signed project not found.", + }); + } + return ownedProjectId; +} + +function callbackUrl( + request: NextRequest, + connectionId: string, + capability: string, +): string { + return new URL( + `/api/dropsbot/webhooks/${connectionId}/${capability}`, + request.nextUrl.origin, + ).toString(); +} + +function registration(note: string) { + return { + mode: "manual-in-@drops", + officialSurface: "https://t.me/Drops", + claimedConfigured: false, + note, + } as const; +} + +function responseError(error: unknown): NextResponse { + if (error instanceof DropsBotWebhookResponseError) { + return json(error.payload, error.status); + } + if (error instanceof DropsBotWebhookValidationError) { + return json({ code: error.code, error: error.message }, error.status); + } + if (error instanceof DropsBotWebhookCapacityError) { + return NextResponse.json({ + code: "DROPSBOT_CALLBACK_CAPACITY_REACHED", + error: error.message, + }, { + status: 507, + headers: { ...NO_STORE_HEADERS, "retry-after": "3600" }, + }); + } + if ( + error instanceof DropsBotWebhookStorageUnavailableError + || error instanceof MemberProjectStorageUnavailableError + ) { + return json({ + error: "Drops Bot callback storage is temporarily unavailable.", + }, 503); + } + console.error("Unexpected Drops Bot callback creation failure.", error); + return json({ + error: "Drops Bot callback creation is temporarily unavailable.", + }, 503); +} + +export async function POST(request: NextRequest): Promise { + try { + const member = account(request); + requireSameOrigin(request); + if (!dropsBotWebhookStorageConfigured()) { + throw new DropsBotWebhookStorageUnavailableError(); + } + const input = await body(request); + requireConsent(input, "creating"); + const ownedProjectId = await requireOwnedProject(member, input); + + const createdAt = new Date().toISOString(); + const capability = createDropsBotWebhookCapability(); + const connectionId = randomUUID(); + const result = await createDropsBotWebhookConnection({ + id: connectionId, + ownerIdentity: member.identity, + projectId: ownedProjectId, + capabilityHash: capability.hash, + createdAt, + consentedAt: createdAt, + }); + if (result.status === "exists") { + throw new DropsBotWebhookResponseError(409, { + code: "DROPSBOT_CALLBACK_EXISTS", + error: "This project already has a Drops Bot callback. Its secret URL is never re-disclosed.", + }); + } + + return json({ + connectionId, + projectId: ownedProjectId, + callbackUrl: callbackUrl(request, connectionId, capability.secret), + createdAt, + registration: registration("Add this callback URL through the official @drops product. Drops Studio does not guess an undocumented provider endpoint or signature header."), + callbackEvidence: result.project.callbackEvidence, + }, 201); + } catch (error) { + return responseError(error); + } +} + +export async function PUT(request: NextRequest): Promise { + try { + const member = account(request); + requireSameOrigin(request); + if (!dropsBotWebhookStorageConfigured()) { + throw new DropsBotWebhookStorageUnavailableError(); + } + const input = await body(request); + requireConsent(input, "rotating"); + const ownedProjectId = await requireOwnedProject(member, input); + const rotatedAt = new Date().toISOString(); + const capability = createDropsBotWebhookCapability(); + const result = await rotateDropsBotWebhookConnection({ + ownerIdentity: member.identity, + projectId: ownedProjectId, + capabilityHash: capability.hash, + consentedAt: rotatedAt, + }); + if (result.status === "not-found") { + throw new DropsBotWebhookResponseError(404, { + error: "Drops Bot callback not found for this project.", + }); + } + return json({ + connectionId: result.project.connectionId, + projectId: ownedProjectId, + callbackUrl: callbackUrl( + request, + result.project.connectionId, + capability.secret, + ), + rotatedAt, + registration: registration("Replace the previous URL inside the official @drops product. The previous secret stopped working when this URL was issued."), + callbackEvidence: result.project.callbackEvidence, + }, 200); + } catch (error) { + return responseError(error); + } +} + +export async function DELETE(request: NextRequest): Promise { + try { + const member = account(request); + requireSameOrigin(request); + if (!dropsBotWebhookStorageConfigured()) { + throw new DropsBotWebhookStorageUnavailableError(); + } + const input = await body(request); + requireConsent(input, "revoking"); + const ownedProjectId = await requireOwnedProject(member, input); + const result = await revokeDropsBotWebhookConnection( + member.identity, + ownedProjectId, + ); + if (result.status === "not-found") { + throw new DropsBotWebhookResponseError(404, { + error: "Drops Bot callback not found for this project.", + }); + } + return json({ + revoked: true, + projectId: ownedProjectId, + revokedAt: new Date().toISOString(), + }, 200); + } catch (error) { + return responseError(error); + } +} diff --git a/app/api/projects/publish/route.ts b/app/api/projects/publish/route.ts index 90cc81b..8368270 100644 --- a/app/api/projects/publish/route.ts +++ b/app/api/projects/publish/route.ts @@ -31,7 +31,11 @@ import type { PublishedProjectRecord, } from "@/lib/project-types"; import { validateProjectSpec } from "@/lib/project-validator"; -import { validateEditableRuntimeHtml } from "@/lib/source-workspace"; +import { + bindPublishedRuntimeHtml, + PublishedRuntimeBindingError, + validateEditableRuntimeHtml, +} from "@/lib/source-workspace"; import { consumeRequestLimit, requestIdentity, @@ -173,10 +177,22 @@ function publishedArtifact(options: { slug: options.slug, dataEndpoint: `${options.origin}/api/public-data`, }); + let sourceHtml: string | undefined; if (options.htmlOverride) { + try { + sourceHtml = bindPublishedRuntimeHtml(options.htmlOverride, publishedSpec); + } catch (error) { + if (error instanceof PublishedRuntimeBindingError) { + throw new PublishResponseError(422, { + error: "The edited source could not be bound to its public runtime.", + criticalFailures: [error.message], + }); + } + throw error; + } const validation = validateEditableRuntimeHtml( publishedSpec, - options.htmlOverride, + sourceHtml, ); if (!validation.valid) { throw new PublishResponseError(422, { @@ -186,7 +202,7 @@ function publishedArtifact(options: { } } const html = stampProviderEvidence( - options.htmlOverride ?? compileProject(publishedSpec), + sourceHtml ?? compileProject(publishedSpec), "unverified", ); assertPublishedArtifactSafe(publishedSpec, html); diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index ac05ea7..e0ca0d8 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -8,11 +8,20 @@ import { MemberProjectStorageUnavailableError, upsertMemberProject, } from "../../../db/member-projects.ts"; +import { + billingStorageConfigured, + readBillingAccount, +} from "../../../db/billing.ts"; import { resolveStudioAccount, STUDIO_ACCOUNT_COOKIE, type StudioAccount, } from "../../../lib/access-tier.ts"; +import { + billingEntitlements, + billingTierForAccount, + stripeProPriceId, +} from "../../../lib/billing.ts"; import { ArtifactSecretError, assertProjectPayloadSafe, @@ -207,6 +216,20 @@ function requireStorage(): void { } } +async function privateProjectLimit(member: StudioAccount): Promise { + const expectedPriceId = stripeProPriceId(); + if (!expectedPriceId || !billingStorageConfigured()) return MEMBER_PROJECT_LIMIT; + try { + const billing = await readBillingAccount(member.identity); + return billingEntitlements( + billingTierForAccount(billing, expectedPriceId), + ).privateProjects; + } catch { + // Paid storage expansion is fail-closed when billing proof is unavailable. + return MEMBER_PROJECT_LIMIT; + } +} + function responseError(error: unknown): NextResponse { if (error instanceof MemberProjectResponseError) { return json(error.payload, error.status); @@ -233,10 +256,13 @@ export async function GET(request: NextRequest) { const member = account(request); requireStorage(); await enforceLimit(member, "read"); - const projects = await listMemberProjects(member.identity); + const [projects, limit] = await Promise.all([ + listMemberProjects(member.identity), + privateProjectLimit(member), + ]); return json({ projects, - limit: MEMBER_PROJECT_LIMIT, + limit, materialization: "compile-spec-client-side", }, 200); } catch (error) { @@ -254,10 +280,13 @@ export async function PUT(request: NextRequest) { requireBodyFields(input, ["project", "expectedRevision"]); const expectedRevision = revision(input.expectedRevision, true); const draft = projectDraft(input.project); + const limit = await privateProjectLimit(member); const result = await upsertMemberProject( member.identity, draft, expectedRevision, + undefined, + limit, ); if (result.status === "conflict") { return json({ @@ -269,7 +298,7 @@ export async function PUT(request: NextRequest) { if (result.status === "limit") { return json({ code: "PROJECT_LIMIT", - error: `A member account can sync up to ${MEMBER_PROJECT_LIMIT} projects. Delete or export one before adding another.`, + error: `This account can sync up to ${limit} projects. Delete or export one before adding another.`, }, 409); } if (result.status === "too-large") { diff --git a/app/api/teams/[workspaceId]/invites/route.ts b/app/api/teams/[workspaceId]/invites/route.ts new file mode 100644 index 0000000..69f07dd --- /dev/null +++ b/app/api/teams/[workspaceId]/invites/route.ts @@ -0,0 +1,78 @@ +import { NextRequest } from "next/server.js"; + +import { + createTeamWorkspaceInvite, + teamWorkspaceStorageConfigured, + TeamWorkspaceStorageUnavailableError, +} from "@/db/team-workspaces"; +import { + resolveTeamInviteSecret, + TeamWorkspaceValidationError, +} from "@/lib/team-workspaces"; +import { + enforceTeamRateLimit, + proTeamEntitlements, + requireTeamSameOrigin, + teamAccount, + teamApiError, + teamJson, + teamRequestBody, +} from "@/lib/team-api"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ workspaceId: string }> }; + +export async function POST(request: NextRequest, context: Context) { + try { + const secret = resolveTeamInviteSecret(); + if (!secret || !teamWorkspaceStorageConfigured()) { + return teamJson({ error: "Team invites are not configured or unavailable." }, 503); + } + const account = teamAccount(request); + requireTeamSameOrigin(request); + const body = await teamRequestBody(request, 8 * 1_024); + const ownerIdentity = String(body.ownerIdentity ?? ""); + const entitlements = await proTeamEntitlements(ownerIdentity); + await enforceTeamRateLimit(account.identity, "team-workspace-invite"); + const hours = Number(body.expiresInHours); + if (!Number.isSafeInteger(hours) || hours < 1 || hours > 24 * 30) { + return teamJson({ error: "Team invite expiry must be 1 to 720 hours." }, 400); + } + const { workspaceId } = await context.params; + const result = await createTeamWorkspaceInvite({ + actorIdentity: account.identity, + ownerIdentity, + workspaceId, + expectedRevision: Number(body.expectedRevision), + role: body.role === "editor" ? "editor" : body.role === "viewer" ? "viewer" : body.role as never, + expiresAt: new Date(Date.now() + hours * 60 * 60 * 1_000).toISOString(), + consent: body.consent === true, + secret, + maxCollaborators: entitlements.collaboratorsPerWorkspace, + }); + if (result.status === "not-found") return teamJson({ error: "Team workspace not found." }, 404); + if (result.status === "forbidden") return teamJson({ error: "Only the team owner can create invites." }, 403); + if (result.status === "conflict") { + return teamJson({ + code: "TEAM_REVISION_CONFLICT", + error: "Team workspace changed in another session.", + current: result.current, + }, 409); + } + return teamJson({ + workspace: result.workspace, + invite: result.invite, + capability: result.capability, + }, 201); + } catch (error) { + const apiError = teamApiError(error); + if (apiError) return apiError; + if (error instanceof TeamWorkspaceValidationError) return teamJson({ error: error.message }, 400); + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return teamJson({ error: "Team invite storage is unavailable." }, 503); + } + return teamJson({ error: "Team invite could not be created safely." }, 500); + } +} diff --git a/app/api/teams/[workspaceId]/members/route.ts b/app/api/teams/[workspaceId]/members/route.ts new file mode 100644 index 0000000..00e1180 --- /dev/null +++ b/app/api/teams/[workspaceId]/members/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from "next/server.js"; + +import { + changeTeamMemberRole, + teamWorkspaceStorageConfigured, + TeamWorkspaceStorageUnavailableError, +} from "@/db/team-workspaces"; +import { TeamWorkspaceValidationError } from "@/lib/team-workspaces"; +import { + enforceTeamRateLimit, + proTeamEntitlements, + requireTeamSameOrigin, + teamAccount, + teamApiError, + teamJson, + teamRequestBody, +} from "@/lib/team-api"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ workspaceId: string }> }; + +export async function PATCH(request: NextRequest, context: Context) { + try { + if (!teamWorkspaceStorageConfigured()) { + return teamJson({ error: "Team workspace storage is not configured or unavailable." }, 503); + } + const account = teamAccount(request); + requireTeamSameOrigin(request); + const body = await teamRequestBody(request); + const ownerIdentity = String(body.ownerIdentity ?? ""); + await proTeamEntitlements(ownerIdentity); + await enforceTeamRateLimit(account.identity, "team-workspace-member-role"); + const { workspaceId } = await context.params; + const result = await changeTeamMemberRole({ + actorIdentity: account.identity, + ownerIdentity, + workspaceId, + memberIdentity: String(body.memberIdentity ?? ""), + role: body.role === "editor" ? "editor" : body.role === "viewer" ? "viewer" : body.role as never, + expectedRevision: Number(body.expectedRevision), + consent: body.consent === true, + }); + if (result.status === "not-found") return teamJson({ error: "Team workspace or member not found." }, 404); + if (result.status === "forbidden") return teamJson({ error: "Only the team owner can change roles." }, 403); + if (result.status === "conflict") { + return teamJson({ + code: "TEAM_REVISION_CONFLICT", + error: "Team workspace changed in another session.", + current: result.current, + }, 409); + } + return teamJson({ workspace: result.workspace }); + } catch (error) { + const apiError = teamApiError(error); + if (apiError) return apiError; + if (error instanceof TeamWorkspaceValidationError) return teamJson({ error: error.message }, 400); + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return teamJson({ error: "Team workspace storage is unavailable." }, 503); + } + return teamJson({ error: "Team member role could not be changed safely." }, 500); + } +} diff --git a/app/api/teams/[workspaceId]/projects/route.ts b/app/api/teams/[workspaceId]/projects/route.ts new file mode 100644 index 0000000..74dd820 --- /dev/null +++ b/app/api/teams/[workspaceId]/projects/route.ts @@ -0,0 +1,83 @@ +import { NextRequest } from "next/server.js"; + +import { + TeamWorkspaceCapacityError, + teamWorkspaceStorageConfigured, + TeamWorkspaceStorageUnavailableError, + upsertTeamWorkspaceProject, +} from "@/db/team-workspaces"; +import { + MemberProjectValidationError, + MEMBER_PROJECT_BODY_LIMIT_BYTES, +} from "@/lib/member-project-cloud"; +import { TeamWorkspaceValidationError } from "@/lib/team-workspaces"; +import { + enforceTeamRateLimit, + proTeamEntitlements, + requireTeamSameOrigin, + teamAccount, + teamApiError, + teamJson, + teamRequestBody, +} from "@/lib/team-api"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ workspaceId: string }> }; + +export async function PUT(request: NextRequest, context: Context) { + try { + if (!teamWorkspaceStorageConfigured()) { + return teamJson({ error: "Team workspace storage is not configured or unavailable." }, 503); + } + const account = teamAccount(request); + requireTeamSameOrigin(request); + const body = await teamRequestBody(request, MEMBER_PROJECT_BODY_LIMIT_BYTES); + const ownerIdentity = String(body.ownerIdentity ?? ""); + await proTeamEntitlements(ownerIdentity); + await enforceTeamRateLimit(account.identity, "team-workspace-project-write"); + const { workspaceId } = await context.params; + const result = await upsertTeamWorkspaceProject({ + actorIdentity: account.identity, + ownerIdentity, + workspaceId, + expectedWorkspaceRevision: Number(body.expectedWorkspaceRevision), + expectedProjectRevision: Number(body.expectedProjectRevision), + project: body.project, + consent: body.consent === true, + }); + if (result.status === "not-found") return teamJson({ error: "Team workspace not found." }, 404); + if (result.status === "forbidden") return teamJson({ error: "Team project write permission is required." }, 403); + if (result.status === "conflict") { + return teamJson({ + code: "TEAM_PROJECT_REVISION_CONFLICT", + error: "Team workspace or shared project changed in another session.", + current: result.current, + ...(result.currentProject ? { currentProject: result.currentProject } : {}), + }, 409); + } + return teamJson({ workspace: result.workspace, project: result.project }, + result.project.revision === 1 ? 201 : 200); + } catch (error) { + const apiError = teamApiError(error); + if (apiError) return apiError; + if (error instanceof TeamWorkspaceCapacityError) { + return teamJson({ + code: "TEAM_SOURCE_CAPACITY_REACHED", + error: error.message, + }, 413); + } + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return teamJson({ error: "Team workspace storage is unavailable." }, 503); + } + if (error instanceof TeamWorkspaceValidationError) { + return teamJson({ error: error.message }, 400); + } + if (error instanceof MemberProjectValidationError) { + return teamJson({ error: error.message }, 400); + } + console.error("Unexpected team shared project write failure.", error); + return teamJson({ error: "Team shared project could not be saved safely." }, 500); + } +} diff --git a/app/api/teams/[workspaceId]/route.ts b/app/api/teams/[workspaceId]/route.ts new file mode 100644 index 0000000..d06b6e7 --- /dev/null +++ b/app/api/teams/[workspaceId]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest } from "next/server.js"; + +import { + readTeamWorkspace, + teamWorkspaceStorageConfigured, + TeamWorkspaceStorageUnavailableError, + updateTeamWorkspace, +} from "@/db/team-workspaces"; +import { + TeamWorkspacePermissionError, + TeamWorkspaceValidationError, +} from "@/lib/team-workspaces"; +import { + enforceTeamRateLimit, + proTeamEntitlements, + requireTeamSameOrigin, + teamAccount, + teamApiError, + teamJson, + teamRequestBody, +} from "@/lib/team-api"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ workspaceId: string }> }; + +export async function GET(request: NextRequest, context: Context) { + try { + if (!teamWorkspaceStorageConfigured()) { + return teamJson({ error: "Team workspace storage is not configured or unavailable." }, 503); + } + const account = teamAccount(request); + const { workspaceId } = await context.params; + const ownerIdentity = request.nextUrl.searchParams.get("owner") ?? account.identity; + const workspace = await readTeamWorkspace( + ownerIdentity, + workspaceId, + account.identity, + ); + return workspace + ? teamJson({ workspace }) + : teamJson({ error: "Team workspace not found." }, 404); + } catch (error) { + const apiError = teamApiError(error); + if (apiError) return apiError; + if (error instanceof TeamWorkspacePermissionError) { + return teamJson({ error: error.message }, 403); + } + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return teamJson({ error: "Team workspace storage is unavailable." }, 503); + } + return teamJson({ error: "Team workspace could not be read safely." }, 400); + } +} + +export async function PATCH(request: NextRequest, context: Context) { + try { + if (!teamWorkspaceStorageConfigured()) { + return teamJson({ error: "Team workspace storage is not configured or unavailable." }, 503); + } + const account = teamAccount(request); + requireTeamSameOrigin(request); + const body = await teamRequestBody(request); + const ownerIdentity = String(body.ownerIdentity ?? ""); + await proTeamEntitlements(ownerIdentity); + await enforceTeamRateLimit(account.identity, "team-workspace-update"); + const { workspaceId } = await context.params; + const result = await updateTeamWorkspace({ + actorIdentity: account.identity, + ownerIdentity, + workspaceId, + expectedRevision: Number(body.expectedRevision), + name: String(body.name ?? ""), + consent: body.consent === true, + }); + if (result.status === "not-found") return teamJson({ error: "Team workspace not found." }, 404); + if (result.status === "forbidden") return teamJson({ error: "Team workspace write permission is required." }, 403); + if (result.status === "conflict") { + return teamJson({ + code: "TEAM_REVISION_CONFLICT", + error: "Team workspace changed in another session.", + current: result.current, + }, 409); + } + return teamJson({ workspace: result.workspace }); + } catch (error) { + const apiError = teamApiError(error); + if (apiError) return apiError; + if (error instanceof TeamWorkspaceValidationError) return teamJson({ error: error.message }, 400); + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return teamJson({ error: "Team workspace storage is unavailable." }, 503); + } + return teamJson({ error: "Team workspace could not be updated safely." }, 500); + } +} diff --git a/app/api/teams/invites/accept/route.ts b/app/api/teams/invites/accept/route.ts new file mode 100644 index 0000000..e9ed283 --- /dev/null +++ b/app/api/teams/invites/accept/route.ts @@ -0,0 +1,65 @@ +import { NextRequest } from "next/server.js"; + +import { + acceptTeamWorkspaceInvite, + teamWorkspaceStorageConfigured, + TeamWorkspaceStorageUnavailableError, +} from "@/db/team-workspaces"; +import { + resolveTeamInviteSecret, + TeamWorkspaceValidationError, + verifyTeamInviteCapability, +} from "@/lib/team-workspaces"; +import { + enforceTeamRateLimit, + proTeamEntitlements, + requireTeamSameOrigin, + teamAccount, + teamApiError, + teamJson, + teamRequestBody, +} from "@/lib/team-api"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + try { + const secret = resolveTeamInviteSecret(); + if (!secret || !teamWorkspaceStorageConfigured()) { + return teamJson({ error: "Team invite acceptance is not configured or unavailable." }, 503); + } + const account = teamAccount(request); + requireTeamSameOrigin(request); + const body = await teamRequestBody(request, 8 * 1_024); + const capability = String(body.capability ?? ""); + const invite = verifyTeamInviteCapability(capability, secret); + if (!invite) { + throw new TeamWorkspaceValidationError("Team invite is invalid or expired."); + } + const entitlements = await proTeamEntitlements(invite.ownerIdentity); + await enforceTeamRateLimit(account.identity, "team-workspace-invite-accept"); + const result = await acceptTeamWorkspaceInvite({ + capability, + memberIdentity: account.identity, + consent: body.consent === true, + secret, + maxCollaborators: entitlements.collaboratorsPerWorkspace, + }); + if (result.status === "not-found") return teamJson({ error: "Team invite not found." }, 404); + return teamJson({ + status: result.status, + workspace: result.workspace, + }); + } catch (error) { + const apiError = teamApiError(error); + if (apiError) return apiError; + if (error instanceof TeamWorkspaceValidationError) { + return teamJson({ error: "Team invite is invalid, expired or missing consent." }, 400); + } + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return teamJson({ error: "Team invite storage is unavailable." }, 503); + } + return teamJson({ error: "Team invite could not be accepted safely." }, 500); + } +} diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts new file mode 100644 index 0000000..c306a08 --- /dev/null +++ b/app/api/teams/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + billingEntitlements, + billingTierForAccount, + stripeProPriceId, +} from "@/lib/billing"; +import { + billingStorageConfigured, + BillingStorageUnavailableError, + readBillingAccount, +} from "@/db/billing"; +import { + createTeamWorkspace, + listTeamWorkspaces, + listTeamWorkspacesForMember, + teamWorkspaceStorageConfigured, + TeamWorkspaceStorageUnavailableError, +} from "@/db/team-workspaces"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, +} from "@/lib/access-tier"; +import { TeamWorkspaceValidationError } from "@/lib/team-workspaces"; +import { consumeRequestLimit } from "@/lib/request-rate-limit"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HEADERS = { "cache-control": "private, no-store, max-age=0", vary: "Cookie" }; + +function json(payload: Record, status: number) { + return NextResponse.json(payload, { status, headers: HEADERS }); +} + +function account(request: NextRequest) { + return resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); +} + +function sameOrigin(request: NextRequest): boolean { + const origin = request.headers.get("origin"); + if (!origin || request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false; + try { + return new URL(origin).origin === request.nextUrl.origin; + } catch { + return false; + } +} + +export async function GET(request: NextRequest) { + const member = account(request); + if (!member) return json({ error: "A signed Studio member account is required." }, 401); + if (!teamWorkspaceStorageConfigured()) { + return json({ error: "Team workspace storage is not configured or unavailable." }, 503); + } + const ownerIdentity = request.nextUrl.searchParams.get("owner"); + try { + const workspaces = ownerIdentity + ? await listTeamWorkspaces(ownerIdentity, member.identity) + : await listTeamWorkspacesForMember(member.identity); + return json({ workspaces, accountIdentity: member.identity }, 200); + } catch (error) { + if (error instanceof TeamWorkspaceStorageUnavailableError) { + return json({ error: "Team workspace storage is unavailable." }, 503); + } + return json({ error: "Team workspaces could not be read safely." }, 400); + } +} + +export async function POST(request: NextRequest) { + if (!teamWorkspaceStorageConfigured() || !billingStorageConfigured()) { + return json({ error: "Team workspaces are not configured or unavailable." }, 503); + } + const member = account(request); + if (!member) return json({ error: "A signed Studio member account is required." }, 401); + if (!sameOrigin(request)) return json({ error: "Cross-origin team request rejected." }, 403); + if (!hasJsonMediaType(request)) { + return json({ error: "Team creation requires application/json." }, 415); + } + let raw: string; + try { + raw = decodeUtf8Body(await readBoundedRequestBody(request, 4_096)); + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + return json({ error: "Team creation request is too large." }, 413); + } + return json({ error: "Team creation request is invalid." }, 400); + } + let body: { name?: unknown; consent?: unknown }; + try { + body = JSON.parse(raw) as typeof body; + } catch { + return json({ error: "Team creation request is invalid." }, 400); + } + try { + const billing = await readBillingAccount(member.identity); + const entitlements = billingEntitlements( + billingTierForAccount(billing, stripeProPriceId()), + ); + if (entitlements.teamWorkspaces < 1) { + return json({ + code: "PRO_REQUIRED", + error: "A verified active Pro subscription is required to create team workspaces.", + }, 403); + } + const limit = await consumeRequestLimit({ + identity: member.identity, + namespace: "team-workspace-create", + max: 20, + windowMs: 60 * 60 * 1_000, + }); + if (limit === "limited") return json({ error: "Too many team requests. Try again later." }, 429); + if (limit === "unavailable") return json({ error: "Team request protection is unavailable." }, 503); + const workspace = await createTeamWorkspace({ + ownerIdentity: member.identity, + name: String(body.name ?? ""), + consent: body.consent === true, + maxWorkspaces: entitlements.teamWorkspaces, + }); + return json({ workspace }, 201); + } catch (error) { + if (error instanceof TeamWorkspaceValidationError) { + return json({ error: error.message }, 400); + } + if ( + error instanceof TeamWorkspaceStorageUnavailableError + || error instanceof BillingStorageUnavailableError + ) { + return json({ error: "Team workspaces are not configured or unavailable." }, 503); + } + return json({ error: "Team workspace could not be created safely." }, 500); + } +} diff --git a/app/api/telegram/account/create-channel/route.ts b/app/api/telegram/account/create-channel/route.ts index 1728fc9..987f0ca 100644 --- a/app/api/telegram/account/create-channel/route.ts +++ b/app/api/telegram/account/create-channel/route.ts @@ -1,20 +1,30 @@ -import { NextRequest, NextResponse } from "next/server.js"; +import { NextRequest } from "next/server.js"; import { createTelegramChannel } from "@/lib/telegram-account"; +import { + readTelegramAccountJson, + telegramAccountJson, + telegramAccountRequestErrorResponse, +} from "@/lib/telegram-account-request"; import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null) as Record | null; + let body: Record; + try { + body = await readTelegramAccountJson(request); + } catch (error) { + return telegramAccountRequestErrorResponse(error); + } const accountToken = typeof body?.accountToken === "string" ? body.accountToken : ""; const requestId = typeof body?.requestId === "string" ? body.requestId : ""; - if (!accountToken) return NextResponse.json({ error: "Connect your Telegram account first." }, { status: 400 }); - if (!/^[a-f0-9-]{16,64}$/i.test(requestId)) return NextResponse.json({ error: "Start a fresh channel creation request." }, { status: 400 }); + if (!accountToken) return telegramAccountJson({ error: "Connect your Telegram account first." }, 400); + if (!/^[a-f0-9-]{16,64}$/i.test(requestId)) return telegramAccountJson({ error: "Start a fresh channel creation request." }, 400); const limit = await consumeRequestLimit({ identity: requestIdentity(request), namespace: "telegram-channel", max: 5, windowMs: 60 * 60 * 1_000 }).catch(() => "unavailable" as const); - if (limit === "limited") return NextResponse.json({ error: "Too many channel creation attempts. Try again later." }, { status: 429 }); - if (limit === "unavailable") return NextResponse.json({ error: "Secure channel creation is temporarily unavailable." }, { status: 503 }); + if (limit === "limited") return telegramAccountJson({ error: "Too many channel creation attempts. Try again later." }, 429); + if (limit === "unavailable") return telegramAccountJson({ error: "Secure channel creation is temporarily unavailable." }, 503); try { const result = await createTelegramChannel({ accountToken, @@ -25,8 +35,11 @@ export async function POST(request: NextRequest) { firstPost: typeof body?.firstPost === "string" ? body.firstPost : "", botToken: typeof body?.botToken === "string" ? body.botToken : undefined, }); - return NextResponse.json(result, { headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson(result); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : "Telegram channel creation failed." }, { status: 422, headers: { "cache-control": "no-store, max-age=0" } }); + console.error("Telegram channel creation failed.", error); + return telegramAccountJson({ + error: "Telegram could not create the channel. Check the connected account, bot, and channel details before retrying.", + }, 422); } } diff --git a/app/api/telegram/account/send-code/route.ts b/app/api/telegram/account/send-code/route.ts index 3e64d70..f93dbd3 100644 --- a/app/api/telegram/account/send-code/route.ts +++ b/app/api/telegram/account/send-code/route.ts @@ -1,24 +1,34 @@ -import { NextRequest, NextResponse } from "next/server.js"; +import { NextRequest } from "next/server.js"; import { sendTelegramLoginCode } from "@/lib/telegram-account"; +import { + readTelegramAccountJson, + telegramAccountJson, + telegramAccountRequestErrorResponse, +} from "@/lib/telegram-account-request"; import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null) as { phoneNumber?: unknown } | null; + let body: Record; + try { + body = await readTelegramAccountJson(request); + } catch (error) { + return telegramAccountRequestErrorResponse(error); + } const phoneNumber = typeof body?.phoneNumber === "string" ? body.phoneNumber.replace(/[\s()-]/g, "") : ""; if (!/^\+\d{7,15}$/.test(phoneNumber)) { - return NextResponse.json({ error: "Enter a valid Telegram phone number with country code." }, { status: 400 }); + return telegramAccountJson({ error: "Enter a valid Telegram phone number with country code." }, 400); } const limit = await consumeRequestLimit({ identity: requestIdentity(request), namespace: "telegram-login", max: 3, windowMs: 60 * 60 * 1_000 }).catch(() => "unavailable" as const); - if (limit === "limited") return NextResponse.json({ error: "Too many Telegram sign-in attempts. Try again later." }, { status: 429 }); - if (limit === "unavailable") return NextResponse.json({ error: "Secure Telegram sign-in is temporarily unavailable." }, { status: 503 }); + if (limit === "limited") return telegramAccountJson({ error: "Too many Telegram sign-in attempts. Try again later." }, 429); + if (limit === "unavailable") return telegramAccountJson({ error: "Secure Telegram sign-in is temporarily unavailable." }, 503); try { const result = await sendTelegramLoginCode(phoneNumber); - return NextResponse.json(result, { headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson(result); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : "Telegram sign-in could not start." }, { status: 422, headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson({ error: error instanceof Error ? error.message : "Telegram sign-in could not start." }, 422); } } diff --git a/app/api/telegram/account/sign-in/route.ts b/app/api/telegram/account/sign-in/route.ts index 0c228e6..445c906 100644 --- a/app/api/telegram/account/sign-in/route.ts +++ b/app/api/telegram/account/sign-in/route.ts @@ -1,25 +1,35 @@ -import { NextRequest, NextResponse } from "next/server.js"; +import { NextRequest } from "next/server.js"; import { signInTelegramAccount } from "@/lib/telegram-account"; +import { + readTelegramAccountJson, + telegramAccountJson, + telegramAccountRequestErrorResponse, +} from "@/lib/telegram-account-request"; import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null) as { flowToken?: unknown; phoneCode?: unknown; password?: unknown } | null; + let body: Record; + try { + body = await readTelegramAccountJson(request); + } catch (error) { + return telegramAccountRequestErrorResponse(error); + } const flowToken = typeof body?.flowToken === "string" ? body.flowToken : ""; const phoneCode = typeof body?.phoneCode === "string" ? body.phoneCode.trim() : ""; const password = typeof body?.password === "string" ? body.password : undefined; - if (!flowToken) return NextResponse.json({ error: "Request a Telegram code first." }, { status: 400 }); - if (!/^\d{3,8}$/.test(phoneCode)) return NextResponse.json({ error: "Enter the numeric code Telegram sent you." }, { status: 400 }); + if (!flowToken) return telegramAccountJson({ error: "Request a Telegram code first." }, 400); + if (!/^\d{3,8}$/.test(phoneCode)) return telegramAccountJson({ error: "Enter the numeric code Telegram sent you." }, 400); const limit = await consumeRequestLimit({ identity: requestIdentity(request), namespace: "telegram-code", max: 8, windowMs: 15 * 60 * 1_000 }).catch(() => "unavailable" as const); - if (limit === "limited") return NextResponse.json({ error: "Too many Telegram code attempts. Request a new code later." }, { status: 429 }); - if (limit === "unavailable") return NextResponse.json({ error: "Secure Telegram sign-in is temporarily unavailable." }, { status: 503 }); + if (limit === "limited") return telegramAccountJson({ error: "Too many Telegram code attempts. Request a new code later." }, 429); + if (limit === "unavailable") return telegramAccountJson({ error: "Secure Telegram sign-in is temporarily unavailable." }, 503); try { const result = await signInTelegramAccount(flowToken, phoneCode, password); - return NextResponse.json(result, { headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson(result); } catch (error) { - return NextResponse.json({ error: error instanceof Error ? error.message : "Telegram sign-in failed." }, { status: 422, headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson({ error: error instanceof Error ? error.message : "Telegram sign-in failed." }, 422); } } diff --git a/app/api/telegram/account/status/route.ts b/app/api/telegram/account/status/route.ts index 0045e64..a81d1c9 100644 --- a/app/api/telegram/account/status/route.ts +++ b/app/api/telegram/account/status/route.ts @@ -1,17 +1,27 @@ -import { NextRequest, NextResponse } from "next/server.js"; +import { NextRequest } from "next/server.js"; import { inspectTelegramAccountToken } from "@/lib/telegram-account"; +import { + readTelegramAccountJson, + telegramAccountJson, + telegramAccountRequestErrorResponse, +} from "@/lib/telegram-account-request"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null) as { accountToken?: unknown } | null; + let body: Record; + try { + body = await readTelegramAccountJson(request); + } catch (error) { + return telegramAccountRequestErrorResponse(error); + } const token = typeof body?.accountToken === "string" ? body.accountToken : ""; - if (!token) return NextResponse.json({ connected: false }); + if (!token) return telegramAccountJson({ connected: false }); try { - return NextResponse.json({ connected: true, account: inspectTelegramAccountToken(token) }, { headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson({ connected: true, account: inspectTelegramAccountToken(token) }); } catch { - return NextResponse.json({ connected: false }, { headers: { "cache-control": "no-store, max-age=0" } }); + return telegramAccountJson({ connected: false }); } } diff --git a/app/api/workspace/patch/route.ts b/app/api/workspace/patch/route.ts new file mode 100644 index 0000000..ac10d5b --- /dev/null +++ b/app/api/workspace/patch/route.ts @@ -0,0 +1,438 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { + applyWorkspaceAiPatch, + assertRunnableWorkspaceAiRevision, + parseWorkspaceAiPatchRequest, + WorkspaceAiPatchConflictError, + WorkspaceAiPatchValidationError, + type WorkspaceAiPatchRequest, +} from "@/lib/workspace-ai-patch"; +import { + generateWorkspaceAiPatch, + WorkspaceAiProviderResponseError, + WorkspaceAiProviderUnavailableError, + type GeneratedWorkspaceAiPatch, + type WorkspaceAiProviderCredentials, +} from "@/lib/workspace-ai-provider"; +import { + reserveWorkspacePlatformQuota, + WorkspaceAiQuotaLimitError, + WorkspaceAiQuotaUnavailableError, + type WorkspaceAiQuotaReservation, +} from "@/lib/workspace-ai-entitlement"; +import { + consumeRequestLimit, + requestIdentity, + type RequestLimitStatus, +} from "@/lib/request-rate-limit"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const MAX_BODY_BYTES = 1_750_000; +const NO_STORE_HEADERS = { "cache-control": "no-store, max-age=0" }; + +type GenerateWorkspacePatch = ( + request: WorkspaceAiPatchRequest, + credentials: WorkspaceAiProviderCredentials, +) => Promise; + +type ConsumeWorkspacePatchLimit = (input: { + identity: string | null; + namespace: string; + max: number; + windowMs: number; +}) => Promise; + +export interface WorkspaceAiPatchRouteDependencies { + generate?: GenerateWorkspacePatch; + consumeLimit?: ConsumeWorkspacePatchLimit; + reservePlatformQuota?: ( + request: NextRequest, + ) => Promise; + now?: () => Date; +} + +function json( + payload: Record, + status: number, + headers: Record = {}, +) { + return NextResponse.json(payload, { + status, + headers: { ...NO_STORE_HEADERS, ...headers }, + }); +} + +function withQuotaCookies( + response: NextResponse, + quota: WorkspaceAiQuotaReservation | null, +): NextResponse { + for (const cookie of quota?.cookies ?? []) { + response.cookies.set(cookie.name, cookie.value, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: cookie.maxAge, + path: "/", + }); + } + return response; +} + +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 false; + try { + const originUrl = new URL(origin); + 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}` : null; + return ( + originUrl.origin === request.nextUrl.origin || + originUrl.origin === visibleOrigin + ); + } catch { + return false; + } +} + +type RequestBodyResult = + | { status: "ok"; value: unknown } + | { status: "invalid" } + | { status: "too-large" }; + +async function requestBody(request: NextRequest): Promise { + let raw: string; + try { + raw = decodeUtf8Body(await readBoundedRequestBody(request, MAX_BODY_BYTES)); + } catch (error) { + return error instanceof RequestBodyBoundaryError && error.reason === "too-large" + ? { status: "too-large" } + : { status: "invalid" }; + } + try { + return { status: "ok", value: JSON.parse(raw) as unknown }; + } catch { + return { status: "invalid" }; + } +} + +function headerCredential(request: NextRequest, name: string): string | undefined { + const value = request.headers.get(name)?.trim() ?? ""; + return value || undefined; +} + +function directCredentialError( + request: WorkspaceAiPatchRequest, + credentials: WorkspaceAiProviderCredentials, +): string | null { + if (request.provider === "openrouter" && !credentials.openRouterKey) { + return "Connect OpenRouter with a request-only API key before generating workspace files."; + } + if ( + ["openai", "anthropic", "kimi"].includes(request.provider) && + !credentials.providerKey + ) { + return `Connect ${request.provider} with a request-only API key before generating workspace files.`; + } + return null; +} + +export async function handleWorkspaceAiPatchRequest( + request: NextRequest, + dependencies: WorkspaceAiPatchRouteDependencies = {}, +) { + if (!sameOrigin(request)) { + return json( + { + error: "Cross-origin AI workspace generation is not allowed.", + code: "WORKSPACE_AI_CROSS_ORIGIN", + }, + 403, + ); + } + if (!hasJsonMediaType(request)) { + return json( + { + error: "AI workspace generation requires application/json.", + code: "WORKSPACE_AI_CONTENT_TYPE", + }, + 415, + ); + } + + const identity = requestIdentity(request); + const localProofStore = + process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && + !process.env.VERCEL; + const consumeLimit = dependencies.consumeLimit ?? consumeRequestLimit; + const limit = await consumeLimit({ + identity, + namespace: "workspace-ai-patch", + max: localProofStore ? 200 : 12, + windowMs: 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (limit === "limited") { + return json( + { + error: "Too many AI workspace requests. Try again later.", + code: "WORKSPACE_AI_RATE_LIMIT", + }, + 429, + { "retry-after": "3600" }, + ); + } + if (limit === "unavailable") { + return json( + { + error: + "Secure AI workspace request identity and rate limiting are unavailable.", + code: "WORKSPACE_AI_RATE_LIMIT_UNAVAILABLE", + }, + 503, + ); + } + + const body = await requestBody(request); + if (body.status === "too-large") { + return json( + { + error: "The bounded workspace AI request exceeds 1.75 MB.", + code: "WORKSPACE_AI_BODY_TOO_LARGE", + }, + 413, + ); + } + if (body.status === "invalid") { + return json( + { + error: "A bounded workspace AI request is required.", + code: "WORKSPACE_AI_INVALID_REQUEST", + }, + 400, + ); + } + + let parsed: WorkspaceAiPatchRequest; + try { + parsed = parseWorkspaceAiPatchRequest(body.value); + } catch (error) { + if (error instanceof WorkspaceAiPatchConflictError) { + return json( + { + error: error.message, + code: "WORKSPACE_REVISION_CONFLICT", + expectedRevision: error.expectedRevision, + receivedRevision: error.receivedRevision, + }, + 409, + ); + } + return json( + { + error: "A bounded workspace AI request is required.", + code: "WORKSPACE_AI_INVALID_REQUEST", + }, + 400, + ); + } + + const credentials: WorkspaceAiProviderCredentials = { + identity: identity ?? undefined, + openRouterKey: headerCredential(request, "x-openrouter-key"), + providerKey: headerCredential(request, "x-provider-key"), + }; + const connectionError = directCredentialError(parsed, credentials); + if (connectionError) { + return json( + { error: connectionError, code: "WORKSPACE_AI_CONNECTION_REQUIRED" }, + 400, + ); + } + + let baseline: ReturnType; + try { + baseline = assertRunnableWorkspaceAiRevision(parsed.workspace); + } catch { + return json( + { + error: "The current workspace must pass canonical validation before AI editing.", + code: "WORKSPACE_AI_INVALID_BASE", + }, + 400, + ); + } + + let quota: WorkspaceAiQuotaReservation | null = null; + if (parsed.provider === "platform") { + try { + quota = await ( + dependencies.reservePlatformQuota ?? reserveWorkspacePlatformQuota + )(request); + credentials.identity = quota.identity; + } catch (error) { + if (error instanceof WorkspaceAiQuotaLimitError) { + return json( + { + error: "Platform AI daily allowance reached.", + code: "WORKSPACE_AI_DAILY_LIMIT", + tier: error.tier, + limit: error.limit, + remaining: 0, + connect: "openrouter", + }, + 429, + { "retry-after": "86400" }, + ); + } + if (error instanceof WorkspaceAiQuotaUnavailableError) { + return json( + { + error: "Platform AI entitlement is not configured or unavailable.", + code: "WORKSPACE_AI_UNAVAILABLE", + }, + 503, + ); + } + return json( + { + error: "Platform AI entitlement could not be reserved safely.", + code: "WORKSPACE_AI_UNAVAILABLE", + }, + 503, + ); + } + } + + let generated: GeneratedWorkspaceAiPatch; + try { + generated = await (dependencies.generate ?? generateWorkspaceAiPatch)( + parsed, + credentials, + ); + } catch (error) { + if (error instanceof WorkspaceAiProviderUnavailableError) { + return withQuotaCookies(json( + { + error: "AI workspace generation is not configured or unavailable.", + code: "WORKSPACE_AI_UNAVAILABLE", + }, + 503, + ), quota); + } + if (error instanceof WorkspaceAiProviderResponseError) { + return withQuotaCookies(json( + { + error: "The AI provider could not return a valid workspace patch.", + code: "WORKSPACE_AI_PROVIDER_FAILURE", + }, + 502, + ), quota); + } + return withQuotaCookies(json( + { + error: "AI workspace generation failed safely.", + code: "WORKSPACE_AI_PROVIDER_FAILURE", + }, + 502, + ), quota); + } + + try { + const applied = applyWorkspaceAiPatch( + parsed.workspace, + parsed.baseRevision, + generated.patch, + { now: dependencies.now }, + ); + const runnable = assertRunnableWorkspaceAiRevision(applied.workspace); + if (runnable.spec.presetId !== baseline.spec.presetId) { + return withQuotaCookies(json( + { + error: "An AI workspace patch cannot change the product category.", + code: "WORKSPACE_AI_CATEGORY_MISMATCH", + }, + 502, + ), quota); + } + return withQuotaCookies(json( + { + workspace: applied.workspace, + spec: runnable.spec, + change: { + baseRevision: parsed.baseRevision, + revision: applied.workspace.revision, + summary: applied.patch.summary, + created: applied.appliedOperations.created, + updated: applied.appliedOperations.updated, + deleted: applied.appliedOperations.deleted, + }, + providerEvidence: generated.evidence, + quota: quota + ? { + tier: quota.tier, + limit: quota.limit, + used: quota.used, + remaining: quota.remaining, + reset: quota.reset, + } + : null, + validation: { + status: "canonical-compiled", + compiledRuntimeBytes: new TextEncoder().encode(runnable.runtimeHtml) + .byteLength, + persisted: false, + }, + }, + 200, + ), quota); + } catch (error) { + if (error instanceof WorkspaceAiPatchConflictError) { + return withQuotaCookies(json( + { + error: error.message, + code: "WORKSPACE_REVISION_CONFLICT", + expectedRevision: error.expectedRevision, + receivedRevision: error.receivedRevision, + }, + 409, + ), quota); + } + if (error instanceof WorkspaceAiPatchValidationError || error instanceof Error) { + return withQuotaCookies(json( + { + error: + "The generated workspace revision failed canonical validation.", + code: "WORKSPACE_AI_INVALID_REVISION", + }, + 502, + ), quota); + } + return withQuotaCookies(json( + { + error: "The generated workspace revision failed safely.", + code: "WORKSPACE_AI_INVALID_REVISION", + }, + 502, + ), quota); + } +} + +export async function POST(request: NextRequest) { + return handleWorkspaceAiPatchRequest(request); +} diff --git a/app/api/workspace/run/route.ts b/app/api/workspace/run/route.ts new file mode 100644 index 0000000..ae71060 --- /dev/null +++ b/app/api/workspace/run/route.ts @@ -0,0 +1,254 @@ +import { NextRequest, NextResponse } from "next/server.js"; +import { + runWorkspaceSandbox, + validateWorkspaceSandboxRun, + WorkspaceSandboxProviderError, + WorkspaceSandboxUnavailableError, + WorkspaceSandboxValidationError, +} from "@/lib/workspace-sandbox"; +import { + consumeRequestLimit, + requestIdentity, +} from "@/lib/request-rate-limit"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "@/lib/http-request-boundary"; +import { + reserveWorkspaceExecutionQuota, + WorkspaceAiQuotaLimitError, + WorkspaceAiQuotaUnavailableError, + type WorkspaceAiQuotaReservation, +} from "@/lib/workspace-ai-entitlement"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +export const maxDuration = 300; + +const MAX_BODY_BYTES = 1_750_000; +const NO_STORE_HEADERS = { "cache-control": "no-store, max-age=0" }; + +export interface WorkspaceRunRouteDependencies { + consumeLimit?: typeof consumeRequestLimit; + reserveExecutionQuota?: ( + request: NextRequest, + ) => Promise; + run?: typeof runWorkspaceSandbox; +} + +function json(payload: Record, status: number) { + return NextResponse.json(payload, { + status, + headers: NO_STORE_HEADERS, + }); +} + +function withQuotaCookies( + response: NextResponse, + quota: WorkspaceAiQuotaReservation | null, +): NextResponse { + for (const cookie of quota?.cookies ?? []) { + response.cookies.set(cookie.name, cookie.value, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: cookie.maxAge, + path: "/", + }); + } + return response; +} + +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 false; + try { + const originUrl = new URL(origin); + 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}` : null; + return ( + originUrl.origin === request.nextUrl.origin || + originUrl.origin === visibleOrigin + ); + } catch { + return false; + } +} + +type RequestBodyResult = + | { status: "ok"; value: unknown } + | { status: "invalid" } + | { status: "too-large" }; + +async function requestBody(request: NextRequest): Promise { + let raw: string; + try { + raw = decodeUtf8Body(await readBoundedRequestBody(request, MAX_BODY_BYTES)); + } catch (error) { + return error instanceof RequestBodyBoundaryError && error.reason === "too-large" + ? { status: "too-large" } + : { status: "invalid" }; + } + try { + return { status: "ok", value: JSON.parse(raw) as unknown }; + } catch { + return { status: "invalid" }; + } +} + +export async function handleWorkspaceRunRequest( + request: NextRequest, + dependencies: WorkspaceRunRouteDependencies = {}, +) { + if (!sameOrigin(request)) { + return json( + { error: "Cross-origin workspace execution is not allowed." }, + 403, + ); + } + if (!hasJsonMediaType(request)) { + return json( + { error: "Workspace execution requires application/json." }, + 415, + ); + } + + const localProofStore = + process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && + !process.env.VERCEL; + const limit = await (dependencies.consumeLimit ?? consumeRequestLimit)({ + identity: requestIdentity(request), + namespace: "workspace-sandbox-run", + max: localProofStore ? 200 : 12, + windowMs: 60 * 60 * 1_000, + }).catch(() => "unavailable" as const); + if (limit === "limited") { + return NextResponse.json( + { error: "Too many workspace execution requests. Try again later." }, + { + status: 429, + headers: { ...NO_STORE_HEADERS, "retry-after": "3600" }, + }, + ); + } + if (limit === "unavailable") { + return json( + { + error: + "Secure workspace execution identity and rate limiting are unavailable.", + }, + 503, + ); + } + + const body = await requestBody(request); + if (body.status === "too-large") { + return json( + { + code: "WORKSPACE_EXECUTION_BODY_TOO_LARGE", + error: "The bounded workspace execution request exceeds 1.75 MB.", + }, + 413, + ); + } + if (body.status === "invalid") { + return json( + { + code: "WORKSPACE_EXECUTION_INVALID_REQUEST", + error: "A bounded workspace execution request is required.", + }, + 400, + ); + } + + try { + validateWorkspaceSandboxRun(body.value); + } catch (error) { + if (error instanceof WorkspaceSandboxValidationError) { + return json({ error: error.message }, 400); + } + return json({ error: "A valid bounded workspace execution request is required." }, 400); + } + + let quota: WorkspaceAiQuotaReservation; + try { + quota = await ( + dependencies.reserveExecutionQuota ?? reserveWorkspaceExecutionQuota + )(request); + } catch (error) { + if (error instanceof WorkspaceAiQuotaLimitError) { + return NextResponse.json({ + code: "WORKSPACE_EXECUTION_DAILY_LIMIT", + error: "Workspace execution daily allowance reached.", + tier: error.tier, + limit: error.limit, + remaining: 0, + }, { + status: 429, + headers: { ...NO_STORE_HEADERS, "retry-after": "86400" }, + }); + } + if (error instanceof WorkspaceAiQuotaUnavailableError) { + return json({ + code: "WORKSPACE_EXECUTION_UNAVAILABLE", + error: "Funded workspace execution entitlement is not configured or unavailable.", + }, 503); + } + return json({ + code: "WORKSPACE_EXECUTION_UNAVAILABLE", + error: "Funded workspace execution entitlement could not be reserved safely.", + }, 503); + } + + try { + const receipt = await (dependencies.run ?? runWorkspaceSandbox)(body.value); + return withQuotaCookies(json({ + receipt, + quota: { + purpose: "execution", + tier: quota.tier, + limit: quota.limit, + used: quota.used, + remaining: quota.remaining, + reset: quota.reset, + }, + }, 200), quota); + } catch (error) { + if (error instanceof WorkspaceSandboxValidationError) { + return withQuotaCookies(json({ error: error.message }, 400), quota); + } + if (error instanceof WorkspaceSandboxUnavailableError) { + return withQuotaCookies(json( + { + error: "Workspace sandbox execution is not configured or unavailable.", + }, + 503, + ), quota); + } + if (error instanceof WorkspaceSandboxProviderError) { + return withQuotaCookies(json( + { error: "Workspace sandbox provider could not complete this task." }, + 502, + ), quota); + } + return withQuotaCookies(json( + { error: "Workspace sandbox execution failed safely." }, + 502, + ), quota); + } +} + +export async function POST(request: NextRequest) { + return handleWorkspaceRunRequest(request); +} diff --git a/app/layout.tsx b/app/layout.tsx index bf3139b..d53e25d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -28,7 +28,7 @@ export const metadata: Metadata = { metadataBase: new URL(productionUrl), title: "Drops Studio — Build crypto products in minutes", description: - "Build useful crypto apps with DropsTab intelligence, Drops Bot automation and the AI model you choose.", + "Build useful crypto apps with DropsTab intelligence, guided Drops Bot setup and the AI model you choose.", openGraph: { title: "Drops Studio", description: "Turn a crypto idea into a live project in five minutes.", diff --git a/app/p/[slug]/route.ts b/app/p/[slug]/route.ts index 187a4c8..02498fc 100644 --- a/app/p/[slug]/route.ts +++ b/app/p/[slug]/route.ts @@ -1,20 +1,53 @@ import { getPublishedProject } from "@/db/projects"; +import { + addProjectArtifactCspMeta, + PROJECT_PUBLIC_RUNTIME_CSP, +} from "@/lib/artifact-csp"; +import { + buildPublicProjectShell, + publicProjectShellCsp, +} from "@/lib/public-project-shell"; export const dynamic = "force-dynamic"; -export async function GET(_request: Request, context: { params: Promise<{ slug: string }> }) { +export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { const { slug } = await context.params; if (!/^[a-z0-9-]{4,72}$/.test(slug)) return new Response("Project not found", { status: 404 }); try { const project = await getPublishedProject(slug); if (!project) return new Response("Project not found", { status: 404 }); - return new Response(project.html, { + const runtimeRequest = new URL(request.url).searchParams.get("runtime") === "1"; + if (runtimeRequest) { + return new Response(addProjectArtifactCspMeta(project.html), { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store, max-age=0", + "content-security-policy": PROJECT_PUBLIC_RUNTIME_CSP, + "cross-origin-resource-policy": "same-origin", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "SAMEORIGIN", + }, + }); + } + const nonce = crypto.randomUUID().replaceAll("-", ""); + const shell = buildPublicProjectShell({ + nonce, + presetId: project.presetId, + runtimeUrl: `/p/${slug}?runtime=1`, + slug, + title: project.title, + }); + return new Response(shell, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store, max-age=0", + "content-security-policy": publicProjectShellCsp(nonce), + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=()", "x-content-type-options": "nosniff", - "referrer-policy": "strict-origin-when-cross-origin", - "content-security-policy": "default-src 'self' data: blob:; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'; base-uri 'none'; form-action 'self' https://t.me https://dropstab.com https://polymarket.com", + "referrer-policy": "no-referrer", }, }); } catch { diff --git a/app/styles/project-studio.chrome.css b/app/styles/project-studio.chrome.css index 4713095..17b0b88 100644 --- a/app/styles/project-studio.chrome.css +++ b/app/styles/project-studio.chrome.css @@ -36,7 +36,8 @@ .project-studio-layout { display: grid; - grid-template-columns: 74px 300px minmax(600px,1fr) 360px; + grid-template-areas: "rail side canvas"; + grid-template-columns: 68px 390px minmax(0, 1fr); height: calc(100vh - 104px); min-height: 690px; } diff --git a/app/styles/project-studio.runtime.css b/app/styles/project-studio.runtime.css index ea1c48d..6eff371 100644 --- a/app/styles/project-studio.runtime.css +++ b/app/styles/project-studio.runtime.css @@ -15,7 +15,7 @@ .assistant-panel { background: white; border-left: 1px solid var(--ps-line); display: flex; flex-direction: column; min-width: 0; } .assistant-panel > header { align-items: center; border-bottom: 1px solid #e6ebf2; display: flex; justify-content: space-between; min-height: 58px; padding: 10px 12px; } -.assistant-panel > header > span { align-items: center; display: flex; gap: 8px; }.assistant-panel > header > span > span:last-child { display: grid; gap: 2px; }.assistant-panel > header strong { font-size: 12px; }.assistant-panel > header small { color: #8a97aa; font-size: 12px; }.director-avatar { align-items: center; background: linear-gradient(145deg,#316cff,#8157ef); border-radius: 10px; box-shadow: 0 7px 18px rgba(67,87,210,.2); color: white; display: flex; height: 33px; justify-content: center; width: 33px; }.director-avatar svg { height: 15px; width: 15px; }.assistant-panel > header button { align-items: center; background: #f2f5f9; border: 0; border-radius: 8px; color: #718096; display: flex; height: 30px; justify-content: center; width: 30px; }.assistant-panel > header button svg { height: 14px; width: 14px; } +.assistant-panel > header > span { align-items: center; display: flex; gap: 8px; }.assistant-panel > header > span > span:last-child { display: grid; gap: 2px; }.assistant-panel > header strong { font-size: 12px; }.assistant-panel > header small { color: #8a97aa; font-size: 12px; }.director-avatar { align-items: center; background: linear-gradient(145deg,#316cff,#8157ef); border-radius: 10px; box-shadow: 0 7px 18px rgba(67,87,210,.2); color: white; display: flex; height: 33px; justify-content: center; width: 33px; }.director-avatar svg { height: 15px; width: 15px; }.assistant-panel > header button { align-items: center; background: #f2f5f9; border: 0; border-radius: 8px; color: #718096; display: flex; height: 44px; justify-content: center; width: 44px; }.assistant-panel > header button svg { height: 14px; width: 14px; } .chat-context { align-items: center; background: #edf3ff; border-bottom: 1px solid #cbdaf7; color: #3461b9; display: flex; font-size: 12px; gap: 6px; padding: 7px 11px; }.chat-context > svg { height: 11px; width: 11px; }.chat-context span { flex: 1; }.chat-context button { background: transparent; border: 0; color: #6681b6; padding: 0; }.chat-context button svg { height: 10px; width: 10px; } .conversation { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 13px 12px; } .assistant-guide { background: linear-gradient(145deg,#f5f7ff,#fbfcff); border: 1px solid #dce3f5; border-radius: 12px; display: grid; gap: 7px; margin-bottom: 14px; padding: 11px; } @@ -33,16 +33,11 @@ .studio-loading,.studio-missing { align-items: center; background: #f7f9fc; color: #71809a; display: flex; justify-content: center; min-height: 100vh; }.studio-loading { gap: 9px; }.studio-loading svg { height: 18px; width: 18px; }.studio-missing { flex-direction: column; text-align: center; }.studio-missing > span { align-items: center; background: #eaf1ff; border-radius: 18px; color: var(--ps-blue); display: flex; height: 64px; justify-content: center; width: 64px; }.studio-missing h1 { color: var(--ps-ink); margin: 20px 0 8px; }.studio-missing p { margin: 0 0 20px; }.studio-missing a { background: var(--ps-blue); border-radius: 9px; color: white; font-size: 13px; padding: 11px 16px; text-decoration: none; } .spin { animation: studio-spin .8s linear infinite; }@keyframes studio-spin { to { transform: rotate(360deg); } } +/* Bounded legacy layer: accessibility fixes may land here for the existing Studio runtime and publish dialog; new product surfaces must use local Tailwind/Base UI components. */ .studio-dialog-overlay { background: rgba(7,15,30,.7); inset: 0; position: fixed; z-index: 60; } -.source-dialog,.publish-dialog { background: white; border-radius: 16px; box-shadow: 0 30px 100px rgba(0,0,0,.26); left: 50%; padding: 18px; position: fixed; top: 50%; transform: translate(-50%,-50%); width: min(92vw,920px); z-index: 61; } -.source-dialog > div,.publish-dialog > header { align-items: flex-start; display: flex; justify-content: space-between; }.source-dialog h2,.publish-dialog h2 { font-size: 17px; margin: 0; }.source-dialog p,.publish-dialog header p { color: #7b899d; font-size: 12px; margin: 5px 0 0; }.source-dialog > div > button,.publish-dialog > header > button { align-items: center; background: #f2f5fa; border: 0; border-radius: 8px; color: #5d6d83; display: flex; height: 32px; justify-content: center; width: 32px; }.source-dialog > div > button svg,.publish-dialog > header > button svg { height: 16px; width: 16px; }.source-tabs { background: #f2f5f9; border: 1px solid #e0e6ef; border-radius: 9px; display: flex; gap: 3px; margin-top: 14px; padding: 3px; }.source-tabs button { align-items: center; background: transparent; border: 0; border-radius: 6px; color: #718198; display: flex; font-size: 12px; gap: 5px; min-height: 29px; padding: 0 9px; }.source-tabs button.active { background: white; box-shadow: 0 2px 7px rgba(35,54,83,.1); color: #2c62c8; }.source-tabs svg { height: 11px; width: 11px; }.source-dialog pre,.source-editor { background: #071326; border: 1px solid #14294a; border-radius: 10px; color: #c8d6ec; font: 12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; height: min(60vh,540px); margin: 10px 0 15px; overflow: auto; padding: 15px; tab-size: 2; white-space: pre; width: 100%; }.source-editor { outline: none; resize: none; }.source-editor:focus { border-color: #477ceb; box-shadow: 0 0 0 3px rgba(49,108,255,.12); }.source-dialog footer { display: flex; gap: 8px; justify-content: flex-end; }.source-dialog footer button { align-items: center; background: white; border: 1px solid #d7e1ee; border-radius: 8px; color: #42628e; display: flex; font-size: 12px; gap: 6px; padding: 9px 12px; }.source-dialog footer button:last-child { background: var(--ps-blue); border-color: var(--ps-blue); color: white; }.source-dialog footer svg { height: 13px; width: 13px; } -.publish-dialog { width: min(94vw,850px); }.publish-grid { display: grid; gap: 12px; grid-template-columns: 1.05fr .95fr; margin-top: 16px; }.host-card,.pro-hosts { border: 1px solid #dce5f1; border-radius: 12px; padding: 14px; }.cloud-card { background: #f9fbff; border-color: #9eb9ef; }.cloud-card.published { border-color: #92d7bb; }.host-title { align-items: center; display: grid; gap: 9px; grid-template-columns: 35px 1fr auto; }.host-title > span { align-items: center; background: #eaf1ff; border-radius: 9px; color: var(--ps-blue); display: flex; height: 34px; justify-content: center; }.host-title svg { height: 15px; width: 15px; }.host-title > div { display: grid; gap: 3px; }.host-title strong { font-size: 12px; }.host-title small { color: #8b98aa; font-size: 12px; }.host-title > b { background: #e7f8ef; border-radius: 99px; color: #16875b; font-size: 12px; padding: 4px 6px; }.host-card ul { display: grid; gap: 7px; list-style: none; margin: 14px 0; padding: 0; }.host-card li { align-items: center; color: #64758d; display: flex; font-size: 12px; gap: 6px; }.host-card li svg { color: #16a16f; height: 10px; width: 10px; }.cloud-publish { align-items: center; background: var(--ps-blue); border: 0; border-radius: 8px; color: white; display: flex; font-size: 12px; font-weight: 750; gap: 6px; justify-content: center; margin-top: 12px; min-height: 34px; width: 100%; }.cloud-card.published .cloud-publish { background: #159c6b; }.cloud-publish svg { height: 12px; width: 12px; }.public-url { background: white; border: 1px solid #dfe6ef; border-radius: 8px; display: grid; gap: 4px; padding: 8px; }.public-url span { color: #8d99a9; font-size: 12px; }.public-url button { align-items: center; background: transparent; border: 0; color: #2f65d4; display: flex; justify-content: space-between; min-width: 0; padding: 0; }.public-url strong { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.public-url svg { flex: 0 0 auto; height: 10px; width: 10px; }.publish-error { color: #c84356; font-size: 12px; }.pro-hosts h3 { font-size: 12px; margin: 0; }.pro-hosts > p { color: #7e8b9e; font-size: 12px; line-height: 1.45; }.pro-hosts > button { align-items: center; background: white; border: 1px solid #e0e6ef; border-radius: 8px; color: #48617f; display: grid; gap: 8px; grid-template-columns: 25px 1fr auto; margin-top: 7px; padding: 8px; text-align: left; width: 100%; }.pro-hosts > button > svg { color: var(--ps-blue); height: 14px; width: 14px; }.pro-hosts > button > span { display: grid; gap: 2px; }.pro-hosts strong { font-size: 12px; }.pro-hosts small { color: #8f9bad; font-size: 12px; }.pro-hosts > button > svg:last-child { color: #9aa7b8; height: 10px; width: 10px; }.publish-dialog > footer { align-items: center; border-top: 1px solid #e7ebf2; display: flex; justify-content: space-between; margin-top: 14px; padding-top: 12px; }.publish-dialog > footer button { align-items: center; background: white; border: 1px solid #d7e1ed; border-radius: 8px; color: #3f65a0; display: flex; font-size: 12px; gap: 6px; padding: 8px 10px; }.publish-dialog > footer span { align-items: center; color: #718399; display: flex; font-size: 12px; gap: 5px; }.publish-dialog > footer svg { color: #26966f; height: 11px; width: 11px; } +.publish-dialog { background: white; border-radius: 16px; box-shadow: 0 30px 100px rgba(0,0,0,.26); left: 50%; padding: 18px; position: fixed; top: 50%; transform: translate(-50%,-50%); width: min(92vw,920px); z-index: 61; } +.publish-dialog > header { align-items: flex-start; display: flex; justify-content: space-between; }.publish-dialog h2 { font-size: 17px; margin: 0; }.publish-dialog header p { color: #7b899d; font-size: 12px; margin: 5px 0 0; }.publish-dialog > header > button { align-items: center; background: #f2f5fa; border: 0; border-radius: 8px; color: #5d6d83; display: flex; height: 44px; justify-content: center; width: 44px; }.publish-dialog > header > button svg { height: 16px; width: 16px; } +.publish-dialog { width: min(94vw,850px); }.publish-grid { display: grid; gap: 12px; grid-template-columns: 1.05fr .95fr; margin-top: 16px; }.host-card,.pro-hosts { border: 1px solid #dce5f1; border-radius: 12px; padding: 14px; }.cloud-card { background: #f9fbff; border-color: #9eb9ef; }.cloud-card.published { border-color: #92d7bb; }.host-title { align-items: center; display: grid; gap: 9px; grid-template-columns: 35px 1fr auto; }.host-title > span { align-items: center; background: #eaf1ff; border-radius: 9px; color: var(--ps-blue); display: flex; height: 34px; justify-content: center; }.host-title svg { height: 15px; width: 15px; }.host-title > div { display: grid; gap: 3px; }.host-title strong { font-size: 14px; }.host-title small { color: #8b98aa; font-size: 12px; }.host-title > b { background: #e7f8ef; border-radius: 99px; color: #16875b; font-size: 12px; padding: 4px 6px; }.host-card ul { display: grid; gap: 7px; list-style: none; margin: 14px 0; padding: 0; }.host-card li { align-items: center; color: #64758d; display: flex; font-size: 12px; gap: 6px; }.host-card li svg { color: #16a16f; height: 10px; width: 10px; }.cloud-publish { align-items: center; background: var(--ps-blue); border: 0; border-radius: 8px; color: white; display: flex; font-size: 14px; font-weight: 750; gap: 6px; justify-content: center; margin-top: 12px; min-height: 44px; width: 100%; }.cloud-card.published .cloud-publish { background: #159c6b; }.cloud-publish svg { height: 12px; width: 12px; }.public-url { background: white; border: 1px solid #dfe6ef; border-radius: 8px; display: grid; gap: 4px; padding: 8px; }.public-url span { color: #8d99a9; font-size: 12px; }.public-url button { align-items: center; background: transparent; border: 0; color: #2f65d4; display: flex; font-size: 14px; justify-content: space-between; min-height: 44px; min-width: 0; padding: 0 8px; }.public-url strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.public-url svg { flex: 0 0 auto; height: 10px; width: 10px; }.publish-error { color: #c84356; font-size: 12px; }.pro-hosts h3 { font-size: 14px; margin: 0; }.pro-hosts > p { color: #7e8b9e; font-size: 12px; line-height: 1.45; }.pro-hosts > button { align-items: center; background: white; border: 1px solid #e0e6ef; border-radius: 8px; color: #48617f; display: grid; font-size: 14px; gap: 8px; grid-template-columns: 25px 1fr auto; margin-top: 7px; min-height: 44px; padding: 8px; text-align: left; width: 100%; }.pro-hosts > button > svg { color: var(--ps-blue); height: 14px; width: 14px; }.pro-hosts > button > span { display: grid; gap: 2px; }.pro-hosts strong { font-size: 14px; }.pro-hosts small { color: #8f9bad; font-size: 12px; }.pro-hosts > button > svg:last-child { color: #9aa7b8; height: 10px; width: 10px; }.publish-dialog > footer { align-items: center; border-top: 1px solid #e7ebf2; display: flex; justify-content: space-between; margin-top: 14px; padding-top: 12px; }.publish-dialog > footer button { align-items: center; background: white; border: 1px solid #d7e1ed; border-radius: 8px; color: #3f65a0; display: flex; font-size: 14px; gap: 6px; min-height: 44px; padding: 8px 10px; }.publish-dialog > footer span { align-items: center; color: #718399; display: flex; font-size: 12px; gap: 5px; }.publish-dialog > footer svg { color: #26966f; height: 11px; width: 11px; } .project-toast { align-items: center; background: #0d1d36; border: 1px solid #273d60; border-radius: 10px; bottom: 60px; box-shadow: 0 18px 50px rgba(0,0,0,.25); color: white; display: flex; font-size: 12px; font-weight: 650; gap: 7px; left: 50%; opacity: 0; padding: 10px 13px; pointer-events: none; position: fixed; transform: translate(-50%,10px); transition: .2s; z-index: 70; }.project-toast.show { opacity: 1; transform: translate(-50%,0); }.project-toast svg { color: #3be0a4; height: 12px; width: 12px; } -/* Source workspace keeps helper and inactive-tab copy readable at WCAG AA. */ -.source-dialog p, .publish-dialog header p { color: #596a82; } -.source-tabs button { color: #53657d; } -.source-validation { background: #fff3f4; border: 1px solid #f2bcc3; border-radius: 10px; color: #8f2332; margin: -5px 0 14px; padding: 10px 12px; } -.source-validation strong { font-size: 14px; } -.source-validation ul { display: grid; font-size: 12px; gap: 4px; margin: 6px 0 0; padding-left: 18px; } diff --git a/app/styles/project-studio.wide.css b/app/styles/project-studio.wide.css deleted file mode 100644 index cb63ea0..0000000 --- a/app/styles/project-studio.wide.css +++ /dev/null @@ -1,13 +0,0 @@ -/* The typography rules below intentionally apply at every desktop width so the workspace never becomes microscopic. */ -@media(min-width:1451px){ - .project-studio-layout{grid-template-columns:78px 340px minmax(680px,1fr) 410px} -} -.studio-rail > button{font-size: 12px;min-height:58px}.studio-rail > button svg{height:19px;width:19px} -.studio-inspector{padding:22px 20px 34px}.inspector-heading > span{font-size:14px}.inspector-copy{font-size: 12px}.inspector-section > label,.design-tokens label,.selected-inspector label,.logic-grid label{font-size: 12px;gap:7px}.inspector-section input,.inspector-section textarea,.inspector-section select{font-size: 12px;min-height:39px}.inspector-primary,.inspector-secondary{font-size: 12px;min-height:42px}.section-label{font-size: 12px}.director-pipeline b{font-size: 12px}.director-pipeline small{font-size: 12px}.director-pipeline > span{padding:11px}.experience-brief > span,.game-brief > span{font-size: 12px}.experience-brief dt,.game-brief dt{font-size: 12px}.experience-brief dd,.game-brief dd{font-size: 12px}.experience-brief p,.game-brief p{font-size: 12px}.experience-brief > div:last-child i{font-size: 12px;padding:5px 7px} -.blueprint-inspector{background:linear-gradient(145deg,#f6f3ff,#f4f8ff);border:1px solid #d8cff2;border-radius:13px;margin:14px 0;padding:13px}.blueprint-inspector > span{align-items:center;color:#6248ad;display:flex;font-size: 12px;font-weight:800;gap:7px;text-transform:uppercase}.blueprint-inspector > span svg{height:14px;width:14px}.blueprint-inspector > p{color:#665e7a;font-size: 12px;line-height:1.55}.blueprint-inspector dl{display:grid;gap:8px;margin:0}.blueprint-inspector dl div{background:rgba(255,255,255,.75);border:1px solid #e4def3;border-radius:9px;padding:9px}.blueprint-inspector dt{color:#8a7fa5;font-size: 12px;font-weight:750;text-transform:uppercase}.blueprint-inspector dd{color:#4f4962;font-size: 12px;line-height:1.5;margin:4px 0 0} -.stage-toolbar button{font-size: 12px;min-height:32px}.browser-bar > strong{font-size: 12px}.browser-bar > b{font-size: 12px}.assistant-panel > header{min-height:66px;padding:12px 15px}.assistant-panel > header strong{font-size:13px}.assistant-panel > header small{font-size: 12px}.director-avatar{height:38px;width:38px}.chat-context{font-size: 12px;padding:9px 13px}.conversation{padding:16px 14px}.assistant-guide{padding:14px}.assistant-guide > strong{font-size:12px}.assistant-guide > p{font-size: 12px}.assistant-guide > span b{font-size: 12px}.assistant-guide > span small{font-size: 12px}.conversation article > span{font-size: 12px}.conversation article > p{font-size: 12px;padding:11px 12px}.proposal-card > header strong{font-size: 12px}.proposal-card li{font-size: 12px}.proposal-card > div button{font-size: 12px;min-height:32px}.quick-prompts button{font-size: 12px;min-height:30px}.chat-composer textarea{font-size:12px;line-height:1.5;padding:12px}.chat-composer footer span{font-size: 12px}.chat-composer footer button{height:34px;width:34px}.assistant-foot{font-size: 12px}.project-statusbar > span,.project-statusbar strong{font-size: 12px}.project-statusbar b{font-size: 12px}.file-tree span,.git-card strong,.checkpoint-list strong{font-size: 12px}.file-tree b,.git-card small,.checkpoint-list small{font-size: 12px}.git-card p{font-size: 12px}.git-card button{font-size: 12px;min-height:36px} - -@media(max-width:1450px){.project-studio-layout{grid-template-columns:68px 300px minmax(540px,1fr) 360px}.studio-inspector{padding-inline:16px}.runtime-stage{padding:9px}} -@media(max-width:1180px){.project-studio-layout{grid-template-columns:66px 270px minmax(520px,1fr)}.assistant-panel{border-left:0;border-top:1px solid var(--ps-line);grid-column:2/-1;height:540px}.project-studio-layout{height:auto}.runtime-stage{height:760px}.studio-rail{grid-row:span 2}.studio-inspector{height:760px}.project-statusbar{display:none}.project-studio-shell{padding-bottom:0}} -@media(max-width:800px){.project-studio-topbar{min-height:58px;padding:8px 10px}.project-crumbs>i,.project-crumbs>span:not(.studio-brand-mark),.project-crumbs>b,.workspace-actions button:nth-child(-n+2){display:none}.project-studio-layout{display:flex;flex-direction:column}.studio-rail{border-bottom:1px solid var(--ps-line);border-right:0;display:flex;flex-direction:row;overflow-x:auto;padding:7px}.studio-rail>button{flex:0 0 55px;min-height:45px}.rail-foundation{display:none}.studio-inspector{border-bottom:1px solid var(--ps-line);border-right:0;height:auto;max-height:none;order:2}.runtime-stage{height:720px;order:1}.assistant-panel{height:560px;order:3}.stage-toolbar>div:last-child button:first-child{font-size:0}.browser-bar>strong{min-width:120px}.publish-grid{grid-template-columns:1fr}.publish-dialog{max-height:92vh;overflow:auto}.publish-dialog>footer{align-items:flex-start;flex-direction:column}.source-dialog{max-height:92vh}.source-dialog pre{height:58vh}.project-toast{bottom:18px}} - diff --git a/app/styles/project-studio.workspace.css b/app/styles/project-studio.workspace.css index 6b3b340..e817188 100644 --- a/app/styles/project-studio.workspace.css +++ b/app/styles/project-studio.workspace.css @@ -65,52 +65,10 @@ } /* - * Current Project Studio contract. - * Keep the rebuilt rail + inspector + live canvas + Director architecture. - * Publish remains a contextual dialog; this layout never adds a publish column. + * Persistent Project Studio contract: one contextual surface on the left and + * one live canvas. Director uses the existing Director tool in that left + * surface; no wide-screen breakpoint may add a permanent right column. */ -@media (min-width: 1600px) { - .project-studio-layout, - .project-studio-layout.tab-connections { - grid-template-areas: "rail inspector canvas director"; - grid-template-columns: - 68px - minmax(300px, 340px) - minmax(680px, 1fr) - minmax(340px, 400px); - } - - .studio-rail { - grid-area: rail; - } - - .studio-inspector, - .project-studio-layout.tab-director .studio-inspector { - display: block; - grid-area: inspector; - } - - .runtime-stage { - grid-area: canvas; - } - - .assistant-panel, - .project-studio-layout:not(.tab-director) .assistant-panel { - display: flex; - grid-area: director; - } -} - -@media (min-width: 1760px) { - .project-studio-layout, - .project-studio-layout.tab-connections { - grid-template-columns: - 72px - minmax(320px, 380px) - minmax(680px, 1fr) - minmax(360px, 400px); - } -} /* Controls and labels are readable without relying on a global emergency override. */ .project-studio-shell :is( @@ -228,8 +186,8 @@ font-size: 14px !important; } -:where(.source-dialog, .publish-dialog) p { - font-size: 14px; +:where(.publish-dialog) p { + font-size: 16px; line-height: 1.55; } diff --git a/components/drops-studio-dialogs.tsx b/components/drops-studio-dialogs.tsx index 1bbff0d..9b2f97d 100644 --- a/components/drops-studio-dialogs.tsx +++ b/components/drops-studio-dialogs.tsx @@ -82,7 +82,6 @@ interface DropsStudioDialogsProps { projectSyncAvailable: boolean; onDisconnectOpenRouter: () => void; onConnectProvider: () => void; - onTelegramConnected: (connected: boolean) => void; onOpenProject: (id: string) => void; } @@ -113,7 +112,6 @@ export function DropsStudioDialogs({ projectSyncAvailable, onDisconnectOpenRouter, onConnectProvider, - onTelegramConnected, onOpenProject, }: DropsStudioDialogsProps) { return ( @@ -191,15 +189,14 @@ export function DropsStudioDialogs({ } defaultAbout={ telegramProject?.spec.tagline ?? - "Sourced crypto intelligence powered by DropsTab and automated with Drops Bot." + "Sourced crypto intelligence prepared in Drops Studio with DropsTab context." } defaultFirstPost={ telegramProject ? `${telegramProject.spec.blueprint.content.headline}\n\n${telegramProject.spec.blueprint.content.subheadline}\n\nDraft prepared in Drops Studio. Verify live DropsTab context before publishing.` - : "Channel created with Drops Studio. DropsTab market context and Drops Bot automation are ready to configure." + : "Channel created with Drops Studio. DropsTab context is attributable; Drops Bot Profile linking remains separate setup." } projectContext={telegramProjectSlug ?? undefined} - onConnected={onTelegramConnected} /> ) : ( <> diff --git a/components/drops-studio.tsx b/components/drops-studio.tsx index 4638e1c..72594dc 100644 --- a/components/drops-studio.tsx +++ b/components/drops-studio.tsx @@ -190,10 +190,10 @@ const providerList: Provider[] = [ }, { id: "dropsbot", - name: "Telegram + Drops Bot", - eyebrow: "Account, bot and channels", + name: "Telegram delivery + Drops Bot setup", + eyebrow: "Separate provider boundaries", description: - "Connect your Telegram account, create a real channel, add a bot as administrator and publish the first post.", + "Create and verify a Telegram channel with the selected Telegram bot, then link an official Drops Bot Profile through its documented guided flow.", docs: "https://core.telegram.org/method/channels.createChannel", }, { @@ -451,17 +451,6 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { : null, [projects, telegramProjectSlug], ); - const handleTelegramConnected = useCallback((connected: boolean) => { - if (connected) { - window.sessionStorage.setItem( - "drops-studio:dropsbot", - "account-connected", - ); - } else { - window.sessionStorage.removeItem("drops-studio:dropsbot"); - } - setConnections((current) => ({ ...current, dropsbot: connected })); - }, []); const applyAccessStatus = useCallback((access: StudioAccessStatus) => { const signedIn = Boolean(access.authenticated && access.account?.connected); const available = access.platformAi?.available === true; @@ -557,16 +546,15 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { /* Legacy drafts are optional. */ } setProjects(savedProjects); + // A Telegram user-account session is not evidence that Drops Bot is linked. + window.sessionStorage.removeItem("drops-studio:dropsbot"); setConnections((current) => { const connected = { ...current }; providerList.forEach((item) => { const marker = window.sessionStorage.getItem( `drops-studio:${item.id}`, ); - if ( - item.id !== "free" && - (item.id === "dropsbot" ? marker === "account-connected" : marker) - ) + if (item.id !== "free" && item.id !== "dropsbot" && marker) connected[item.id] = true; }); return connected; @@ -706,12 +694,15 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { ); useEffect(() => { - if (!centeredPresetRef.current) { - centeredPresetRef.current = true; + const isInitialSelection = !centeredPresetRef.current; + centeredPresetRef.current = true; + if ( + isInitialSelection && + !window.matchMedia("(max-width: 620px)").matches + ) return; - } const frame = window.requestAnimationFrame(() => { - centerPreset(selectedId, "smooth"); + centerPreset(selectedId, isInitialSelection ? "auto" : "smooth"); }); return () => window.cancelAnimationFrame(frame); }, [centerPreset, selectedId]); @@ -1224,9 +1215,17 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { return; } if (providerId === "dropsbot") { - window.open("https://t.me/Drops", "_blank", "noopener,noreferrer"); + const popup = window.open("about:blank", "_blank"); + if (!popup) { + setToast( + "Your browser blocked the Drops Bot tab. Allow popups or open the official bot manually to continue.", + ); + return; + } + popup.opener = null; + popup.location.replace("https://t.me/Drops"); setToast( - "Official Drops Bot opened. Finish setup there; Drops Studio marks it connected only after account verification.", + "Official Drops Bot opened. Telegram account verification remains separate; follow the documented Profile steps before treating alerts as configured.", ); setConnectionOpen(false); return; @@ -2047,7 +2046,6 @@ export function DropsStudio({ hero }: { hero: ReactNode }) { projectSyncAvailable={projectSyncAvailable} onDisconnectOpenRouter={() => void disconnectOpenRouterAccount()} onConnectProvider={() => void connectProvider()} - onTelegramConnected={handleTelegramConnected} onOpenProject={(id) => router.push(`/studio/${id}`)} /> )} diff --git a/components/dropsbot-webhook-connection.tsx b/components/dropsbot-webhook-connection.tsx new file mode 100644 index 0000000..0f20592 --- /dev/null +++ b/components/dropsbot-webhook-connection.tsx @@ -0,0 +1,360 @@ +"use client" + +import "@/app/styles/tailwind.css" + +import { useCallback, useEffect, useState } from "react" +import { + Bot, + Check, + Copy, + ExternalLink, + KeyRound, + LoaderCircle, + RefreshCw, + ShieldCheck, + Trash2, + Webhook, +} from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" + +interface DropsBotWebhookEventView { + id: string + receivedAt: string + contentHash: string + payload: Record +} + +interface DropsBotCallbackEvidenceView { + status: "pending" | "callback-received" + providerVerified: false + providerSignatureVerified: false + receivedAt?: string +} + +interface DropsBotWebhookConnectionProps { + projectId: string + onToast: (message: string) => void +} + +export function DropsBotWebhookConnection({ + projectId, + onToast, +}: DropsBotWebhookConnectionProps) { + const [consent, setConsent] = useState(false) + const [loading, setLoading] = useState(true) + const [creating, setCreating] = useState(false) + const [mutating, setMutating] = useState<"rotate" | "revoke" | null>(null) + const [mutationConsent, setMutationConsent] = useState(false) + const [callbackUrl, setCallbackUrl] = useState("") + const [events, setEvents] = useState([]) + const [evidence, setEvidence] = useState(null) + const [message, setMessage] = useState("") + const [canCreate, setCanCreate] = useState(false) + + const refresh = useCallback(async () => { + setLoading(true) + try { + const response = await fetch( + `/api/dropsbot/events?projectId=${encodeURIComponent(projectId)}&limit=20`, + { credentials: "same-origin", cache: "no-store" }, + ) + const payload = (await response.json().catch(() => ({}))) as { + events?: DropsBotWebhookEventView[] + callbackEvidence?: DropsBotCallbackEvidenceView + error?: string + code?: string + } + if (response.status === 404) { + setCanCreate(true) + setEvidence(null) + setEvents([]) + setMessage("No callback exists yet. Create one, then register its one-time URL inside the official @drops bot.") + return + } + if (response.status === 401) { + setCallbackUrl("") + setEvidence(null) + setEvents([]) + setConsent(false) + setCanCreate(false) + setMessage("Connect a signed Studio account before creating an owner-scoped Drops Bot callback.") + return + } + if (!response.ok) throw new Error(payload.error || "Callback events are unavailable.") + setCanCreate(false) + setEvidence(payload.callbackEvidence ?? null) + setEvents(Array.isArray(payload.events) ? payload.events : []) + setMutationConsent(false) + setMessage("") + } catch (error) { + setMessage(error instanceof Error ? error.message : "Callback events are unavailable.") + } finally { + setLoading(false) + } + }, [projectId]) + + useEffect(() => { + const timer = window.setTimeout(() => { + void refresh() + }, 0) + + return () => window.clearTimeout(timer) + }, [refresh]) + + async function createCallback() { + if (!consent || creating) return + setCreating(true) + setMessage("") + try { + const response = await fetch("/api/dropsbot/webhooks", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectId, consent: true }), + }) + const payload = (await response.json().catch(() => ({}))) as { + callbackUrl?: string + callbackEvidence?: DropsBotCallbackEvidenceView + error?: string + } + if (response.status === 401) { + setCallbackUrl("") + setEvidence(null) + setEvents([]) + setConsent(false) + setCanCreate(false) + } + if (!response.ok || !payload.callbackUrl) { + throw new Error(payload.error || "Drops Bot callback could not be created.") + } + setCallbackUrl(payload.callbackUrl) + setEvidence(payload.callbackEvidence ?? null) + setCanCreate(false) + setConsent(false) + setMessage( + "Copy this URL now. Its secret is shown once. Registration still happens inside the official @drops API screen.", + ) + onToast("Drops Bot callback created — copy the one-time URL") + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Drops Bot callback could not be created." + setMessage(errorMessage) + onToast(errorMessage) + } finally { + setCreating(false) + } + } + + async function mutateCallback(method: "PUT" | "DELETE") { + if (!mutationConsent || mutating) return + const action = method === "PUT" ? "rotate" : "revoke" + if ( + method === "DELETE" + && !window.confirm("Revoke this callback now? Its secret URL and saved event receipts will be permanently removed.") + ) { + return + } + setMutating(action) + setMessage("") + try { + const response = await fetch("/api/dropsbot/webhooks", { + method, + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ projectId, consent: true }), + }) + const payload = (await response.json().catch(() => ({}))) as { + callbackUrl?: string + callbackEvidence?: DropsBotCallbackEvidenceView + revoked?: boolean + error?: string + } + if (response.status === 401) { + setCallbackUrl("") + setEvidence(null) + setEvents([]) + setCanCreate(false) + } + if (!response.ok) { + throw new Error(payload.error || `Drops Bot callback could not be ${action}d.`) + } + setMutationConsent(false) + if (method === "PUT") { + if (!payload.callbackUrl) { + throw new Error("The rotated one-time callback URL was not returned.") + } + setCallbackUrl(payload.callbackUrl) + setEvidence(payload.callbackEvidence ?? null) + setMessage("Copy the replacement URL now and update @drops. The previous secret URL no longer works.") + onToast("Drops Bot callback secret rotated") + } else { + setCallbackUrl("") + setEvidence(null) + setEvents([]) + setCanCreate(true) + setMessage("Callback revoked. Its previous secret URL no longer works; you can create a new callback when ready.") + onToast("Drops Bot callback revoked") + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : `Drops Bot callback could not be ${action}d.` + setMessage(errorMessage) + onToast(errorMessage) + } finally { + setMutating(null) + } + } + + return ( +
+
+ + +
+ Drops Bot webhook receiver + + Wallet, swap, and tracked-event delivery · official free tier: 20 wallets / 10,000 callbacks monthly + +
+ {evidence?.status === "callback-received" ? ( + + + ) : evidence ? ( + Awaiting first callback + ) : null} + +
+ +
+ {loading ? ( +

+

+ ) : null} + + {callbackUrl ? ( +
+ One-time callback URL + {callbackUrl} +
+ + +
+
+ ) : null} + + {canCreate ? ( +
+ + +
+ ) : null} + + {message ?

{message}

: null} + + {evidence ? ( +
+
+ + + + Provider identity and signature: unverified because the public specification defines neither + + {evidence.receivedAt ? ( + + Last capability-authenticated callback: {new Date(evidence.receivedAt).toLocaleString()} + + ) : null} +
+
+ +
+ + +
+
+
+ ) : null} + + {events.length ? ( +
+ Recent sanitized events + {events.slice(0, 5).map((event) => ( +
+ + {String(event.payload.event ?? event.payload.type ?? "Unverified callback event")} · {new Date(event.receivedAt).toLocaleString()} + +
+                  {JSON.stringify(event.payload, null, 2)}
+                
+
+ ))} +
+ ) : null} +
+
+ ) +} diff --git a/components/preview-canvas.tsx b/components/preview-canvas.tsx index 9e32fbf..c5fe864 100644 --- a/components/preview-canvas.tsx +++ b/components/preview-canvas.tsx @@ -286,7 +286,7 @@ export function PreviewCanvas({ preset, spec, values, market, dataMode, predicti {isTelegram && }
{isTelegram String(item).slice(0, 160)).slice(0, 5) : []; - const provider = String(input.dataProvider || "").trim().toLowerCase(); return { - mode: input.mode === "server-artifact" ? "server-artifact" : "browser", - dataProvider: - provider === "dropstab" || provider === "fallback" - ? provider - : "unverified", + // Every value here originates inside editable srcdoc code. Preserve it as + // browser telemetry only; the iframe cannot mint host/provider evidence. + mode: "browser", + dataProvider: "unverified", executed: input.executed === true, runtime: input.runtime === true, interactions: input.interactions === true, @@ -352,6 +371,14 @@ function normalizeRuntimeSmoke( }; } +function normalizeHostDataProvider( + value: unknown, +): "dropstab" | "fallback" | "unverified" { + return value === "dropstab" || value === "fallback" + ? value + : "unverified"; +} + function downloadBlob(filename: string, blob: Blob): void { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -516,6 +543,24 @@ export function ProjectStudio() { const [sourceFile, setSourceFile] = useState("index.html"); const [sourceDraft, setSourceDraft] = useState(""); const [sourceIssues, setSourceIssues] = useState([]); + const [workspaceRunningTask, setWorkspaceRunningTask] = + useState(null); + const [workspaceRunReceipt, setWorkspaceRunReceipt] = + useState(null); + const [workspaceRunDigestEvidence, setWorkspaceRunDigestEvidence] = + useState<{ + project: GeneratedProject; + receipt: WorkspaceRunReceiptView; + digest: string; + } | null>(null); + const [workspaceRunError, setWorkspaceRunError] = useState(""); + const [workspaceAiPrompt, setWorkspaceAiPrompt] = useState(""); + const [workspaceAiRunning, setWorkspaceAiRunning] = useState(false); + const [workspaceAiError, setWorkspaceAiError] = useState(""); + const [workspaceAiEvidence, setWorkspaceAiEvidence] = + useState(null); + const [workspaceAiQuota, setWorkspaceAiQuota] = + useState(null); const [publishOpen, setPublishOpen] = useState(false); const [publishError, setPublishError] = useState(""); const [newModule, setNewModule] = useState(""); @@ -525,6 +570,8 @@ export function ProjectStudio() { }); const [runtimeSmoke, setRuntimeSmoke] = useState(null); + const [hostDataProvider, setHostDataProvider] = + useState<"dropstab" | "fallback" | "unverified">("unverified"); const [projectSyncStatus, setProjectSyncStatus] = useState("loading"); @@ -556,11 +603,6 @@ export function ProjectStudio() { return true; } - if (next.sourceEditedAt) { - setProjectSyncStatus("local"); - return true; - } - setProjectSyncStatus("saving"); try { const record = await saveMemberProjectToCloud( @@ -665,11 +707,24 @@ export function ProjectStudio() { found.sourceEditedAt && validateEditableRuntimeHtml(spec, found.html).valid, ); - const html = storedSourceIsValid ? found.html : compiledHtml; + const storedHtml = storedSourceIsValid ? found.html : compiledHtml; + const storedWorkspaceValid = Boolean( + found.workspace && + validateProjectWorkspace(spec, found.workspace).valid, + ); + const workspace = storedWorkspaceValid + ? found.workspace! + : materializeProjectWorkspace({ + ...found, + spec, + html: storedHtml, + }); + const html = compileWorkspaceRuntime(spec, workspace); const migrated: GeneratedProject = { ...found, spec, html, + workspace, sourceEditedAt: storedSourceIsValid ? found.sourceEditedAt : undefined, @@ -769,43 +824,55 @@ export function ProjectStudio() { ) return; setRuntimeSmoke(smoke); - const quality = evaluateProjectQuality( - committed.spec, - committed.html, - smoke, + return; + } + if (event.data.type === "drops-studio-open-external") { + const committed = committedProjectRef.current; + if ( + !committed + || String(event.data.slug || "") !== committed.spec.slug + ) { + return; + } + const approvedUrl = approvedPreviewExternalUrl( + event.data.url, + window.location.origin, ); - const nextCommitted = { ...committed, quality }; - committedProjectRef.current = nextCommitted; - setRuntimeProject(nextCommitted); - const current = projectRef.current; - if (current && !pendingSpecRef.current) { - const next = { ...current, quality }; - projectRef.current = next; - void persistProject(next, current.updatedAt); - setProject(next); + if (!approvedUrl) { + setToast( + "Preview blocked an unapproved external link. Use Fullscreen for standalone navigation.", + ); + return; } + window.open(approvedUrl, "_blank", "noopener,noreferrer"); return; } if (event.data.type === "drops-studio-data-request") { + const source = event.source as Window; void fetch("/api/public-data", { headers: { accept: "application/json" }, }) - .then((response) => response.json()) - .then((payload) => - (event.source as Window).postMessage( + .then(async (response) => { + const payload = (await response.json()) as Record; + const provider = response.ok + ? normalizeHostDataProvider(payload.provider) + : "unverified"; + setHostDataProvider(provider); + source.postMessage( { type: "drops-studio-data-response", payload }, "*", - ), - ) - .catch(() => - (event.source as Window).postMessage( + ); + }) + .catch(() => { + setHostDataProvider("unverified"); + source.postMessage( { type: "drops-studio-data-response", payload: { source: "Saved DropsTab-compatible snapshot" }, }, "*", - ), - ); + ); + }); } if (event.data.type === "drops-studio-product-hunt-request") { const source = event.source as Window; @@ -903,6 +970,39 @@ export function ProjectStudio() { return () => window.removeEventListener("message", handleMessage); }, [persistProject]); + useEffect(() => { + let cancelled = false; + if (!project || !workspaceRunReceipt) return; + const workspace = project.workspace ?? materializeProjectWorkspace(project); + const task = workspace.tasks.find( + (candidate) => candidate.id === workspaceRunReceipt.task, + ); + if (!task) return; + void createWorkspaceRunDigest({ + files: workspace.files, + task: { + id: task.id, + argv: [task.command, ...task.args], + cwd: task.cwd ?? ".", + timeoutMs: 15_000, + previewPort: task.port, + }, + }) + .then((digest) => { + if (!cancelled) { + setWorkspaceRunDigestEvidence({ + project, + receipt: workspaceRunReceipt, + digest, + }); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [project, workspaceRunReceipt]); + useEffect(() => { iframeRef.current?.contentWindow?.postMessage( { type: "drops-studio-design-mode", enabled: designMode }, @@ -965,19 +1065,31 @@ export function ProjectStudio() { `src="${previewGameAssets.sprite || transparentPixel}"`, ); }, [previewGameAssets, runtimeProject]); + const runtimeSrcDoc = useMemo( + () => secureEditableRuntimeSrcDoc(runtimeHtml), + [runtimeHtml], + ); + const trustedRuntimeSmoke = + (runtimeProject?.quality?.runtimeSmoke?.mode === "server-artifact" + || runtimeProject?.quality?.runtimeSmoke?.mode === "server-inspection") + ? runtimeProject.quality.runtimeSmoke + : null; const qualityReport = useMemo( () => runtimeProject ? evaluateProjectQuality( runtimeProject.spec, runtimeProject.html, - runtimeSmoke, + trustedRuntimeSmoke ?? runtimeSmoke, + { dataProvider: hostDataProvider }, ) : null, - [runtimeProject, runtimeSmoke], + [hostDataProvider, runtimeProject, runtimeSmoke, trustedRuntimeSmoke], ); - const runtimeReady = Boolean( - runtimeSmoke?.executed && runtimeSmoke.runtime, + const browserTelemetryReady = Boolean( + runtimeSmoke?.mode === "browser" + && runtimeSmoke.executed + && runtimeSmoke.runtime, ); const activeProvider = useMemo(() => { if (!project) return "free" as ProjectProvider; @@ -986,24 +1098,70 @@ export function ProjectStudio() { "free") as ProjectProvider; }, [project]); + const adoptProject = useCallback((next: GeneratedProject) => { + if (quietCommitTimerRef.current !== null) { + window.clearTimeout(quietCommitTimerRef.current); + quietCommitTimerRef.current = null; + } + pendingSpecRef.current = null; + projectRef.current = next; + committedProjectRef.current = next; + setRuntimeSmoke(null); + setProject(next); + setRuntimeProject(next); + setRuntimeRevision((revision) => revision + 1); + setDirty(true); + }, []); + const replaceProject = useCallback( (next: GeneratedProject) => { const current = projectRef.current; - if (quietCommitTimerRef.current !== null) { - window.clearTimeout(quietCommitTimerRef.current); - quietCommitTimerRef.current = null; - } - pendingSpecRef.current = null; - projectRef.current = next; - committedProjectRef.current = next; - setRuntimeSmoke(null); + adoptProject(next); void persistProject(next, current?.updatedAt ?? null); - setProject(next); - setRuntimeProject(next); - setRuntimeRevision((revision) => revision + 1); - setDirty(true); }, - [persistProject], + [adoptProject, persistProject], + ); + + const applyTeamProject = useCallback( + async function applyTeamProject( + sharedProject: GeneratedProject, + ): Promise { + const appliedAt = new Date().toISOString(); + const localProject: GeneratedProject = { + ...sharedProject, + sourceEditedAt: appliedAt, + updatedAt: appliedAt, + }; + const existing = readProjectsFromStore().find( + (item) => item.id === localProject.id, + ); + try { + const saved = await saveProjectSafely(localProject, { + expectedUpdatedAt: existing?.updatedAt ?? null, + }); + if (saved.status === "conflict") { + setToast( + "A newer browser copy of this shared project already exists. Reload before applying the team revision.", + ); + return false; + } + if (localProject.id === params.id) { + adoptProject(localProject); + setProjectSyncStatus("local"); + return true; + } + window.location.assign( + `/studio/${encodeURIComponent(localProject.id)}`, + ); + return true; + } catch { + setToast( + "The shared project could not be saved locally. Free browser storage, then retry.", + ); + return false; + } + }, + [adoptProject, params.id], ); const persistPendingSpec = useCallback(() => { @@ -1705,7 +1863,9 @@ export function ProjectStudio() { ); else { const url = URL.createObjectURL( - new Blob([currentProject.html], { type: "text/html" }), + new Blob([createIsolatedRuntimeFullscreenDocument(currentProject.html)], { + type: "text/html", + }), ); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); @@ -1754,12 +1914,21 @@ export function ProjectStudio() { const currentProject = commitPendingSpec() ?? projectRef.current ?? project; if (!currentProject || publishing || unpublishing) return null; + const trustedPublishSmoke = + (currentProject.quality?.runtimeSmoke?.mode === "server-artifact" + || currentProject.quality?.runtimeSmoke?.mode === "server-inspection") + ? currentProject.quality.runtimeSmoke + : null; const quality = evaluateProjectQuality( currentProject.spec, currentProject.html, - runtimeSmoke, + trustedPublishSmoke, + { dataProvider: hostDataProvider }, ); - if (!quality.readyToPublish) { + // Browser postMessage telemetry is never an authorization gate. When an + // edited workspace has no bound server inspection yet, the publish API is + // still reached and performs the authoritative release inspection. + if (trustedPublishSmoke && !quality.readyToPublish) { const next = { ...currentProject, quality }; projectRef.current = next; setProject(next); @@ -1798,6 +1967,7 @@ export function ProjectStudio() { url?: string; slug?: string; capability?: string; + quality?: unknown; code?: string; error?: string; }; @@ -1829,9 +1999,19 @@ export function ProjectStudio() { "The public app was created without a browser management capability. Reload before publishing again.", ); } + const publishedQuality = acceptPublishedQuality( + payload.quality, + currentProject.spec.presetId, + ); + if (!publishedQuality) { + throw new Error( + "The publish service did not return valid server inspection evidence for this product.", + ); + } const publishedAt = new Date().toISOString(); const next = { ...currentProject, + quality: publishedQuality, publishedUrl: payload.url, publishedSlug: payload.slug, publishedAt, @@ -1860,7 +2040,7 @@ export function ProjectStudio() { setToast( publishMutation === "update" ? "Public app updated at the same URL" - : quality.externalSetupRequired + : publishedQuality.externalSetupRequired ? "Setup app published — connect and verify the external destination next" : "Working public app published", ); @@ -2024,19 +2204,32 @@ export function ProjectStudio() { const currentProject = commitPendingSpec() ?? projectRef.current ?? project; if (!currentProject) return; + const workspace = + currentProject.workspace ?? materializeProjectWorkspace(currentProject); + const selectedFile = + file === "quality-report.json" + ? file + : workspace.files.some((item) => item.path === file) + ? file + : "index.html"; sourceReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; - setSourceFile(file); + if (!currentProject.workspace) { + const next = { ...currentProject, workspace }; + projectRef.current = next; + committedProjectRef.current = next; + setProject(next); + setRuntimeProject(next); + void persistProject(next, currentProject.updatedAt); + } + setSourceFile(selectedFile); setSourceDraft( - file === "index.html" - ? prepareEditableRuntimeHtml(currentProject.html) - : file === "project.json" - ? JSON.stringify(currentProject.spec, null, 2) - : "", + workspace.files.find((item) => item.path === selectedFile)?.content ?? "", ); setSourceIssues([]); + setWorkspaceRunError(""); setSourceOpen(true); } @@ -2050,11 +2243,29 @@ export function ProjectStudio() { } function applyProjectJson() { - if (!project) return; + const currentProject = projectRef.current ?? project; + if (!currentProject) return; try { const spec = validateProjectSpec(JSON.parse(sourceDraft)); - commitSpec(spec, "Edited project.json", "manual"); - setSourceDraft(JSON.stringify(spec, null, 2)); + const baseWorkspace = + currentProject.workspace ?? materializeProjectWorkspace(currentProject); + const normalized = JSON.stringify(spec, null, 2); + const workspace = updateWorkspaceFile( + spec, + baseWorkspace, + "project.json", + normalized, + ); + const transition = commitProjectCheckpoint(currentProject, { + id: nowId("checkpoint"), + label: "Edited project.json", + createdAt: new Date().toISOString(), + source: "manual", + spec, + workspace, + }); + replaceProject(transition.project); + setSourceDraft(normalized); setSourceIssues([]); setToast("Validated project.json applied — checkpoint created"); } catch (error) { @@ -2070,28 +2281,306 @@ export function ProjectStudio() { function applyRuntimeHtml() { const currentProject = projectRef.current ?? project; if (!currentProject) return; - const validation = validateEditableRuntimeHtml( - currentProject.spec, - sourceDraft, - ); - if (!validation.valid) { - setSourceIssues(validation.issues); - setToast(validation.issues[0] ?? "index.html did not pass validation"); - return; + try { + const baseWorkspace = + currentProject.workspace ?? materializeProjectWorkspace(currentProject); + const workspace = updateWorkspaceFile( + currentProject.spec, + baseWorkspace, + sourceFile, + sourceDraft, + ); + const transition = commitProjectCheckpoint(currentProject, { + id: nowId("checkpoint"), + label: `Edited ${sourceFile}`, + createdAt: new Date().toISOString(), + source: "manual", + spec: currentProject.spec, + workspace, + }); + replaceProject(transition.project); + setSourceIssues([]); + setToast( + sourceFile === "index.html" + ? "Validated index.html applied — preview updated and checkpoint created" + : `Validated ${sourceFile} applied — preview and revision updated`, + ); + } catch (error) { + const issue = + error instanceof Error ? error.message : `${sourceFile} is invalid`; + setSourceIssues([issue]); + setToast(issue); + } + } + + async function runWorkspaceTask(task: ProjectWorkspaceTask) { + const currentProject = projectRef.current ?? project; + if (!currentProject || workspaceRunningTask) return; + const workspace = + currentProject.workspace ?? materializeProjectWorkspace(currentProject); + setWorkspaceRunningTask(task.id); + setWorkspaceRunError(""); + try { + const submittedArgv = [task.command, ...task.args]; + const submittedDigest = await createWorkspaceRunDigest({ + files: workspace.files, + task: { + id: task.id, + argv: submittedArgv, + cwd: task.cwd ?? ".", + timeoutMs: 15_000, + previewPort: task.port, + }, + }); + const response = await fetch("/api/workspace/run", { + method: "POST", + credentials: "same-origin", + headers: studioRequestHeaders(), + body: JSON.stringify({ + workspaceId: currentProject.id, + workspace, + taskId: task.id, + }), + }); + const payload = (await response.json().catch(() => ({}))) as { + receipt?: WorkspaceRunReceiptView; + error?: string; + } & Partial; + if (!response.ok) { + throw new Error(payload.error || "The isolated workspace task failed."); + } + const receipt = payload.receipt ?? + (payload.providerRunId ? (payload as WorkspaceRunReceiptView) : null); + if ( + !receipt || + receipt.provider !== "vercel-sandbox" || + receipt.workspaceId !== currentProject.id || + receipt.workspaceRevision !== workspace.revision || + receipt.workspaceDigest !== submittedDigest || + receipt.task !== task.id || + !Array.isArray(receipt.argv) || + receipt.argv.length !== submittedArgv.length || + receipt.argv.some((value, index) => value !== submittedArgv[index]) + ) { + throw new Error( + "The sandbox did not return a verifiable receipt for the submitted workspace revision.", + ); + } + setWorkspaceRunReceipt(receipt); + setToast( + receipt.exitCode === 0 || receipt.exitCode === null + ? `${task.label} completed in the isolated workspace` + : `${task.label} exited with code ${receipt.exitCode}`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Workspace task failed."; + setWorkspaceRunError(message); + setToast(message); + } finally { + setWorkspaceRunningTask(null); + } + } + + async function generateWorkspacePatch() { + const currentProject = projectRef.current ?? project; + const prompt = workspaceAiPrompt.trim(); + if (!currentProject || workspaceAiRunning || prompt.length < 3) return; + const workspace = + currentProject.workspace ?? materializeProjectWorkspace(currentProject); + const selectedProvider = activeProvider; + const provider = ["openrouter", "openai", "anthropic", "kimi"].includes( + selectedProvider, + ) + ? (selectedProvider as "openrouter" | "openai" | "anthropic" | "kimi") + : "platform"; + const model = + provider === "platform" + ? undefined + : window.sessionStorage.getItem( + `drops-studio:${selectedProvider}:model`, + ) || currentProject.spec.brain.model; + const headers = studioRequestHeaders(); + if (provider === "openrouter") { + const key = window.sessionStorage.getItem("drops-studio:openrouter"); + if (key) headers["x-openrouter-key"] = key; + } else if (["openai", "anthropic", "kimi"].includes(provider)) { + const key = window.sessionStorage.getItem(`drops-studio:${provider}`); + if (key) headers["x-provider-key"] = key; + } + + setWorkspaceAiRunning(true); + setWorkspaceAiError(""); + try { + const response = await fetch("/api/workspace/patch", { + method: "POST", + credentials: "same-origin", + headers, + body: JSON.stringify({ + prompt, + baseRevision: workspace.revision, + workspace, + provider, + ...(model ? { model } : {}), + }), + }); + const payload = (await response.json().catch(() => ({}))) as { + workspace?: ProjectWorkspace; + spec?: unknown; + change?: { + revision?: number; + summary?: string; + created?: number; + updated?: number; + deleted?: number; + }; + providerEvidence?: WorkspaceAiEvidenceView; + quota?: WorkspaceAiQuotaView | null; + error?: string; + }; + if (!response.ok || !payload.workspace) { + throw new Error( + payload.error || + "The AI provider could not create a valid source revision.", + ); + } + const projectFile = payload.workspace.files.find( + (file) => file.path === "project.json", + ); + const nextSpec = validateProjectSpec( + payload.spec ?? JSON.parse(projectFile?.content ?? "null"), + ); + if (nextSpec.presetId !== currentProject.spec.presetId) { + throw new Error( + "AI source changes cannot switch this project's product category.", + ); + } + const validation = validateProjectWorkspace(nextSpec, payload.workspace); + if (!validation.valid) { + throw new Error( + validation.issues[0] || "The returned workspace revision is invalid.", + ); + } + compileWorkspaceRuntime(nextSpec, payload.workspace); + const transition = commitProjectCheckpoint(currentProject, { + id: nowId("checkpoint"), + label: payload.change?.summary || "AI source revision", + createdAt: new Date().toISOString(), + source: "director", + spec: nextSpec, + workspace: payload.workspace, + }); + replaceProject(transition.project); + setSourceFile("index.html"); + setSourceDraft( + payload.workspace.files.find((file) => file.path === "index.html") + ?.content ?? "", + ); + setSourceIssues([]); + setWorkspaceRunError(""); + setWorkspaceAiEvidence(payload.providerEvidence ?? null); + setWorkspaceAiQuota(payload.quota ?? null); + setWorkspaceAiPrompt(""); + const operations = [ + payload.change?.created ? `${payload.change.created} created` : "", + payload.change?.updated ? `${payload.change.updated} updated` : "", + payload.change?.deleted ? `${payload.change.deleted} deleted` : "", + ].filter(Boolean); + setToast( + `AI workspace revision ${payload.workspace.revision} applied${ + operations.length ? ` · ${operations.join(", ")}` : "" + }`, + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "AI workspace generation failed safely."; + setWorkspaceAiError(message); + setToast(message); + } finally { + setWorkspaceAiRunning(false); + } + } + + function createWorkspaceFile(path: string) { + const currentProject = projectRef.current ?? project; + if (!currentProject) return; + try { + const workspace = addWorkspaceFile( + currentProject.spec, + currentProject.workspace ?? materializeProjectWorkspace(currentProject), + { + path, + content: path.endsWith(".json") + ? "{}\n" + : path.endsWith(".css") + ? "/* Workspace styles */\n" + : path.endsWith(".md") + ? "# Workspace note\n" + : "export {};\n", + language: path.endsWith(".json") + ? "json" + : path.endsWith(".css") + ? "css" + : path.endsWith(".md") + ? "markdown" + : "javascript", + role: path.startsWith("tests/") ? "test" : "client", + }, + ); + const transition = commitProjectCheckpoint(currentProject, { + id: nowId("checkpoint"), + label: `Created ${path}`, + createdAt: new Date().toISOString(), + source: "manual", + spec: currentProject.spec, + workspace, + }); + replaceProject(transition.project); + setSourceFile(path); + setSourceDraft( + workspace.files.find((file) => file.path === path)?.content ?? "", + ); + setSourceIssues([]); + setToast(`${path} created in workspace revision ${workspace.revision}`); + } catch (error) { + const issue = error instanceof Error ? error.message : "Could not create file."; + setSourceIssues([issue]); + setToast(issue); + } + } + + function removeWorkspaceFile(path: string) { + const currentProject = projectRef.current ?? project; + if (!currentProject) return; + try { + const workspace = deleteWorkspaceFile( + currentProject.spec, + currentProject.workspace ?? materializeProjectWorkspace(currentProject), + path, + ); + const transition = commitProjectCheckpoint(currentProject, { + id: nowId("checkpoint"), + label: `Deleted ${path}`, + createdAt: new Date().toISOString(), + source: "manual", + spec: currentProject.spec, + workspace, + }); + replaceProject(transition.project); + setSourceFile("index.html"); + setSourceDraft( + workspace.files.find((file) => file.path === "index.html")?.content ?? + "", + ); + setSourceIssues([]); + setToast(`${path} removed from workspace`); + } catch (error) { + const issue = error instanceof Error ? error.message : "Could not delete file."; + setSourceIssues([issue]); + setToast(issue); } - const transition = commitProjectCheckpoint(currentProject, { - id: nowId("checkpoint"), - label: "Edited runnable index.html", - createdAt: new Date().toISOString(), - source: "manual", - spec: currentProject.spec, - runtimeHtml: sourceDraft, - }); - replaceProject(transition.project); - setSourceIssues([]); - setToast( - "Validated index.html applied — preview updated and checkpoint created", - ); } if (!loaded) @@ -2147,24 +2636,16 @@ export function ProjectStudio() { ]; const game = project.spec.gameDirection; const quickPrompts = categoryPrompts[project.spec.presetId]; - const activeSourceContent = - sourceFile === "index.html" - ? sourceDraft || project.html - : sourceFile === "project.json" - ? sourceDraft || JSON.stringify(project.spec, null, 2) - : JSON.stringify(quality, null, 2); + const activeWorkspace = + project.workspace ?? materializeProjectWorkspace(project); const openInspectorTab = (nextTab: InspectorTab) => { - if ( - nextTab === "director" && - window.matchMedia("(min-width: 1600px)").matches - ) { + setTab(nextTab); + if (nextTab === "director") { window.requestAnimationFrame(() => { document.querySelector(".chat-composer textarea")?.focus(); }); - return; } - setTab(nextTab); }; return ( @@ -2299,7 +2780,7 @@ export function ProjectStudio() { - +
@@ -2334,7 +2815,7 @@ export function ProjectStudio() { 3 Foundation - DropsTab × Drops Bot + DropsTab × guided Drops Bot setup 4 @@ -2417,7 +2898,7 @@ export function ProjectStudio() {
{project.spec.blueprint.dropsTabUse.join(" · ")}
-
Drops Bot automation
+
Drops Bot setup recipe
{project.spec.blueprint.dropsBotUse.join(" · ")}
{project.spec.blueprint.revisionNotes?.length ? ( @@ -3403,10 +3884,19 @@ export function ProjectStudio() { + + )} @@ -3668,12 +4158,14 @@ export function ProjectStudio() { Live preview - {runtimeReady ? "Runtime ready" : "Loading runtime"} + {browserTelemetryReady + ? "Browser telemetry" + : "Loading preview"} @@ -3681,8 +4173,8 @@ export function ProjectStudio() { ref={iframeRef} key={`${runtimeRevision}:${Boolean(previewGameAssets.background)}:${Boolean(previewGameAssets.sprite)}`} title={`${project.spec.name} live application`} - srcDoc={runtimeHtml} - sandbox="allow-scripts allow-forms allow-popups allow-downloads" + srcDoc={runtimeSrcDoc} + sandbox="allow-scripts allow-forms allow-downloads" onLoad={() => iframeRef.current?.contentWindow?.postMessage( { type: "drops-studio-design-mode", enabled: designMode }, @@ -3894,7 +4386,7 @@ export function ProjectStudio() { DropsTab data 15 min
- Drops Bot {externalSetup ? "Needs connection" : "Ready"} + Drops Bot {externalSetup ? "Needs setup" : "Guided handoff"} Quality {quality.score}/100 @@ -3904,131 +4396,59 @@ export function ProjectStudio() { - - - - { - event.preventDefault(); - sourceReturnFocusRef.current?.focus(); - }} - > -
-
- Owned source workspace - - Edit the exact runnable HTML or project graph, validate it, - and export the same files you run. - -
- - - -
- - {sourceFile === "index.html" || sourceFile === "project.json" ? ( -
The token is sent over HTTPS only to the current app origin. It is never written to project state, localStorage, publish history or ZIP. Success requires Telegram to return the channel identity and bot administrator status.
Waiting for a BotFather token and an existing channel.
';document.body.appendChild(modal)} + function dropsbotSetup(){var existing=document.getElementById("integrationModal");if(existing)existing.remove();var recipe=(spec.blueprint&&spec.blueprint.dropsBotUse||[]).join("\\n");var defaultMessage=(state.posts&&state.posts[0]&&state.posts[0].copy)||((spec.blueprint&&spec.blueprint.content&&spec.blueprint.content.headline)||spec.name)+"\\n\\nBuilt from DropsTab market context. Verify the full research trail before acting.\\nhttps://dropstab.com/";var modal=document.createElement("div");modal.className="integration-modal";modal.id="integrationModal";modal.innerHTML='
VERIFIED TELEGRAM SETUP

Create a new channel in Studio, or verify one you already own

The Telegram phone below is a preview only. For a new destination, Drops Studio first connects your Telegram user account through its existing MTProto wizard; the external action happens only after your explicit approval.

OPTION A · NEW CHANNEL VIA STUDIO

Connect the user account, then add the selected Telegram bot

1Connect through MTProtoOpen the secure Studio wizard, enter the code sent by Telegram and an optional two-step password, and keep the account session outside this export.
2Approve the real destinationCreate or select the channel only after reviewing its title, description and first post.
3Add the selected Telegram botGrant the selected bot administrator permission and verify delivery from Telegram. Link an official Drops Bot Profile separately with /profiles and /use_thread.
Open Studio Telegram connection
Drops Bot setup recipe: '+esc(recipe||"Price, wallet and event alerts with Telegram delivery")+'. Opening or copying it does not prove that an official Drops Bot Profile is configured. This export receives no MTProto credentials and never treats its preview as proof of a channel.
OPTION B · EXISTING CHANNEL FALLBACK

Verify and send with your bot

The token is sent over HTTPS only to the current app origin. It is never written to project state, localStorage, publish history or ZIP. Success requires Telegram to return the channel identity and bot administrator status.
Waiting for a BotFather token and an existing channel.
';document.body.appendChild(modal)} var renderDropsbotSetup=dropsbotSetup; var integrationTrigger=null; var integrationObserver=null; @@ -231,7 +231,7 @@ export function compileProject(input: GeneratedProjectSpec): string { function closeIntegration(restoreFocus){var modal=document.getElementById("integrationModal");if(modal)modal.remove();teardownIntegration(restoreFocus)} function integrationModalKeydown(event){var modal=document.getElementById("integrationModal");if(!modal)return;if(event.key==="Escape"){event.preventDefault();closeIntegration();return}if(event.key!=="Tab")return;var focusable=integrationFocusable(modal);var card=modal.querySelector(".integration-card");if(!focusable.length){event.preventDefault();if(card)card.focus();return}var first=focusable[0];var last=focusable[focusable.length-1];var active=document.activeElement;if(event.shiftKey&&(active===first||active===card||!modal.contains(active))){event.preventDefault();last.focus()}else if(!event.shiftKey&&(active===last||!modal.contains(active))){event.preventDefault();first.focus()}} dropsbotSetup=function(trigger){closeIntegration(false);integrationTrigger=trigger&&typeof trigger.focus==="function"?trigger:document.activeElement;renderDropsbotSetup();var modal=document.getElementById("integrationModal");if(!modal)return;var heading=modal.querySelector("h2");var description=modal.querySelector(".integration-card > p");var card=modal.querySelector(".integration-card");if(heading)heading.id="integrationModalTitle";if(description)description.id="integrationModalDescription";modal.setAttribute("role","dialog");modal.setAttribute("aria-modal","true");modal.setAttribute("aria-labelledby","integrationModalTitle");modal.setAttribute("aria-describedby","integrationModalDescription");if(card)card.setAttribute("tabindex","-1");document.addEventListener("keydown",integrationModalKeydown,true);integrationObserver=new MutationObserver(function(){if(!modal.isConnected)teardownIntegration(true)});integrationObserver.observe(document.body,{childList:true});requestAnimationFrame(function(){if(card)card.focus()})}; - async function verifyTelegram(sendTest){if(telegramPending)return;var token=document.getElementById("tgToken");var channel=document.getElementById("tgChannel");var message=document.getElementById("tgMessage");var status=document.getElementById("telegramStatus");if(!token||!channel||!status)return;var controls=Array.from(document.querySelectorAll('[data-action="telegram-verify"],[data-action="telegram-send"]'));telegramPending=true;controls.forEach(function(control){control.disabled=true});status.className="integration-status";status.textContent=sendTest?"Verifying permissions and sending…":"Verifying bot and channel permissions…";var requestBody=JSON.stringify({token:token.value,channel:channel.value,message:message?message.value:"",sendTest:Boolean(sendTest)});try{if(!telegramEndpoint)throw new Error("Deploy this export to Vercel before verifying a custom bot, or use official Drops Bot.");var response=await fetch(telegramEndpoint,{method:"POST",headers:{"content-type":"application/json","x-drops-session":telegramSession},body:requestBody});var payload=await response.json().catch(function(){return null});if(!payload)throw new Error("Telegram verification returned an unreadable response.");if(!response.ok)throw new Error(payload.error||"Telegram verification failed");state.telegramChannel=(payload.channel&&((payload.channel.username||payload.channel.title)))||channel.value;state.telegramBot=(payload.bot&&payload.bot.username)||"Telegram bot";state.telegramVerifiedAt=new Date().toISOString();if(payload.sent)state.lastTelegramDelivery=new Date().toISOString();save();status.className="integration-status success";status.textContent=payload.sent?"Test post delivered to "+state.telegramChannel+". Open Telegram to see the real message.":state.telegramBot+" can post to "+state.telegramChannel+". No message was sent.";token.value="";if(payload.sent)setTimeout(function(){closeIntegration();render()},1200)}catch(error){status.className="integration-status error";status.textContent=error&&error.message?error.message:"Telegram verification failed"}finally{telegramPending=false;controls.forEach(function(control){control.disabled=false})}} + async function verifyTelegram(sendTest){if(telegramPending)return;var token=document.getElementById("tgToken");var channel=document.getElementById("tgChannel");var message=document.getElementById("tgMessage");var status=document.getElementById("telegramStatus");if(!token||!channel||!status)return;var controls=Array.from(document.querySelectorAll('[data-action="telegram-verify"],[data-action="telegram-send"]'));telegramPending=true;controls.forEach(function(control){control.disabled=true});status.className="integration-status";status.textContent=sendTest?"Verifying permissions and sending…":"Verifying bot and channel permissions…";try{if(!telegramEndpoint)throw new Error("Deploy this export over HTTPS before verifying a custom bot, or use official Drops Bot.");var requestBody=JSON.stringify({token:token.value,channel:channel.value,message:message?message.value:"",sendTest:Boolean(sendTest)});var response=await fetch(telegramEndpoint,{method:"POST",headers:{"content-type":"application/json","x-drops-session":telegramSession},body:requestBody});var payload=await response.json().catch(function(){return null});if(!payload)throw new Error("Telegram verification returned an unreadable response.");if(!response.ok)throw new Error(payload.error||"Telegram verification failed");state.telegramChannel=(payload.channel&&((payload.channel.username||payload.channel.title)))||channel.value;state.telegramBot=(payload.bot&&payload.bot.username)||"Telegram bot";state.telegramVerifiedAt=new Date().toISOString();if(payload.sent)state.lastTelegramDelivery=new Date().toISOString();save();status.className="integration-status success";status.textContent=payload.sent?"Test post delivered to "+state.telegramChannel+". Open Telegram to see the real message.":state.telegramBot+" can post to "+state.telegramChannel+". No message was sent.";token.value="";if(payload.sent)setTimeout(function(){closeIntegration();render()},1200)}catch(error){status.className="integration-status error";status.textContent=error&&error.message?error.message:"Telegram verification failed"}finally{telegramPending=false;controls.forEach(function(control){control.disabled=false})}} function share(text){var value=text||("Built "+spec.name+" with DropsTab and Drops Bot: "+location.href);if(navigator.share){navigator.share({title:spec.name,text:value,url:location.href}).catch(function(){})}else if(navigator.clipboard){navigator.clipboard.writeText(value).then(function(){notify("Challenge link copied")})}else{notify("Copy this page URL to share")}} function block(id,label,content,className){var config=(spec.blocks&&spec.blocks[id])||{};if(config.visible===false)return "";return '
'+content+'
'} function configurationStrip(){return ''} @@ -427,7 +427,7 @@ export function compileProject(input: GeneratedProjectSpec): string { function nativeAction(button,action){if(button.dataset.move){moveGame(n(button.dataset.move));return true}if(action==="play-catcher"){startCatcher();return true}if(button.dataset.quizChoice){var quizRows=gameMarket().slice(0,3);var quizTarget=quizRows[n(state.gameQuizIndex)%Math.max(1,quizRows.length)]||{};state.gameQuizChoice=button.dataset.quizChoice;state.rounds+=1;if(state.gameQuizChoice===quizTarget.symbol){state.score+=100;state.streak+=1;state.lastResult="Correct — "+(quizTarget.symbol||"asset")+" matched the sourced clue"}else{state.streak=0;state.lastResult="Not this round — the sourced answer was "+(quizTarget.symbol||"unavailable")}save();renderGameNative();return true}if(action==="next-quiz"){state.gameQuizIndex=n(state.gameQuizIndex)+1;state.gameQuizChoice="";state.lastResult="";save();renderGameNative();return true}if(button.dataset.battlePick){var pick=button.dataset.battlePick;var current=Array.isArray(state.gameBattlePicks)?state.gameBattlePicks.slice():[];state.gameBattlePicks=current.indexOf(pick)>=0?current.filter(function(item){return item!==pick}):current.concat(pick).slice(-2);state.lastResult="";save();renderGameNative();return true}if(action==="clear-battle"){state.gameBattlePicks=[];state.lastResult="";save();renderGameNative();return true}if(action==="resolve-battle"){var battlePicks=Array.isArray(state.gameBattlePicks)?state.gameBattlePicks:[];if(battlePicks.length!==2){notify("Select exactly two assets first");return true}var battleRows=battlePicks.map(function(symbol){return gameMarket().find(function(coin){return coin.symbol===symbol})||{symbol:symbol,change:null}});if(!battleRows.every(function(coin){return available(coin.change)})){state.lastResult="Waiting for percentage change data for both drafted assets"}else{var winner=n(battleRows[0].change)>=n(battleRows[1].change)?battleRows[0]:battleRows[1];state.score+=Math.max(10,50+Math.round(n(winner.change)));state.rounds+=1;state.lastResult=(winner.symbol||"Asset")+" wins this snapshot at "+change(winner.change)}save();renderGameNative();return true}if(action==="start-market-race"){var raceRows=gameMarket().slice(0,3);var sourced=raceRows.filter(function(coin){return available(coin.change)}).sort(function(a,b){return n(b.change)-n(a.change)});if(!sourced.length){state.gameRaceWinner="";state.lastResult="No percentage change is available yet — refresh the market adapter"}else{state.gameRaceWinner=sourced[0].symbol;state.score+=100+Math.max(0,Math.round(n(sourced[0].change)));state.rounds+=1;state.lastResult=(sourced[0].symbol||"Asset")+" leads at "+change(sourced[0].change)}save();renderGameNative();return true}if(action==="compose-post"&&spec.presetId==="alpha-channel"){var coin=market.slice().sort(function(a,b){return Math.abs(n(b.change))-Math.abs(n(a.change))})[0]||{};state.posts=[{title:(coin.symbol||"Market")+" signal caught early",tag:"MOVERS",copy:(coin.name||"Tracked asset")+" "+movement(coin.change)+". DropsTab context confirms only the available market snapshot; verify liquidity and the catalyst before acting."}].concat(state.posts||[]).slice(0,8);save();notify("Sourced Telegram post generated");renderAlphaNative();return true}if(action==="add-wallet"){var wallet=document.getElementById("walletInput");var value=wallet?wallet.value.trim():"";if(!/^0x[a-f0-9]{40}$/i.test(value)&&!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value)){notify("Enter a valid EVM or Solana address");return true}state.wallets=Array.from(new Set((state.wallets||[]).concat(value))).slice(0,12);save();renderCopy();return true}if(action==="dropsbot-setup"){dropsbotSetup(button);return true}if(action==="telegram-verify"){verifyTelegram(false);return true}if(action==="telegram-send"){verifyTelegram(true);return true}if(action==="close-integration"){closeIntegration();return true}if(action==="open-official-bot"){dropsbot("Opening the official Drops Bot. Finish and confirm setup inside Telegram.");return true}if(action==="copy-recipe"){var recipe=(spec.blueprint&&spec.blueprint.dropsBotUse||[]).join("\\n");if(navigator.clipboard)navigator.clipboard.writeText(recipe).then(function(){notify("Drops Bot recipe copied")});return true}return false} document.addEventListener("dblclick",function(event){if(!designMode)return;var target=event.target&&event.target.closest?event.target.closest('[data-text-editable="true"]'):null;if(!target)return;event.preventDefault();event.stopImmediatePropagation();var originalText=String(target.textContent||"");var cancelled=false;target.contentEditable="true";target.classList.add("studio-inline-editing");target.focus();var selection=window.getSelection();if(selection){var range=document.createRange();range.selectNodeContents(target);selection.removeAllRanges();selection.addRange(range)}var inlineKey=function(event){if(event.key==="Enter"&&!event.shiftKey){event.preventDefault();target.blur()}if(event.key==="Escape"){event.preventDefault();cancelled=true;target.removeEventListener("blur",finish);target.removeEventListener("keydown",inlineKey);target.textContent=originalText;target.contentEditable="false";target.classList.remove("studio-inline-editing")}};var finish=function(){target.removeEventListener("blur",finish);target.removeEventListener("keydown",inlineKey);target.contentEditable="false";target.classList.remove("studio-inline-editing");if(cancelled)return;var payload=elementPayload(target);payload.type="drops-studio-element-inline-edit";payload.text=String(target.textContent||"").trim().slice(0,800);payload.overrides=Object.assign({},payload.overrides,{text:payload.text});postParent(payload)};target.addEventListener("blur",finish);target.addEventListener("keydown",inlineKey)},true); document.addEventListener("click",function(event){if(!designMode)return;var target=event.target&&event.target.closest?event.target:null;if(!target)return;var element=target.closest("[data-studio-element]");var selected=element||target.closest("[data-studio-block]");if(!selected)return;event.preventDefault();event.stopImmediatePropagation();document.querySelectorAll(".studio-selected").forEach(function(node){node.classList.remove("studio-selected")});selected.classList.add("studio-selected");if(element)postParent(elementPayload(element));else postParent({type:"drops-studio-block-selected",blockId:selected.dataset.studioBlock,label:selected.dataset.studioLabel||selected.dataset.studioBlock})},true); - document.addEventListener("click",function(event){var button=event.target.closest&&event.target.closest("button");if(!button)return;var action=button.dataset.action;if(customAction(button)||safeCopyAction(button,action)||safeHuntAction(button,action)||nativeAction(button,action)){event.preventDefault();event.stopImmediatePropagation()}},true); + document.addEventListener("click",function(event){var externalLink=event.target.closest&&event.target.closest('a[target="_blank"]');if(externalLink&&window.parent!==window){event.preventDefault();event.stopImmediatePropagation();openTab(externalLink.href);return}var button=event.target.closest&&event.target.closest("button");if(!button)return;var action=button.dataset.action;if(customAction(button)||safeCopyAction(button,action)||safeHuntAction(button,action)||nativeAction(button,action)){event.preventDefault();event.stopImmediatePropagation()}},true); document.addEventListener("keydown",function(event){if(spec.presetId!=="crypto-game")return;if(event.key==="ArrowLeft"||event.key.toLowerCase()==="a"){event.preventDefault();moveGame(-1)}if(event.key==="ArrowRight"||event.key.toLowerCase()==="d"){event.preventDefault();moveGame(1)}}); window.addEventListener("message",function(event){if(!trustedParentMessage(event))return;if(event.data&&event.data.type==="drops-studio-data-response"&&event.data.payload)applyLiveData(event.data.payload);if(event.data&&event.data.type==="drops-studio-product-hunt-response"){var pending=huntRequests[String(event.data.requestId||"")];if(pending){clearTimeout(pending.timeout);delete huntRequests[String(event.data.requestId||"")];if(event.data.ok)pending.resolve(event.data.payload||{});else pending.reject(new Error(event.data.payload&&event.data.payload.error||"Community request failed"))}}if(event.data&&event.data.type==="drops-studio-design-mode"){designMode=Boolean(event.data.enabled);document.body.classList.toggle("studio-designing",designMode);document.querySelectorAll(".studio-selected").forEach(function(node){node.classList.remove("studio-selected")})}}); window.addEventListener("message",function(event){if(!trustedParentMessage(event)||!event.data||event.data.type!=="drops-studio-element-preview")return;var elementId=String(event.data.elementId||"");var node=Array.from(document.querySelectorAll("[data-studio-element]")).find(function(item){return item.dataset.studioElement===elementId});if(!node)return;applyElementConfig(node,event.data.config||{})}); diff --git a/lib/project-export.ts b/lib/project-export.ts index eb97645..c30f6b9 100644 --- a/lib/project-export.ts +++ b/lib/project-export.ts @@ -1,6 +1,18 @@ import { strToU8, zipSync } from "fflate"; import { assertArtifactFilesSafe, assertProjectPayloadSafe } from "./artifact-security.ts"; +import { + addProjectArtifactCspMeta, + PROJECT_ARTIFACT_CSP, +} from "./artifact-csp.ts"; +import { + findHtmlOpeningTag, + stripHtmlOpeningTagAttribute, +} from "./html-opening-tag.ts"; import { getProductReality, STUDIO_TELEGRAM_CONNECTION_URL } from "./product-reality.ts"; +import { + staticWorkspaceServerSource, + workspaceFilesForSandbox, +} from "./project-workspace.ts"; import type { GeneratedProject, ProjectQualityReport } from "@/lib/project-types"; const OFFICIAL_DROPSTAB_MARK = /https:\/\/(?:www\.)?dropstab\.com\/images\/dropstab-logo-drop-default\.svg(?:[?#][^\s"'()<>]*)?/gi; @@ -11,6 +23,9 @@ const UNRESOLVED_ROOT_ARCHIVE_ASSET = /(^|[\s"'(=,:])\/(?:assets|brand)\//im; const REMOTE_BRAND_ASSET = /https?:\/\/[^\s"'()<>]*\/(?:dropstab-logo-drop-default\.svg|drops-bot-avatar\.(?:jpe?g|png|webp))(?:[?#][^\s"'()<>]*)?/i; const INLINE_SVG_ELEMENT = /]*\bdata-provider-evidence=)/i, ``; + return `${html.slice(0, root.start)}${stamped}${html.slice(root.end)}`; +} + +function archiveQualityReport( + quality: ProjectQualityReport, +): ProjectQualityReport { + const providerDetail = + "Provider evidence is unverified in a client ZIP; browser telemetry is not provider proof."; + const sourceChecks = quality.checks.some( + (check) => check.id === "provider-evidence", + ) + ? quality.checks + : [ + ...quality.checks, + { + id: "provider-evidence", + label: "Live provider evidence", + passed: false, + detail: providerDetail, + weight: 1, + critical: false, + }, + ]; + const checks = sourceChecks.map((check) => + check.id === "provider-evidence" + ? { + ...check, + passed: false, + detail: providerDetail, + } + : check, + ); + const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0); + const passedWeight = checks + .filter((check) => check.passed) + .reduce((sum, check) => sum + check.weight, 0); + const score = totalWeight > 0 + ? Math.round((passedWeight / totalWeight) * 100) + : quality.score; + const criticalFailures = checks.length > 0 + ? checks + .filter((check) => check.critical && !check.passed) + .map((check) => check.id) + : quality.criticalFailures; + return { + ...quality, + score, + readyToPublish: score >= 85 && criticalFailures.length === 0, + checks, + criticalFailures, + ...(quality.runtimeSmoke + ? { + runtimeSmoke: { + ...quality.runtimeSmoke, + dataProvider: "unverified", + }, + } + : {}), + }; } function archiveTelegramConnectionUrl(projectSlug: string): string { @@ -68,7 +146,7 @@ export function makeArchiveHtmlPortable( if (LOOPBACK_DEPENDENCY.test(portable)) { throw new Error("ZIP export cannot depend on a localhost or loopback URL."); } - if (/\bblob:/i.test(portable)) { + if (SESSION_BLOB_URL.test(portable)) { throw new Error("ZIP export cannot include a session-only blob URL. Upload or embed the asset before exporting."); } if (INLINE_SVG_ELEMENT.test(portable) || INLINE_SVG_DATA_URI.test(portable)) { @@ -89,7 +167,11 @@ function requiredAsset(bytes: Uint8Array | undefined, path: string): Uint8Array function assertLocalReferencesBundled(html: string, files: Record): void { const references = new Set( - [...html.matchAll(/\.\/((?:assets|brand)\/[a-z0-9._/-]+)/gi)].map((match) => match[1]), + [ + ...html.matchAll( + /(?:\.\.\/|\.\/)+((?:assets|brand)\/[a-z0-9._/-]+)/gi, + ), + ].map((match) => match[1]), ); for (const reference of references) { if (!files[reference]?.byteLength) { @@ -98,6 +180,80 @@ function assertLocalReferencesBundled(html: string, files: Record { + return new Set( + [ + ...source.matchAll( + /(?:\.\.\/|\.\/)+((?:assets|brand)\/[a-z0-9._/-]+)/gi, + ), + ].map((match) => match[1]), + ); +} + +function workspaceRelativeAssetPaths(source: string, path: string): string { + const depth = Math.max(0, path.split("/").length - 1); + if (depth === 0) return source; + const prefix = "../".repeat(depth); + return source.replace( + /(^|[\s"'(=,:])\.\/((?:assets|brand)\/[a-z0-9._/-]+)/gim, + `$1${prefix}$2`, + ); +} + +function portableWorkspaceSource( + path: string, + role: string, + source: string, + studioTelegramUrl: string, +): string { + if (path === "project.json") { + let spec: Record; + try { + spec = JSON.parse(source) as Record; + } catch { + throw new Error("workspace/project.json must contain valid JSON before export."); + } + if (typeof spec.dataEndpoint === "string") { + spec.dataEndpoint = portableEndpoint(spec.dataEndpoint); + } + return makeArchiveHtmlPortable( + JSON.stringify(spec, null, 2), + studioTelegramUrl, + ); + } + + if ( + role !== "integration-config" && + !/\.(?:html?|css|js|jsx|mjs|cjs|ts|tsx|mts|cts)$/i.test(path) + ) { + return source; + } + + const portable = makeArchiveHtmlPortable(source, studioTelegramUrl); + return /\.css$/i.test(path) || /\.html?$/i.test(path) + ? workspaceRelativeAssetPaths(portable, path) + : portable; +} + function projectReadme(project: GeneratedProject): string { const reality = getProductReality(project.spec.presetId); const productHuntSetup = project.spec.presetId === "crypto-product-hunt" @@ -131,7 +287,7 @@ This is a runnable crypto product generated by Drops Studio. Publishing this web app does not claim that an external Telegram channel, wallet feed, trade, community backend or scheduled job exists. Those outcomes are marked pending until their provider verifies them. -For a new Telegram channel, open the [Drops Studio Telegram connection flow](${STUDIO_TELEGRAM_CONNECTION_URL}). It connects the user's Telegram account through the existing MTProto wizard only after explicit consent, creates or selects the real destination, then adds and configures Drops Bot. The Telegram-shaped preview in \`index.html\` is never evidence that this external setup finished. +For a new Telegram channel, open the [Drops Studio Telegram connection flow](${STUDIO_TELEGRAM_CONNECTION_URL}). It connects the user's Telegram account through the existing MTProto wizard only after explicit consent, creates or selects the real destination, then adds the selected platform or user-supplied Telegram bot. Official Drops Bot Profile linking remains a separate guided step in Telegram. The Telegram-shaped preview in \`index.html\` is never evidence that either setup finished. For a channel the user already owns, a Vercel deployment can use the included session-only BotFather fallback to verify administrator permissions and optionally send a test post. Completion requires Telegram's returned channel identity and, for delivery, a message ID. @@ -179,7 +335,9 @@ export default async function handler(req, res) { if (!["creator", "administrator"].includes(member.status) || member.can_post_messages === false) return reply(res, 409, { error: "Bot needs channel admin post permission" }); if (sendTest && !message) return reply(res, 400, { error: "Add a test message before sending" }); const sent = sendTest ? await call(token, "sendMessage", { chat_id: chat.id, text: message, disable_web_page_preview: false }) : null; - return reply(res, 200, { verified: true, sent: Boolean(sent), bot: { username: bot.username || bot.first_name || "Telegram bot" }, channel: { id: String(chat.id), title: chat.title || chat.username || channel, username: chat.username ? "@" + chat.username : undefined }, messageId: sent?.message_id, storage: "session-only" }); + const messageId = sent?.message_id; + if (sendTest && (!Number.isSafeInteger(messageId) || messageId <= 0)) throw new Error("Telegram delivery receipt is missing"); + return reply(res, 200, { verified: true, sent: messageId !== undefined, bot: { username: bot.username || bot.first_name || "Telegram bot" }, channel: { id: String(chat.id), title: chat.title || chat.username || channel, username: chat.username ? "@" + chat.username : undefined }, messageId, storage: "session-only" }); } catch { return reply(res, 422, { error: "Check the token, channel and bot admin permissions" }); } } `; @@ -636,10 +794,14 @@ export function buildProjectArchiveFiles( assertProjectPayloadSafe(project.spec, "exported project spec"); const slug = project.spec.slug; const reality = getProductReality(project.spec.presetId); - const providerEvidence = archiveProviderEvidence(quality); - const archiveHtml = makeArchiveHtmlPortable( - stampArchiveProviderEvidence(project.html, providerEvidence), - archiveTelegramConnectionUrl(project.spec.slug), + const providerEvidence = archiveProviderEvidence(); + const exportedQuality = archiveQualityReport(quality); + const studioTelegramUrl = archiveTelegramConnectionUrl(project.spec.slug); + const archiveHtml = addProjectArtifactCspMeta( + makeArchiveHtmlPortable( + stampArchiveProviderEvidence(project.html, providerEvidence), + studioTelegramUrl, + ), ); const portableSpec = { ...project.spec, @@ -664,7 +826,7 @@ export function buildProjectArchiveFiles( url: STUDIO_TELEGRAM_CONNECTION_URL, userConsentRequired: true, credentialsIncluded: false, - completionEvidence: ["Telegram channel identity", "Configured bot administrator", "Test-message ID"], + completionEvidence: ["Telegram channel identity", "Selected Telegram bot administrator", "Test-message ID"], }, existingChannel: { mode: "session-only-bot-verification", @@ -694,9 +856,9 @@ export function buildProjectArchiveFiles( "index.html": strToU8(archiveHtml), "README.md": strToU8(projectReadme(project)), "project.json": strToU8(portableProjectJson), - "quality-report.json": strToU8(JSON.stringify(quality, null, 2)), + "quality-report.json": strToU8(JSON.stringify(exportedQuality, null, 2)), "drops.config.json": strToU8(JSON.stringify(integrationManifest, null, 2)), - "tests/smoke.mjs": strToU8(`import assert from "node:assert/strict";\nimport { readFile } from "node:fs/promises";\nconst html = await readFile(new URL("../index.html", import.meta.url), "utf8");\nassert.match(html, /data-project-kind="${project.spec.presetId}"/);\nassert.match(html, /data-provider-evidence="(?:dropstab|fallback|unverified)"/);\nassert.match(html, /DropsTab/);\nassert.match(html, /Drops Bot/);\nassert.doesNotMatch(html, /(?:^|[\\s"'(=,:])\\\/(?:assets|brand)\\\//im);\nassert.doesNotMatch(html, /https?:\\/\\/[^\\s"'()<>]*\\/(?:dropstab-logo-drop-default\\.svg|drops-bot-avatar\\.(?:jpe?g|png|webp))/i);\nassert.doesNotMatch(html, /https?:\\/\\/(?:localhost|127(?:\\.\\d{1,3}){3}|0\\.0\\.0\\.0|\\[::1\\])(?::\\d+)?/i);\nassert.doesNotMatch(html, /\\bblob:/i);\nassert.doesNotMatch(html, / match[1]));\nfor (const asset of assetReferences) await readFile(new URL("../" + asset, import.meta.url));\nawait readFile(new URL("../brand/dropstab-mark.svg", import.meta.url));\nawait readFile(new URL("../brand/drops-bot-avatar.jpg", import.meta.url));\nawait readFile(new URL("../api/public-data.mjs", import.meta.url));\nif (/data-project-kind="crypto-game"/.test(html)) {\n await readFile(new URL("../assets/market-catcher-retro.png", import.meta.url));\n}\nif (/data-project-kind="(?:crypto-game|portfolio-tamagotchi)"/.test(html)) {\n await readFile(new URL("../assets/market-wolf-catcher.png", import.meta.url));\n}\nif (/data-project-kind="(?:alpha-channel|morning-alpha)"/.test(html)) {\n assert.match(html, /flow=telegram-channel/);\n assert.match(html, /existing channel/i);\n assert.match(html, /PREVIEW · NOT PUBLISHED/);\n}\nconsole.log("Drops Studio smoke checks passed");\n`), + "tests/smoke.mjs": strToU8(`import assert from "node:assert/strict";\nimport { readFile } from "node:fs/promises";\nconst html = await readFile(new URL("../index.html", import.meta.url), "utf8");\nassert.match(html, /data-project-kind="${project.spec.presetId}"/);\nassert.match(html, /data-provider-evidence="unverified"/);\nassert.match(html, /DropsTab/);\nassert.match(html, /Drops Bot/);\nassert.doesNotMatch(html, /(?:^|[\\s"'(=,:])\\\/(?:assets|brand)\\\//im);\nassert.doesNotMatch(html, /https?:\\/\\/[^\\s"'()<>]*\\/(?:dropstab-logo-drop-default\\.svg|drops-bot-avatar\\.(?:jpe?g|png|webp))/i);\nassert.doesNotMatch(html, /https?:\\/\\/(?:localhost|127(?:\\.\\d{1,3}){3}|0\\.0\\.0\\.0|\\[::1\\])(?::\\d+)?/i);\nassert.doesNotMatch(html, /(?:src|href)\\s*=\\s*["']blob:/i);\nassert.doesNotMatch(html, / match[1]));\nfor (const asset of assetReferences) await readFile(new URL("../" + asset, import.meta.url));\nawait readFile(new URL("../brand/dropstab-mark.svg", import.meta.url));\nawait readFile(new URL("../brand/drops-bot-avatar.jpg", import.meta.url));\nawait readFile(new URL("../api/public-data.mjs", import.meta.url));\nif (/data-project-kind="crypto-game"/.test(html)) {\n await readFile(new URL("../assets/market-catcher-retro.png", import.meta.url));\n}\nif (/data-project-kind="(?:crypto-game|portfolio-tamagotchi)"/.test(html)) {\n await readFile(new URL("../assets/market-wolf-catcher.png", import.meta.url));\n}\nif (/data-project-kind="(?:alpha-channel|morning-alpha)"/.test(html)) {\n assert.match(html, /flow=telegram-channel/);\n assert.match(html, /existing channel/i);\n assert.match(html, /PREVIEW · NOT PUBLISHED/);\n}\nconsole.log("Drops Studio smoke checks passed");\n`), "api/telegram/verify.mjs": strToU8(telegramFunction), "api/public-data.mjs": strToU8(publicDataFunction), "vercel.json": strToU8(JSON.stringify({ @@ -706,12 +868,12 @@ export function buildProjectArchiveFiles( source: "/(.*)", headers: [ { key: "X-Frame-Options", value: "SAMEORIGIN" }, - { key: "Content-Security-Policy", value: "frame-ancestors 'self'" }, + { key: "Content-Security-Policy", value: PROJECT_ARTIFACT_CSP }, { key: "X-Content-Type-Options", value: "nosniff" }, ], }], }, null, 2)), - "netlify.toml": strToU8(`[build]\n publish = "."\n\n[[headers]]\n for = "/*"\n [headers.values]\n X-Content-Type-Options = "nosniff"\n X-Frame-Options = "SAMEORIGIN"\n Content-Security-Policy = "frame-ancestors 'self'"\n`), + "netlify.toml": strToU8(`[build]\n publish = "."\n\n[[headers]]\n for = "/*"\n [headers.values]\n X-Content-Type-Options = "nosniff"\n X-Frame-Options = "SAMEORIGIN"\n Content-Security-Policy = "${PROJECT_ARTIFACT_CSP}"\n`), "wrangler.toml": strToU8(`name = "${slug}"\ncompatibility_date = "2026-07-28"\n[assets]\ndirectory = "."\n`), ".github/workflows/pages.yml": strToU8(`name: Deploy static site to Pages\non:\n push:\n branches: [main]\n workflow_dispatch:\npermissions:\n contents: read\n pages: write\n id-token: write\njobs:\n deploy:\n environment:\n name: github-pages\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/configure-pages@v5\n - uses: actions/upload-pages-artifact@v3\n with:\n path: .\n - uses: actions/deploy-pages@v4\n`), "brand/dropstab-mark.svg": requiredAsset( @@ -758,6 +920,88 @@ export function buildProjectArchiveFiles( files["api/product-hunt/launches/[id]/vote.mjs"] = strToU8(productHuntVoteFunction); files["tests/community-smoke.mjs"] = strToU8(`import assert from "node:assert/strict";\nimport { readFile } from "node:fs/promises";\nfor (const file of ["../api/product-hunt/launches.mjs", "../api/product-hunt/launches/[id]/vote.mjs", "../server/product-hunt-store.mjs"]) await readFile(new URL(file, import.meta.url));\nconst envExample = await readFile(new URL("../.env.example", import.meta.url), "utf8");\nassert.match(envExample, /^BLOB_READ_WRITE_TOKEN=$/m);\nconst manifest = JSON.parse(await readFile(new URL("../drops.config.json", import.meta.url), "utf8"));\nassert.equal(manifest.community.provider, "Vercel Blob");\nassert.equal(manifest.community.credentialsIncluded, false);\nconsole.log("Drops Studio community backend smoke checks passed");\n`); } + if (project.workspace) { + const snapshot = workspaceFilesForSandbox(project.spec, project.workspace); + const roles = new Map( + project.workspace.files.map((source) => [source.path, source.role]), + ); + const workspaceFiles: Record = {}; + const portableSources: string[] = []; + for (const source of snapshot.files) { + const content = portableWorkspaceSource( + source.path, + roles.get(source.path) ?? "documentation", + source.content, + studioTelegramUrl, + ); + workspaceFiles[source.path] = strToU8(content); + portableSources.push(content); + } + const portableManifest = JSON.parse( + new TextDecoder().decode(workspaceFiles["package.json"]), + ) as Record; + const sourceScripts = + portableManifest.scripts + && typeof portableManifest.scripts === "object" + && !Array.isArray(portableManifest.scripts) + ? portableManifest.scripts as Record + : {}; + const sourceStart = typeof sourceScripts.start === "string" + ? sourceScripts.start + : null; + portableManifest.scripts = { + ...sourceScripts, + start: "node .drops-studio/serve.mjs", + }; + const safeManifest = JSON.stringify(portableManifest, null, 2); + const safeServer = staticWorkspaceServerSource(); + workspaceFiles["package.json"] = strToU8(safeManifest); + workspaceFiles[".drops-studio/serve.mjs"] = strToU8(safeServer); + workspaceFiles[".drops-studio/export.json"] = strToU8( + JSON.stringify( + { + schemaVersion: 1, + safeStart: "node .drops-studio/serve.mjs", + sourceStart, + editableSourceServer: "../server.mjs", + reason: + "The exported default start command uses the Studio-owned CSP server. The editable source server remains available for intentional sandbox work.", + }, + null, + 2, + ), + ); + portableSources.push(safeManifest, safeServer); + workspaceFiles["brand/dropstab-mark.svg"] = files["brand/dropstab-mark.svg"]; + workspaceFiles["brand/drops-bot-avatar.jpg"] = + files["brand/drops-bot-avatar.jpg"]; + for (const source of portableSources) { + for (const reference of workspaceAssetReferences(source)) { + if (!workspaceFiles[reference]) { + const bytes = workspaceAssetBytes(reference, assets); + if (bytes) workspaceFiles[reference] = bytes; + } + } + } + for (const source of portableSources) { + assertLocalReferencesBundled(source, workspaceFiles); + } + for (const [path, bytes] of Object.entries(workspaceFiles)) { + files[`workspace/${path}`] = bytes; + } + files["workspace/.drops-studio-revision.json"] = strToU8( + JSON.stringify( + { + schemaVersion: project.workspace.schemaVersion, + revision: project.workspace.revision, + updatedAt: project.workspace.updatedAt, + credentialsIncluded: false, + }, + null, + 2, + ), + ); + } assertLocalReferencesBundled(archiveHtml, files); assertArtifactFilesSafe(files); return files; diff --git a/lib/project-history.ts b/lib/project-history.ts index ebb5f86..08d45e7 100644 --- a/lib/project-history.ts +++ b/lib/project-history.ts @@ -5,6 +5,11 @@ import type { ProjectCheckpoint, } from "./project-types.ts"; import { validateEditableRuntimeHtml } from "./source-workspace.ts"; +import { + compileWorkspaceRuntime, + materializeProjectWorkspace, + validateProjectWorkspace, +} from "./project-workspace.ts"; import { validateProjectSpec } from "./project-validator.ts"; export const PROJECT_HISTORY_LIMIT = 12; @@ -23,22 +28,43 @@ function compiledProjectAtCheckpoint( futureCheckpoints: ProjectCheckpoint[], ): GeneratedProject { const spec = validateProjectSpec(checkpoint.spec); + const workspaceValidation = checkpoint.workspace + ? validateProjectWorkspace(spec, checkpoint.workspace) + : null; + if (workspaceValidation && !workspaceValidation.valid) { + throw new Error( + workspaceValidation.issues[0] ?? "The workspace checkpoint is invalid.", + ); + } const sourceValidation = checkpoint.runtimeHtml ? validateEditableRuntimeHtml(spec, checkpoint.runtimeHtml) : null; if (sourceValidation && !sourceValidation.valid) { throw new Error(sourceValidation.issues[0] ?? "The source checkpoint is invalid."); } - const html = checkpoint.runtimeHtml ?? compileProject(spec); + const html = checkpoint.workspace + ? compileWorkspaceRuntime(spec, checkpoint.workspace) + : checkpoint.runtimeHtml ?? compileProject(spec); + const workspace = checkpoint.workspace ?? materializeProjectWorkspace({ + ...project, + spec, + html, + updatedAt: changedAt, + workspace: undefined, + }); return { ...project, spec, html, + workspace, quality: evaluateProjectQuality(spec, html), updatedAt: changedAt, checkpoints, futureCheckpoints, - sourceEditedAt: checkpoint.runtimeHtml ? checkpoint.createdAt : undefined, + sourceEditedAt: + checkpoint.workspace || checkpoint.runtimeHtml + ? checkpoint.createdAt + : undefined, }; } @@ -47,9 +73,11 @@ export function commitProjectCheckpoint( checkpointInput: ProjectCheckpoint, ): ProjectHistoryTransition { const sourceOwningCheckpoint = - checkpointInput.runtimeHtml || !project.sourceEditedAt + checkpointInput.workspace || checkpointInput.runtimeHtml || !project.sourceEditedAt ? checkpointInput - : { ...checkpointInput, runtimeHtml: project.html }; + : project.workspace + ? { ...checkpointInput, workspace: project.workspace } + : { ...checkpointInput, runtimeHtml: project.html }; const futureCheckpoints = (project.futureCheckpoints ?? []).slice( 0, PROJECT_HISTORY_LIMIT, diff --git a/lib/project-quality.ts b/lib/project-quality.ts index b315a4c..6299a27 100644 --- a/lib/project-quality.ts +++ b/lib/project-quality.ts @@ -41,30 +41,53 @@ function categoryNative(spec: GeneratedProjectSpec): boolean { return true; } -export function evaluateProjectQuality(spec: GeneratedProjectSpec, html: string, runtimeSmoke?: ProjectRuntimeSmokeResult | null): ProjectQualityReport { - const smokePassed = Boolean(runtimeSmoke?.executed && (runtimeSmoke.errors?.length ?? 0) === 0); - const providerEvidence = String(runtimeSmoke?.dataProvider || "unverified").trim().toLowerCase(); - const runtimeEvidence = runtimeSmoke?.mode === "server-artifact" - ? "Server artifact smoke parsed the runtime and verified category-native release markers" - : "Compiled app executed inside the sandbox and rendered its category runtime"; +export interface ProjectQualityHostEvidence { + dataProvider?: "dropstab" | "fallback" | "unverified"; +} + +export function evaluateProjectQuality( + spec: GeneratedProjectSpec, + html: string, + runtimeSmoke?: ProjectRuntimeSmokeResult | null, + hostEvidence?: ProjectQualityHostEvidence, +): ProjectQualityReport { + const staticInspection = runtimeSmoke?.mode === "server-inspection" + || runtimeSmoke?.mode === "server-artifact"; + const smokePassed = Boolean( + staticInspection + && (runtimeSmoke.errors?.length ?? 0) === 0, + ); + const trustedApiProvider = hostEvidence?.dataProvider === "dropstab" + || hostEvidence?.dataProvider === "fallback" + ? hostEvidence.dataProvider + : null; + const providerEvidence = trustedApiProvider + ?? (staticInspection + ? String(runtimeSmoke?.dataProvider || "unverified").trim().toLowerCase() + : "unverified"); + const runtimeEvidence = staticInspection + ? "Static server inspection parsed JavaScript syntax and found the category runtime contract; it did not execute the application" + : runtimeSmoke + ? "Browser telemetry received; isolated execution remains unverified until a host-side check completes" + : "Waiting for a host-side runtime check"; const reality = getProductReality(spec.presetId); const truthfulness = truthfulnessViolations(spec.presetId, html); const deliveryMarker = `data-delivery-mode="${reality.deliveryMode}"`; const checks: ProjectQualityCheck[] = [ check("category", "Category-native product", categoryNative(spec), `${spec.experience.archetype} matches ${spec.presetId}`, 3, true), check("truthfulness", "Truthful delivery contract", truthfulness.length === 0 && html.includes(deliveryMarker), truthfulness.length ? `Unsupported claims: ${truthfulness.join(", ")}` : `${reality.deliveryMode} contract is visible in the runtime`, 3, true), - check("runtime", "Runnable standalone output", html.length > 18_000 && html.includes(`data-project-kind="${spec.presetId}"`) && smokePassed && Boolean(runtimeSmoke?.runtime), runtimeSmoke ? runtimeEvidence : "Waiting for the sandboxed runtime smoke test", 3, true), + check("runtime", "Runnable standalone output", html.length > 18_000 && html.includes(`data-project-kind="${spec.presetId}"`) && smokePassed && Boolean(runtimeSmoke?.runtime), runtimeEvidence, 3, true), check("screens", "Complete experience map", spec.blueprint.screens.length >= 3 && spec.blueprint.modules.length >= 4, `${spec.blueprint.screens.length} screens · ${spec.blueprint.modules.length} modules`, 2), - check("interactions", "Working interaction contract", spec.blueprint.interactions.length >= 4 && /addEventListener\(["']click["']/.test(html) && smokePassed && Boolean(runtimeSmoke?.interactions), runtimeSmoke ? `${spec.blueprint.interactions.length} declared interactions and live controls verified` : "Waiting for live controls to execute in the sandbox", 2, true), - check("data-adapter", "DropsTab-compatible adapter contract", spec.blueprint.dropsTabUse.length >= 2 && html.includes("refreshData") && smokePassed && Boolean(runtimeSmoke?.dropstab), runtimeSmoke ? `${spec.blueprint.dropsTabUse.length} mapped capabilities and the honest data-adapter contract passed` : "Waiting for the sandboxed data-adapter handshake", 2, true), - check("provider-evidence", "Live DropsTab provider evidence", providerEvidence === "dropstab", providerEvidence === "dropstab" ? "Runtime response verified provider=dropstab" : `Provider evidence: ${providerEvidence || "unverified"}. The runnable fallback is not labelled as live DropsTab data.`, 1), - check("dropsbot", "Drops Bot action handoff", spec.blueprint.dropsBotUse.length >= 1 && html.includes("dropsbotSetup") && smokePassed && Boolean(runtimeSmoke?.dropsbot), runtimeSmoke ? `${spec.blueprint.dropsBotUse.length} truthful setup or approval handoffs verified in the runtime; no external action is claimed` : "Waiting for the Drops Bot handoff to be discovered in the runtime", 2, true), + check("interactions", "Working interaction contract", spec.blueprint.interactions.length >= 4 && /addEventListener\(["']click["']/.test(html) && smokePassed && Boolean(runtimeSmoke?.interactions), staticInspection ? `${spec.blueprint.interactions.length} declared interactions and their static event contracts were inspected` : runtimeSmoke ? "Browser interaction telemetry received; host verification is still required" : "Waiting for a host-side interaction check", 2, true), + check("data-adapter", "DropsTab-compatible adapter contract", spec.blueprint.dropsTabUse.length >= 2 && html.includes("refreshData") && smokePassed && Boolean(runtimeSmoke?.dropstab), staticInspection ? `${spec.blueprint.dropsTabUse.length} mapped capabilities and the static data-adapter contract were inspected` : runtimeSmoke ? "Browser adapter telemetry received; provider and host execution remain unverified" : "Waiting for a host-side data-adapter check", 2, true), + check("provider-evidence", "Live DropsTab provider evidence", providerEvidence === "dropstab", providerEvidence === "dropstab" ? "Same-origin host/API evidence reports provider=dropstab" : `Provider evidence: ${providerEvidence || "unverified"}. Browser telemetry cannot assert a live DropsTab provider.`, 1), + check("dropsbot", "Drops Bot action handoff", spec.blueprint.dropsBotUse.length >= 1 && html.includes("dropsbotSetup") && smokePassed && Boolean(runtimeSmoke?.dropsbot), staticInspection ? `${spec.blueprint.dropsBotUse.length} truthful setup or approval handoff contracts were inspected; no external action is claimed` : runtimeSmoke ? "Browser handoff telemetry received; host verification is still required" : "Waiting for a host-side Drops Bot handoff check", 2, true), check("state", "Persistent product state", html.includes("localStorage") && html.includes("function save"), "User progress and settings persist in the standalone app", 1), check("design", "Visual editing contract", html.includes("data-studio-block") && Object.keys(spec.blocks).length <= 32, "Runtime exposes safe selectable blocks", 1), check("responsive", "Responsive runtime", html.includes("@media(max-width:760px)"), "Desktop and mobile layout rules compiled", 1), check("a11y", "Document essentials", /[\s\S]+<\/title>/.test(html) && html.includes('name="viewport"'), "Title and viewport metadata are present", 1), check("security", "No executable secret or unsafe evaluator", findArtifactSecrets(html, "runtime").length === 0 && !/\beval\s*\(|new Function/.test(html), "No known credential pattern, eval or Function constructor found", 3, true), - check("actions", "Approval-safe external actions", /Nothing was executed|no trade executed|approve|approval/i.test(html) && smokePassed && Boolean(runtimeSmoke?.actions), runtimeSmoke ? "The executed runtime exposes handoffs without an automatic trade action" : "Waiting for action safety verification in the sandbox", 2, true), + check("actions", "Approval-safe external actions", /Nothing was executed|no trade executed|approve|approval/i.test(html) && smokePassed && Boolean(runtimeSmoke?.actions), staticInspection ? "Static inspection found approval boundaries and no automatic trade control" : runtimeSmoke ? "Browser action telemetry received; it is not execution evidence" : "Waiting for a host-side action safety check", 2, true), ]; const totalWeight = checks.reduce((sum, item) => sum + item.weight, 0); const passedWeight = checks.filter((item) => item.passed).reduce((sum, item) => sum + item.weight, 0); diff --git a/lib/project-types.ts b/lib/project-types.ts index 98eb9f1..6d1f260 100644 --- a/lib/project-types.ts +++ b/lib/project-types.ts @@ -1,4 +1,5 @@ import type { PresetId } from "@/lib/presets"; +import type { ProjectWorkspace } from "./project-workspace.ts"; export type ProjectProvider = "free" | "gateway" | "openai" | "anthropic" | "openrouter" | "kimi" | "custom"; @@ -254,8 +255,10 @@ export interface GeneratedProject { publishCapability?: string; checkpoints?: ProjectCheckpoint[]; futureCheckpoints?: ProjectCheckpoint[]; - /** Browser-owned manual source state. Cloud sync intentionally omits it. */ + /** Local marker for manually edited or explicitly applied shared source state. */ sourceEditedAt?: string; + /** Canonical multi-file source. Sync/share boundaries validate and strip runtime evidence. */ + workspace?: ProjectWorkspace; conversation?: ProjectChatMessage[]; quality?: ProjectQualityReport; } @@ -282,7 +285,7 @@ export interface ProjectQualityReport { } export interface ProjectRuntimeSmokeResult { - mode?: "browser" | "server-artifact"; + mode?: "browser" | "server-artifact" | "server-inspection"; dataProvider?: "dropstab" | "fallback" | "unverified" | (string & {}); executed: boolean; runtime: boolean; @@ -302,6 +305,8 @@ export interface ProjectCheckpoint { spec: GeneratedProjectSpec; /** Validated standalone source for a manual Code workspace checkpoint. */ runtimeHtml?: string; + /** Validated canonical file graph for a multi-file Code workspace checkpoint. */ + workspace?: ProjectWorkspace; branch?: { fromCheckpointId: string; replacedCheckpointCount: number; diff --git a/lib/project-workspace.ts b/lib/project-workspace.ts new file mode 100644 index 0000000..e024145 --- /dev/null +++ b/lib/project-workspace.ts @@ -0,0 +1,1135 @@ +import { + ArtifactSecretError, + findArtifactSecrets, +} from "./artifact-security.ts"; +import { PROJECT_WORKSPACE_CSP } from "./artifact-csp.ts"; +import type { + GeneratedProject, + GeneratedProjectSpec, +} from "./project-types.ts"; +import { + prepareEditableRuntimeHtml, + validateEditableRuntimeHtml, +} from "./source-workspace.ts"; +import { unexpectedRuntimeActiveContent } from "./runtime-active-content.ts"; + +export const PROJECT_WORKSPACE_FILE_LIMIT = 64; +export const PROJECT_WORKSPACE_BYTES_LIMIT = 1_500_000; +export const PROJECT_WORKSPACE_FILE_BYTES_LIMIT = 1_500_000; +export const PROJECT_WORKSPACE_PACKAGE_LIMIT = 6; +export const PROJECT_WORKSPACE_DEPENDENCY_LIMIT = 24; + +export const PROJECT_WORKSPACE_FILE_ROLES = [ + "entry", + "style", + "client", + "project-config", + "integration-config", + "package-manifest", + "server", + "task", + "test", + "documentation", +] as const; + +export type ProjectWorkspaceFileRole = + (typeof PROJECT_WORKSPACE_FILE_ROLES)[number]; + +export const PROJECT_WORKSPACE_FILE_LANGUAGES = [ + "html", + "css", + "javascript", + "typescript", + "jsx", + "tsx", + "json", + "markdown", + "text", +] as const; + +export type ProjectWorkspaceFileLanguage = + (typeof PROJECT_WORKSPACE_FILE_LANGUAGES)[number]; + +export interface ProjectWorkspaceFile { + path: string; + content: string; + language: ProjectWorkspaceFileLanguage; + role: ProjectWorkspaceFileRole; + editable: boolean; +} + +export interface ProjectWorkspaceTask { + id: string; + label: string; + command: "npm"; + args: string[]; + cwd?: string; + port?: number; +} + +export interface ProjectWorkspace { + schemaVersion: 1; + revision: number; + updatedAt: string; + files: ProjectWorkspaceFile[]; + tasks: ProjectWorkspaceTask[]; + runtime: { + executionMode: "static-preview"; + provider: "unconfigured"; + isolation: "browser-iframe"; + runtime: "node24"; + packageManager: "npm"; + installScripts: false; + }; +} + +export interface ProjectWorkspaceValidation { + valid: boolean; + issues: string[]; +} + +const REQUIRED_FILES = [ + "index.html", + "src/styles.css", + "src/app.js", + "project.json", + "drops.config.json", + "package.json", + "server.mjs", + "scripts/check.mjs", + "tests/smoke.mjs", + "README.md", +] as const; + +const BLOCKED_LIFECYCLE_SCRIPTS = new Set([ + "preinstall", + "install", + "postinstall", + "prepare", + "prepublish", + "prepublishonly", + "prepack", + "postpack", + "publish", + "postpublish", +]); + +const BLOCKED_PACKAGE_FIELDS = [ + "overrides", + "resolutions", + "pnpm", + "publishConfig", +] as const; + +const BLOCKED_OPTIONAL_DEPENDENCY_FIELDS = [ + "optionalDependencies", + "bundledDependencies", + "bundleDependencies", +] as const; + +const BLOCKED_PACKAGE_FILES = new Set([ + ".npmrc", + ".yarnrc", + ".yarnrc.yml", + ".pnpmfile.cjs", + "bun.lock", + "bun.lockb", + "npm-shrinkwrap.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", +]); + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const REGISTRY_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const WORKSPACE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\\)(?!.*\/\/)[A-Za-z0-9@._/-]{1,160}$/; +const PACKAGE_WORKSPACE_PATH = /^packages\/[a-z0-9][a-z0-9._-]{0,63}$/; +const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/; +const PACKAGE_SCRIPT_NAME = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,47}$/; +const PACKAGE_TASK_PRIORITY = ["start", "build", "test", "check"] as const; +const PROJECT_WORKSPACE_TASK_LIMIT = 16; +const PROJECT_WORKSPACE_ROOT_TASK_LIMIT = 4; +const CANONICAL_STYLESHEET_LINK = + '<link rel="stylesheet" href="./src/styles.css">'; +const CANONICAL_RUNTIME_SCRIPT = '<script src="./src/app.js"></script>'; +const WORKSPACE_FILE_ROLE_SET = new Set<string>(PROJECT_WORKSPACE_FILE_ROLES); +const WORKSPACE_FILE_LANGUAGE_SET = new Set<string>( + PROJECT_WORKSPACE_FILE_LANGUAGES, +); + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function file( + path: string, + content: string, + language: ProjectWorkspaceFileLanguage, + role: ProjectWorkspaceFileRole, + editable = true, +): ProjectWorkspaceFile { + return { path, content, language, role, editable }; +} + +export function isUnsafeProjectWorkspacePath(path: unknown): boolean { + if (typeof path !== "string" || !WORKSPACE_PATH.test(path)) return true; + const segments = path.toLowerCase().split("/"); + const fileName = segments.at(-1) ?? ""; + return ( + segments.some( + (segment) => segment === ".git" || segment.startsWith(".env"), + ) || + segments.includes("node_modules") || + BLOCKED_PACKAGE_FILES.has(fileName) + ); +} + +function htmlAttribute(attributes: string, name: string): string | null { + const match = new RegExp( + `(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`, + "i", + ).exec(attributes); + return match ? (match[1] ?? match[2] ?? match[3] ?? "") : null; +} + +function isInsideTemplate(html: string, offset: number): boolean { + const prefix = html.slice(0, offset).toLowerCase(); + return prefix.lastIndexOf("<template") > prefix.lastIndexOf("</template"); +} + +function isNonExecutableScript(attributes: string): boolean { + const type = htmlAttribute(attributes, "type")?.trim().toLowerCase(); + return Boolean( + type && + ![ + "module", + "text/javascript", + "application/javascript", + "text/ecmascript", + "application/ecmascript", + ].includes(type), + ); +} + +function extractRuntimeSource(html: string): { + indexHtml: string; + css: string; + javascript: string; +} { + const styles: string[] = []; + const scripts: string[] = []; + let styleAttached = false; + let scriptAttached = false; + + let indexHtml = html.replace( + /<style(?:\s[^>]*)?>([\s\S]*?)<\/style>/gi, + (_match, source: string) => { + styles.push(source.trim()); + if (styleAttached) return ""; + styleAttached = true; + return CANONICAL_STYLESHEET_LINK; + }, + ); + + indexHtml = indexHtml.replace( + /<script([^>]*)>([\s\S]*?)<\/script>/gi, + (match, attributes: string, source: string, offset: number) => { + if ( + isInsideTemplate(indexHtml, offset) || + htmlAttribute(attributes, "src") !== null || + isNonExecutableScript(attributes) || + !source.trim() + ) { + return match; + } + if (attributes.trim()) { + throw new Error( + "The compiled runtime script must be one classic inline script without attributes; module, async and deferred scripts cannot be rewritten safely.", + ); + } + if (scriptAttached) { + throw new Error( + "The compiled product must contain exactly one executable inline runtime script so script scope and ordering are preserved.", + ); + } + scripts.push(source.trim()); + scriptAttached = true; + return CANONICAL_RUNTIME_SCRIPT; + }, + ); + + if (!styleAttached || !scriptAttached) { + throw new Error("The compiled product must contain inline style and runtime script sources."); + } + + return { + indexHtml, + css: styles.filter(Boolean).join("\n\n"), + javascript: scripts.filter(Boolean).join("\n\n"), + }; +} + +export function staticWorkspaceServerSource(): string { + return `import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; + +const root = process.cwd(); +const port = Number(process.env.PORT || 4173); +const types = { ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml" }; +const htmlCsp = ${JSON.stringify(PROJECT_WORKSPACE_CSP)}; + +createServer(async (request, response) => { + try { + const url = new URL(request.url || "/", "http://workspace.local"); + const relative = decodeURIComponent(url.pathname).replace(/^\\/+/, "") || "index.html"; + const normalized = normalize(relative); + if (normalized.startsWith("..") || normalized.includes("/../")) throw new Error("Invalid path"); + let target = join(root, normalized); + if ((await stat(target)).isDirectory()) target = join(target, "index.html"); + const content = await readFile(target); + const extension = extname(target); + const headers = { "content-type": types[extension] || "application/octet-stream", "x-content-type-options": "nosniff", "cache-control": "no-store" }; + if (extension === ".html") headers["content-security-policy"] = htmlCsp; + response.writeHead(200, headers); + response.end(content); + } catch { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); + response.end("Not found"); + } +}).listen(port, "0.0.0.0", () => console.log(\`Drops workspace ready on :\${port}\`)); +`; +} + +function checkTaskSource(spec: GeneratedProjectSpec): string { + return `import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const [html, css, javascript, manifest] = await Promise.all([ + readFile(new URL("../index.html", import.meta.url), "utf8"), + readFile(new URL("../src/styles.css", import.meta.url), "utf8"), + readFile(new URL("../src/app.js", import.meta.url), "utf8"), + readFile(new URL("../drops.config.json", import.meta.url), "utf8").then(JSON.parse), +]); +assert.match(html, /data-project-kind="${spec.presetId}"/); +assert.match(html, /\\.\\/src\\/styles\\.css/); +assert.match(html, /\\.\\/src\\/app\\.js/); +assert.ok(css.trim().length > 100, "styles.css must contain the product visual system"); +assert.match(javascript, /function\\s+refreshData\\s*\\(/); +assert.equal(manifest.project.presetId, "${spec.presetId}"); +assert.equal(manifest.runtime.provider, "Unconfigured"); +console.log("Workspace structure and runtime contracts passed"); +`; +} + +function smokeTaskSource(spec: GeneratedProjectSpec): string { + return `import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { Script } from "node:vm"; + +const javascript = await readFile(new URL("../src/app.js", import.meta.url), "utf8"); +new Script(javascript, { filename: "src/app.js" }); +const html = await readFile(new URL("../index.html", import.meta.url), "utf8"); +assert.match(html, /data-project-kind="${spec.presetId}"/); +assert.doesNotMatch(html + javascript, /\\beval\\s*\\(|new Function/); +console.log("Workspace runtime smoke passed"); +`; +} + +function packageManifest(spec: GeneratedProjectSpec): string { + return JSON.stringify( + { + name: spec.slug, + version: "1.0.0", + private: true, + type: "module", + engines: { node: ">=22.13.0" }, + scripts: { + check: "node scripts/check.mjs", + test: "node tests/smoke.mjs", + build: "node scripts/check.mjs && node tests/smoke.mjs", + start: "node server.mjs", + }, + dependencies: {}, + }, + null, + 2, + ); +} + +function integrationManifest(project: GeneratedProject): string { + return JSON.stringify( + { + schemaVersion: 1, + project: { + id: project.id, + presetId: project.spec.presetId, + slug: project.spec.slug, + }, + runtime: { + provider: "Unconfigured", + executionMode: "Static preview", + isolation: "Browser iframe", + credentialsIncluded: false, + providerEvidenceRequired: true, + }, + data: { + provider: "DropsTab Public API", + endpoint: project.spec.dataEndpoint, + keyIncluded: false, + }, + automation: { + provider: "Drops Bot API", + credentialsIncluded: false, + providerEvidenceRequired: true, + }, + }, + null, + 2, + ); +} + +function workspaceReadme(project: GeneratedProject): string { + return `# ${project.spec.name} + +This is the editable multi-file source workspace produced by Drops Studio. + +## Tasks + +- \`npm run check\` validates the file graph and integration manifest. +- \`npm test\` parses the runnable JavaScript in an isolated task. +- \`npm run build\` runs both release checks. +- \`npm start\` serves the workspace on \`PORT\` (default 4173). + +Drops Studio preserves the root \`index.html\`, \`src/styles.css\` and \`src/app.js\` as the static preview and publishing runtime. The root manifest may additionally declare up to six explicit \`packages/<safe-name>\` npm workspaces. Package tasks run only from the root or a declared package directory and must match a script in that directory's private manifest. + +Server tasks run in an ephemeral Vercel Sandbox only after the API returns a provider run receipt. Dependencies use exact registry versions, install scripts are disabled, and runtime network access is denied. Connected provider credentials are never written into workspace files or inherited by a sandbox. +`; +} + +export function materializeProjectWorkspace( + project: GeneratedProject, +): ProjectWorkspace { + const source = extractRuntimeSource(prepareEditableRuntimeHtml(project.html)); + return { + schemaVersion: 1, + revision: 1, + updatedAt: project.updatedAt, + files: [ + file("index.html", source.indexHtml, "html", "entry"), + file("src/styles.css", source.css, "css", "style"), + file("src/app.js", source.javascript, "javascript", "client"), + file( + "project.json", + prepareEditableRuntimeHtml(JSON.stringify(project.spec, null, 2)), + "json", + "project-config", + ), + file( + "drops.config.json", + prepareEditableRuntimeHtml(integrationManifest(project)), + "json", + "integration-config", + ), + file("package.json", packageManifest(project.spec), "json", "package-manifest"), + file("server.mjs", staticWorkspaceServerSource(), "javascript", "server"), + file("scripts/check.mjs", checkTaskSource(project.spec), "javascript", "task"), + file("tests/smoke.mjs", smokeTaskSource(project.spec), "javascript", "test"), + file("README.md", workspaceReadme(project), "markdown", "documentation"), + ], + tasks: [ + { id: "check", label: "Check workspace", command: "npm", args: ["run", "check"] }, + { id: "test", label: "Run tests", command: "npm", args: ["test"] }, + { id: "build", label: "Build release", command: "npm", args: ["run", "build"] }, + { id: "start", label: "Start preview", command: "npm", args: ["start"], port: 4173 }, + ], + runtime: { + executionMode: "static-preview", + provider: "unconfigured", + isolation: "browser-iframe", + runtime: "node24", + packageManager: "npm", + installScripts: false, + }, + }; +} + +interface ProjectPackageContract { + dependencies: Record<string, string>; + dependencyCount: number; + scriptsByCwd: Map<string, Record<string, string>>; + packageNamesByCwd: Map<string, string>; + workspaceDirectories: string[]; +} + +function parseManifest( + content: string, + path: string, + issues: string[], +): Record<string, unknown> { + try { + const value = JSON.parse(content) as unknown; + if (!value || typeof value !== "object" || Array.isArray(value)) { + issues.push(`${path} must contain one JSON object.`); + return {}; + } + return value as Record<string, unknown>; + } catch { + issues.push(`${path} must contain valid JSON.`); + return {}; + } +} + +function manifestCwd(path: string): string | null { + if (path === "package.json") return "."; + const match = /^(packages\/[a-z0-9][a-z0-9._-]{0,63})\/package\.json$/.exec( + path, + ); + return match?.[1] ?? null; +} + +function packageScripts( + manifest: Record<string, unknown>, + path: string, + issues: string[], + requireRootScripts: boolean, +): Record<string, string> { + const raw = manifest.scripts; + if (raw === undefined && !requireRootScripts) return {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + issues.push(`${path} must declare package scripts as an object.`); + return {}; + } + const scripts: Record<string, string> = {}; + for (const [name, command] of Object.entries(raw as Record<string, unknown>)) { + if (BLOCKED_LIFECYCLE_SCRIPTS.has(name.toLowerCase())) { + issues.push(`npm lifecycle scripts are blocked in ${path} (${name}).`); + continue; + } + if (!PACKAGE_SCRIPT_NAME.test(name)) { + issues.push( + `${path} script ${name} must use a bounded alphanumeric npm script name.`, + ); + continue; + } + if ( + typeof command !== "string" || + !command.trim() || + command.length > 500 + ) { + issues.push(`${path} script ${name} must be a bounded command string.`); + continue; + } + scripts[name] = command; + } + if (requireRootScripts) { + for (const required of ["check", "test", "build", "start"]) { + if (!Object.hasOwn(scripts, required)) { + issues.push(`package.json must keep the ${required} task.`); + } + } + } + return scripts; +} + +function packageDisplayName( + manifest: Record<string, unknown>, + cwd: string, + path: string, + issues: string[], +): string { + if (manifest.name === undefined) return cwd.split("/").at(-1) ?? cwd; + if ( + typeof manifest.name !== "string" || + manifest.name.length > 214 || + !PACKAGE_NAME.test(manifest.name) + ) { + issues.push(`${path} name must be a valid bounded npm package name.`); + return cwd.split("/").at(-1) ?? cwd; + } + return manifest.name; +} + +function packageDependencies( + manifest: Record<string, unknown>, + path: string, + issues: string[], +): { dependencies: Record<string, string>; count: number } { + const dependencies: Record<string, string> = {}; + let count = 0; + for (const section of ["dependencies", "devDependencies"] as const) { + const raw = manifest[section] ?? {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + issues.push(`${path} ${section} must be an object.`); + continue; + } + for (const [name, version] of Object.entries( + raw as Record<string, unknown>, + )) { + count += 1; + if ( + !PACKAGE_NAME.test(name) || + typeof version !== "string" || + !REGISTRY_VERSION.test(version) + ) { + issues.push( + `${path} dependency ${name} must use an exact package registry version, not a range, URL, file, git or workspace spec.`, + ); + continue; + } + dependencies[name] = version; + } + } + return { dependencies, count }; +} + +function validatePackageFields( + manifest: Record<string, unknown>, + path: string, + issues: string[], +): void { + if (manifest.private !== true) { + issues.push(`${path} must remain private.`); + } + for (const field of BLOCKED_PACKAGE_FIELDS) { + if (manifest[field] !== undefined) { + issues.push(`${path} ${field} is blocked from the bounded package contract.`); + } + } + for (const field of BLOCKED_OPTIONAL_DEPENDENCY_FIELDS) { + const value = manifest[field]; + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length > 0 + ) { + issues.push(`${path} ${field} is blocked from installation.`); + } + } + const config = manifest.config; + if ( + config && + typeof config === "object" && + !Array.isArray(config) && + "registry" in config + ) { + issues.push(`${path} cannot declare a custom npm registry.`); + } +} + +function workspaceDirectories( + manifest: Record<string, unknown>, + byPath: Map<string, ProjectWorkspaceFile>, + issues: string[], +): string[] { + if (manifest.workspaces === undefined) return []; + if (!Array.isArray(manifest.workspaces)) { + issues.push("package.json workspaces must be an array of explicit package directories."); + return []; + } + if (manifest.workspaces.length > PROJECT_WORKSPACE_PACKAGE_LIMIT) { + issues.push( + `A workspace may declare at most ${PROJECT_WORKSPACE_PACKAGE_LIMIT} package directories.`, + ); + } + const directories: string[] = []; + const seen = new Set<string>(); + for (const value of manifest.workspaces) { + if (typeof value !== "string" || !PACKAGE_WORKSPACE_PATH.test(value)) { + issues.push( + "package.json workspaces must use explicit packages/<safe-name> directories without globs, URLs or traversal.", + ); + continue; + } + if (seen.has(value)) { + issues.push(`package.json workspace ${value} appears more than once.`); + continue; + } + seen.add(value); + directories.push(value); + if (!byPath.has(`${value}/package.json`)) { + issues.push(`${value}/package.json is required by the root workspace declaration.`); + } + } + return directories; +} + +function parsePackageGraph( + byPath: Map<string, ProjectWorkspaceFile>, + issues: string[], +): ProjectPackageContract { + const rootFile = byPath.get("package.json"); + const root = parseManifest(rootFile?.content ?? "", "package.json", issues); + if (root.type !== "module") { + issues.push('package.json must keep type "module".'); + } + const directories = workspaceDirectories(root, byPath, issues); + const scriptsByCwd = new Map<string, Record<string, string>>(); + const packageNamesByCwd = new Map<string, string>(); + const dependencies: Record<string, string> = {}; + let dependencyCount = 0; + + const manifests = [...byPath.entries()] + .filter(([path]) => path === "package.json" || path.endsWith("/package.json")) + .sort(([left], [right]) => left.localeCompare(right)); + for (const [path, item] of manifests) { + const cwd = manifestCwd(path); + if (!cwd) { + issues.push( + `${path} is not an allowed package manifest; packages must use packages/<safe-name>/package.json.`, + ); + continue; + } + const manifest = path === "package.json" + ? root + : parseManifest(item.content, path, issues); + if (path !== "package.json" && manifest.workspaces !== undefined) { + issues.push(`${path} cannot declare nested npm workspaces.`); + } + validatePackageFields(manifest, path, issues); + packageNamesByCwd.set( + cwd, + packageDisplayName(manifest, cwd, path, issues), + ); + scriptsByCwd.set( + cwd, + packageScripts(manifest, path, issues, path === "package.json"), + ); + const parsed = packageDependencies(manifest, path, issues); + Object.assign(dependencies, parsed.dependencies); + dependencyCount += parsed.count; + } + + if (dependencyCount > PROJECT_WORKSPACE_DEPENDENCY_LIMIT) { + issues.push( + `A canonical AI workspace may declare at most ${PROJECT_WORKSPACE_DEPENDENCY_LIMIT} npm dependencies across all package manifests.`, + ); + } + return { + dependencies, + dependencyCount, + scriptsByCwd, + packageNamesByCwd, + workspaceDirectories: directories, + }; +} + +function npmTaskScript(args: string[]): string | null { + if (args[0] === "test") return "test"; + if (args[0] === "start") return "start"; + if (args[0] === "run" && args[1]) return args[1]; + return null; +} + +function isPackageInstallCommand(command: string | undefined): boolean { + return [ + "add", + "ci", + "install", + "i", + "link", + "rebuild", + "remove", + "uninstall", + "update", + ].includes(command?.toLowerCase() ?? ""); +} + +function validateTasks( + tasks: ProjectWorkspaceTask[], + contract: ProjectPackageContract, + issues: string[], +): void { + if (!Array.isArray(tasks) || tasks.length < 1 || tasks.length > 16) { + issues.push("A workspace must declare between 1 and 16 bounded tasks."); + return; + } + const seen = new Set<string>(); + for (const task of tasks) { + if (!TASK_ID.test(task.id) || seen.has(task.id)) { + issues.push(`${task.id || "A task"} has an invalid or duplicate task id.`); + continue; + } + seen.add(task.id); + const cwd = task.cwd === undefined || task.cwd === "." ? "." : task.cwd; + if (cwd !== "." && !contract.workspaceDirectories.includes(cwd)) { + issues.push(`${task.id} cwd must be the root or a declared package workspace.`); + continue; + } + if ( + task.command !== "npm" || + !Array.isArray(task.args) || + task.args.length < 1 || + task.args.length > 4 || + task.args.some((argument) => typeof argument !== "string" || !argument) + ) { + issues.push(`${task.id} must run one bounded npm script.`); + continue; + } + if (isPackageInstallCommand(task.args[0])) { + issues.push(`${task.id} cannot install packages directly.`); + continue; + } + const script = npmTaskScript(task.args); + const scripts = contract.scriptsByCwd.get(cwd) ?? {}; + if (!script || !Object.hasOwn(scripts, script)) { + issues.push( + `${task.id} must match a declared package.json script in ${cwd === "." ? "the root" : cwd}.`, + ); + } + } +} + +function packageTaskHash(value: string): string { + let hash = 2_166_136_261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0).toString(36).padStart(7, "0").slice(-7); +} + +function packageTaskId( + cwd: string, + script: string, + usedIds: Set<string>, +): string { + const readable = `${cwd.slice("packages/".length)}-${script}` + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + let attempt = 0; + while (attempt < 100) { + const key = `${cwd}:${script}:${attempt}`; + const id = `pkg-${readable.slice(0, 36)}-${packageTaskHash(key)}`; + if (!usedIds.has(id)) return id; + attempt += 1; + } + throw new Error("Could not derive a unique package task id."); +} + +function sameTasks( + left: ProjectWorkspaceTask[], + right: ProjectWorkspaceTask[], +): boolean { + return ( + left.length === right.length && + left.every((task, index) => { + const candidate = right[index]; + return ( + candidate && + task.id === candidate.id && + task.label === candidate.label && + task.command === candidate.command && + task.cwd === candidate.cwd && + task.port === candidate.port && + task.args.length === candidate.args.length && + task.args.every((argument, argumentIndex) => + argument === candidate.args[argumentIndex] + ) + ); + }) + ); +} + +export function reconcileProjectWorkspaceTasks( + workspace: ProjectWorkspace, +): ProjectWorkspace { + const issues: string[] = []; + const contract = parsePackageGraph( + new Map(workspace.files.map((item) => [item.path, item])), + issues, + ); + if (issues.length) { + throw new Error(issues[0] ?? "Workspace package graph is invalid."); + } + + const rootTasks = workspace.tasks + .filter((task) => !task.cwd || task.cwd === ".") + .slice(0, PROJECT_WORKSPACE_ROOT_TASK_LIMIT); + const usedIds = new Set(rootTasks.map((task) => task.id)); + const ordered: Array<{ cwd: string; script: string }> = []; + for (const script of PACKAGE_TASK_PRIORITY) { + for (const cwd of contract.workspaceDirectories) { + if (Object.hasOwn(contract.scriptsByCwd.get(cwd) ?? {}, script)) { + ordered.push({ cwd, script }); + } + } + } + for (const cwd of contract.workspaceDirectories) { + const scripts = Object.keys(contract.scriptsByCwd.get(cwd) ?? {}) + .filter( + (script) => + !PACKAGE_TASK_PRIORITY.includes( + script as (typeof PACKAGE_TASK_PRIORITY)[number], + ) && PACKAGE_SCRIPT_NAME.test(script), + ) + .sort((left, right) => left.localeCompare(right)); + for (const script of scripts) ordered.push({ cwd, script }); + } + + const tasks = [...rootTasks]; + for (const { cwd, script } of ordered) { + if (tasks.length >= PROJECT_WORKSPACE_TASK_LIMIT) break; + const id = packageTaskId(cwd, script, usedIds); + usedIds.add(id); + const packageName = contract.packageNamesByCwd.get(cwd) ?? cwd; + tasks.push({ + id, + label: `${packageName} · ${script}`.slice(0, 80), + command: "npm", + args: ["run", script], + cwd, + }); + } + + return sameTasks(workspace.tasks, tasks) + ? workspace + : { ...workspace, tasks }; +} + +function validateProjectWorkspaceValue( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, +): ProjectWorkspaceValidation { + const issues: string[] = []; + if (workspace.schemaVersion !== 1) issues.push("Unsupported workspace schema version."); + if (!Number.isSafeInteger(workspace.revision) || workspace.revision < 1) { + issues.push("Workspace revision must be a positive integer."); + } + if ( + typeof workspace.updatedAt !== "string" || + !Number.isFinite(Date.parse(workspace.updatedAt)) + ) { + issues.push("Workspace updatedAt must be a valid timestamp."); + } + const runtime = workspace.runtime; + if ( + !runtime || + runtime.executionMode !== "static-preview" || + runtime.provider !== "unconfigured" || + runtime.isolation !== "browser-iframe" || + runtime.runtime !== "node24" || + runtime.packageManager !== "npm" || + runtime.installScripts !== false + ) { + issues.push("Workspace runtime must preserve the bounded Node 24 preview contract."); + } + if (!Array.isArray(workspace.files) || workspace.files.length > PROJECT_WORKSPACE_FILE_LIMIT) { + issues.push(`A workspace may contain at most ${PROJECT_WORKSPACE_FILE_LIMIT} files.`); + } + const seen = new Set<string>(); + let totalBytes = 0; + for (const item of workspace.files ?? []) { + if (isUnsafeProjectWorkspacePath(item.path)) { + issues.push(`${item.path || "A file"} has an unsafe workspace path.`); + } + if (!WORKSPACE_FILE_LANGUAGE_SET.has(item.language)) { + issues.push(`${item.path} must use a supported workspace language.`); + } + if (!WORKSPACE_FILE_ROLE_SET.has(item.role)) { + issues.push(`${item.path} must use a supported workspace file role.`); + } + if (typeof item.content !== "string" || typeof item.editable !== "boolean") { + issues.push(`${item.path} must contain string source and an editable flag.`); + continue; + } + if (seen.has(item.path)) issues.push(`${item.path} appears more than once.`); + seen.add(item.path); + const bytes = byteLength(item.content); + totalBytes += bytes; + if (bytes > PROJECT_WORKSPACE_FILE_BYTES_LIMIT) { + issues.push(`${item.path} exceeds the 1.5 MB file limit.`); + } + const secrets = findArtifactSecrets(item.content, item.path); + if (secrets.length) issues.push(new ArtifactSecretError(secrets).message); + } + if (totalBytes > PROJECT_WORKSPACE_BYTES_LIMIT) { + issues.push("Workspace source exceeds the 1.5 MB total limit."); + } + for (const required of REQUIRED_FILES) { + if (!seen.has(required)) issues.push(`${required} is required.`); + } + const byPath = new Map((workspace.files ?? []).map((item) => [item.path, item])); + const index = byPath.get("index.html")?.content ?? ""; + if (!index.includes(`data-project-kind="${spec.presetId}"`)) { + issues.push("index.html must preserve the product-kind contract."); + } + const stylesheetReferences = + index.match( + /<link\b[^>]*\bhref\s*=\s*["']\.\/src\/styles\.css["'][^>]*>/gi, + ) ?? []; + const runtimeReferences = + index.match( + /<script\b[^>]*\bsrc\s*=\s*["']\.\/src\/app\.js["'][^>]*>\s*<\/script\s*>/gi, + ) ?? []; + if ( + stylesheetReferences.length !== 1 || + stylesheetReferences[0] !== CANONICAL_STYLESHEET_LINK || + runtimeReferences.length !== 1 || + runtimeReferences[0] !== CANONICAL_RUNTIME_SCRIPT + ) { + issues.push( + "index.html must load src/styles.css and src/app.js using the exact canonical entry tags.", + ); + } + issues.push(...unexpectedRuntimeActiveContent(index, "canonical-workspace")); + if (/<\/style/i.test(byPath.get("src/styles.css")?.content ?? "")) { + issues.push("src/styles.css cannot close the runtime style element."); + } + if (/<\/script/i.test(byPath.get("src/app.js")?.content ?? "")) { + issues.push("src/app.js cannot close the runtime script element."); + } + const packageContract = parsePackageGraph(byPath, issues); + validateTasks(workspace.tasks, packageContract, issues); + return { valid: issues.length === 0, issues }; +} + +export function validateProjectWorkspace( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, +): ProjectWorkspaceValidation; +export function validateProjectWorkspace( + spec: GeneratedProjectSpec, + workspace: unknown, +): ProjectWorkspaceValidation; +export function validateProjectWorkspace( + spec: GeneratedProjectSpec, + workspace: unknown, +): ProjectWorkspaceValidation { + if (!workspace || typeof workspace !== "object" || Array.isArray(workspace)) { + return { valid: false, issues: ["Workspace must be one bounded object."] }; + } + try { + return validateProjectWorkspaceValue(spec, workspace as ProjectWorkspace); + } catch { + return { + valid: false, + issues: ["Workspace persisted structure is malformed and was rejected."], + }; + } +} + +function requiredFile(workspace: ProjectWorkspace, path: string): ProjectWorkspaceFile { + const item = workspace.files.find((candidate) => candidate.path === path); + if (!item) throw new Error(`${path} is missing from the workspace.`); + return item; +} + +export function compileWorkspaceRuntime( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, +): string { + const validation = validateProjectWorkspace(spec, workspace); + if (!validation.valid) throw new Error(validation.issues[0] ?? "Workspace validation failed."); + const index = requiredFile(workspace, "index.html").content; + const css = requiredFile(workspace, "src/styles.css").content; + const javascript = requiredFile(workspace, "src/app.js").content; + const runtime = index + .replace(CANONICAL_STYLESHEET_LINK, () => `<style>${css}</style>`) + .replace(CANONICAL_RUNTIME_SCRIPT, () => `<script>${javascript}</script>`); + const runtimeValidation = validateEditableRuntimeHtml(spec, runtime); + if (!runtimeValidation.valid) { + throw new Error(runtimeValidation.issues[0] ?? "Compiled workspace runtime is invalid."); + } + return runtime; +} + +export function updateWorkspaceFile( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, + path: string, + content: string, +): ProjectWorkspace { + const existing = workspace.files.find((item) => item.path === path); + if (!existing) throw new Error(`${path} is not part of this workspace.`); + if (!existing.editable) throw new Error(`${path} is read-only.`); + const secrets = findArtifactSecrets(content, path); + if (secrets.length) throw new ArtifactSecretError(secrets); + const next = reconcileProjectWorkspaceTasks({ + ...workspace, + revision: workspace.revision + 1, + updatedAt: new Date().toISOString(), + files: workspace.files.map((item) => + item.path === path ? { ...item, content } : item, + ), + }); + const validation = validateProjectWorkspace(spec, next); + if (!validation.valid) throw new Error(validation.issues[0] ?? "Workspace validation failed."); + return next; +} + +export function addWorkspaceFile( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, + input: Omit<ProjectWorkspaceFile, "editable"> & { editable?: boolean }, +): ProjectWorkspace { + if (isUnsafeProjectWorkspacePath(input.path)) { + throw new Error(`${input.path || "A file"} has an unsafe workspace path.`); + } + if (workspace.files.some((item) => item.path === input.path)) { + throw new Error(`${input.path} already exists in this workspace.`); + } + if (workspace.files.length >= PROJECT_WORKSPACE_FILE_LIMIT) { + throw new Error(`A workspace may contain at most ${PROJECT_WORKSPACE_FILE_LIMIT} files.`); + } + const secrets = findArtifactSecrets(input.content, input.path); + if (secrets.length) throw new ArtifactSecretError(secrets); + const next = reconcileProjectWorkspaceTasks({ + ...workspace, + revision: workspace.revision + 1, + updatedAt: new Date().toISOString(), + files: [ + ...workspace.files, + { ...input, editable: input.editable ?? true }, + ], + }); + const validation = validateProjectWorkspace(spec, next); + if (!validation.valid) throw new Error(validation.issues[0] ?? "Workspace validation failed."); + return next; +} + +export function deleteWorkspaceFile( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, + path: string, +): ProjectWorkspace { + if ((REQUIRED_FILES as readonly string[]).includes(path)) { + throw new Error(`${path} is a required workspace file and cannot be deleted.`); + } + const existing = workspace.files.find((item) => item.path === path); + if (!existing) throw new Error(`${path} is not part of this workspace.`); + if (!existing.editable) throw new Error(`${path} is read-only.`); + const next = reconcileProjectWorkspaceTasks({ + ...workspace, + revision: workspace.revision + 1, + updatedAt: new Date().toISOString(), + files: workspace.files.filter((item) => item.path !== path), + }); + const validation = validateProjectWorkspace(spec, next); + if (!validation.valid) throw new Error(validation.issues[0] ?? "Workspace validation failed."); + return next; +} + +export function workspaceFilesForSandbox( + spec: GeneratedProjectSpec, + workspace: ProjectWorkspace, +): { + files: Array<{ path: string; content: string }>; + dependencies: Record<string, string>; +} { + const validation = validateProjectWorkspace(spec, workspace); + if (!validation.valid) throw new Error(validation.issues[0] ?? "Workspace validation failed."); + const packageIssues: string[] = []; + const dependencies = parsePackageGraph( + new Map(workspace.files.map((item) => [item.path, item])), + packageIssues, + ).dependencies; + if (packageIssues.length) throw new Error(packageIssues[0]); + return { + files: workspace.files.map(({ path, content }) => ({ path, content })), + dependencies, + }; +} diff --git a/lib/public-project-shell.ts b/lib/public-project-shell.ts new file mode 100644 index 0000000..b6589fa --- /dev/null +++ b/lib/public-project-shell.ts @@ -0,0 +1,163 @@ +import type { PresetId } from "./presets.ts"; + +function htmlAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function scriptValue(value: string): string { + return JSON.stringify(value).replaceAll("<", "\\u003c"); +} + +export function publicProjectShellCsp(nonce: string): string { + return [ + "default-src 'none'", + `script-src 'nonce-${nonce}'`, + `style-src 'nonce-${nonce}'`, + "connect-src 'self'", + "frame-src 'self'", + "object-src 'none'", + "worker-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'self'", + ].join("; "); +} + +export function buildPublicProjectShell(options: { + nonce: string; + presetId: PresetId; + runtimeUrl: string; + slug: string; + title: string; +}): string { + const title = htmlAttribute(options.title); + const runtimeUrl = htmlAttribute(options.runtimeUrl); + const nonce = htmlAttribute(options.nonce); + return `<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <title>${title} · Drops Studio + + + +
+ Sandboxed public app + Runtime isolated from your Drops Studio account. + +
+ + + +`; +} diff --git a/lib/published-quality-evidence.ts b/lib/published-quality-evidence.ts new file mode 100644 index 0000000..a3d67ac --- /dev/null +++ b/lib/published-quality-evidence.ts @@ -0,0 +1,91 @@ +import { getProductReality, launchStatusFor } from "./product-reality.ts"; +import type { PresetId } from "./presets.ts"; +import type { + ProjectQualityCheck, + ProjectQualityReport, + ProjectRuntimeSmokeResult, +} from "./project-types.ts"; + +export const PUBLISHED_QUALITY_MAX_AGE_MS = 5 * 60 * 1_000; +export const PUBLISHED_QUALITY_MAX_FUTURE_SKEW_MS = 30 * 1_000; + +function recentTimestamp(value: unknown, now: number): value is string { + if (typeof value !== "string") return false; + const timestamp = Date.parse(value); + return ( + Number.isFinite(timestamp) + && timestamp >= now - PUBLISHED_QUALITY_MAX_AGE_MS + && timestamp <= now + PUBLISHED_QUALITY_MAX_FUTURE_SKEW_MS + ); +} + +function qualityCheck(value: unknown): value is ProjectQualityCheck { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const input = value as Record; + return ( + typeof input.id === "string" + && typeof input.label === "string" + && typeof input.passed === "boolean" + && typeof input.detail === "string" + && typeof input.weight === "number" + && Number.isFinite(input.weight) + && typeof input.critical === "boolean" + ); +} + +function serverInspection( + value: unknown, + now: number, +): value is ProjectRuntimeSmokeResult { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const input = value as Record; + return ( + input.mode === "server-inspection" + && input.executed === false + && typeof input.runtime === "boolean" + && typeof input.interactions === "boolean" + && typeof input.dropstab === "boolean" + && typeof input.dropsbot === "boolean" + && typeof input.actions === "boolean" + && (input.dataProvider === "dropstab" + || input.dataProvider === "fallback" + || input.dataProvider === "unverified") + && Array.isArray(input.errors) + && input.errors.every((error) => typeof error === "string") + && recentTimestamp(input.checkedAt, now) + ); +} + +/** Accepts only the authoritative inspection returned for this publish call. */ +export function acceptPublishedQuality( + value: unknown, + presetId: PresetId, + now = Date.now(), +): ProjectQualityReport | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const input = value as Record; + const reality = getProductReality(presetId); + if ( + !Number.isSafeInteger(input.score) + || Number(input.score) < 0 + || Number(input.score) > 100 + || input.readyToPublish !== true + || input.launchStatus !== launchStatusFor(presetId) + || input.deliveryMode !== reality.deliveryMode + || input.externalSetupRequired !== reality.externalSetupRequired + || !Number.isFinite(now) + || !recentTimestamp(input.checkedAt, now) + || !Array.isArray(input.checks) + || input.checks.length < 1 + || !input.checks.every(qualityCheck) + || !Array.isArray(input.criticalFailures) + || input.criticalFailures.length !== 0 + || input.checks.some( + (check) => qualityCheck(check) && check.critical && !check.passed, + ) + || !serverInspection(input.runtimeSmoke, now) + ) { + return null; + } + return input as unknown as ProjectQualityReport; +} diff --git a/lib/runtime-active-content.ts b/lib/runtime-active-content.ts new file mode 100644 index 0000000..b0d28e3 --- /dev/null +++ b/lib/runtime-active-content.ts @@ -0,0 +1,185 @@ +export type RuntimeDocumentProfile = "canonical-workspace" | "compiled-runtime"; + +const CANONICAL_STYLESHEET_LINK = + ''; +const CANONICAL_RUNTIME_SCRIPT = ''; +const PROJECT_SPEC_OPEN = ' + +`; +} +import { findHtmlOpeningTag } from "./html-opening-tag.ts"; diff --git a/lib/server-release-quality.ts b/lib/server-release-quality.ts index 4ee7af7..feab519 100644 --- a/lib/server-release-quality.ts +++ b/lib/server-release-quality.ts @@ -1,5 +1,9 @@ import { Script } from "node:vm"; +import { + findHtmlOpeningTag, + stripHtmlOpeningTagAttribute, +} from "./html-opening-tag.ts"; import { evaluateProjectQuality } from "./project-quality.ts"; import type { GeneratedProjectSpec, ProjectQualityReport, ProjectRuntimeSmokeResult } from "./project-types.ts"; @@ -54,9 +58,9 @@ export function inspectServerReleaseRuntime( const hasUnsafeExecution = /]*data-action=["'](?:execute-trade|auto-trade)["']/i.test(html); const hasApprovalBoundary = /Nothing was executed|no trade executed|approve|approval/i.test(html); return { - mode: "server-artifact", + mode: "server-inspection", dataProvider: normalizeProvider(provider), - executed: true, + executed: false, runtime: scripts.length > 0 && errors.length === 0 && categoryRuntime, interactions: hasClickContract, dropstab: hasAdapterContract, @@ -78,5 +82,14 @@ export function evaluateServerReleaseQuality( export function stampProviderEvidence(html: string, provider: ReleaseProviderEvidence | string): string { const evidence = normalizeProvider(provider); - return html.replace(/]*\bdata-provider-evidence=)/i, ``; + return `${html.slice(0, root.start)}${stamped}${html.slice(root.end)}`; } diff --git a/lib/source-workspace.ts b/lib/source-workspace.ts index ae74ca0..11d9abd 100644 --- a/lib/source-workspace.ts +++ b/lib/source-workspace.ts @@ -1,10 +1,16 @@ import { assertPublishedArtifactSafe } from "./artifact-security.ts"; -import { getProductReality } from "./product-reality.ts"; +import { unexpectedRuntimeActiveContent } from "./runtime-active-content.ts"; +import { + getProductReality, + studioTelegramConnectionUrl, +} from "./product-reality.ts"; import type { GeneratedProjectSpec } from "./project-types.ts"; export const SOURCE_WORKSPACE_HTML_LIMIT_BYTES = 1_500_000; const LOOPBACK_ORIGIN = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/gi; +const PROJECT_SPEC_MARKER = '", payloadAt); + if (payloadEnd < 0) { + throw new PublishedRuntimeBindingError( + "Edited source has an incomplete generated projectSpec payload.", + ); + } + + let embeddedSpec: Record; + try { + const value = JSON.parse(html.slice(payloadAt, payloadEnd)) as unknown; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); + embeddedSpec = value as Record; + } catch { + throw new PublishedRuntimeBindingError( + "Edited source has an unreadable generated projectSpec payload.", + ); + } + + const reboundPayload = safeJson({ + ...embeddedSpec, + slug: publishedSpec.slug, + dataEndpoint: publishedSpec.dataEndpoint, + }); + let rebound = `${html.slice(0, payloadAt)}${reboundPayload}${html.slice(payloadEnd)}`; + const telegramAssignments = [...rebound.matchAll(STUDIO_TELEGRAM_URL_ASSIGNMENT)]; + if (telegramAssignments.length !== 1) { + throw new PublishedRuntimeBindingError( + "Edited source must keep the generated Telegram handoff binding.", + ); + } + const telegramUrl = safeJson(studioTelegramConnectionUrl( + publishedSpec.dataEndpoint, + publishedSpec.slug, + )); + rebound = rebound.replace( + STUDIO_TELEGRAM_URL_ASSIGNMENT, + () => `var studioTelegramUrl=${telegramUrl};`, + ); + return rebound; +} + /** * Makes Studio-generated source safe to edit and run from the current host. * Only loopback origins are removed; root-relative product and brand assets @@ -59,6 +136,7 @@ export function validateEditableRuntimeHtml( if (/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?/i.test(source)) { issues.push("Runnable source cannot depend on a loopback URL."); } + issues.push(...unexpectedRuntimeActiveContent(source, "compiled-runtime")); try { assertPublishedArtifactSafe(spec, source); } catch (error) { diff --git a/lib/team-api.ts b/lib/team-api.ts new file mode 100644 index 0000000..bf6b309 --- /dev/null +++ b/lib/team-api.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +import { + billingEntitlements, + billingTierForAccount, + stripeProPriceId, + type BillingEntitlements, +} from "./billing.ts"; +import { + billingStorageConfigured, + readBillingAccount, +} from "../db/billing.ts"; +import { + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, + type StudioAccount, +} from "./access-tier.ts"; +import { consumeRequestLimit } from "./request-rate-limit.ts"; +import { + decodeUtf8Body, + hasJsonMediaType, + readBoundedRequestBody, + RequestBodyBoundaryError, +} from "./http-request-boundary.ts"; + +export const TEAM_API_HEADERS = { + "cache-control": "private, no-store, max-age=0", + vary: "Cookie", +}; + +export class TeamApiError extends Error { + readonly status: number; + readonly code?: string; + + constructor(status: number, message: string, code?: string) { + super(message); + this.name = "TeamApiError"; + this.status = status; + this.code = code; + } +} + +export function teamJson( + payload: Record, + status = 200, +) { + return NextResponse.json(payload, { status, headers: TEAM_API_HEADERS }); +} + +export function teamAccount(request: NextRequest): StudioAccount { + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + ); + if (!account) throw new TeamApiError(401, "A signed Studio member account is required."); + return account; +} + +export function requireTeamSameOrigin(request: NextRequest): void { + const origin = request.headers.get("origin"); + if (!origin || request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + throw new TeamApiError(403, "Cross-origin team request rejected."); + } + try { + if (new URL(origin).origin !== request.nextUrl.origin) throw new Error(); + } catch { + throw new TeamApiError(403, "Cross-origin team request rejected."); + } +} + +export async function teamRequestBody( + request: NextRequest, + maxBytes = 16 * 1_024, +): Promise> { + if (!hasJsonMediaType(request)) { + throw new TeamApiError(415, "Team request requires application/json."); + } + let raw: string; + try { + raw = decodeUtf8Body(await readBoundedRequestBody(request, maxBytes)); + } catch (error) { + if (error instanceof RequestBodyBoundaryError && error.reason === "too-large") { + throw new TeamApiError(413, "Team request payload is too large."); + } + throw new TeamApiError(400, "Team request body is invalid."); + } + try { + const value = JSON.parse(raw) as unknown; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); + return value as Record; + } catch { + throw new TeamApiError(400, "Team request body is invalid."); + } +} + +export async function enforceTeamRateLimit( + identity: string, + namespace: string, +): Promise { + const status = await consumeRequestLimit({ + identity, + namespace, + max: 60, + windowMs: 60 * 60 * 1_000, + }); + if (status === "limited") throw new TeamApiError(429, "Too many team requests. Try again later."); + if (status === "unavailable") throw new TeamApiError(503, "Team request protection is unavailable."); +} + +export async function proTeamEntitlements( + ownerIdentity: string, +): Promise { + const priceId = stripeProPriceId(); + if (!priceId || !billingStorageConfigured()) { + throw new TeamApiError(503, "Team billing entitlement is not configured or unavailable."); + } + const billing = await readBillingAccount(ownerIdentity).catch(() => { + throw new TeamApiError(503, "Team billing entitlement is unavailable."); + }); + const tier = billingTierForAccount(billing, priceId); + if (tier !== "pro") { + throw new TeamApiError( + 403, + "An active subscription to the configured Pro Price is required for team writes.", + "PRO_REQUIRED", + ); + } + return billingEntitlements(tier); +} + +export function teamApiError(error: unknown) { + if (!(error instanceof TeamApiError)) return null; + return teamJson({ + ...(error.code ? { code: error.code } : {}), + error: error.message, + }, error.status); +} diff --git a/lib/team-workspaces.ts b/lib/team-workspaces.ts new file mode 100644 index 0000000..5994f23 --- /dev/null +++ b/lib/team-workspaces.ts @@ -0,0 +1,195 @@ +import { + createHash, + createHmac, + timingSafeEqual, +} from "node:crypto"; +import type { MemberProjectDraft } from "./member-project-cloud.ts"; + +export type TeamRole = "owner" | "editor" | "viewer"; +export type TeamAction = "read" | "write" | "manage"; + +export interface TeamMember { + identity: string; + role: TeamRole; + joinedAt: string; + consentedAt: string; +} + +export interface TeamInvite { + id: string; + role: Exclude; + createdAt: string; + expiresAt: string; + acceptedAt: string | null; + acceptedBy: string | null; +} + +export interface TeamWorkspace { + id: string; + ownerIdentity: string; + name: string; + revision: number; + createdAt: string; + updatedAt: string; + members: TeamMember[]; + invites: TeamInvite[]; + projects: TeamSharedProject[]; +} + +export interface TeamSharedProject { + projectId: string; + revision: number; + draft: MemberProjectDraft; + createdAt: string; + updatedAt: string; + updatedBy: string; +} + +export interface TeamInviteCapabilityPayload { + ownerIdentity: string; + workspaceId: string; + inviteId: string; + role: Exclude; + expiresAt: string; +} + +export class TeamWorkspaceValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "TeamWorkspaceValidationError"; + } +} + +export class TeamWorkspacePermissionError extends Error { + constructor(message = "This account does not have permission for the team workspace.") { + super(message); + this.name = "TeamWorkspacePermissionError"; + } +} + +export function validTeamIdentity(value: string): boolean { + return /^[a-f0-9]{64}$/.test(value); +} + +export function validTeamId(value: string): boolean { + return /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(value); +} + +export function validTeamTimestamp(value: string): boolean { + return typeof value === "string" && Number.isFinite(Date.parse(value)); +} + +export function teamWorkspaceName(value: unknown): string { + if (typeof value !== "string") { + throw new TeamWorkspaceValidationError("Team workspace name must be text."); + } + const name = value + .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/\s+/g, " ") + .trim(); + if (name.length < 2 || name.length > 80) { + throw new TeamWorkspaceValidationError("Team workspace name must contain 2 to 80 characters."); + } + return name; +} + +function validInviteRole(value: unknown): value is "editor" | "viewer" { + return value === "editor" || value === "viewer"; +} + +function capabilityPayload(value: unknown): TeamInviteCapabilityPayload | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const input = value as Record; + if ( + input.v !== 1 + || typeof input.ownerIdentity !== "string" + || !validTeamIdentity(input.ownerIdentity) + || typeof input.workspaceId !== "string" + || !validTeamId(input.workspaceId) + || typeof input.inviteId !== "string" + || !validTeamId(input.inviteId) + || !validInviteRole(input.role) + || typeof input.expiresAt !== "string" + || !validTeamTimestamp(input.expiresAt) + ) { + return null; + } + return { + ownerIdentity: input.ownerIdentity, + workspaceId: input.workspaceId, + inviteId: input.inviteId, + role: input.role, + expiresAt: input.expiresAt, + }; +} + +function validInviteSecret(secret: string): boolean { + return Buffer.byteLength(secret, "utf8") >= 32; +} + +function signCapability(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("hex"); +} + +function signaturesMatch(left: string, right: string): boolean { + if (!/^[a-f0-9]{64}$/.test(left) || !/^[a-f0-9]{64}$/.test(right)) return false; + const leftBuffer = Buffer.from(left, "hex"); + const rightBuffer = Buffer.from(right, "hex"); + return leftBuffer.length === rightBuffer.length + && timingSafeEqual(leftBuffer, rightBuffer); +} + +export function createTeamInviteCapability( + input: TeamInviteCapabilityPayload, + secret: string, +): string { + if (!validInviteSecret(secret) || !capabilityPayload({ v: 1, ...input })) { + throw new TeamWorkspaceValidationError("Team invite signing is not configured or the invite is invalid."); + } + const payload = Buffer.from(JSON.stringify({ v: 1, ...input }), "utf8").toString("base64url"); + return `${payload}.${signCapability(payload, secret)}`; +} + +export function verifyTeamInviteCapability( + capability: string, + secret: string, + now = new Date(), +): TeamInviteCapabilityPayload | null { + if (!validInviteSecret(secret)) return null; + const separator = capability.lastIndexOf("."); + if (separator <= 0 || capability.length > 2_048) return null; + const encoded = capability.slice(0, separator); + const signature = capability.slice(separator + 1); + if (!signaturesMatch(signature, signCapability(encoded, secret))) return null; + try { + const parsed = capabilityPayload( + JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as unknown, + ); + if (!parsed || Date.parse(parsed.expiresAt) <= now.getTime()) return null; + return parsed; + } catch { + return null; + } +} + +export function hashTeamInviteCapability(capability: string): string { + return createHash("sha256").update(capability, "utf8").digest("hex"); +} + +export function resolveTeamInviteSecret( + env: NodeJS.ProcessEnv = process.env, +): string { + const secret = env.DROPS_TEAM_INVITE_SECRET?.trim() ?? ""; + return validInviteSecret(secret) ? secret : ""; +} + +export function teamPermission( + workspace: Pick, + identity: string, + action: TeamAction, +): boolean { + const role = workspace.members.find((member) => member.identity === identity)?.role; + if (role === "owner") return true; + if (role === "editor") return action === "read" || action === "write"; + return role === "viewer" && action === "read"; +} diff --git a/lib/telegram-account-request.ts b/lib/telegram-account-request.ts new file mode 100644 index 0000000..a5969dd --- /dev/null +++ b/lib/telegram-account-request.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from "next/server.js"; + +export const TELEGRAM_ACCOUNT_BODY_LIMIT_BYTES = 16 * 1024; + +const NO_STORE_HEADERS = { + "cache-control": "no-store, max-age=0", + pragma: "no-cache", + "referrer-policy": "no-referrer", +}; + +export class TelegramAccountRequestError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "TelegramAccountRequestError"; + this.status = status; + } +} + +export function telegramAccountJson(payload: unknown, status = 200): NextResponse { + return NextResponse.json(payload, { + status, + headers: NO_STORE_HEADERS, + }); +} + +export function telegramAccountRequestErrorResponse(error: unknown): NextResponse { + if (!(error instanceof TelegramAccountRequestError)) throw error; + return telegramAccountJson({ error: error.message }, error.status); +} + +function requireSameOrigin(request: NextRequest): void { + if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") { + throw new TelegramAccountRequestError( + 403, + "Cross-origin Telegram account actions are not accepted.", + ); + } + + const origin = request.headers.get("origin"); + if (!origin) { + throw new TelegramAccountRequestError( + 403, + "A same-origin Telegram account request is required.", + ); + } + + try { + if (new URL(origin).origin !== request.nextUrl.origin) throw new Error(); + } catch { + throw new TelegramAccountRequestError( + 403, + "Cross-origin Telegram account actions are not accepted.", + ); + } +} + +function requireJsonContentType(request: NextRequest): void { + const mediaType = request.headers + .get("content-type") + ?.split(";", 1)[0] + .trim() + .toLowerCase(); + if (mediaType !== "application/json") { + throw new TelegramAccountRequestError( + 415, + "Telegram account requests require application/json.", + ); + } +} + +function declaredBodyLength(request: NextRequest): number | null { + const header = request.headers.get("content-length"); + if (header === null) return null; + const normalized = header.trim(); + if (!/^\d+$/.test(normalized)) { + throw new TelegramAccountRequestError( + 400, + "Telegram account request Content-Length is invalid.", + ); + } + const length = Number(normalized); + if (!Number.isSafeInteger(length)) { + throw new TelegramAccountRequestError( + 400, + "Telegram account request Content-Length is invalid.", + ); + } + return length; +} + +async function readBoundedBody(request: NextRequest): Promise { + const declaredLength = declaredBodyLength(request); + if ( + declaredLength !== null + && declaredLength > TELEGRAM_ACCOUNT_BODY_LIMIT_BYTES + ) { + throw new TelegramAccountRequestError( + 413, + "Telegram account request payload is too large.", + ); + } + + if (!request.body) return new Uint8Array(); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + byteLength += value.byteLength; + if (byteLength > TELEGRAM_ACCOUNT_BODY_LIMIT_BYTES) { + await reader.cancel().catch(() => undefined); + throw new TelegramAccountRequestError( + 413, + "Telegram account request payload is too large.", + ); + } + chunks.push(value); + } + + const raw = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + raw.set(chunk, offset); + offset += chunk.byteLength; + } + return raw; +} + +export async function readTelegramAccountJson( + request: NextRequest, +): Promise> { + requireSameOrigin(request); + requireJsonContentType(request); + const raw = await readBoundedBody(request); + + let parsed: unknown; + try { + parsed = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(raw), + ) as unknown; + } catch { + throw new TelegramAccountRequestError( + 400, + "Telegram account requests require a valid JSON body.", + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new TelegramAccountRequestError( + 400, + "Telegram account requests require a JSON object.", + ); + } + return parsed as Record; +} diff --git a/lib/workspace-ai-entitlement.ts b/lib/workspace-ai-entitlement.ts new file mode 100644 index 0000000..d3086fe --- /dev/null +++ b/lib/workspace-ai-entitlement.ts @@ -0,0 +1,315 @@ +import type { NextRequest } from "next/server.js"; +import { + createGuestUsageCookie, + GUEST_DAILY_LIMIT, + GUEST_IDENTITY_COOKIE, + GUEST_USAGE_COOKIE, + MEMBER_DAILY_LIMIT, + MEMBER_USAGE_COOKIE, + platformAiReadiness, + resolveAccountCookieSecret, + resolveGuestAccess, + resolveStudioAccount, + STUDIO_ACCOUNT_COOKIE, + type StudioAccount, +} from "./access-tier.ts"; +import { + billingTierForAccount, + memberPlatformBuildLimit, + stripeProPriceId, + type BillingAccountRecord, +} from "./billing.ts"; +import { + billingStorageConfigured, + readBillingAccount, +} from "../db/billing.ts"; +import { + consumeRequestLimitState, + type RequestLimitState, +} from "./request-rate-limit.ts"; + +const DAILY_WINDOW_MS = 24 * 60 * 60 * 1_000; +type WorkspaceQuotaPurpose = "generation" | "execution"; + +export interface WorkspaceAiQuotaCookie { + name: typeof GUEST_IDENTITY_COOKIE | typeof GUEST_USAGE_COOKIE | typeof MEMBER_USAGE_COOKIE; + value: string; + maxAge: number; +} + +export interface WorkspaceAiQuotaReservation { + tier: "guest" | "member" | "pro"; + identity: string; + account: StudioAccount | null; + limit: number; + used: number; + remaining: number; + reset: "daily-utc"; + cookies: WorkspaceAiQuotaCookie[]; +} + +export interface WorkspaceAiQuotaDependencies { + env?: NodeJS.ProcessEnv; + now?: () => Date; + createGuestIdentity?: () => string; + consumeQuota?: (input: { + identity: string | null; + namespace: string; + max: number; + windowMs: number; + }) => Promise; + billingStorageConfigured?: () => boolean; + readBillingAccount?: ( + identity: string, + ) => Promise; +} + +export class WorkspaceAiQuotaLimitError extends Error { + readonly tier: WorkspaceAiQuotaReservation["tier"]; + readonly limit: number; + + constructor(tier: WorkspaceAiQuotaReservation["tier"], limit: number) { + super(`${tier} platform AI daily allowance reached.`); + this.name = "WorkspaceAiQuotaLimitError"; + this.tier = tier; + this.limit = limit; + } +} + +export class WorkspaceAiQuotaUnavailableError extends Error { + constructor(message = "Platform AI entitlement could not be reserved safely.") { + super(message); + this.name = "WorkspaceAiQuotaUnavailableError"; + } +} + +function dateUtc(now: Date): string { + return now.toISOString().slice(0, 10); +} + +function durableQuotaReady(env: NodeJS.ProcessEnv): boolean { + return Boolean( + (env.DROPS_STUDIO_LOCAL_PROJECT_STORE === "1" && !env.VERCEL) + || env.BLOB_READ_WRITE_TOKEN?.trim() + || (env.BLOB_STORE_ID?.trim() && env.VERCEL_OIDC_TOKEN?.trim()), + ); +} + +function reservationFromState(input: { + state: RequestLimitState; + tier: WorkspaceAiQuotaReservation["tier"]; + identity: string; + account: StudioAccount | null; + limit: number; + cookies: WorkspaceAiQuotaCookie[]; +}): WorkspaceAiQuotaReservation { + if (input.state.status === "limited") { + throw new WorkspaceAiQuotaLimitError(input.tier, input.limit); + } + if ( + input.state.status !== "allowed" || + input.state.count === null || + input.state.remaining === null + ) { + throw new WorkspaceAiQuotaUnavailableError(); + } + return { + tier: input.tier, + identity: input.identity, + account: input.account, + limit: input.limit, + used: input.state.count, + remaining: input.state.remaining, + reset: "daily-utc", + cookies: input.cookies, + }; +} + +async function reserveMemberQuota( + account: StudioAccount, + dependencies: WorkspaceAiQuotaDependencies, + purpose: WorkspaceQuotaPurpose, +): Promise { + const env = dependencies.env ?? process.env; + if ( + purpose === "generation" + ? !platformAiReadiness("member", env).available + : !resolveAccountCookieSecret(env) || !durableQuotaReady(env) + ) { + throw new WorkspaceAiQuotaUnavailableError( + purpose === "generation" + ? "Signed-in platform AI is not fully configured." + : "Signed-in funded execution quota is not fully configured.", + ); + } + + let tier: "member" | "pro" = "member"; + let limit = MEMBER_DAILY_LIMIT; + const expectedPrice = stripeProPriceId(env); + const storageConfigured = + dependencies.billingStorageConfigured ?? billingStorageConfigured; + const readBilling = dependencies.readBillingAccount ?? readBillingAccount; + if (expectedPrice && storageConfigured()) { + try { + const billing = await readBilling(account.identity); + const now = (dependencies.now ?? (() => new Date()))(); + tier = billingTierForAccount(billing, expectedPrice, now); + limit = memberPlatformBuildLimit(billing, expectedPrice, now); + } catch { + // Billing reads fail closed to the signed member entitlement. + } + } + + const consumeQuota = dependencies.consumeQuota ?? consumeRequestLimitState; + const state = await consumeQuota({ + identity: account.identity, + namespace: purpose === "generation" + ? "member-ai-plan" + : "member-sandbox-execution", + max: limit, + windowMs: DAILY_WINDOW_MS, + }).catch(() => ({ + status: "unavailable" as const, + count: null, + remaining: null, + })); + const secret = resolveAccountCookieSecret(env); + const cookies: WorkspaceAiQuotaCookie[] = []; + if (purpose === "generation" && secret && state.count !== null) { + cookies.push({ + name: MEMBER_USAGE_COOKIE, + value: createGuestUsageCookie( + { + date: dateUtc((dependencies.now ?? (() => new Date()))()), + count: state.count, + identity: account.identity, + }, + secret, + ), + maxAge: 60 * 60 * 36, + }); + } + return reservationFromState({ + state, + tier, + identity: account.identity, + account, + limit, + cookies, + }); +} + +async function reserveGuestQuota( + request: NextRequest, + dependencies: WorkspaceAiQuotaDependencies, + purpose: WorkspaceQuotaPurpose, +): Promise { + const env = dependencies.env ?? process.env; + const now = (dependencies.now ?? (() => new Date()))(); + const context = resolveGuestAccess({ + identityCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value, + usageCookie: request.cookies.get(GUEST_USAGE_COOKIE)?.value, + date: dateUtc(now), + env, + createIdentity: dependencies.createGuestIdentity, + }); + if ( + !context.configured || + !context.identity || + (purpose === "generation" + ? !platformAiReadiness("guest", env).available + : !durableQuotaReady(env)) + ) { + throw new WorkspaceAiQuotaUnavailableError( + purpose === "generation" + ? "Guest platform AI is not fully configured." + : "Guest funded execution quota is not fully configured.", + ); + } + + const consumeQuota = dependencies.consumeQuota ?? consumeRequestLimitState; + const state = await consumeQuota({ + identity: context.identity, + namespace: purpose === "generation" + ? "guest-ai-plan" + : "guest-sandbox-execution", + max: GUEST_DAILY_LIMIT, + windowMs: DAILY_WINDOW_MS, + }).catch(() => ({ + status: "unavailable" as const, + count: null, + remaining: null, + })); + const cookies: WorkspaceAiQuotaCookie[] = []; + if (context.identityCookie) { + cookies.push({ + name: GUEST_IDENTITY_COOKIE, + value: context.identityCookie, + maxAge: 60 * 60 * 24 * 90, + }); + } + if (purpose === "generation" && state.count !== null) { + cookies.push({ + name: GUEST_USAGE_COOKIE, + value: createGuestUsageCookie( + { + date: dateUtc(now), + count: state.count, + identity: context.identity, + }, + context.secret, + ), + maxAge: 60 * 60 * 36, + }); + } + return reservationFromState({ + state, + tier: "guest", + identity: context.identity, + account: null, + limit: GUEST_DAILY_LIMIT, + cookies, + }); +} + +export async function reserveWorkspacePlatformQuota( + request: NextRequest, + dependencies: WorkspaceAiQuotaDependencies = {}, +): Promise { + const env = dependencies.env ?? process.env; + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + env, + ); + return account + ? reserveMemberQuota(account, dependencies, "generation") + : reserveGuestQuota(request, dependencies, "generation"); +} + +/** + * Vercel Sandbox runs have their own tier-derived daily counter. Keeping this + * namespace separate from AI generation prevents one generate-then-run flow + * from being charged twice against the model allowance while still bounding + * the independently funded execution resource. + */ +export async function reserveWorkspaceExecutionQuota( + request: NextRequest, + dependencies: WorkspaceAiQuotaDependencies = {}, +): Promise { + const env = dependencies.env ?? process.env; + const account = resolveStudioAccount( + request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, + env, + ); + const reservation = account + ? await reserveMemberQuota(account, dependencies, "execution") + : await reserveGuestQuota(request, dependencies, "execution"); + return { + ...reservation, + // Preserve a newly minted signed guest identity, but never overwrite the + // generation usage cookie with the independent execution count. + cookies: reservation.cookies.filter( + (cookie) => cookie.name === GUEST_IDENTITY_COOKIE, + ), + }; +} diff --git a/lib/workspace-ai-patch.ts b/lib/workspace-ai-patch.ts new file mode 100644 index 0000000..1187f7b --- /dev/null +++ b/lib/workspace-ai-patch.ts @@ -0,0 +1,765 @@ +import { z } from "zod"; +import { + ArtifactSecretError, + findArtifactSecrets, +} from "./artifact-security.ts"; +import type { GeneratedProjectSpec } from "./project-types.ts"; +import type { + ProjectWorkspace, + ProjectWorkspaceFile, +} from "./project-workspace.ts"; +import { + compileWorkspaceRuntime, + isUnsafeProjectWorkspacePath, + PROJECT_WORKSPACE_FILE_LANGUAGES, + PROJECT_WORKSPACE_FILE_ROLES, + reconcileProjectWorkspaceTasks, + validateProjectWorkspace, +} from "./project-workspace.ts"; +import { projectPresetIds } from "./presets.ts"; +import { validateWorkspaceSandboxRun } from "./workspace-sandbox.ts"; + +export const WORKSPACE_AI_OPERATION_LIMIT = 24; +export const WORKSPACE_AI_FILE_LIMIT = 64; +export const WORKSPACE_AI_FILE_BYTES_LIMIT = 512_000; +export const WORKSPACE_AI_TOTAL_BYTES_LIMIT = 1_500_000; +export const WORKSPACE_AI_PROMPT_LIMIT = 8_000; +export const WORKSPACE_AI_PACKAGE_LIMIT = 6; +export const WORKSPACE_AI_DEPENDENCY_LIMIT = 24; +export const WORKSPACE_AI_TASK_LIMIT = 16; + +const REQUIRED_WORKSPACE_FILES = new Set([ + "index.html", + "src/styles.css", + "src/app.js", + "project.json", + "drops.config.json", + "package.json", + "server.mjs", + "scripts/check.mjs", + "tests/smoke.mjs", + "README.md", +]); + +const BLOCKED_LIFECYCLE_SCRIPTS = new Set([ + "preinstall", + "install", + "postinstall", + "prepare", + "prepublish", + "prepublishonly", + "prepack", + "postpack", + "publish", + "postpublish", +]); + +const BLOCKED_EXECUTABLE_EXTENSIONS = [ + ".bash", + ".bat", + ".cmd", + ".fish", + ".ps1", + ".sh", + ".zsh", +]; + +const CODE_FILE_EXTENSIONS = [ + ".cjs", + ".html", + ".js", + ".jsx", + ".mjs", + ".ts", + ".tsx", +]; + +const UNSAFE_EXECUTABLE_PATTERNS: Array<[RegExp, string]> = [ + [/\beval\s*\(/i, "eval is an executable escape hatch"], + [/\bnew\s+Function\b/i, "new Function is an executable escape hatch"], + [/(?:node:)?child_process/i, "child_process is not available to generated files"], + [/\bprocess\s*\.\s*binding\s*\(/i, "process.binding is an executable escape hatch"], + [/\bBun\s*\.\s*(?:spawn|spawnSync)\s*\(/i, "Bun process spawning is not available"], + [/\bDeno\s*\.\s*(?:Command|run)\b/i, "Deno process spawning is not available"], +]; + +const PACKAGE_NAME = + /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const EXACT_REGISTRY_VERSION = + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const PACKAGE_WORKSPACE_PATH = /^packages\/[a-z0-9][a-z0-9._-]{0,63}$/; +const PACKAGE_SCRIPT_NAME = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,47}$/; +const AI_CREATED_PACKAGE_SCRIPTS = new Set([ + "start", + "build", + "test", + "check", + "lint", + "typecheck", +]); + +const fileRoleSchema = z.enum(PROJECT_WORKSPACE_FILE_ROLES); +const fileLanguageSchema = z.enum(PROJECT_WORKSPACE_FILE_LANGUAGES); + +const createOperationSchema = z + .object({ + type: z.literal("create"), + path: z.string().min(1).max(160), + content: z.string(), + language: fileLanguageSchema, + role: fileRoleSchema, + }) + .strict(); + +const updateOperationSchema = z + .object({ + type: z.literal("update"), + path: z.string().min(1).max(160), + content: z.string(), + }) + .strict(); + +const deleteOperationSchema = z + .object({ + type: z.literal("delete"), + path: z.string().min(1).max(160), + }) + .strict(); + +export const workspaceAiPatchSchema = z + .object({ + baseRevision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), + summary: z.string().trim().min(8).max(240), + operations: z + .array( + z.discriminatedUnion("type", [ + createOperationSchema, + updateOperationSchema, + deleteOperationSchema, + ]), + ) + .min(1) + .max( + WORKSPACE_AI_OPERATION_LIMIT, + `At most ${WORKSPACE_AI_OPERATION_LIMIT} file operations are allowed per patch.`, + ), + }) + .strict() + .superRefine((value, context) => { + const seen = new Set(); + for (const operation of value.operations) { + if (seen.has(operation.path)) { + context.addIssue({ + code: "custom", + message: `${operation.path} cannot be changed more than once in one patch.`, + path: ["operations"], + }); + } + seen.add(operation.path); + } + }); + +export type WorkspaceAiPatch = z.infer; + +const requestWorkspaceFileSchema = z + .object({ + path: z.string().min(1).max(160), + content: z.string(), + language: fileLanguageSchema, + role: fileRoleSchema, + editable: z.boolean(), + }) + .strict(); + +const requestWorkspaceSchema = z + .object({ + schemaVersion: z.literal(1), + revision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), + updatedAt: z.string().min(1).max(64), + files: z.array(requestWorkspaceFileSchema).max(WORKSPACE_AI_FILE_LIMIT), + tasks: z + .array( + z + .object({ + id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/), + label: z.string().min(1).max(80), + command: z.literal("npm"), + args: z.array(z.string().min(1).max(80)).min(1).max(4), + cwd: z + .string() + .regex(/^packages\/[a-z0-9][a-z0-9._-]{0,63}$/) + .optional(), + port: z.number().int().min(1).max(65_535).optional(), + }) + .strict(), + ) + .min(1) + .max(WORKSPACE_AI_TASK_LIMIT), + runtime: z + .object({ + executionMode: z.literal("static-preview"), + provider: z.literal("unconfigured"), + isolation: z.literal("browser-iframe"), + runtime: z.literal("node24"), + packageManager: z.literal("npm"), + installScripts: z.literal(false), + }) + .strict(), + }) + .strict(); + +export const workspaceAiPatchRequestSchema = z + .object({ + prompt: z.string().trim().min(3).max(WORKSPACE_AI_PROMPT_LIMIT), + baseRevision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), + workspace: requestWorkspaceSchema, + provider: z + .enum(["platform", "openrouter", "openai", "anthropic", "kimi"]) + .default("platform"), + model: z + .string() + .regex(/^[A-Za-z0-9._:/-]{1,160}$/) + .optional(), + }) + .strict(); + +export type WorkspaceAiPatchRequest = z.infer< + typeof workspaceAiPatchRequestSchema +>; + +export const workspaceAiPatchJsonSchema = z.toJSONSchema( + workspaceAiPatchSchema, + { target: "draft-7" }, +) as unknown as Record & { + type: "object"; + additionalProperties: false; + properties: { + operations: { maxItems: number }; + }; + required: string[]; +}; + +export class WorkspaceAiPatchValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkspaceAiPatchValidationError"; + } +} + +export class WorkspaceAiPatchConflictError extends Error { + readonly expectedRevision: number; + readonly receivedRevision: number; + + constructor(expectedRevision: number, receivedRevision: number) { + super( + `Workspace revision conflict: expected ${expectedRevision}, received ${receivedRevision}. Refresh the workspace before retrying.`, + ); + this.name = "WorkspaceAiPatchConflictError"; + this.expectedRevision = expectedRevision; + this.receivedRevision = receivedRevision; + } +} + +function bytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function validationError(message: string): never { + throw new WorkspaceAiPatchValidationError(message); +} + +function zodMessage(error: z.ZodError): string { + const issue = error.issues[0]; + return issue?.message ?? "The model returned an invalid workspace patch."; +} + +export function parseWorkspaceAiPatch(value: unknown): WorkspaceAiPatch { + const parsed = workspaceAiPatchSchema.safeParse(value); + if (!parsed.success) { + throw new WorkspaceAiPatchValidationError( + `Invalid workspace AI patch: ${zodMessage(parsed.error)}`, + ); + } + return parsed.data; +} + +export function parseWorkspaceAiPatchRequest( + value: unknown, +): WorkspaceAiPatchRequest { + const parsed = workspaceAiPatchRequestSchema.safeParse(value); + if (!parsed.success) { + throw new WorkspaceAiPatchValidationError( + `Invalid workspace AI request: ${zodMessage(parsed.error)}`, + ); + } + if (parsed.data.baseRevision !== parsed.data.workspace.revision) { + throw new WorkspaceAiPatchConflictError( + parsed.data.workspace.revision, + parsed.data.baseRevision, + ); + } + validateFileGraph(parsed.data.workspace.files); + return parsed.data; +} + +function assertSafePath(path: string): void { + const lower = path.toLowerCase(); + if ( + isUnsafeProjectWorkspacePath(path) || + BLOCKED_EXECUTABLE_EXTENSIONS.some((extension) => + lower.endsWith(extension), + ) + ) { + validationError(`${path || "A file"} has an unsafe workspace path.`); + } +} + +function assertSafeContent( + path: string, + content: string, + checkExecutable = false, +): void { + const byteLength = bytes(content); + if (byteLength > WORKSPACE_AI_FILE_BYTES_LIMIT) { + validationError( + `${path} exceeds the ${WORKSPACE_AI_FILE_BYTES_LIMIT} byte per-file limit.`, + ); + } + const findings = findArtifactSecrets(content, path); + if (findings.length) throw new ArtifactSecretError(findings); + + const lower = path.toLowerCase(); + if ( + !checkExecutable || + !CODE_FILE_EXTENSIONS.some((extension) => lower.endsWith(extension)) + ) { + return; + } + for (const [pattern, reason] of UNSAFE_EXECUTABLE_PATTERNS) { + if (pattern.test(content)) { + validationError(`${path} is unsafe: ${reason}.`); + } + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function parseManifest(content: string): Record { + try { + const parsed = JSON.parse(content) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + validationError("package.json must contain one JSON object."); + } + return parsed as Record; + } catch (error) { + if (error instanceof WorkspaceAiPatchValidationError) throw error; + return validationError("package.json must contain valid JSON."); + } +} + +function dependencyEntries( + manifest: Record, + path: string, +): Array<[string, unknown]> { + const sections = ["dependencies", "devDependencies"] as const; + return sections.flatMap((section) => { + const value = manifest[section] ?? {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + validationError(`${path} ${section} must be an object.`); + } + return Object.entries(value as Record); + }); +} + +function packageManifestCwd(path: string): string | null { + if (path === "package.json") return "."; + const match = /^(packages\/[a-z0-9][a-z0-9._-]{0,63})\/package\.json$/.exec( + path, + ); + return match?.[1] ?? null; +} + +interface ValidatedManifest { + manifest: Record; + dependencyCount: number; +} + +function validateManifest( + nextContent: string, + path: string, + previousContent?: string, + enforceCreatedScriptAllowlist = false, +): ValidatedManifest { + const next = parseManifest(nextContent); + if (next.private !== true) { + validationError(`${path} must remain private.`); + } + if (path === "package.json" && next.type !== "module") { + validationError('package.json must keep type "module".'); + } + if (path !== "package.json" && next.workspaces !== undefined) { + validationError(`${path} cannot declare nested npm workspaces.`); + } + for (const field of [ + "overrides", + "resolutions", + "pnpm", + "publishConfig", + ]) { + if (next[field] !== undefined) { + validationError( + `${path} ${field} is blocked from the bounded package contract.`, + ); + } + } + const config = next.config; + if ( + config && + typeof config === "object" && + !Array.isArray(config) && + "registry" in config + ) { + validationError(`${path} cannot declare a custom npm registry.`); + } + for (const field of [ + "optionalDependencies", + "bundledDependencies", + "bundleDependencies", + ]) { + const value = next[field]; + if ( + value && + typeof value === "object" && + Object.keys(value as object).length > 0 + ) { + validationError(`${path} ${field} is blocked from installation.`); + } + } + const nextScripts = next.scripts; + if (nextScripts === undefined && path === "package.json") { + validationError("package.json must keep its declared scripts."); + } + if ( + nextScripts !== undefined && + (!nextScripts || + typeof nextScripts !== "object" || + Array.isArray(nextScripts)) + ) { + validationError(`${path} scripts must be an object.`); + } + for (const [name, command] of Object.entries( + (nextScripts ?? {}) as Record, + )) { + if (BLOCKED_LIFECYCLE_SCRIPTS.has(name.toLowerCase())) { + validationError(`npm lifecycle scripts are blocked in ${path} (${name}).`); + } + if (!PACKAGE_SCRIPT_NAME.test(name)) { + validationError(`${path} script names must be bounded and alphanumeric.`); + } + if ( + typeof command !== "string" || + !command.trim() || + command.length > 500 + ) { + validationError(`${path} script ${name} must be a bounded command string.`); + } + if (enforceCreatedScriptAllowlist && !AI_CREATED_PACKAGE_SCRIPTS.has(name)) { + validationError( + `${path} AI-created scripts must use the start, build, test, check, lint or typecheck allowlist.`, + ); + } + } + if (previousContent) { + const previous = parseManifest(previousContent); + if (stableJson(previous.scripts) !== stableJson(nextScripts)) { + validationError( + `${path} scripts cannot be changed by an AI workspace patch.`, + ); + } + } + + const dependencies = dependencyEntries(next, path); + for (const [name, version] of dependencies) { + if ( + !PACKAGE_NAME.test(name) || + typeof version !== "string" || + !EXACT_REGISTRY_VERSION.test(version) + ) { + validationError( + `${path} dependency ${name} must use an exact registry version, not a range, URL, file, git or workspace spec.`, + ); + } + } + return { manifest: next, dependencyCount: dependencies.length }; +} + +function validateWorkspaceDeclarations( + root: Record, + filePaths: Set, +): void { + if (root.workspaces === undefined) return; + if (!Array.isArray(root.workspaces)) { + validationError( + "package.json workspaces must be an array of explicit package directories.", + ); + } + if (root.workspaces.length > WORKSPACE_AI_PACKAGE_LIMIT) { + validationError( + `A workspace may declare at most ${WORKSPACE_AI_PACKAGE_LIMIT} package directories.`, + ); + } + const seen = new Set(); + for (const value of root.workspaces) { + if (typeof value !== "string" || !PACKAGE_WORKSPACE_PATH.test(value)) { + validationError( + "package.json workspaces must use explicit packages/ directories without globs, URLs or traversal.", + ); + } + if (seen.has(value)) { + validationError(`package.json workspace ${value} appears more than once.`); + } + seen.add(value); + if (!filePaths.has(`${value}/package.json`)) { + validationError( + `${value}/package.json is required by the root workspace declaration.`, + ); + } + } +} + +function validateFileGraph( + files: Array>, + previousManifests?: Map, +): void { + if (files.length > WORKSPACE_AI_FILE_LIMIT) { + validationError( + `A workspace may contain at most ${WORKSPACE_AI_FILE_LIMIT} files.`, + ); + } + const seen = new Set(); + let totalBytes = 0; + for (const item of files) { + assertSafePath(item.path); + if (seen.has(item.path)) { + validationError(`${item.path} appears more than once.`); + } + seen.add(item.path); + assertSafeContent(item.path, item.content); + totalBytes += bytes(item.content); + } + if (totalBytes > WORKSPACE_AI_TOTAL_BYTES_LIMIT) { + validationError( + `Workspace source exceeds the ${WORKSPACE_AI_TOTAL_BYTES_LIMIT} byte total limit.`, + ); + } + const manifestFiles = files + .filter((item) => + item.path === "package.json" || item.path.endsWith("/package.json") + ) + .sort((left, right) => left.path.localeCompare(right.path)); + let root: Record | null = null; + let dependencyCount = 0; + for (const manifestFile of manifestFiles) { + if (!packageManifestCwd(manifestFile.path)) { + validationError( + `${manifestFile.path} is not allowed; package manifests must use packages//package.json.`, + ); + } + const validated = validateManifest( + manifestFile.content, + manifestFile.path, + previousManifests?.get(manifestFile.path), + previousManifests !== undefined && + !previousManifests.has(manifestFile.path), + ); + dependencyCount += validated.dependencyCount; + if (manifestFile.path === "package.json") root = validated.manifest; + } + if (dependencyCount > WORKSPACE_AI_DEPENDENCY_LIMIT) { + validationError( + `A canonical AI workspace may declare at most ${WORKSPACE_AI_DEPENDENCY_LIMIT} npm dependencies across all package manifests.`, + ); + } + if (root) { + validateWorkspaceDeclarations(root, new Set(files.map((item) => item.path))); + } +} + +export interface ApplyWorkspaceAiPatchOptions { + now?: () => Date; +} + +export interface AppliedWorkspaceAiPatch { + workspace: ProjectWorkspace; + patch: WorkspaceAiPatch; + appliedOperations: { + created: number; + updated: number; + deleted: number; + }; +} + +export interface RunnableWorkspaceAiRevision { + spec: GeneratedProjectSpec; + runtimeHtml: string; +} + +function workspaceProjectSpec(workspace: ProjectWorkspace): GeneratedProjectSpec { + const source = workspace.files.find((item) => item.path === "project.json") + ?.content; + if (!source) { + validationError("project.json is required for canonical workspace validation."); + } + let value: unknown; + try { + value = JSON.parse(source); + } catch { + return validationError("project.json must contain valid JSON."); + } + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + (value as Record).schemaVersion !== 1 || + typeof (value as Record).presetId !== "string" || + !projectPresetIds.includes( + (value as Record).presetId as (typeof projectPresetIds)[number], + ) + ) { + validationError( + "project.json must contain a supported generated project specification.", + ); + } + return value as GeneratedProjectSpec; +} + +/** + * Final release gate for a model patch. The route calls this only after the + * atomic patch is assembled, so no partially valid revision can be returned. + */ +export function assertRunnableWorkspaceAiRevision( + workspace: ProjectWorkspace, +): RunnableWorkspaceAiRevision { + validateFileGraph(workspace.files); + const spec = workspaceProjectSpec(workspace); + const validation = validateProjectWorkspace(spec, workspace); + if (!validation.valid) { + validationError( + validation.issues[0] ?? "Canonical workspace validation failed.", + ); + } + validateWorkspaceSandboxRun({ + workspaceId: "workspace-ai-validation", + workspace, + taskId: workspace.tasks[0]?.id ?? "", + }); + try { + return { spec, runtimeHtml: compileWorkspaceRuntime(spec, workspace) }; + } catch (error) { + if (error instanceof WorkspaceAiPatchValidationError) throw error; + validationError( + error instanceof Error + ? error.message + : "Canonical workspace compilation failed.", + ); + } +} + +export function applyWorkspaceAiPatch( + workspace: ProjectWorkspace, + baseRevision: number, + rawPatch: unknown, + options: ApplyWorkspaceAiPatchOptions = {}, +): AppliedWorkspaceAiPatch { + if (!workspace || !Array.isArray(workspace.files)) { + validationError("A valid editable workspace is required."); + } + if (baseRevision !== workspace.revision) { + throw new WorkspaceAiPatchConflictError(workspace.revision, baseRevision); + } + const patch = parseWorkspaceAiPatch(rawPatch); + if (patch.baseRevision !== workspace.revision) { + throw new WorkspaceAiPatchConflictError( + workspace.revision, + patch.baseRevision, + ); + } + + const previousManifests = new Map( + workspace.files + .filter((item) => + item.path === "package.json" || item.path.endsWith("/package.json") + ) + .map((item) => [item.path, item.content]), + ); + validateFileGraph(workspace.files); + + const nextFiles = workspace.files.map((item) => ({ ...item })); + const appliedOperations = { created: 0, updated: 0, deleted: 0 }; + + for (const operation of patch.operations) { + assertSafePath(operation.path); + const index = nextFiles.findIndex((item) => item.path === operation.path); + + if (operation.type === "create") { + if (index >= 0) { + validationError(`${operation.path} already exists in this workspace.`); + } + assertSafeContent(operation.path, operation.content, true); + nextFiles.push({ + path: operation.path, + content: operation.content, + language: operation.language, + role: operation.role, + editable: true, + }); + appliedOperations.created += 1; + continue; + } + + if (index < 0) { + validationError(`${operation.path} is not part of this workspace.`); + } + const existing = nextFiles[index]; + if (!existing.editable) { + validationError(`${operation.path} is read-only.`); + } + + if (operation.type === "update") { + assertSafeContent(operation.path, operation.content, true); + nextFiles[index] = { ...existing, content: operation.content }; + appliedOperations.updated += 1; + continue; + } + + if (REQUIRED_WORKSPACE_FILES.has(operation.path)) { + validationError( + `${operation.path} is a required workspace file and cannot be deleted.`, + ); + } + nextFiles.splice(index, 1); + appliedOperations.deleted += 1; + } + + validateFileGraph(nextFiles, previousManifests); + const now = options.now?.() ?? new Date(); + const nextWorkspace = reconcileProjectWorkspaceTasks({ + ...workspace, + revision: workspace.revision + 1, + updatedAt: now.toISOString(), + files: nextFiles, + }); + + return { workspace: nextWorkspace, patch, appliedOperations }; +} diff --git a/lib/workspace-ai-provider.ts b/lib/workspace-ai-provider.ts new file mode 100644 index 0000000..162513d --- /dev/null +++ b/lib/workspace-ai-provider.ts @@ -0,0 +1,469 @@ +import { createGateway, generateText, Output } from "ai"; +import { + parseWorkspaceAiPatch, + workspaceAiPatchJsonSchema, + workspaceAiPatchSchema, + WorkspaceAiPatchValidationError, + type WorkspaceAiPatch, + type WorkspaceAiPatchRequest, +} from "./workspace-ai-patch.ts"; + +export const PLATFORM_WORKSPACE_MODELS = [ + "openai/gpt-5.6-sol", + "inclusionai/ling-3.0-flash-free", +] as const; + +export const DEFAULT_WORKSPACE_BYOK_MODELS = { + openrouter: "openrouter/free", + openai: "gpt-5.6-sol", + anthropic: "claude-sonnet-5", + kimi: "kimi-k3", +} as const; + +const MAX_OUTPUT_TOKENS = 24_000; +const PROVIDER_TIMEOUT_MS = 45_000; +const MAX_CREDENTIAL_LENGTH = 4_096; +const UNSUPPORTED_STRICT_SCHEMA_KEYWORDS = new Set([ + "minItems", + "maxItems", + "minLength", + "maxLength", + "pattern", +]); + +function providerCompatibleSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map(providerCompatibleSchema); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !UNSUPPORTED_STRICT_SCHEMA_KEYWORDS.has(key)) + .map(([key, entry]) => [key, providerCompatibleSchema(entry)]), + ); +} + +export const workspaceAiProviderJsonSchema = providerCompatibleSchema( + workspaceAiPatchJsonSchema, +) as typeof workspaceAiPatchJsonSchema; + +const SYSTEM_PROMPT = `You are the AI workspace engineer inside Drops Studio. Return one bounded multi-file patch that turns the existing source workspace into the distinct crypto product requested by the user. + +The response must match the supplied JSON Schema exactly. Use only create, update and delete file operations. Keep baseRevision exactly equal to the supplied workspace revision. Make the smallest coherent multi-file change that produces an editable, runnable product rather than a renamed card mockup. + +Security boundaries are immutable: never include credentials, API keys, bearer tokens, shell commands, lockfiles, dot-env files, package lifecycle scripts, eval, dynamic Function construction, child processes, executable expressions or host filesystem access. You may add registry dependencies with explicit semver versions, but you must not change package.json scripts. Do not claim provider automation or execution unless the existing workspace already contains verified provider evidence. Return no markdown or prose outside the schema.`; + +type WorkspaceProvider = WorkspaceAiPatchRequest["provider"]; +type DirectProvider = Exclude; + +export type WorkspaceAiSchemaEnforcement = + | "ai-sdk-output-object" + | "provider-json-schema" + | "forced-tool-schema" + | "application-validated-json"; + +export interface WorkspaceAiProviderEvidence { + status: "provider-response"; + provider: + | "vercel-ai-gateway" + | "openrouter" + | "openai" + | "anthropic" + | "kimi"; + model: string; + requestedModel: string | null; + providerRequestId: string | null; + credentialOwner: "platform" | "visitor"; + keyPersisted: false; + billing: "platform-funded" | "provider-direct-no-studio-markup"; + schemaEnforcement: WorkspaceAiSchemaEnforcement; + generatedAt: string; +} + +export interface GeneratedWorkspaceAiPatch { + patch: WorkspaceAiPatch; + evidence: WorkspaceAiProviderEvidence; +} + +export interface WorkspaceAiProviderCredentials { + identity?: string; + openRouterKey?: string; + providerKey?: string; +} + +interface PlatformGenerateInput { + model: (typeof PLATFORM_WORKSPACE_MODELS)[number]; + schema: typeof workspaceAiPatchJsonSchema; + system: string; + prompt: string; + identity: string; + gatewayToken: string; +} + +interface PlatformGenerateResult { + output: unknown; + providerRequestId?: string | null; +} + +export interface WorkspaceAiProviderDependencies { + env?: Partial>; + fetch?: typeof globalThis.fetch; + now?: () => Date; + platformGenerate?: ( + input: PlatformGenerateInput, + ) => Promise; +} + +export class WorkspaceAiProviderUnavailableError extends Error { + constructor(message = "The selected AI workspace provider is not configured.") { + super(message); + this.name = "WorkspaceAiProviderUnavailableError"; + } +} + +export class WorkspaceAiProviderResponseError extends Error { + constructor(message = "The AI provider did not return a valid workspace patch.") { + super(message); + this.name = "WorkspaceAiProviderResponseError"; + } +} + +function generationPrompt(request: WorkspaceAiPatchRequest): string { + return JSON.stringify({ + request: request.prompt, + baseRevision: request.baseRevision, + workspace: { + schemaVersion: request.workspace.schemaVersion, + revision: request.workspace.revision, + files: request.workspace.files, + }, + outputSchema: workspaceAiProviderJsonSchema, + }); +} + +function providerRequestId(value: unknown): string | null { + return typeof value === "string" && value.length > 0 && value.length <= 240 + ? value + : null; +} + +function evidence( + request: WorkspaceAiPatchRequest, + input: { + provider: WorkspaceAiProviderEvidence["provider"]; + model: string; + requestId?: unknown; + credentialOwner: WorkspaceAiProviderEvidence["credentialOwner"]; + billing: WorkspaceAiProviderEvidence["billing"]; + schemaEnforcement: WorkspaceAiSchemaEnforcement; + }, + now: () => Date, +): WorkspaceAiProviderEvidence { + return { + status: "provider-response", + provider: input.provider, + model: input.model, + requestedModel: request.model ?? null, + providerRequestId: providerRequestId(input.requestId), + credentialOwner: input.credentialOwner, + keyPersisted: false, + billing: input.billing, + schemaEnforcement: input.schemaEnforcement, + generatedAt: now().toISOString(), + }; +} + +function credential(value: string | undefined): string | null { + const normalized = value?.trim() ?? ""; + if ( + normalized.length < 8 || + normalized.length > MAX_CREDENTIAL_LENGTH || + /[\r\n]/.test(normalized) + ) { + return null; + } + return normalized; +} + +function timeoutSignal(): AbortSignal { + return AbortSignal.timeout(PROVIDER_TIMEOUT_MS); +} + +async function defaultPlatformGenerate( + input: PlatformGenerateInput, +): Promise { + const gateway = createGateway({ apiKey: input.gatewayToken }); + const result = await generateText({ + model: gateway(input.model), + output: Output.object({ + schema: workspaceAiPatchSchema, + name: "drops_workspace_patch", + description: + "A bounded atomic patch for an editable Drops Studio source workspace.", + }), + maxOutputTokens: MAX_OUTPUT_TOKENS, + maxRetries: 0, + system: input.system, + prompt: input.prompt, + abortSignal: AbortSignal.timeout(18_000), + providerOptions: { + gateway: { + user: input.identity, + tags: ["feature:workspace-patch", "surface:drops-studio"], + }, + }, + }); + const metadata = result as unknown as { + response?: { id?: unknown }; + providerMetadata?: { gateway?: { requestId?: unknown } }; + }; + return { + output: result.output, + providerRequestId: providerRequestId( + metadata.response?.id ?? metadata.providerMetadata?.gateway?.requestId, + ), + }; +} + +async function generatePlatformPatch( + request: WorkspaceAiPatchRequest, + credentials: WorkspaceAiProviderCredentials, + dependencies: WorkspaceAiProviderDependencies, +): Promise { + const env = dependencies.env ?? process.env; + const gatewayToken = credential( + env.AI_GATEWAY_API_KEY || env.VERCEL_OIDC_TOKEN, + ); + if (!gatewayToken) { + throw new WorkspaceAiProviderUnavailableError( + "Platform AI workspace generation is not configured.", + ); + } + const identity = credentials.identity?.trim() || "anonymous-workspace"; + const generate = dependencies.platformGenerate ?? defaultPlatformGenerate; + const prompt = generationPrompt(request); + + for (const model of PLATFORM_WORKSPACE_MODELS) { + try { + const result = await generate({ + model, + schema: workspaceAiPatchJsonSchema, + system: SYSTEM_PROMPT, + prompt, + identity, + gatewayToken, + }); + const patch = parseWorkspaceAiPatch(result.output); + return { + patch, + evidence: evidence( + request, + { + provider: "vercel-ai-gateway", + model, + requestId: result.providerRequestId, + credentialOwner: "platform", + billing: "platform-funded", + schemaEnforcement: "ai-sdk-output-object", + }, + dependencies.now ?? (() => new Date()), + ), + }; + } catch { + // The fixed fallback order is intentional. No provider error text or token + // is exposed to the caller, and no local/mock patch is substituted. + } + } + throw new WorkspaceAiProviderResponseError( + "Platform AI could not return a valid workspace patch.", + ); +} + +interface JsonProviderResponse { + id?: unknown; + choices?: Array<{ message?: { content?: unknown } }>; + content?: Array<{ + type?: unknown; + name?: unknown; + input?: unknown; + }>; +} + +async function providerJson( + response: Response, + provider: DirectProvider, +): Promise { + const payload = (await response.json().catch(() => null)) as unknown; + if (!response.ok || !payload || typeof payload !== "object") { + throw new WorkspaceAiProviderResponseError( + `${provider} could not complete workspace generation.`, + ); + } + return payload as JsonProviderResponse; +} + +function jsonContent(payload: JsonProviderResponse): unknown { + const content = payload.choices?.[0]?.message?.content; + if (typeof content !== "string") { + throw new WorkspaceAiProviderResponseError(); + } + try { + return JSON.parse(content) as unknown; + } catch { + throw new WorkspaceAiProviderResponseError(); + } +} + +function structuredResponseFormat() { + return { + type: "json_schema", + json_schema: { + name: "drops_workspace_patch", + strict: true, + schema: workspaceAiProviderJsonSchema, + }, + } as const; +} + +async function generateDirectPatch( + request: WorkspaceAiPatchRequest, + credentials: WorkspaceAiProviderCredentials, + dependencies: WorkspaceAiProviderDependencies, +): Promise { + const provider = request.provider as DirectProvider; + const suppliedKey = + provider === "openrouter" + ? credential(credentials.openRouterKey) + : credential(credentials.providerKey); + if (!suppliedKey) { + throw new WorkspaceAiProviderUnavailableError( + `Connect ${provider} with a request-only API key before generating workspace files.`, + ); + } + + const model = request.model ?? DEFAULT_WORKSPACE_BYOK_MODELS[provider]; + const fetchProvider = dependencies.fetch ?? globalThis.fetch; + const prompt = generationPrompt(request); + let endpoint: string; + let body: Record; + let headers: Record; + let schemaEnforcement: WorkspaceAiSchemaEnforcement; + + if (provider === "anthropic") { + endpoint = "https://api.anthropic.com/v1/messages"; + headers = { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-api-key": suppliedKey, + }; + schemaEnforcement = "forced-tool-schema"; + body = { + model, + max_tokens: MAX_OUTPUT_TOKENS, + temperature: 0.15, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: prompt }], + tools: [ + { + name: "submit_workspace_patch", + description: + "Return the validated file operations. This is data-only and is never executed as a tool.", + input_schema: workspaceAiProviderJsonSchema, + }, + ], + tool_choice: { type: "tool", name: "submit_workspace_patch" }, + }; + } else { + endpoint = + provider === "openrouter" + ? "https://openrouter.ai/api/v1/chat/completions" + : provider === "kimi" + ? "https://api.moonshot.ai/v1/chat/completions" + : "https://api.openai.com/v1/chat/completions"; + headers = { + authorization: `Bearer ${suppliedKey}`, + "content-type": "application/json", + }; + schemaEnforcement = + provider === "kimi" + ? "application-validated-json" + : "provider-json-schema"; + body = { + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: prompt }, + ], + ...(provider === "openai" && /^gpt-5(?:\.|-|$)/i.test(model) + ? { max_completion_tokens: MAX_OUTPUT_TOKENS } + : { max_tokens: MAX_OUTPUT_TOKENS, temperature: 0.15 }), + ...(provider === "kimi" + ? { response_format: { type: "json_object" } } + : { response_format: structuredResponseFormat() }), + ...(provider === "openrouter" + ? { provider: { require_parameters: true } } + : {}), + }; + } + + let payload: JsonProviderResponse; + try { + const response = await fetchProvider(endpoint, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: timeoutSignal(), + }); + payload = await providerJson(response, provider); + } catch (error) { + if (error instanceof WorkspaceAiProviderResponseError) throw error; + throw new WorkspaceAiProviderResponseError( + `${provider} could not complete workspace generation.`, + ); + } + + let rawPatch: unknown; + if (provider === "anthropic") { + const toolUse = payload.content?.find( + (part) => + part.type === "tool_use" && part.name === "submit_workspace_patch", + ); + rawPatch = toolUse?.input; + } else { + rawPatch = jsonContent(payload); + } + + let patch: WorkspaceAiPatch; + try { + patch = parseWorkspaceAiPatch(rawPatch); + } catch (error) { + if (error instanceof WorkspaceAiPatchValidationError) { + throw new WorkspaceAiProviderResponseError(); + } + throw error; + } + + return { + patch, + evidence: evidence( + request, + { + provider, + model, + requestId: payload.id, + credentialOwner: "visitor", + billing: "provider-direct-no-studio-markup", + schemaEnforcement, + }, + dependencies.now ?? (() => new Date()), + ), + }; +} + +export async function generateWorkspaceAiPatch( + request: WorkspaceAiPatchRequest, + credentials: WorkspaceAiProviderCredentials = {}, + dependencies: WorkspaceAiProviderDependencies = {}, +): Promise { + return request.provider === "platform" + ? generatePlatformPatch(request, credentials, dependencies) + : generateDirectPatch(request, credentials, dependencies); +} diff --git a/lib/workspace-run-digest.ts b/lib/workspace-run-digest.ts new file mode 100644 index 0000000..5055726 --- /dev/null +++ b/lib/workspace-run-digest.ts @@ -0,0 +1,84 @@ +export interface WorkspaceRunDigestFile { + path: string; + content: string; +} + +export interface WorkspaceRunDigestTask { + id: string; + argv: readonly string[]; + cwd?: string; + timeoutMs?: number; + previewPort?: number; +} + +export interface WorkspaceRunDigestInput { + files: readonly WorkspaceRunDigestFile[]; + task: WorkspaceRunDigestTask; +} + +function compareCodePoints(left: string, right: string): number { + const leftPoints = Array.from(left, (character) => + character.codePointAt(0) ?? 0, + ); + const rightPoints = Array.from(right, (character) => + character.codePointAt(0) ?? 0, + ); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index += 1) { + if (leftPoints[index] !== rightPoints[index]) { + return leftPoints[index] - rightPoints[index]; + } + } + return leftPoints.length - rightPoints.length; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + } + return `{${Object.entries(value as Record) + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(",")}}`; +} + +function normalizedDigestPayload(input: WorkspaceRunDigestInput): string { + const files = input.files + .map((file) => ({ path: file.path.trim(), content: file.content })) + .sort((left, right) => compareCodePoints(left.path, right.path)); + const manifestFile = files.find((file) => file.path === "package.json"); + if (!manifestFile) { + throw new Error("Workspace run digest requires package.json."); + } + let manifest: unknown; + try { + manifest = JSON.parse(manifestFile.content) as unknown; + } catch { + throw new Error("Workspace run digest requires valid package.json."); + } + return canonicalJson({ + schemaVersion: 1, + files, + manifest, + task: { + id: input.task.id.trim(), + argv: [...input.task.argv], + cwd: input.task.cwd?.trim() || ".", + timeoutMs: input.task.timeoutMs ?? 15_000, + previewPort: input.task.previewPort ?? null, + }, + }); +} + +export async function createWorkspaceRunDigest( + input: WorkspaceRunDigestInput, +): Promise { + const bytes = new TextEncoder().encode(normalizedDigestPayload(input)); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/lib/workspace-run-receipt.ts b/lib/workspace-run-receipt.ts new file mode 100644 index 0000000..03a6a87 --- /dev/null +++ b/lib/workspace-run-receipt.ts @@ -0,0 +1,64 @@ +export interface WorkspaceRunReceiptIdentity { + workspaceId: string; + workspaceRevision: number; + workspaceDigest: string; + task: string; +} + +export interface CurrentWorkspaceRunIdentity { + workspaceId: string; + workspaceRevision: number; + workspaceDigest: string | null; + task: string | null; +} + +export function workspaceRunReceiptMatchesRevision( + receipt: WorkspaceRunReceiptIdentity | null, + workspaceId: string, + workspaceRevision: number, +): boolean { + return Boolean( + receipt + && receipt.workspaceId === workspaceId + && Number.isSafeInteger(receipt.workspaceRevision) + && receipt.workspaceRevision === workspaceRevision, + ); +} + +export function workspaceRunReceiptMatchesWorkspace( + receipt: WorkspaceRunReceiptIdentity | null, + current: CurrentWorkspaceRunIdentity, +): boolean { + return Boolean( + receipt + && workspaceRunReceiptMatchesRevision( + receipt, + current.workspaceId, + current.workspaceRevision, + ) + && current.workspaceDigest + && /^[a-f0-9]{64}$/.test(receipt.workspaceDigest) + && receipt.workspaceDigest === current.workspaceDigest + && current.task + && receipt.task === current.task, + ); +} + +export type WorkspaceRunReceiptStatus = + | "none" + | "verified" + | "previous" + | "historical"; + +export function workspaceRunReceiptStatus( + receipt: WorkspaceRunReceiptIdentity | null, + current: CurrentWorkspaceRunIdentity, + currentAttempt: { running: boolean; error: string }, +): WorkspaceRunReceiptStatus { + if (!receipt) return "none"; + if (!workspaceRunReceiptMatchesWorkspace(receipt, current)) { + return "historical"; + } + if (currentAttempt.running || currentAttempt.error.trim()) return "previous"; + return "verified"; +} diff --git a/lib/workspace-sandbox.ts b/lib/workspace-sandbox.ts new file mode 100644 index 0000000..d80d01b --- /dev/null +++ b/lib/workspace-sandbox.ts @@ -0,0 +1,1059 @@ +import { findArtifactSecrets } from "./artifact-security.ts"; +import { createWorkspaceRunDigest } from "./workspace-run-digest.ts"; + +const MAX_FILES = 64; +const MAX_TASKS = 16; +const MAX_TOTAL_BYTES = 1_500_000; +const MAX_OUTPUT_BYTES = 64_000; +const MAX_DEPENDENCIES = 64; +const MAX_PACKAGE_WORKSPACES = 6; +const MAX_TASK_TIMEOUT_MS = 30_000; +const INSTALL_TIMEOUT_MS = 30_000; +const MAX_RUNTIME_ASSET_BYTES = 1_500_000; +const RUNTIME_ASSET_FETCH_TIMEOUT_MS = 5_000; +const WORKSPACE_ROOT = "/vercel/sandbox"; +const RUNTIME_ASSET_PATHS = [ + "brand/dropstab-mark.svg", + "brand/drops-bot-avatar.jpg", + "assets/market-catcher-retro.png", + "assets/market-wolf-catcher.png", +] as const; +const BLOCKED_WORKSPACE_FILES = new Set([ + ".npmrc", + ".yarnrc", + ".yarnrc.yml", + ".pnpmfile.cjs", + "bun.lock", + "bun.lockb", + "npm-shrinkwrap.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", +]); +const LIFECYCLE_SCRIPTS = new Set([ + "preinstall", + "install", + "postinstall", + "prepare", + "prepack", + "postpack", + "publish", + "postpublish", +]); +const BLOCKED_NODE_FLAGS = new Set([ + "-e", + "--eval", + "-p", + "--print", + "-r", + "--require", + "--import", +]); +const BLOCKED_NPM_COMMANDS = new Set([ + "add", + "ci", + "install", + "i", + "link", + "rebuild", + "remove", + "uninstall", + "update", +]); +const PACKAGE_WORKSPACE_PATH = /^packages\/[a-z0-9][a-z0-9._-]{0,63}$/; +const PACKAGE_SCRIPT_NAME = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,47}$/; + +export interface WorkspaceSandboxFile { + path: string; + content: string; +} + +export interface WorkspaceSandboxTask { + id: string; + argv?: string[]; + command?: string; + args?: string[]; + cwd?: string; + timeoutMs?: number; + previewPort?: number; + port?: number; +} + +export interface WorkspaceSandboxDefinition { + id?: string; + revision: number; + files: WorkspaceSandboxFile[]; + tasks: WorkspaceSandboxTask[]; +} + +export interface WorkspaceSandboxRunInput { + workspaceId?: string; + workspace: WorkspaceSandboxDefinition; + taskId: string; +} + +export interface NormalizedWorkspaceSandboxTask { + id: string; + argv: string[]; + cwd: string; + timeoutMs: number; + previewPort?: number; +} + +export interface ValidatedWorkspaceSandboxRun { + workspaceId: string; + revision: number; + files: WorkspaceSandboxFile[]; + task: NormalizedWorkspaceSandboxTask; + dependencies: Record; + workspaceDirectories: string[]; +} + +export interface WorkspaceSandboxProviderResult { + provider: "vercel-sandbox"; + isolation: "firecracker-microvm"; + providerRunId: string; + exitCode: number | null; + stdout: string; + stderr: string; + startedAt: string; + finishedAt: string; + previewUrl: string | null; +} + +export interface WorkspaceSandboxProvider { + execute( + input: ValidatedWorkspaceSandboxRun, + ): Promise; +} + +function configuredRuntimeAssetOrigin(): string | null { + const raw = + process.env.NEXT_PUBLIC_APP_URL?.trim() || + process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || + process.env.VERCEL_URL?.trim() || + ""; + if (!raw) return null; + try { + const url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); + if (url.protocol !== "https:" || url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} + +export async function loadWorkspaceRuntimeAssets( + files: WorkspaceSandboxFile[], + options: { + origin?: string | null; + fetch?: typeof fetch; + fetchTimeoutMs?: number; + } = {}, +): Promise> { + const source = files.map((file) => file.content).join("\n"); + const paths = RUNTIME_ASSET_PATHS.filter((path) => + source.includes(`/${path}`), + ); + if (!paths.length) return []; + const origin = options.origin ?? configuredRuntimeAssetOrigin(); + if (!origin) { + throw new WorkspaceSandboxUnavailableError( + "The deployment origin for preview assets is not configured.", + ); + } + let parsedOrigin: URL; + try { + parsedOrigin = new URL(origin); + if ( + parsedOrigin.protocol !== "https:" || + parsedOrigin.username || + parsedOrigin.password + ) { + throw new Error(); + } + } catch { + throw new WorkspaceSandboxUnavailableError( + "The deployment origin for preview assets is invalid.", + ); + } + const fetchAsset = options.fetch ?? fetch; + const requestedFetchTimeoutMs = + Number.isSafeInteger(options.fetchTimeoutMs) + && Number(options.fetchTimeoutMs) > 0 + ? Number(options.fetchTimeoutMs) + : RUNTIME_ASSET_FETCH_TIMEOUT_MS; + const fetchTimeoutMs = Math.min( + requestedFetchTimeoutMs, + RUNTIME_ASSET_FETCH_TIMEOUT_MS, + ); + return Promise.all( + paths.map(async (path) => { + const signal = AbortSignal.timeout(fetchTimeoutMs); + try { + const response = await fetchAsset(new URL(`/${path}`, parsedOrigin), { + cache: "no-store", + signal, + }); + if (!response.ok) { + throw new WorkspaceSandboxProviderError( + `Required preview asset ${path} is unavailable.`, + ); + } + const content = new Uint8Array(await response.arrayBuffer()); + if (!content.byteLength || content.byteLength > MAX_RUNTIME_ASSET_BYTES) { + throw new WorkspaceSandboxProviderError( + `Required preview asset ${path} has an invalid size.`, + ); + } + return { path, content }; + } catch (error) { + if (error instanceof WorkspaceSandboxProviderError) throw error; + throw new WorkspaceSandboxProviderError( + signal.aborted + ? `Required preview asset ${path} timed out.` + : `Required preview asset ${path} is unavailable.`, + ); + } + }), + ); +} + +export interface WorkspaceSandboxReceipt { + provider: "vercel-sandbox"; + isolation: "firecracker-microvm"; + providerRunId: string; + workspaceId: string; + workspaceRevision: number; + workspaceDigest: string; + task: string; + argv: string[]; + exitCode: number | null; + stdout: string; + stderr: string; + startedAt: string; + finishedAt: string; + previewUrl: string | null; +} + +function secretFreeMessage( + value: unknown, + fallback: string, +): string { + const message = value instanceof Error ? value.message : String(value || ""); + if (!message || findArtifactSecrets(message, "sandbox error").length) { + return fallback; + } + return message + .replace(/[\r\n\t]+/g, " ") + .replace(/[\u0000-\u001f\u007f]/g, "") + .trim() + .slice(0, 220) || fallback; +} + +export class WorkspaceSandboxValidationError extends Error { + constructor(message: string) { + super(secretFreeMessage(message, "Workspace sandbox input is invalid.")); + this.name = "WorkspaceSandboxValidationError"; + } +} + +export class WorkspaceSandboxUnavailableError extends Error { + constructor(message = "Workspace sandbox execution is not configured.") { + super( + secretFreeMessage( + message, + "Workspace sandbox execution is not configured.", + ), + ); + this.name = "WorkspaceSandboxUnavailableError"; + } +} + +export class WorkspaceSandboxProviderError extends Error { + constructor(message = "Workspace sandbox provider failed.") { + super(secretFreeMessage(message, "Workspace sandbox provider failed.")); + this.name = "WorkspaceSandboxProviderError"; + } +} + +function fail(message: string): never { + throw new WorkspaceSandboxValidationError(message); +} + +function object( + value: unknown, + label: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${label} must be an object.`); + } + return value as Record; +} + +function safeWorkspacePath(value: unknown, label: string): string { + if (typeof value !== "string") fail(`${label} must be a relative path.`); + const path = value.trim(); + if ( + !path || + path.length > 180 || + path.startsWith("/") || + path.includes("\\") || + path.includes("//") || + path.includes("\0") || + !/^[a-z0-9@._/-]+$/i.test(path) || + path.split("/").some((part) => !part || part === "." || part === "..") + ) { + fail(`${label} must stay inside the workspace.`); + } + const fileName = path.toLowerCase().split("/").at(-1) ?? ""; + if (BLOCKED_WORKSPACE_FILES.has(fileName)) { + fail(`${label} is blocked from sandbox execution.`); + } + return path; +} + +function safeWorkingDirectory(value: unknown): string { + if (value === undefined || value === null || value === "" || value === ".") { + return "."; + } + return safeWorkspacePath(value, "Task cwd"); +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function workspaceFiles(value: unknown): WorkspaceSandboxFile[] { + if (!Array.isArray(value) || value.length < 1 || value.length > MAX_FILES) { + fail(`Workspace must contain between 1 and ${MAX_FILES} files.`); + } + const seen = new Set(); + let totalBytes = 0; + return value.map((item, index) => { + const input = object(item, `Workspace file ${index + 1}`); + const path = safeWorkspacePath(input.path, `Workspace file ${index + 1} path`); + if (seen.has(path)) fail(`Workspace file path ${path} is duplicated.`); + seen.add(path); + if (typeof input.content !== "string") { + fail(`Workspace file ${path} must contain text.`); + } + totalBytes += byteLength(input.content); + if (totalBytes > MAX_TOTAL_BYTES) { + fail("Workspace files exceed the sandbox size limit."); + } + if (findArtifactSecrets(input.content, path).length) { + fail("Workspace files contain potential secret material."); + } + return { path, content: input.content }; + }); +} + +function packageName(value: string): boolean { + return /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/.test( + value, + ); +} + +function registryVersion(value: unknown): value is string { + return ( + typeof value === "string" && + /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i.test(value) + ); +} + +function dependencySet( + value: unknown, + label: string, +): Record { + if (value === undefined) return {}; + const input = object(value, label); + const entries = Object.entries(input); + if (entries.length > MAX_DEPENDENCIES) { + fail(`${label} exceeds the dependency limit.`); + } + const dependencies: Record = {}; + for (const [name, version] of entries) { + if (!packageName(name) || !registryVersion(version)) { + fail(`${label} must use exact npm registry versions.`); + } + dependencies[name] = version; + } + return dependencies; +} + +interface SandboxPackageContract { + dependencies: Record; + dependencyCount: number; + scriptsByCwd: Map>; + workspaceDirectories: string[]; +} + +function parsePackageManifest( + file: WorkspaceSandboxFile, +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(file.content) as unknown; + } catch { + fail(`${file.path} must contain valid JSON.`); + } + return object(parsed, file.path); +} + +function packageManifestCwd(path: string): string | null { + if (path === "package.json") return "."; + const match = /^(packages\/[a-z0-9][a-z0-9._-]{0,63})\/package\.json$/.exec( + path, + ); + return match?.[1] ?? null; +} + +function declaredWorkspaceDirectories( + root: Record, + filePaths: Set, +): string[] { + if (root.workspaces === undefined) return []; + if (!Array.isArray(root.workspaces)) { + fail("package.json workspaces must be an array of explicit package directories."); + } + if (root.workspaces.length > MAX_PACKAGE_WORKSPACES) { + fail(`A sandbox workspace may declare at most ${MAX_PACKAGE_WORKSPACES} packages.`); + } + const directories: string[] = []; + const seen = new Set(); + for (const value of root.workspaces) { + if (typeof value !== "string" || !PACKAGE_WORKSPACE_PATH.test(value)) { + fail( + "package.json workspaces must use explicit packages/ directories without globs, URLs or traversal.", + ); + } + if (seen.has(value)) { + fail(`package.json workspace ${value} is duplicated.`); + } + seen.add(value); + directories.push(value); + if (!filePaths.has(`${value}/package.json`)) { + fail(`${value}/package.json is required by the root workspace declaration.`); + } + } + return directories; +} + +function packageManifestScripts( + input: Record, + path: string, +): Record { + const scriptsInput = input.scripts === undefined + ? {} + : object(input.scripts, `${path} scripts`); + const scripts: Record = {}; + for (const [name, command] of Object.entries(scriptsInput)) { + if (LIFECYCLE_SCRIPTS.has(name.toLowerCase())) { + fail(`npm lifecycle scripts are blocked in ${path}.`); + } + if (!PACKAGE_SCRIPT_NAME.test(name)) { + fail(`${path} script names must be bounded and alphanumeric.`); + } + if ( + typeof command !== "string" || + !command.trim() || + command.length > 500 || + findArtifactSecrets(command, `${path} script ${name}`).length + ) { + fail(`${path} script ${name} is invalid.`); + } + scripts[name] = command; + } + return scripts; +} + +function validatePackageManifestFields( + input: Record, + path: string, +): void { + if (input.private !== true) { + fail(`${path} must set private to true.`); + } + for (const field of [ + "overrides", + "resolutions", + "pnpm", + "publishConfig", + ]) { + if (input[field] !== undefined) { + fail(`${path} ${field} is blocked from the bounded sandbox package contract.`); + } + } + const config = input.config; + if ( + config && + typeof config === "object" && + !Array.isArray(config) && + "registry" in config + ) { + fail(`${path} cannot declare a custom npm registry.`); + } + for (const field of [ + "optionalDependencies", + "bundledDependencies", + "bundleDependencies", + ]) { + if ( + input[field] && + typeof input[field] === "object" && + !Array.isArray(input[field]) && + Object.keys(input[field] as object).length + ) { + fail(`${path} ${field} is blocked from sandbox installation.`); + } + } +} + +function packageContract(files: WorkspaceSandboxFile[]): { + dependencies: Record; + dependencyCount: number; + scriptsByCwd: Map>; + workspaceDirectories: string[]; +} { + const packageFile = files.find((file) => file.path === "package.json"); + if (!packageFile) { + return { + dependencies: {}, + dependencyCount: 0, + scriptsByCwd: new Map([[".", {}]]), + workspaceDirectories: [], + }; + } + const root = parsePackageManifest(packageFile); + const filePaths = new Set(files.map((file) => file.path)); + const workspaceDirectories = declaredWorkspaceDirectories(root, filePaths); + const manifests = files + .filter((file) => + file.path === "package.json" || file.path.endsWith("/package.json") + ) + .sort((left, right) => left.path.localeCompare(right.path)); + const dependencies: Record = {}; + const scriptsByCwd = new Map>(); + let dependencyCount = 0; + for (const manifestFile of manifests) { + const cwd = packageManifestCwd(manifestFile.path); + if (!cwd) { + fail( + `${manifestFile.path} is not allowed; package manifests must use packages//package.json.`, + ); + } + const input = manifestFile.path === "package.json" + ? root + : parsePackageManifest(manifestFile); + if (manifestFile.path !== "package.json" && input.workspaces !== undefined) { + fail(`${manifestFile.path} cannot declare nested npm workspaces.`); + } + validatePackageManifestFields(input, manifestFile.path); + scriptsByCwd.set( + cwd, + packageManifestScripts(input, manifestFile.path), + ); + const runtimeDependencies = dependencySet( + input.dependencies, + `${manifestFile.path} dependencies`, + ); + const developmentDependencies = dependencySet( + input.devDependencies, + `${manifestFile.path} devDependencies`, + ); + dependencyCount += + Object.keys(runtimeDependencies).length + + Object.keys(developmentDependencies).length; + Object.assign(dependencies, runtimeDependencies, developmentDependencies); + } + if (dependencyCount > MAX_DEPENDENCIES) { + fail( + `Workspace package manifests exceed the aggregate ${MAX_DEPENDENCIES} dependency limit.`, + ); + } + return { + dependencies, + dependencyCount, + scriptsByCwd, + workspaceDirectories, + }; +} + +function safeArgument(value: unknown, label: string): string { + if ( + typeof value !== "string" || + !value || + value.length > 240 || + /[\u0000-\u001f\u007f]/.test(value) || + findArtifactSecrets(value, label).length + ) { + fail(`${label} is invalid.`); + } + return value; +} + +function validateNodeArguments(argv: string[]): void { + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + const flag = argument.split("=", 1)[0]; + if (BLOCKED_NODE_FLAGS.has(flag)) { + fail(`Node flag ${flag} is blocked from workspace tasks.`); + } + if ( + !argument.startsWith("-") && + (argument.startsWith("/") || argument.includes("\\") || argument + .split("/") + .some((part) => part === "..")) + ) { + fail("Node task arguments must stay inside the workspace."); + } + } +} + +function validateNpmArguments( + argv: string[], + scripts: Record, +): void { + const command = argv[1] ?? ""; + if (BLOCKED_NPM_COMMANDS.has(command.toLowerCase())) { + fail("Workspace tasks cannot install packages directly."); + } + const scriptName = command === "test" + ? "test" + : command === "start" + ? "start" + : command === "run" + ? argv[2] ?? "" + : ""; + if (!scriptName || !Object.hasOwn(scripts, scriptName)) { + fail("npm workspace tasks must run a declared package.json script for their cwd."); + } +} + +function workspaceTasks( + value: unknown, + packageJson: SandboxPackageContract, +): NormalizedWorkspaceSandboxTask[] { + if (!Array.isArray(value) || value.length < 1 || value.length > MAX_TASKS) { + fail(`Workspace must contain between 1 and ${MAX_TASKS} tasks.`); + } + const seen = new Set(); + return value.map((item, index) => { + const input = object(item, `Workspace task ${index + 1}`); + const id = typeof input.id === "string" ? input.id.trim() : ""; + if (!/^[a-z0-9][a-z0-9-]{0,47}$/i.test(id) || seen.has(id)) { + fail(`Workspace task ${index + 1} has an invalid or duplicate id.`); + } + seen.add(id); + const hasArgv = Array.isArray(input.argv); + const hasCommandArgs = + typeof input.command === "string" && Array.isArray(input.args); + if (hasArgv === hasCommandArgs) { + fail( + `Workspace task ${id} must declare either argv or command with args.`, + ); + } + const rawArgv = hasArgv + ? input.argv as unknown[] + : [input.command, ...(input.args as unknown[])]; + if (rawArgv.length < 2 || rawArgv.length > 24) { + fail(`Workspace task ${id} must declare a bounded argv array.`); + } + const argv = rawArgv.map((argument, argumentIndex) => + safeArgument(argument, `Workspace task ${id} argument ${argumentIndex + 1}`), + ); + if (argv[0] !== "node" && argv[0] !== "npm") { + fail("Workspace tasks may execute only node or declared npm scripts."); + } + const cwd = safeWorkingDirectory(input.cwd); + if ( + cwd !== "." && + !packageJson.workspaceDirectories.includes(cwd) + ) { + fail("Task cwd must be the root or a declared package workspace."); + } + if (argv[0] === "node") validateNodeArguments(argv); + else { + validateNpmArguments( + argv, + packageJson.scriptsByCwd.get(cwd) ?? {}, + ); + } + const timeoutMs = input.timeoutMs === undefined + ? 15_000 + : Number(input.timeoutMs); + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1_000 || + timeoutMs > MAX_TASK_TIMEOUT_MS + ) { + fail(`Workspace task ${id} has an invalid timeout.`); + } + if (input.previewPort !== undefined && input.port !== undefined) { + fail(`Workspace task ${id} declares more than one preview port.`); + } + const rawPreviewPort = input.previewPort ?? input.port; + const previewPort = rawPreviewPort === undefined + ? undefined + : Number(rawPreviewPort); + if ( + previewPort !== undefined && + (!Number.isSafeInteger(previewPort) || + previewPort < 1_024 || + previewPort > 65_535) + ) { + fail(`Workspace task ${id} has an invalid preview port.`); + } + return { + id, + argv, + cwd, + timeoutMs, + ...(previewPort === undefined ? {} : { previewPort }), + }; + }); +} + +export function validateWorkspaceSandboxRun( + value: unknown, +): ValidatedWorkspaceSandboxRun { + const input = object(value, "Workspace sandbox request"); + if ("argv" in input || "cmd" in input || "command" in input) { + fail("Raw commands are not accepted; select a declared workspace task."); + } + const workspace = object(input.workspace, "Workspace"); + const workspaceIdValue = input.workspaceId ?? workspace.id; + const workspaceId = typeof workspaceIdValue === "string" + ? workspaceIdValue.trim() + : ""; + if (!/^[a-z0-9][a-z0-9_-]{2,127}$/i.test(workspaceId)) { + fail("Workspace id is invalid."); + } + const revision = Number(workspace.revision); + if (!Number.isSafeInteger(revision) || revision < 0) { + fail("Workspace revision is invalid."); + } + const files = workspaceFiles(workspace.files); + const packageJson = packageContract(files); + const tasks = workspaceTasks(workspace.tasks, packageJson); + const taskId = typeof input.taskId === "string" ? input.taskId.trim() : ""; + const task = tasks.find((candidate) => candidate.id === taskId); + if (!task) fail("Requested task is not declared by this workspace."); + return { + workspaceId, + revision, + files, + task: { + id: task.id, + argv: [...task.argv], + cwd: task.cwd ?? ".", + timeoutMs: task.timeoutMs ?? 15_000, + ...(task.previewPort === undefined + ? {} + : { previewPort: task.previewPort }), + }, + dependencies: packageJson.dependencies, + workspaceDirectories: [...packageJson.workspaceDirectories], + }; +} + +function safeTimestamp(value: unknown, label: string): string { + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) { + throw new WorkspaceSandboxProviderError( + `Sandbox provider returned an invalid ${label}.`, + ); + } + return new Date(value).toISOString(); +} + +function safeProviderRunId(value: unknown): string { + if ( + typeof value !== "string" || + !/^[a-z0-9][a-z0-9:._-]{5,255}$/i.test(value) + ) { + throw new WorkspaceSandboxProviderError( + "Sandbox provider returned an invalid run id.", + ); + } + return value; +} + +function safeOutput(value: unknown, label: string): string { + const output = typeof value === "string" ? value.slice(0, MAX_OUTPUT_BYTES) : ""; + if (findArtifactSecrets(output, label).length) { + return "[redacted secret material]"; + } + return output.replace(/\u0000/g, ""); +} + +function safePreviewUrl(value: unknown): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + throw new WorkspaceSandboxProviderError( + "Sandbox provider returned an invalid preview URL.", + ); + } + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password) throw new Error(); + return url.href; + } catch { + throw new WorkspaceSandboxProviderError( + "Sandbox provider returned an invalid preview URL.", + ); + } +} + +export function workspaceSandboxConfigured(): boolean { + return Boolean( + process.env.VERCEL_OIDC_TOKEN || + (process.env.VERCEL_TEAM_ID && + process.env.VERCEL_PROJECT_ID && + process.env.VERCEL_TOKEN), + ); +} + +function taskCwd(task: ValidatedWorkspaceSandboxRun["task"]): string { + return task.cwd === "." ? WORKSPACE_ROOT : `${WORKSPACE_ROOT}/${task.cwd}`; +} + +async function commandResult( + command: { + exitCode: number; + startedAt: number; + durationMs?: number; + stdout(): Promise; + stderr(): Promise; + }, +): Promise> { + const [stdout, stderr] = await Promise.all([ + command.stdout(), + command.stderr(), + ]); + const started = new Date(command.startedAt); + const finished = new Date( + command.startedAt + Math.max(0, command.durationMs ?? 0), + ); + return { + exitCode: command.exitCode, + stdout, + stderr, + startedAt: started.toISOString(), + finishedAt: finished.toISOString(), + }; +} + +export const vercelWorkspaceSandboxProvider: WorkspaceSandboxProvider = { + async execute(input) { + if (!workspaceSandboxConfigured()) { + throw new WorkspaceSandboxUnavailableError( + "Vercel Sandbox is not configured for this deployment.", + ); + } + const { Sandbox } = await import("@vercel/sandbox"); + const dependencyInstall = Object.keys(input.dependencies).length > 0; + const packageInstall = + dependencyInstall || input.workspaceDirectories.length > 0; + let sandbox: Awaited> | null = null; + let keepPreview = false; + try { + sandbox = await Sandbox.create({ + name: `drops-${input.workspaceId.slice(0, 28)}-${crypto + .randomUUID() + .slice(0, 8)}`, + runtime: "node24", + resources: { vcpus: 1 }, + timeout: 300_000, + ports: input.task.previewPort ? [input.task.previewPort] : [], + persistent: false, + networkPolicy: dependencyInstall + ? { allow: ["registry.npmjs.org", "*.npmjs.org"] } + : "deny-all", + tags: { + product: "drops-studio", + workspace: input.workspaceId.slice(0, 48), + }, + }); + await sandbox.writeFiles( + input.files.map((file) => ({ + path: file.path, + content: file.content, + })), + ); + if (input.task.previewPort) { + const runtimeAssets = await loadWorkspaceRuntimeAssets(input.files); + if (runtimeAssets.length) await sandbox.writeFiles(runtimeAssets); + } + + if (packageInstall) { + const install = await sandbox.runCommand({ + cmd: "npm", + args: [ + "install", + ...(input.workspaceDirectories.length + ? ["--workspaces", "--include-workspace-root"] + : []), + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + ], + cwd: WORKSPACE_ROOT, + timeoutMs: INSTALL_TIMEOUT_MS, + }); + if (install.exitCode !== 0) { + return { + provider: "vercel-sandbox", + isolation: "firecracker-microvm", + providerRunId: `${sandbox.name}:${sandbox.currentSession().sessionId}`, + ...(await commandResult(install)), + previewUrl: null, + }; + } + await sandbox.update({ networkPolicy: "deny-all" }); + } + + const [cmd, ...args] = input.task.argv; + if (input.task.previewPort) { + const previewCommand = await sandbox.runCommand({ + cmd, + args, + cwd: taskCwd(input.task), + detached: true, + }); + const previewPort = input.task.previewPort; + const readiness = await sandbox.runCommand({ + cmd: "node", + args: [ + "-e", + `const url = "http://127.0.0.1:${previewPort}/"; const deadline = Date.now() + 7000; while (Date.now() < deadline) { try { const response = await fetch(url); if (response.status < 500) { console.log("Preview process is ready"); process.exit(0); } } catch {} await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error("Preview process did not become ready");`, + ], + cwd: WORKSPACE_ROOT, + timeoutMs: 8_000, + }); + const output = await commandResult(readiness); + let previewUrl: string | null = null; + if (readiness.exitCode === 0) { + previewUrl = sandbox.domain(previewPort); + keepPreview = true; + } else { + await previewCommand.kill().catch(() => undefined); + } + return { + provider: "vercel-sandbox", + isolation: "firecracker-microvm", + providerRunId: `${sandbox.name}:${sandbox.currentSession().sessionId}:${previewCommand.cmdId}`, + ...output, + exitCode: previewUrl ? null : output.exitCode, + previewUrl, + }; + } + const command = await sandbox.runCommand({ + cmd, + args, + cwd: taskCwd(input.task), + timeoutMs: input.task.timeoutMs, + }); + const output = await commandResult(command); + return { + provider: "vercel-sandbox", + isolation: "firecracker-microvm", + providerRunId: `${sandbox.name}:${sandbox.currentSession().sessionId}`, + ...output, + previewUrl: null, + }; + } catch (error) { + if (error instanceof WorkspaceSandboxUnavailableError) throw error; + throw new WorkspaceSandboxProviderError( + secretFreeMessage(error, "Vercel Sandbox execution failed."), + ); + } finally { + if (sandbox && !keepPreview) { + await sandbox.stop().catch(() => undefined); + } + } + }, +}; + +export async function runWorkspaceSandbox( + input: unknown, + options: { provider?: WorkspaceSandboxProvider } = {}, +): Promise { + const validated = validateWorkspaceSandboxRun(input); + const workspaceDigest = await createWorkspaceRunDigest({ + files: validated.files, + task: { + id: validated.task.id, + argv: validated.task.argv, + cwd: validated.task.cwd, + timeoutMs: validated.task.timeoutMs, + previewPort: validated.task.previewPort, + }, + }); + let result: WorkspaceSandboxProviderResult; + try { + result = await (options.provider ?? vercelWorkspaceSandboxProvider).execute( + validated, + ); + } catch (error) { + if (error instanceof WorkspaceSandboxUnavailableError) { + throw new WorkspaceSandboxUnavailableError(error.message); + } + if (error instanceof WorkspaceSandboxProviderError) { + throw new WorkspaceSandboxProviderError(error.message); + } + throw new WorkspaceSandboxProviderError( + secretFreeMessage(error, "Workspace sandbox provider failed."), + ); + } + + const startedAt = safeTimestamp(result.startedAt, "start timestamp"); + const finishedAt = safeTimestamp(result.finishedAt, "finish timestamp"); + if ( + result.provider !== "vercel-sandbox" || + result.isolation !== "firecracker-microvm" + ) { + throw new WorkspaceSandboxProviderError( + "Sandbox provider did not return verified execution evidence.", + ); + } + if (Date.parse(finishedAt) < Date.parse(startedAt)) { + throw new WorkspaceSandboxProviderError( + "Sandbox provider returned timestamps out of order.", + ); + } + const previewUrl = safePreviewUrl(result.previewUrl); + if ( + result.exitCode !== null && + !Number.isSafeInteger(result.exitCode) + ) { + throw new WorkspaceSandboxProviderError( + "Sandbox provider returned an invalid exit code.", + ); + } + if (result.exitCode === null && !previewUrl) { + throw new WorkspaceSandboxProviderError( + "A running sandbox process requires a verified preview URL.", + ); + } + return { + provider: result.provider, + isolation: result.isolation, + providerRunId: safeProviderRunId(result.providerRunId), + workspaceId: validated.workspaceId, + workspaceRevision: validated.revision, + workspaceDigest, + task: validated.task.id, + argv: [...validated.task.argv], + exitCode: result.exitCode, + stdout: safeOutput(result.stdout, "sandbox stdout"), + stderr: safeOutput(result.stderr, "sandbox stderr"), + startedAt, + finishedAt, + previewUrl, + }; +} diff --git a/package-lock.json b/package-lock.json index 0975ab4..15ef49e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,23 +7,25 @@ "": { "name": "drops-studio", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@base-ui/react": "1.6.0", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-switch": "^1.3.7", "@vercel/blob": "^2.6.1", + "@vercel/sandbox": "2.9.0", "ai": "^7.0.41", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "drizzle-orm": "^0.45.2", "fflate": "^0.8.2", - "framer-motion": "^12.43.0", "jsonrepair": "^3.15.0", "lucide-react": "^1.27.0", "next": "^16.2.12", "react": "^19.2.8", "react-dom": "^19.2.8", + "stripe": "22.4.0", "tailwind-merge": "3.6.0", "teleproto": "^1.228.4", "tw-animate-css": "1.4.0", @@ -6807,7 +6809,7 @@ "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7538,6 +7540,72 @@ "node": ">= 20" } }, + "node_modules/@vercel/sandbox": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@vercel/sandbox/-/sandbox-2.9.0.tgz", + "integrity": "sha512-AMnzmOUG765PJnNyrj12Ed4XD+M2ns8C+o9UTBERuZeUQxbRo2izJTKPezaeDCl+KEpyw+JXepRCKIxSsylsMQ==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "3.2.0", + "@workflow/serde": "4.1.0-beta.2", + "async-retry": "1.3.3", + "jose": "6.2.3", + "jsonlines": "0.1.1", + "ms": "2.1.3", + "picocolors": "^1.1.1", + "tar-stream": "3.1.7", + "undici": "^7.27.1", + "xdg-app-paths": "5.1.0", + "zod": "^4.1.1" + } + }, + "node_modules/@vercel/sandbox/node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vercel/sandbox/node_modules/@workflow/serde": { + "version": "4.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0-beta.2.tgz", + "integrity": "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==", + "license": "Apache-2.0" + }, + "node_modules/@vercel/sandbox/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@vercel/sandbox/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@vercel/sandbox/node_modules/xdg-app-paths": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.1.0.tgz", + "integrity": "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==", + "license": "MIT", + "dependencies": { + "xdg-portable": "^7.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -8527,7 +8595,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -8552,7 +8619,6 @@ "version": "2.9.1", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -11480,7 +11546,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -11691,7 +11756,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -11909,33 +11973,6 @@ "node": ">= 0.6" } }, - "node_modules/framer-motion": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", - "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.43.0", - "motion-utils": "^12.39.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -13716,6 +13753,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonlines": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", + "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==", + "license": "MIT" + }, "node_modules/jsonrepair": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.15.0.tgz", @@ -14826,21 +14869,6 @@ "dev": true, "license": "MIT" }, - "node_modules/motion-dom": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", - "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", - "license": "MIT" - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -14855,7 +14883,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mute-stream": { @@ -17899,7 +17926,6 @@ "version": "2.28.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", - "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -18190,6 +18216,23 @@ "dev": true, "license": "MIT" }, + "node_modules/stripe": { + "version": "22.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz", + "integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -18434,7 +18477,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -18966,7 +19008,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -18976,7 +19017,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unenv": { diff --git a/package.json b/package.json index 8e59547..cf9df6d 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build", "build:vercel": "next build", "start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start", - "serve:test": "DROPS_STUDIO_LOCAL_PROJECT_STORE=1 DROPS_PUBLISH_CAPABILITY_SECRET=drops-studio-local-proof-secret-not-for-production next start -p 4173 -H 127.0.0.1", - "serve:lighthouse": "next start -p 4274 -H 127.0.0.1", + "serve:test": "DROPSTAB_API_KEY= DROPS_STUDIO_LOCAL_PROJECT_STORE=1 DROPS_PUBLISH_CAPABILITY_SECRET=drops-studio-local-proof-secret-not-for-production next start -p 4173 -H 127.0.0.1", + "serve:lighthouse": "DROPSTAB_API_KEY= next start -p 4274 -H 127.0.0.1", "guardrails:ui": "node scripts/check-ui-guardrails.mjs", "typecheck": "tsc --noEmit", "test": "npm run test:unit", @@ -23,7 +23,7 @@ "test:lighthouse": "npm run build:vercel && lhci autorun", "test:lighthouse:prepared": "lhci autorun", "storybook": "storybook dev -p 6006 --ci", - "serve:storybook:test": "storybook dev -p 6006 --ci --host 127.0.0.1", + "serve:storybook:test": "DROPSTAB_API_KEY= node scripts/serve-storybook-static.mjs", "build-storybook": "storybook build --output-dir outputs/storybook-static", "postinstall": "node scripts/patch-minimatch-brace-compat.mjs", "test:storybook": "vitest --project=storybook --run", @@ -37,17 +37,18 @@ "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-switch": "^1.3.7", "@vercel/blob": "^2.6.1", + "@vercel/sandbox": "2.9.0", "ai": "^7.0.41", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "drizzle-orm": "^0.45.2", "fflate": "^0.8.2", - "framer-motion": "^12.43.0", "jsonrepair": "^3.15.0", "lucide-react": "^1.27.0", "next": "^16.2.12", "react": "^19.2.8", "react-dom": "^19.2.8", + "stripe": "22.4.0", "tailwind-merge": "3.6.0", "teleproto": "^1.228.4", "tw-animate-css": "1.4.0", diff --git a/playwright.config.ts b/playwright.config.ts index 2eec9a4..8212b19 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -68,6 +68,9 @@ const projects = [ export default defineConfig({ testDir: "./e2e", outputDir: "outputs/test-results", + snapshotPathTemplate: executablePath + ? "{snapshotDir}/{testFilePath}-snapshots/{arg}-{projectName}-{platform}-system{ext}" + : "{snapshotDir}/{testFilePath}-snapshots/{arg}-{projectName}-{platform}{ext}", fullyParallel: false, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, @@ -80,7 +83,10 @@ export default defineConfig({ caret: "hide", maxDiffPixels: 0, scale: "css", - threshold: 0, + // The GitHub release gate uses the canonical bundled browser and exact + // color matching. The optional local system browser gets only a + // one-channel antialias tolerance while keeping a zero-pixel budget. + threshold: executablePath ? 0.01 : 0, }, }, reporter: [ diff --git a/playwright.storybook.config.ts b/playwright.storybook.config.ts index ca0a203..471be4c 100644 --- a/playwright.storybook.config.ts +++ b/playwright.storybook.config.ts @@ -37,19 +37,24 @@ const projects = [ export default defineConfig({ testDir: "./storybook-e2e", outputDir: "outputs/storybook-test-results", + snapshotPathTemplate: executablePath + ? "{snapshotDir}/{testFilePath}-snapshots/{arg}-{projectName}-{platform}-system{ext}" + : "{snapshotDir}/{testFilePath}-snapshots/{arg}-{projectName}-{platform}{ext}", fullyParallel: false, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 1 : undefined, + workers: 1, timeout: 60_000, expect: { timeout: 10_000, toHaveScreenshot: { animations: "disabled", caret: "hide", - maxDiffPixels: 0, + // Canonical CI snapshots are exact; the optional local system browser + // gets a bounded raster tolerance for the host GPU/font renderer. + maxDiffPixels: executablePath ? 50 : 0, scale: "css", - threshold: 0, + threshold: executablePath ? 0.01 : 0, }, }, reporter: [ diff --git a/public/brand/drops-bot-avatar.png b/public/brand/drops-bot-avatar.png new file mode 100644 index 0000000..e99d9fa Binary files /dev/null and b/public/brand/drops-bot-avatar.png differ diff --git a/scripts/check-ui-guardrails.mjs b/scripts/check-ui-guardrails.mjs index c6c3c41..5413d59 100644 --- a/scripts/check-ui-guardrails.mjs +++ b/scripts/check-ui-guardrails.mjs @@ -11,6 +11,7 @@ const SOURCE_EXTENSIONS = new Set([ ".ts", ".tsx", ]) +const MAX_MANUAL_STYLESHEET_BYTES = 48 * 1024 const NUMBER = String.raw`(?:\d+(?:\.\d+)?|\.\d+)` const FONT_PATTERNS = [ { @@ -113,6 +114,54 @@ export async function collectUiGuardrailViolations(projectRoot = process.cwd()) return violations } +export async function collectUiArchitectureViolations( + projectRoot = process.cwd() +) { + const globalsPath = path.join(projectRoot, "app", "globals.css") + + try { + const globals = await readFile(globalsPath, "utf8") + const violations = [] + const byteLength = Buffer.byteLength(globals, "utf8") + + if (byteLength > 4096) { + violations.push( + `app/globals.css is ${byteLength} bytes; keep the import manifest below 4096 bytes` + ) + } + if (/\{/.test(globals)) { + violations.push( + "app/globals.css must remain an import-only manifest; put tokens in the token layer and new UI in local Tailwind/Base UI components" + ) + } + + const stylesDirectory = path.join(projectRoot, "app", "styles") + try { + const stylesheets = (await sourceFiles(stylesDirectory)).filter( + (file) => path.extname(file) === ".css" + ) + + for (const stylesheet of stylesheets) { + const source = await readFile(stylesheet, "utf8") + const stylesheetBytes = Buffer.byteLength(source, "utf8") + + if (stylesheetBytes > MAX_MANUAL_STYLESHEET_BYTES) { + violations.push( + `${path.relative(projectRoot, stylesheet)} is ${stylesheetBytes} bytes; split manual CSS below ${MAX_MANUAL_STYLESHEET_BYTES} bytes and keep new product UI in local Tailwind/Base UI components` + ) + } + } + } catch (error) { + if (error?.code !== "ENOENT") throw error + } + + return violations + } catch (error) { + if (error?.code === "ENOENT") return ["app/globals.css is missing"] + throw error + } +} + function formatViolation(violation) { return `${violation.file}:${violation.line} ${violation.source} (${violation.value}px)` } @@ -122,17 +171,24 @@ const isMainModule = import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href if (isMainModule) { - const violations = await collectUiGuardrailViolations() + const [violations, architectureViolations] = await Promise.all([ + collectUiGuardrailViolations(), + collectUiArchitectureViolations(), + ]) - if (violations.length > 0) { + if (violations.length > 0 || architectureViolations.length > 0) { console.error( - `UI font-size guardrail failed: ${violations.length} source declaration(s) below 12px.\n${violations + `UI guardrails failed: ${violations.length} source declaration(s) below 12px; ${architectureViolations.length} architecture violation(s).\n${[ + ...violations .slice(0, 50) - .map(formatViolation) - .join("\n")}${violations.length > 50 ? "\n…output truncated" : ""}` + .map(formatViolation), + ...architectureViolations, + ].join("\n")}${violations.length > 50 ? "\n…output truncated" : ""}` ) process.exitCode = 1 } else { - console.log("UI font-size guardrail passed.") + console.log( + "UI guardrails passed: no sub-12px source declarations, globals.css is import-only, and manual stylesheets stay below 48 KiB." + ) } } diff --git a/scripts/serve-storybook-static.mjs b/scripts/serve-storybook-static.mjs new file mode 100644 index 0000000..a7650f2 --- /dev/null +++ b/scripts/serve-storybook-static.mjs @@ -0,0 +1,75 @@ +import { createReadStream, existsSync } from "node:fs" +import { stat } from "node:fs/promises" +import { createServer } from "node:http" +import { extname, resolve, sep } from "node:path" +import { pipeline } from "node:stream/promises" + +const host = "127.0.0.1" +const port = 6006 +const root = resolve("outputs/storybook-static") +const indexPath = resolve(root, "index.html") + +if (!existsSync(indexPath)) { + throw new Error( + "Static Storybook build is missing. Run `npm run build-storybook` before visual tests." + ) +} + +const contentTypes = new Map([ + [".css", "text/css; charset=utf-8"], + [".gif", "image/gif"], + [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".woff", "font/woff"], + [".woff2", "font/woff2"], +]) + +const server = createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url ?? "/", `http://${host}:${port}`) + const pathname = decodeURIComponent(requestUrl.pathname) + let filePath = resolve(root, `.${pathname}`) + + if (filePath !== root && !filePath.startsWith(`${root}${sep}`)) { + response.writeHead(403).end("Forbidden") + return + } + + const fileStat = await stat(filePath) + if (fileStat.isDirectory()) filePath = resolve(filePath, "index.html") + + const file = await stat(filePath) + if (!file.isFile()) throw new Error("Not a file") + + response.writeHead(200, { + "cache-control": "no-store", + "content-length": String(file.size), + "content-type": + contentTypes.get(extname(filePath).toLowerCase()) ?? + "application/octet-stream", + }) + await pipeline(createReadStream(filePath), response) + } catch { + if (response.headersSent) { + if (!response.destroyed) response.destroy() + return + } + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }) + response.end("Not found") + } +}) + +server.listen(port, host, () => { + console.log(`Static Storybook ready at http://${host}:${port}`) +}) + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))) +} diff --git a/storybook-e2e/visual.spec.ts b/storybook-e2e/visual.spec.ts index c595649..d7fa2e8 100644 --- a/storybook-e2e/visual.spec.ts +++ b/storybook-e2e/visual.spec.ts @@ -35,8 +35,36 @@ function requireApprovedSnapshot(testInfo: TestInfo, name: string) { async function settleStory(page: Page) { await expect(page.locator("#storybook-root")).toBeVisible() await expect(page.locator(".sb-errordisplay")).toBeHidden() + await expect(page.locator(".preview-stage > *").first()).toBeVisible() await page.evaluate(async () => { await document.fonts.ready + + const imageUrls = new Set() + for (const image of document.images) { + if (!image.complete || image.naturalWidth === 0) { + await image.decode() + } + } + + for (const element of document.querySelectorAll("*")) { + const backgroundImage = getComputedStyle(element).backgroundImage + for (const match of backgroundImage.matchAll(/url\(["']?([^"')]+)["']?\)/g)) { + imageUrls.add(new URL(match[1], document.baseURI).href) + } + } + + await Promise.all( + [...imageUrls].map( + (url) => + new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve() + image.onerror = () => reject(new Error(`Unable to load visual asset: ${url}`)) + image.src = url + }) + ) + ) + await new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())) }) diff --git a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux-system.png new file mode 100644 index 0000000..8bede3b Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux.png index 8bede3b..a241dea 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux-system.png new file mode 100644 index 0000000..2c8c854 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux.png index 880a032..3528366 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux-system.png new file mode 100644 index 0000000..033dcf4 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux.png index 033dcf4..2bff9df 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/action-engine-desktop-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux-system.png new file mode 100644 index 0000000..2636e67 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux.png index 65dba0e..2636e67 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux-system.png new file mode 100644 index 0000000..e713158 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux.png index 907e777..e713158 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux-system.png new file mode 100644 index 0000000..1881980 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux.png index 3484fb8..1881980 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/alpha-channel-disconnected-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux-system.png new file mode 100644 index 0000000..9c79092 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux.png index 9c79092..8ea7bd4 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux-system.png new file mode 100644 index 0000000..40d1e3a Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux.png index 40d1e3a..888847b 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux-system.png new file mode 100644 index 0000000..a02e7e9 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux.png index a02e7e9..81a25d9 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-aggregator-connected-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux-system.png new file mode 100644 index 0000000..b542bb9 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux.png index daf46b3..b542bb9 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux-system.png new file mode 100644 index 0000000..14d67a0 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux.png index a468ab7..14d67a0 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-390-linux-system.png new file mode 100644 index 0000000..ba61cb7 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-game-desktop-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux-system.png new file mode 100644 index 0000000..fc0b143 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux.png index fc0b143..3b127ed 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux-system.png new file mode 100644 index 0000000..7204b8a Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux.png index 7204b8a..3589132 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux-system.png new file mode 100644 index 0000000..b162005 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux.png index b162005..f9bb591 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-product-hunt-empty-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux-system.png new file mode 100644 index 0000000..33b8aea Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux.png index 33b8aea..26fa666 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux-system.png new file mode 100644 index 0000000..2c5f859 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux.png index 2c5f859..86a0eaa 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux-system.png new file mode 100644 index 0000000..707d777 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux.png index 707d777..dc79536 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-radio-playing-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux-system.png new file mode 100644 index 0000000..0491ca3 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux.png index 0491ca3..3c36492 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux-system.png new file mode 100644 index 0000000..45aef5c Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux.png index 45aef5c..1f6d74b 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux-system.png new file mode 100644 index 0000000..7127c8f Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux.png index 7127c8f..b11a62b 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/crypto-siri-mobile-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux-system.png new file mode 100644 index 0000000..175bf15 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux.png index 175bf15..c83165b 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux-system.png new file mode 100644 index 0000000..d2971e4 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux.png index d2971e4..44b706c 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux-system.png new file mode 100644 index 0000000..c0a1799 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux.png index c0a1799..451385e 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/loading-product-plan-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux-system.png new file mode 100644 index 0000000..d9db827 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux.png index fe3ed95..d9db827 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux-system.png new file mode 100644 index 0000000..c3a2b41 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux.png index 9c9f1ec..c3a2b41 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux-system.png new file mode 100644 index 0000000..8385a09 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux.png index dd0bf34..8385a09 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-data-error-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux-system.png new file mode 100644 index 0000000..5180f9e Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux.png index 837cfe9..5180f9e 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux-system.png new file mode 100644 index 0000000..7199560 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux.png index fb2eb4f..7199560 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux-system.png new file mode 100644 index 0000000..f463efe Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux.png index c370b16..f463efe 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/morning-alpha-populated-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux-system.png new file mode 100644 index 0000000..cd1a80c Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux.png index cd1a80c..8e53237 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux-system.png new file mode 100644 index 0000000..693a9d3 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux.png index 693a9d3..6d6ee6e 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux-system.png new file mode 100644 index 0000000..f8b386c Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux.png index f8b386c..80eb7ac 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/personal-companion-mobile-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux-system.png new file mode 100644 index 0000000..f9543e3 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux.png index f9543e3..5b264d2 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux-system.png new file mode 100644 index 0000000..62b0e2b Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux.png index 62b0e2b..e7d1334 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux-system.png new file mode 100644 index 0000000..5f5afc5 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux.png index 5f5afc5..59b1f76 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/portfolio-tamagotchi-empty-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux-system.png new file mode 100644 index 0000000..d7994e8 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux.png index d7994e8..959f4ed 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux-system.png new file mode 100644 index 0000000..461df80 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux.png index 461df80..07b24ed 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux-system.png new file mode 100644 index 0000000..c506b33 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux.png index c506b33..873116d 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/prediction-impact-desktop-chromium-390-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux-system.png new file mode 100644 index 0000000..d5a4d6b Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux.png b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux.png index d5a4d6b..4b9c01b 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1024-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux-system.png new file mode 100644 index 0000000..9346946 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux.png b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux.png index 9346946..78b4af0 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-1440-linux.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux-system.png b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux-system.png new file mode 100644 index 0000000..0b32c59 Binary files /dev/null and b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux-system.png differ diff --git a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux.png b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux.png index 0b32c59..af940db 100644 Binary files a/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux.png and b/storybook-e2e/visual.spec.ts-snapshots/smart-money-copy-empty-chromium-390-linux.png differ diff --git a/tests/access-tier.test.mjs b/tests/access-tier.test.mjs index f79d2c0..59fa1cd 100644 --- a/tests/access-tier.test.mjs +++ b/tests/access-tier.test.mjs @@ -7,6 +7,7 @@ import * as accessTierModule from "../lib/access-tier.ts"; const { GUEST_DAILY_LIMIT, MEMBER_DAILY_LIMIT, + PRO_DAILY_LIMIT, MEMBER_USAGE_COOKIE, STUDIO_ACCOUNT_COOKIE, accessMetadata, @@ -116,6 +117,32 @@ test("OpenRouter member identity is signed, private and rejects tampering or exp assert.equal(resolveAccountCookieSecret({ DROPS_ACCOUNT_COOKIE_SECRET: "account-secret" }), "account-secret"); }); +test("OpenRouter storage ownership survives cookie-signing secret rotation", () => { + const issuedAt = Math.floor(Date.parse("2026-07-29T12:00:00Z") / 1_000); + const firstSecret = "first-cookie-signing-secret-with-enough-entropy"; + const rotatedSecret = "rotated-cookie-signing-secret-with-enough-entropy"; + const input = { provider: "openrouter", subject: accountSubject, issuedAt }; + const first = readStudioAccountCookie( + createStudioAccountCookie(input, firstSecret), + firstSecret, + Date.parse("2026-07-29T12:05:00Z"), + ); + const rotated = readStudioAccountCookie( + createStudioAccountCookie(input, rotatedSecret), + rotatedSecret, + Date.parse("2026-07-29T12:05:00Z"), + ); + + assert.ok(first); + assert.ok(rotated); + assert.equal(first.identity, rotated.identity); + assert.notEqual( + createStudioAccountCookie(input, firstSecret), + createStudioAccountCookie(input, rotatedSecret), + "cookie signatures still rotate independently from durable storage ownership", + ); +}); + test("access metadata exposes only tiers that actually work", () => { const guest = accessMetadata({ tier: "guest", used: 1 }); @@ -151,6 +178,79 @@ test("access metadata exposes only tiers that actually work", () => { assert.equal(member.platformAi.remaining, MEMBER_DAILY_LIMIT - 4); assert.equal(member.account.connected, true); assert.equal(member.account.projectSync, true); + + const proFailClosed = accessMetadata({ tier: "pro", used: 4, account }); + assert.equal(proFailClosed.platformAi.limit, MEMBER_DAILY_LIMIT); + const pro = accessMetadata({ + tier: "pro", + used: 4, + account, + platformLimit: PRO_DAILY_LIMIT, + }); + assert.equal(pro.platformAi.limit, PRO_DAILY_LIMIT); + assert.equal(pro.platformAi.remaining, PRO_DAILY_LIMIT - 4); +}); + +test("funded Pro quota resolves tier and limit from one billing instant", async () => { + const { + applyBillingWebhookEvent, + resetLocalBillingStateForTests, + } = await import("../db/billing.ts"); + const previous = { + DROPS_STUDIO_LOCAL_PROJECT_STORE: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE, + STRIPE_PRO_PRICE_ID: process.env.STRIPE_PRO_PRICE_ID, + VERCEL: process.env.VERCEL, + }; + const NativeDate = globalThis.Date; + try { + process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE = "1"; + process.env.STRIPE_PRO_PRICE_ID = "price_pro_monthly"; + delete process.env.VERCEL; + resetLocalBillingStateForTests(); + + const accountCookie = createStudioAccountCookie( + { provider: "openrouter", subject: "quota-boundary-member" }, + secret, + ); + const account = readStudioAccountCookie(accountCookie, secret); + assert.ok(account); + await applyBillingWebhookEvent({ + id: "evt_quota_boundary_active_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2098-12-01T00:00:00.000Z", + accountIdentity: account.identity, + stripeCustomerId: "cus_quota_boundary_123456", + stripeSubscriptionId: "sub_quota_boundary_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2099-01-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + }); + + let clockReads = 0; + globalThis.Date = class BoundaryDate extends NativeDate { + constructor(...args) { + if (args.length > 0) super(...args); + else super(clockReads++ === 0 + ? "2098-12-31T23:59:59.000Z" + : "2099-01-01T00:00:01.000Z"); + } + }; + const quota = await accessTierModule.resolveFundedBuildQuota({ + kind: "account", + account, + }); + assert.deepEqual([quota.tier, quota.limit], ["pro", PRO_DAILY_LIMIT]); + assert.equal(clockReads, 1); + } finally { + globalThis.Date = NativeDate; + resetLocalBillingStateForTests(); + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } }); test("access status issues a signed HttpOnly anonymous identity without claiming authentication or Pro", async () => { diff --git a/tests/artifact-csp.test.mjs b/tests/artifact-csp.test.mjs new file mode 100644 index 0000000..34fd713 --- /dev/null +++ b/tests/artifact-csp.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { addProjectArtifactCspMeta } from "../lib/artifact-csp.ts"; + +test("artifact CSP insertion honors quoted greater-than characters in opening tags", () => { + const withHead = addProjectArtifactCspMeta( + 'Safe', + ); + assert.match( + withHead, + /Safe', + ); + assert.match( + withoutHead, + / null); +const billingStoreModule = await import("../db/billing.ts").catch(() => null); +const checkoutRouteModule = await import( + "../app/api/billing/checkout/route.ts" +).catch(() => null); +const portalRouteModule = await import( + "../app/api/billing/portal/route.ts" +).catch(() => null); +const statusRouteModule = await import( + "../app/api/billing/status/route.ts" +).catch(() => null); +const webhookRouteModule = await import( + "../app/api/billing/webhook/route.ts" +).catch(() => null); + +const accountIdentity = "a".repeat(64); +const accountSecret = "billing-account-cookie-test-secret-with-32-bytes"; +const webhookSecret = "whsec_billing_test_secret_1234567890"; + +function modules() { + assert.ok(billingModule, "billing module must exist"); + assert.ok(billingStoreModule, "billing store module must exist"); + return { ...billingModule, ...billingStoreModule }; +} + +function withEnv(values, run) { + const previous = Object.fromEntries( + Object.keys(values).map((name) => [name, process.env[name]]), + ); + for (const [name, value] of Object.entries(values)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + return Promise.resolve() + .then(run) + .finally(() => { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); +} + +function fakeBillingD1(initialAccounts = [], options = {}) { + const accounts = new Map(initialAccounts.map((account) => [account.accountIdentity, { ...account }])); + const events = new Map(); + let beforeBatchRan = false; + + function accountFor(sql, args, source = accounts) { + if (/WHERE account_identity = \?/i.test(sql)) { + return source.get(args[0]) ?? null; + } + if (/WHERE stripe_customer_id = \?/i.test(sql)) { + return [...source.values()].find((account) => account.stripeCustomerId === args[0]) ?? null; + } + if (/WHERE stripe_subscription_id = \?/i.test(sql)) { + return [...source.values()].find((account) => account.stripeSubscriptionId === args[0]) ?? null; + } + return null; + } + + function row(account) { + return account && { + account_identity: account.accountIdentity, + stripe_customer_id: account.stripeCustomerId, + stripe_subscription_id: account.stripeSubscriptionId, + price_id: account.priceId, + status: account.status, + current_period_end: account.currentPeriodEnd, + cancel_at_period_end: account.cancelAtPeriodEnd ? 1 : 0, + updated_at: account.updatedAt, + }; + } + + function bound(sql, args = []) { + return { + sql, + args, + bind(...nextArgs) { + return bound(sql, nextArgs); + }, + async first() { + if (/FROM billing_events/i.test(sql)) { + const event = events.get(args[0]); + return event ? { event_id: event.id } : null; + } + const account = accountFor(sql, args); + if (!account) return null; + if (/SELECT account_identity/i.test(sql) && !/SELECT \*/i.test(sql)) { + return { account_identity: account.accountIdentity }; + } + if (/SELECT updated_at, status/i.test(sql)) { + return { updated_at: account.updatedAt, status: account.status }; + } + return row(account); + }, + async run() { + if (/^CREATE TABLE/i.test(sql.trim())) return { meta: { changes: 0 } }; + if (/INSERT INTO billing_events/i.test(sql)) { + if (options.failReceiptStorage) throw new Error("D1 receipt storage failed"); + if (events.has(args[0])) throw new Error("UNIQUE billing_events.event_id"); + events.set(args[0], { id: args[0], type: args[1], processedAt: args[2] }); + return { meta: { changes: 1 } }; + } + if (/INSERT OR IGNORE INTO billing_accounts/i.test(sql)) { + const [identity, customerId, updatedAt] = args; + if ( + accounts.has(identity) + || [...accounts.values()].some((account) => account.stripeCustomerId === customerId) + ) { + return { meta: { changes: 0 } }; + } + accounts.set(identity, { + accountIdentity: identity, + stripeCustomerId: customerId, + stripeSubscriptionId: null, + priceId: null, + status: "none", + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + updatedAt, + }); + return { meta: { changes: 1 } }; + } + throw new Error(`Unsupported D1 run: ${sql}`); + }, + }; + } + + return { + accounts, + events, + prepare(sql) { + return bound(sql); + }, + async batch(statements) { + if (!beforeBatchRan && options.beforeBatch) { + beforeBatchRan = true; + options.beforeBatch({ accounts, events }); + } + if (options.failBatchStorage) throw new Error("D1 account storage failed"); + const nextAccounts = new Map([...accounts].map(([key, value]) => [key, { ...value }])); + const nextEvents = new Map(events); + const results = []; + for (const statement of statements) { + const { sql, args } = statement; + if (/INSERT INTO billing_events/i.test(sql)) { + if (nextEvents.has(args[0])) throw new Error("UNIQUE billing_events.event_id"); + nextEvents.set(args[0], { id: args[0], type: args[1], processedAt: args[2] }); + results.push({ meta: { changes: 1 } }); + continue; + } + if (/INSERT INTO billing_accounts/i.test(sql)) { + const [identity, customerId, subscriptionId, priceId, status, + currentPeriodEnd, cancelAtPeriodEnd, updatedAt] = args; + const customerOwner = [...nextAccounts.values()].find((account) => + account.stripeCustomerId === customerId && account.accountIdentity !== identity); + const subscriptionOwner = subscriptionId + ? [...nextAccounts.values()].find((account) => + account.stripeSubscriptionId === subscriptionId + && account.accountIdentity !== identity) + : null; + if (customerOwner || subscriptionOwner) throw new Error("UNIQUE billing account mapping"); + const current = nextAccounts.get(identity); + const precedence = (value) => ({ + active: 0, + trialing: 0, + incomplete: 1, + past_due: 2, + paused: 3, + unpaid: 4, + canceled: 5, + incomplete_expired: 5, + none: 6, + })[value] ?? 6; + const canApply = !current || ( + current.stripeCustomerId === customerId + && ( + updatedAt > current.updatedAt + || (updatedAt === current.updatedAt && precedence(status) > precedence(current.status)) + ) + ); + if (!canApply) { + results.push({ meta: { changes: 0 } }); + continue; + } + nextAccounts.set(identity, { + accountIdentity: identity, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId ?? current?.stripeSubscriptionId ?? null, + priceId: priceId ?? current?.priceId ?? null, + status: status === "none" ? current?.status ?? "none" : status, + currentPeriodEnd: currentPeriodEnd ?? current?.currentPeriodEnd ?? null, + cancelAtPeriodEnd: Boolean(cancelAtPeriodEnd), + updatedAt, + }); + results.push({ meta: { changes: 1 } }); + continue; + } + throw new Error(`Unsupported D1 batch: ${sql}`); + } + accounts.clear(); + for (const [key, value] of nextAccounts) accounts.set(key, value); + events.clear(); + for (const [key, value] of nextEvents) events.set(key, value); + return results; + }, + }; +} + +test("guest, member and Pro entitlements expand honestly while BYOK remains zero-markup", () => { + const { + billingEntitlements, + billingTierForAccount, + memberPlatformBuildLimit, + } = modules(); + const guest = billingEntitlements("guest"); + const member = billingEntitlements("member"); + const pro = billingEntitlements("pro"); + + assert.deepEqual( + [guest.platformDailyBuilds, member.platformDailyBuilds, pro.platformDailyBuilds], + [3, 10, 100], + ); + assert.deepEqual( + [guest.privateProjects, member.privateProjects, pro.privateProjects], + [0, 50, 500], + ); + assert.deepEqual( + [guest.teamWorkspaces, member.teamWorkspaces, pro.teamWorkspaces], + [0, 0, 10], + ); + for (const entitlement of [guest, member, pro]) { + assert.deepEqual(entitlement.byok, { + available: true, + sessionOnly: true, + billingOwner: "user", + markupBasisPoints: 0, + providers: ["openrouter", "openai", "anthropic", "kimi", "custom"], + }); + } + + const active = { + accountIdentity, + stripeCustomerId: "cus_owner_123456", + stripeSubscriptionId: "sub_pro_123456", + priceId: "price_other_product", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + updatedAt: "2026-07-30T12:00:00.000Z", + }; + const now = new Date("2026-07-30T12:00:01.000Z"); + assert.equal(billingTierForAccount(active, "price_pro_monthly", now), "member"); + assert.equal(memberPlatformBuildLimit(active, "price_pro_monthly", now), 10); + assert.equal( + billingTierForAccount( + { ...active, priceId: "price_pro_monthly" }, + "price_pro_monthly", + now, + ), + "pro", + ); + assert.equal( + memberPlatformBuildLimit( + { ...active, priceId: "price_pro_monthly" }, + "price_pro_monthly", + now, + ), + 100, + ); + assert.equal( + billingTierForAccount( + { + ...active, + priceId: "price_pro_monthly", + currentPeriodEnd: "2026-07-30T12:00:01.000Z", + }, + "price_pro_monthly", + now, + ), + "member", + ); + assert.equal( + billingTierForAccount( + { ...active, priceId: "price_pro_monthly", currentPeriodEnd: null }, + "price_pro_monthly", + now, + ), + "member", + ); +}); + +test("checkout reuses an owner mapping and sends one server-owned recurring Price", async () => { + const { createProCheckout } = modules(); + let stored = null; + let customerCreates = 0; + const checkoutCalls = []; + const repository = { + async readAccount() { + return stored; + }, + async saveCustomer(identity, customerId) { + stored ??= { + accountIdentity: identity, + stripeCustomerId: customerId, + stripeSubscriptionId: null, + priceId: null, + status: "none", + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + updatedAt: "2026-07-30T12:00:00.000Z", + }; + return stored; + }, + }; + const provider = { + async createCustomer(input) { + customerCreates += 1; + assert.equal(input.accountIdentity, accountIdentity); + assert.doesNotMatch(JSON.stringify(input), /secret|token|api.?key/i); + return { id: "cus_owner_123456" }; + }, + async createCheckoutSession(input) { + checkoutCalls.push(input); + return { + id: `cs_checkout_${checkoutCalls.length}`, + url: "https://checkout.stripe.com/c/pay/cs_checkout", + }; + }, + }; + + const input = { + accountIdentity, + origin: "https://drops-studio.vercel.app", + consent: true, + }; + const options = { + config: { priceId: "price_pro_monthly", portalReturnPath: "/studio" }, + repository, + provider, + }; + await createProCheckout(input, options); + await createProCheckout(input, options); + + assert.equal(customerCreates, 1); + assert.equal(checkoutCalls.length, 2); + assert.equal(checkoutCalls[0].customerId, "cus_owner_123456"); + assert.deepEqual(checkoutCalls[0].lineItems, [ + { price: "price_pro_monthly", quantity: 1 }, + ]); + assert.equal(checkoutCalls[0].mode, "subscription"); + assert.equal(checkoutCalls[0].allowPromotionCodes, false); + assert.match( + checkoutCalls[0].idempotencyKey, + /^drops-checkout-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/, + ); + assert.notEqual( + checkoutCalls[0].idempotencyKey, + checkoutCalls[1].idempotencyKey, + "each server-issued checkout attempt must receive a fresh Stripe idempotency key", + ); +}); + +test("checkout refuses a second session for an already-active Pro account", async () => { + const { BillingValidationError, createProCheckout } = modules(); + let providerTouched = false; + const active = { + accountIdentity, + stripeCustomerId: "cus_active_pro_123456", + stripeSubscriptionId: "sub_active_pro_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + updatedAt: "2026-07-30T12:00:00.000Z", + }; + + await assert.rejects( + () => createProCheckout( + { + accountIdentity, + origin: "https://drops-studio.vercel.app", + consent: true, + }, + { + config: { priceId: "price_pro_monthly", portalReturnPath: "/studio" }, + repository: { + async readAccount() { return active; }, + async saveCustomer() { throw new Error("must not save"); }, + }, + provider: { + async createCustomer() { providerTouched = true; throw new Error("must not call"); }, + async createCheckoutSession() { providerTouched = true; throw new Error("must not call"); }, + async createPortalSession() { providerTouched = true; throw new Error("must not call"); }, + }, + }, + ), + BillingValidationError, + ); + assert.equal(providerTouched, false); +}); + +test("raw-body Stripe verification is signature-bound and webhook application is idempotent", async () => { + const { + applyBillingWebhookEvent, + readBillingAccount, + resetLocalBillingStateForTests, + verifyStripeWebhook, + } = modules(); + resetLocalBillingStateForTests(); + const payload = JSON.stringify({ + id: "evt_subscription_active_123", + object: "event", + api_version: "2026-02-25.clover", + created: 1785412800, + livemode: false, + pending_webhooks: 1, + request: null, + type: "customer.subscription.updated", + data: { + object: { + id: "sub_pro_123456", + object: "subscription", + customer: "cus_owner_123456", + status: "active", + cancel_at_period_end: false, + metadata: { drops_account_identity: accountIdentity }, + items: { + data: [{ + current_period_end: 1788004800, + price: { id: "price_pro_monthly", object: "price" }, + }], + }, + }, + }, + }); + const stripe = new Stripe("sk_test_not_used_for_network_calls"); + const signature = stripe.webhooks.generateTestHeaderString({ + payload, + secret: webhookSecret, + timestamp: 1785412800, + }); + + assert.throws( + () => verifyStripeWebhook(Buffer.from(payload), `${signature}bad`, webhookSecret), + /signature/i, + ); + const event = verifyStripeWebhook( + Buffer.from(payload), + signature, + webhookSecret, + 1785412800, + ); + assert.equal(event.mutation, "subscription"); + assert.equal(event.currentPeriodEnd, "2026-08-29T12:00:00.000Z"); + + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + const first = await applyBillingWebhookEvent(event); + const duplicate = await applyBillingWebhookEvent(event); + const account = await readBillingAccount(accountIdentity); + + assert.deepEqual(first, { status: "processed" }); + assert.deepEqual(duplicate, { status: "duplicate" }); + assert.equal(account.status, "active"); + assert.equal(account.stripeCustomerId, "cus_owner_123456"); + assert.equal(account.stripeSubscriptionId, "sub_pro_123456"); + assert.equal(account.priceId, "price_pro_monthly"); + assert.equal(JSON.stringify(account).includes(webhookSecret), false); + }, + ); +}); + +test("billing canonicalizes offset webhook instants before persistence", async () => { + const { + applyBillingWebhookEvent, + readBillingAccount, + resetLocalBillingStateForTests, + } = modules(); + resetLocalBillingStateForTests(); + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + await applyBillingWebhookEvent({ + id: "evt_offset_time_123456", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T14:00:00+02:00", + accountIdentity, + stripeCustomerId: "cus_offset_123456", + stripeSubscriptionId: "sub_offset_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + }); + assert.equal( + (await readBillingAccount(accountIdentity)).updatedAt, + "2026-07-30T12:00:00.000Z", + ); + }, + ); +}); + +test("new customer placeholders accept subscription events created before checkout persistence", async () => { + const { + applyBillingWebhookEvent, + readBillingAccount, + resetLocalBillingStateForTests, + saveBillingCustomer, + } = modules(); + resetLocalBillingStateForTests(); + + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + const placeholder = await saveBillingCustomer(accountIdentity, "cus_placeholder_123456"); + assert.equal(placeholder.updatedAt, "1970-01-01T00:00:00.000Z"); + + const result = await applyBillingWebhookEvent({ + id: "evt_placeholder_active_123", + type: "customer.subscription.created", + mutation: "subscription", + createdAt: "2000-01-01T00:00:00.000Z", + accountIdentity, + stripeCustomerId: "cus_placeholder_123456", + stripeSubscriptionId: "sub_placeholder_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2099-01-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const account = await readBillingAccount(accountIdentity); + + assert.deepEqual(result, { status: "processed" }); + assert.equal(account?.status, "active"); + assert.equal(account?.updatedAt, "2000-01-01T00:00:00.000Z"); + }, + ); +}); + +test("unsupported Stripe events and unsupported Prices never grant Pro", async () => { + const { + applyBillingWebhookEvent, + billingTierForAccount, + readBillingAccount, + resetLocalBillingStateForTests, + verifyStripeWebhook, + } = modules(); + resetLocalBillingStateForTests(); + const payload = JSON.stringify({ + id: "evt_payment_intent_ignored_123", + object: "event", + api_version: "2026-02-25.clover", + created: 1785412800, + type: "payment_intent.succeeded", + data: { + object: { + id: "pi_ignored_123456", + customer: "cus_ignored_123456", + status: "active", + metadata: { drops_account_identity: accountIdentity }, + items: { data: [{ price: { id: "price_pro_monthly" } }] }, + }, + }, + }); + const stripe = new Stripe("sk_test_not_used_for_network_calls"); + const signature = stripe.webhooks.generateTestHeaderString({ + payload, + secret: webhookSecret, + timestamp: 1785412800, + }); + const event = verifyStripeWebhook( + Buffer.from(payload), + signature, + webhookSecret, + 1785412800, + ); + assert.equal(event.mutation, "ignored"); + + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + assert.deepEqual(await applyBillingWebhookEvent(event), { status: "ignored" }); + assert.equal(await readBillingAccount(accountIdentity), null); + + await applyBillingWebhookEvent({ + id: "evt_wrong_price_active_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T13:00:00.000Z", + accountIdentity, + stripeCustomerId: "cus_wrong_price_123456", + stripeSubscriptionId: "sub_wrong_price_123456", + priceId: "price_other_product", + status: "active", + currentPeriodEnd: "2026-08-30T13:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const wrongPrice = await readBillingAccount(accountIdentity); + assert.equal( + billingTierForAccount( + wrongPrice, + "price_pro_monthly", + new Date("2026-07-30T13:00:01.000Z"), + ), + "member", + ); + }, + ); +}); + +test("older Stripe events are receipted but cannot roll back newer subscription state", async () => { + const { + applyBillingWebhookEvent, + readBillingAccount, + resetLocalBillingStateForTests, + } = modules(); + resetLocalBillingStateForTests(); + const base = { + mutation: "subscription", + type: "customer.subscription.updated", + accountIdentity, + stripeCustomerId: "cus_ordered_123456", + stripeSubscriptionId: "sub_ordered_123456", + currentPeriodEnd: "2026-09-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + }; + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + const newer = await applyBillingWebhookEvent({ + ...base, + id: "evt_newer_active_123", + createdAt: "2026-08-01T00:00:00.000Z", + priceId: "price_pro_monthly", + status: "active", + }); + const stale = await applyBillingWebhookEvent({ + ...base, + id: "evt_older_canceled_123", + type: "customer.subscription.deleted", + createdAt: "2026-07-01T00:00:00.000Z", + priceId: "price_other_product", + status: "canceled", + }); + const replay = await applyBillingWebhookEvent({ + ...base, + id: "evt_older_canceled_123", + type: "customer.subscription.deleted", + createdAt: "2026-07-01T00:00:00.000Z", + priceId: "price_other_product", + status: "canceled", + }); + const account = await readBillingAccount(accountIdentity); + + assert.deepEqual(newer, { status: "processed" }); + assert.deepEqual(stale, { status: "stale" }); + assert.deepEqual(replay, { status: "duplicate" }); + assert.equal(account.status, "active"); + assert.equal(account.priceId, "price_pro_monthly"); + assert.equal(account.updatedAt, "2026-08-01T00:00:00.000Z"); + }, + ); +}); + +test("same-created Stripe delivery always keeps the more restrictive status", async () => { + const { + applyBillingWebhookEvent, + readBillingAccount, + resetLocalBillingStateForTests, + } = modules(); + const base = { + mutation: "subscription", + accountIdentity, + stripeCustomerId: "cus_same_second_123456", + stripeSubscriptionId: "sub_same_second_123456", + priceId: "price_pro_monthly", + currentPeriodEnd: "2026-09-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + createdAt: "2026-07-30T12:00:00.000Z", + }; + const active = { + ...base, + id: "evt_same_second_active_123", + type: "customer.subscription.updated", + status: "active", + }; + const canceled = { + ...base, + id: "evt_same_second_canceled_123", + type: "customer.subscription.deleted", + status: "canceled", + }; + + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + for (const [index, events] of [[active, canceled], [canceled, active]].entries()) { + resetLocalBillingStateForTests(); + const results = []; + for (const event of events) results.push(await applyBillingWebhookEvent(event)); + const account = await readBillingAccount(accountIdentity); + assert.equal(account.status, "canceled"); + assert.equal(account.updatedAt, base.createdAt); + assert.equal(results[1].status, index === 0 ? "processed" : "stale"); + } + }, + ); +}); + +test("local billing never reassigns Stripe customers or subscriptions across account identities", async () => { + const { + applyBillingWebhookEvent, + readBillingAccount, + resetLocalBillingStateForTests, + saveBillingCustomer, + } = modules(); + const secondIdentity = "b".repeat(64); + resetLocalBillingStateForTests(); + const telemetry = []; + const originalWarn = console.warn; + console.warn = (...args) => telemetry.push(args); + + try { + await withEnv( + { DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", VERCEL: undefined }, + async () => { + await saveBillingCustomer(accountIdentity, "cus_local_owner_123456"); + await assert.rejects( + () => saveBillingCustomer(secondIdentity, "cus_local_owner_123456"), + /already linked|mapping/i, + ); + + await applyBillingWebhookEvent({ + id: "evt_local_subscription_owner_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2099-07-30T12:00:00.000Z", + accountIdentity, + stripeCustomerId: "cus_local_owner_123456", + stripeSubscriptionId: "sub_local_owner_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2099-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const conflictingCustomer = await applyBillingWebhookEvent({ + id: "evt_local_customer_conflict_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2099-07-30T13:00:00.000Z", + accountIdentity: secondIdentity, + stripeCustomerId: "cus_local_owner_123456", + stripeSubscriptionId: "sub_local_other_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2099-08-30T13:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const conflictingSubscription = await applyBillingWebhookEvent({ + id: "evt_local_subscription_conflict_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2099-07-30T14:00:00.000Z", + accountIdentity: secondIdentity, + stripeCustomerId: "cus_local_other_123456", + stripeSubscriptionId: "sub_local_owner_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2099-08-30T14:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const metadataFreeReassignment = await applyBillingWebhookEvent({ + id: "evt_local_metadata_free_conflict_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2099-07-30T15:00:00.000Z", + accountIdentity: null, + stripeCustomerId: "cus_local_reassigned_123456", + stripeSubscriptionId: "sub_local_owner_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2099-08-30T15:00:00.000Z", + cancelAtPeriodEnd: false, + }); + + assert.deepEqual(conflictingCustomer, { status: "ignored" }); + assert.deepEqual(conflictingSubscription, { status: "ignored" }); + assert.deepEqual(metadataFreeReassignment, { status: "ignored" }); + assert.equal((await readBillingAccount(accountIdentity)).stripeCustomerId, "cus_local_owner_123456"); + assert.equal((await readBillingAccount(accountIdentity)).stripeSubscriptionId, "sub_local_owner_123456"); + assert.equal(await readBillingAccount(secondIdentity), null); + }, + ); + } finally { + console.warn = originalWarn; + } + + assert.equal(telemetry.length, 3); + for (const entry of telemetry) { + assert.deepEqual(entry, [ + "Drops Studio billing integrity event.", + { code: "BILLING_MAPPING_CONFLICT", source: "stripe-webhook" }, + ]); + } + assert.doesNotMatch( + JSON.stringify(telemetry), + new RegExp(`${accountIdentity}|${secondIdentity}|cus_|sub_|evt_|token`, "i"), + ); +}); + +test("D1 receipts identity conflicts once while unrelated storage failures remain retryable", async () => { + const { + applyBillingWebhookEvent, + BillingStorageUnavailableError, + } = modules(); + const secondIdentity = "b".repeat(64); + const owner = { + accountIdentity, + stripeCustomerId: "cus_d1_owner_123456", + stripeSubscriptionId: "sub_d1_owner_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + updatedAt: "2026-07-30T12:00:00.000Z", + }; + const db = fakeBillingD1([owner]); + const event = { + id: "evt_d1_mapping_conflict_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T13:00:00.000Z", + accountIdentity: secondIdentity, + stripeCustomerId: owner.stripeCustomerId, + stripeSubscriptionId: "sub_d1_other_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T13:00:00.000Z", + cancelAtPeriodEnd: false, + }; + + globalThis.__DROPS_STUDIO_ENV__ = { DB: db }; + try { + assert.deepEqual(await applyBillingWebhookEvent(event), { status: "ignored" }); + assert.deepEqual(await applyBillingWebhookEvent(event), { status: "duplicate" }); + const subscriptionConflict = { + ...event, + id: "evt_d1_subscription_conflict_123", + stripeCustomerId: "cus_d1_other_123456", + stripeSubscriptionId: owner.stripeSubscriptionId, + }; + assert.deepEqual( + await applyBillingWebhookEvent(subscriptionConflict), + { status: "ignored" }, + ); + assert.equal(db.events.has(event.id), true); + assert.equal(db.events.has(subscriptionConflict.id), true); + assert.deepEqual(db.accounts.get(accountIdentity), owner); + assert.equal(db.accounts.has(secondIdentity), false); + } finally { + globalThis.__DROPS_STUDIO_ENV__ = undefined; + } + + const raceOwner = { ...owner, stripeCustomerId: "cus_d1_race_123456" }; + const race = fakeBillingD1([], { + beforeBatch({ accounts }) { + accounts.set(accountIdentity, raceOwner); + }, + }); + const racedEvent = { + ...event, + id: "evt_d1_raced_mapping_123", + stripeCustomerId: raceOwner.stripeCustomerId, + stripeSubscriptionId: "sub_d1_raced_other_123456", + }; + globalThis.__DROPS_STUDIO_ENV__ = { DB: race }; + try { + assert.deepEqual(await applyBillingWebhookEvent(racedEvent), { status: "ignored" }); + assert.deepEqual(await applyBillingWebhookEvent(racedEvent), { status: "duplicate" }); + assert.equal(race.events.has(racedEvent.id), true); + assert.equal(race.accounts.has(secondIdentity), false); + } finally { + globalThis.__DROPS_STUDIO_ENV__ = undefined; + } + + const unavailable = fakeBillingD1([], { failBatchStorage: true }); + globalThis.__DROPS_STUDIO_ENV__ = { DB: unavailable }; + try { + await assert.rejects( + () => applyBillingWebhookEvent({ + ...event, + id: "evt_d1_storage_failure_123", + stripeCustomerId: "cus_d1_new_owner_123456", + stripeSubscriptionId: "sub_d1_new_owner_123456", + }), + BillingStorageUnavailableError, + ); + assert.equal(unavailable.events.has("evt_d1_storage_failure_123"), false); + } finally { + globalThis.__DROPS_STUDIO_ENV__ = undefined; + } +}); + +test("D1 reports a receipted webhook as stale when its conditional account write loses an ordering race", async () => { + const { applyBillingWebhookEvent } = modules(); + const older = { + accountIdentity, + stripeCustomerId: "cus_d1_ordering_123456", + stripeSubscriptionId: "sub_d1_ordering_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-09-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + updatedAt: "2026-07-30T12:00:00.000Z", + }; + const newer = { + ...older, + status: "canceled", + updatedAt: "2026-07-30T14:00:00.000Z", + }; + const db = fakeBillingD1([older], { + beforeBatch({ accounts }) { + accounts.set(accountIdentity, newer); + }, + }); + const event = { + id: "evt_d1_ordering_race_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T13:00:00.000Z", + accountIdentity, + stripeCustomerId: older.stripeCustomerId, + stripeSubscriptionId: older.stripeSubscriptionId, + priceId: older.priceId, + status: "past_due", + currentPeriodEnd: older.currentPeriodEnd, + cancelAtPeriodEnd: false, + }; + + globalThis.__DROPS_STUDIO_ENV__ = { DB: db }; + try { + assert.deepEqual(await applyBillingWebhookEvent(event), { status: "stale" }); + assert.deepEqual(await applyBillingWebhookEvent(event), { status: "duplicate" }); + assert.deepEqual(db.accounts.get(accountIdentity), newer); + assert.equal(db.events.has(event.id), true); + } finally { + globalThis.__DROPS_STUDIO_ENV__ = undefined; + } +}); + +test("checkout endpoint fails closed with 503 when Stripe is unconfigured", async () => { + assert.ok(checkoutRouteModule, "checkout route must exist"); + const { createStudioAccountCookie, STUDIO_ACCOUNT_COOKIE } = await import( + "../lib/access-tier.ts" + ); + const cookie = createStudioAccountCookie( + { provider: "openrouter", subject: "billing-user-123" }, + accountSecret, + ); + await withEnv( + { + DROPS_ACCOUNT_COOKIE_SECRET: accountSecret, + STRIPE_SECRET_KEY: undefined, + STRIPE_PRO_PRICE_ID: undefined, + STRIPE_WEBHOOK_SECRET: undefined, + }, + async () => { + const response = await checkoutRouteModule.POST( + new NextRequest("https://drops-studio.vercel.app/api/billing/checkout", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://drops-studio.vercel.app", + }, + body: JSON.stringify({ consent: true }), + }), + ); + const body = await response.json(); + assert.equal(response.status, 503); + assert.match(body.error, /not configured|unavailable/i); + assert.doesNotMatch(JSON.stringify(body), /STRIPE_|sk_|whsec_/); + }, + ); +}); + +test("checkout endpoint rejects JSON-like media types before calling Stripe", async () => { + assert.ok(checkoutRouteModule, "checkout route must exist"); + const { createStudioAccountCookie, STUDIO_ACCOUNT_COOKIE } = await import( + "../lib/access-tier.ts" + ); + const cookie = createStudioAccountCookie( + { provider: "openrouter", subject: "billing-boundary-user-123" }, + accountSecret, + ); + await withEnv( + { + DROPS_ACCOUNT_COOKIE_SECRET: accountSecret, + DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", + VERCEL: undefined, + STRIPE_SECRET_KEY: "sk_test_boundary_only_123456", + STRIPE_PRO_PRICE_ID: "price_pro_monthly", + }, + async () => { + const response = await checkoutRouteModule.POST( + new NextRequest("https://drops-studio.vercel.app/api/billing/checkout", { + method: "POST", + headers: { + "content-type": "application/jsonp", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://drops-studio.vercel.app", + }, + body: JSON.stringify({ consent: true }), + }), + ); + assert.equal(response.status, 415); + }, + ); +}); + +test("customer portal is available only for the signed owner's mapped customer", async () => { + const { createCustomerPortal } = modules(); + const calls = []; + const result = await createCustomerPortal( + { + accountIdentity, + origin: "https://drops-studio.vercel.app", + }, + { + config: { priceId: "price_pro_monthly", portalReturnPath: "/studio" }, + repository: { + async readAccount(identity) { + assert.equal(identity, accountIdentity); + return { + accountIdentity, + stripeCustomerId: "cus_owner_123456", + stripeSubscriptionId: "sub_pro_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + updatedAt: "2026-07-30T12:00:00.000Z", + }; + }, + async saveCustomer() { + throw new Error("portal must not create a customer"); + }, + }, + provider: { + async createCustomer() { + throw new Error("portal must not create a customer"); + }, + async createCheckoutSession() { + throw new Error("portal must not create checkout"); + }, + async createPortalSession(input) { + calls.push(input); + return { + id: "bps_portal_123456", + url: "https://billing.stripe.com/p/session/test", + }; + }, + }, + }, + ); + + assert.deepEqual(calls, [{ + customerId: "cus_owner_123456", + returnUrl: "https://drops-studio.vercel.app/studio", + }]); + assert.equal(result.sessionId, "bps_portal_123456"); + assert.equal(result.portalUrl, "https://billing.stripe.com/p/session/test"); +}); + +test("webhook route verifies raw bytes before parsing and returns duplicate receipts", async () => { + assert.ok(webhookRouteModule, "billing webhook route must exist"); + const { resetLocalBillingStateForTests } = modules(); + resetLocalBillingStateForTests(); + const payload = JSON.stringify({ + id: "evt_route_subscription_123", + object: "event", + api_version: "2026-02-25.clover", + created: 1785412800, + livemode: false, + pending_webhooks: 1, + request: null, + type: "customer.subscription.updated", + data: { + object: { + id: "sub_route_123456", + object: "subscription", + customer: "cus_route_123456", + status: "trialing", + cancel_at_period_end: false, + current_period_end: 1788004800, + metadata: { drops_account_identity: accountIdentity }, + items: { data: [{ price: { id: "price_pro_monthly" } }] }, + }, + }, + }); + const stripe = new Stripe("sk_test_not_used_for_network_calls"); + const signature = stripe.webhooks.generateTestHeaderString({ + payload, + secret: webhookSecret, + }); + + await withEnv( + { + DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", + VERCEL: undefined, + STRIPE_WEBHOOK_SECRET: webhookSecret, + }, + async () => { + const send = () => webhookRouteModule.POST( + new NextRequest("https://drops-studio.vercel.app/api/billing/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": signature, + }, + body: payload, + }), + ); + const first = await send(); + const duplicate = await send(); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { received: true, duplicate: false }); + assert.equal(duplicate.status, 200); + assert.deepEqual(await duplicate.json(), { received: true, duplicate: true }); + + const tampered = await webhookRouteModule.POST( + new NextRequest("https://drops-studio.vercel.app/api/billing/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": signature, + }, + body: `${payload} `, + }), + ); + assert.equal(tampered.status, 400); + }, + ); +}); + +test("billing status derives Pro only from an active signed owner mapping", async () => { + assert.ok(statusRouteModule, "billing status route must exist"); + assert.ok(portalRouteModule, "billing portal route must exist"); + const { + applyBillingWebhookEvent, + resetLocalBillingStateForTests, + } = modules(); + const { + createStudioAccountCookie, + readStudioAccountCookie, + STUDIO_ACCOUNT_COOKIE, + } = await import("../lib/access-tier.ts"); + const cookie = createStudioAccountCookie( + { provider: "openrouter", subject: "billing-status-user" }, + accountSecret, + ); + const account = readStudioAccountCookie(cookie, accountSecret); + assert.ok(account); + resetLocalBillingStateForTests(); + + await withEnv( + { + DROPS_ACCOUNT_COOKIE_SECRET: accountSecret, + DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", + STRIPE_PRO_PRICE_ID: "price_pro_monthly", + VERCEL: undefined, + }, + async () => { + await applyBillingWebhookEvent({ + id: "evt_status_active_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T12:00:00.000Z", + accountIdentity: account.identity, + stripeCustomerId: "cus_status_123456", + stripeSubscriptionId: "sub_status_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const response = await statusRouteModule.GET( + new NextRequest("https://drops-studio.vercel.app/api/billing/status", { + headers: { cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}` }, + }), + ); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.tier, "pro"); + assert.equal(body.entitlements.byok.markupBasisPoints, 0); + assert.equal(body.entitlements.teamWorkspaces, 10); + assert.equal("stripeCustomerId" in body, false); + }, + ); +}); + +test("planner quota enforces 10 member builds and 100 exact-Price Pro builds", async () => { + const planRoute = await import("../app/api/agent/plan/route.ts"); + const { + applyBillingWebhookEvent, + resetLocalBillingStateForTests, + } = modules(); + const { + createStudioAccountCookie, + readStudioAccountCookie, + STUDIO_ACCOUNT_COOKIE, + } = await import("../lib/access-tier.ts"); + const memberCookie = createStudioAccountCookie( + { provider: "openrouter", subject: "quota-member-user" }, + accountSecret, + ); + const proCookie = createStudioAccountCookie( + { provider: "openrouter", subject: "quota-pro-user" }, + accountSecret, + ); + const member = readStudioAccountCookie(memberCookie, accountSecret); + const pro = readStudioAccountCookie(proCookie, accountSecret); + assert.ok(member); + assert.ok(pro); + resetLocalBillingStateForTests(); + + await withEnv({ + DROPS_ACCOUNT_COOKIE_SECRET: accountSecret, + DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", + VERCEL: undefined, + AI_GATEWAY_API_KEY: "gateway_test_key_not_returned", + STRIPE_PRO_PRICE_ID: "price_pro_monthly", + }, async () => { + await applyBillingWebhookEvent({ + id: "evt_quota_pro_active_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T12:00:00.000Z", + accountIdentity: pro.identity, + stripeCustomerId: "cus_quota_pro_123456", + stripeSubscriptionId: "sub_quota_pro_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const windowMs = 24 * 60 * 60 * 1_000; + const bucket = Math.floor(Date.now() / windowMs); + globalThis.__DROPS_STUDIO_LOCAL_RATE_LIMITS__ = new Map( + [bucket, bucket + 1].flatMap((candidate) => { + const expiresAt = (candidate + 1) * windowMs; + return [ + [`member-ai-plan:${candidate}:${member.identity}`, { count: 10, expiresAt }], + [`member-ai-plan:${candidate}:${pro.identity}`, { count: 100, expiresAt }], + ]; + }), + ); + try { + const request = (cookie) => new NextRequest( + "https://drops-studio.vercel.app/api/agent/plan", + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + }, + body: JSON.stringify({ prompt: "Build a real crypto research product" }), + }, + ); + const memberResponse = await planRoute.POST(request(memberCookie)); + const proResponse = await planRoute.POST(request(proCookie)); + const memberBody = await memberResponse.json(); + const proBody = await proResponse.json(); + + assert.equal(memberResponse.status, 429); + assert.equal(memberBody.tier ?? memberBody.access.tier, "member"); + assert.equal(memberBody.access.platformAi.limit, 10); + assert.equal(proResponse.status, 429); + assert.equal(proBody.tier ?? proBody.access.tier, "pro"); + assert.equal(proBody.access.platformAi.limit, 100); + } finally { + delete globalThis.__DROPS_STUDIO_LOCAL_RATE_LIMITS__; + } + }); +}); + +test("shared funded quota helper exposes authoritative guest/member/Pro daily boundaries", async () => { + const { + consumeFundedBuildQuota, + createStudioAccountCookie, + readStudioAccountCookie, + resolveFundedBuildQuota, + } = await import("../lib/access-tier.ts"); + const { + applyBillingWebhookEvent, + resetLocalBillingStateForTests, + } = modules(); + const memberCookie = createStudioAccountCookie( + { provider: "openrouter", subject: "helper-member-user" }, + accountSecret, + ); + const proCookie = createStudioAccountCookie( + { provider: "openrouter", subject: "helper-pro-user" }, + accountSecret, + ); + const member = readStudioAccountCookie(memberCookie, accountSecret); + const pro = readStudioAccountCookie(proCookie, accountSecret); + assert.ok(member); + assert.ok(pro); + resetLocalBillingStateForTests(); + + await withEnv({ + DROPS_STUDIO_LOCAL_PROJECT_STORE: "1", + VERCEL: undefined, + STRIPE_PRO_PRICE_ID: "price_pro_monthly", + }, async () => { + await applyBillingWebhookEvent({ + id: "evt_helper_pro_active_123", + type: "customer.subscription.updated", + mutation: "subscription", + createdAt: "2026-07-30T12:00:00.000Z", + accountIdentity: pro.identity, + stripeCustomerId: "cus_helper_pro_123456", + stripeSubscriptionId: "sub_helper_pro_123456", + priceId: "price_pro_monthly", + status: "active", + currentPeriodEnd: "2026-08-30T12:00:00.000Z", + cancelAtPeriodEnd: false, + }); + const guest = await resolveFundedBuildQuota({ + kind: "guest", + identity: "11111111-1111-4111-8111-111111111111", + }); + const freeMember = await resolveFundedBuildQuota({ kind: "account", account: member }); + const paid = await resolveFundedBuildQuota({ kind: "account", account: pro }); + assert.deepEqual( + [guest.tier, guest.limit, guest.namespace], + ["guest", 3, "guest-ai-plan"], + ); + assert.deepEqual( + [freeMember.tier, freeMember.limit, freeMember.namespace], + ["member", 10, "member-ai-plan"], + ); + assert.deepEqual( + [paid.tier, paid.limit, paid.namespace], + ["pro", 100, "member-ai-plan"], + ); + + let consumedOptions; + const consumed = await consumeFundedBuildQuota( + { kind: "account", account: pro }, + { + async consume(options) { + consumedOptions = options; + return { status: "allowed", count: 11, remaining: 89 }; + }, + }, + ); + assert.equal(consumed.tier, "pro"); + assert.equal(consumed.status, "allowed"); + assert.equal(consumed.remaining, 89); + assert.deepEqual(consumedOptions, { + identity: pro.identity, + namespace: "member-ai-plan", + max: 100, + windowMs: 24 * 60 * 60 * 1_000, + }); + }); +}); diff --git a/tests/dropsbot-webhook.test.mjs b/tests/dropsbot-webhook.test.mjs new file mode 100644 index 0000000..16eaaa1 --- /dev/null +++ b/tests/dropsbot-webhook.test.mjs @@ -0,0 +1,902 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { registerHooks } from "node:module"; +import test from "node:test"; + +import { + createStudioAccountCookie, + readStudioAccountCookie, + STUDIO_ACCOUNT_COOKIE, +} from "../lib/access-tier.ts"; + +const projectRoot = new URL("../", import.meta.url); + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "next/server") return nextResolve("next/server.js", context); + if (!specifier.startsWith("@/")) return nextResolve(specifier, context); + const projectPath = specifier.slice(2); + return { + shortCircuit: true, + url: new URL( + projectPath.endsWith(".ts") ? projectPath : `${projectPath}.ts`, + projectRoot, + ).href, + }; + }, +}); + +const accountSecret = "dropsbot-webhook-test-secret-with-enough-entropy"; +const firstSubject = "user_dropsbot_webhook_owner_one"; +const secondSubject = "user_dropsbot_webhook_owner_two"; +const projectId = "dropsbot-webhook-project"; + +function signedAccount(subject) { + const cookie = createStudioAccountCookie({ + provider: "openrouter", + subject, + }, accountSecret); + const account = readStudioAccountCookie(cookie, accountSecret); + assert.ok(account); + return { account, cookie }; +} + +function seedProject(identity, id = projectId) { + globalThis.__DROPS_STUDIO_LOCAL_MEMBER_PROJECTS__.set(identity, { + schemaVersion: 1, + revision: 1, + updatedAt: "2026-07-30T00:00:00.000Z", + projects: [{ id }], + }); +} + +async function withLocalDropsBot(run) { + const previous = { + NODE_ENV: process.env.NODE_ENV, + DROPS_ACCOUNT_COOKIE_SECRET: process.env.DROPS_ACCOUNT_COOKIE_SECRET, + DROPS_STUDIO_LOCAL_PROJECT_STORE: process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE, + VERCEL: process.env.VERCEL, + }; + process.env.NODE_ENV = "test"; + process.env.DROPS_ACCOUNT_COOKIE_SECRET = accountSecret; + process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE = "1"; + delete process.env.VERCEL; + globalThis.__DROPS_STUDIO_LOCAL_MEMBER_PROJECTS__ = new Map(); + globalThis.__DROPS_STUDIO_LOCAL_DROPSBOT_WEBHOOKS__ = undefined; + globalThis.__DROPS_STUDIO_LOCAL_RATE_LIMITS__ = undefined; + try { + return await run(); + } finally { + globalThis.__DROPS_STUDIO_LOCAL_MEMBER_PROJECTS__ = undefined; + globalThis.__DROPS_STUDIO_LOCAL_DROPSBOT_WEBHOOKS__ = undefined; + globalThis.__DROPS_STUDIO_LOCAL_RATE_LIMITS__ = undefined; + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +async function createConnection(cookie) { + const { POST } = await import("../app/api/dropsbot/webhooks/route.ts"); + const { NextRequest } = await import("next/server.js"); + const response = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://drops.example", + }, + body: JSON.stringify({ projectId, consent: true }), + })); + return { response, payload: await response.json() }; +} + +async function mutateConnection(cookie, method, consent = true) { + const route = await import("../app/api/dropsbot/webhooks/route.ts"); + const { NextRequest } = await import("next/server.js"); + const response = await route[method](new NextRequest( + "https://drops.example/api/dropsbot/webhooks", + { + method, + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://drops.example", + }, + body: JSON.stringify({ projectId, consent }), + }, + )); + return { response, payload: await response.json() }; +} + +function callbackParts(callbackUrl) { + const segments = new URL(callbackUrl).pathname.split("/").filter(Boolean); + return { + connectionId: segments.at(-2), + capability: segments.at(-1), + }; +} + +test("Drops Bot receiver clears stale provider evidence after account expiry", async () => { + const source = await readFile( + new URL("../components/dropsbot-webhook-connection.tsx", import.meta.url), + "utf8", + ); + const unauthorizedBranch = source.slice( + source.indexOf("if (response.status === 401)"), + source.indexOf("if (!response.ok)", source.indexOf("if (response.status === 401)")), + ); + + assert.match(unauthorizedBranch, /setCallbackUrl\(""\)/); + assert.match(unauthorizedBranch, /setEvidence\(null\)/); + assert.match(unauthorizedBranch, /setEvents\(\[\]\)/); + assert.match(unauthorizedBranch, /setConsent\(false\)/); + assert.match(unauthorizedBranch, /setCanCreate\(false\)/); + assert.match(source, /Consent to rotate or revoke the Drops Bot callback/); + assert.match(source, /method === "PUT" \? "rotate" : "revoke"/); + assert.match(source, /mutateCallback\("PUT"\)/); + assert.match(source, /mutateCallback\("DELETE"\)/); + assert.match(source, /current secret URL will stop working immediately/); +}); + +test("Drops Bot callback capabilities store only a hash and redact credential material", async () => { + const { + createDropsBotWebhookCapability, + redactDropsBotWebhookPayload, + verifyDropsBotWebhookCapability, + } = await import("../lib/dropsbot-webhook.ts"); + + const capability = createDropsBotWebhookCapability(); + assert.match(capability.secret, /^[A-Za-z0-9_-]{43}$/); + assert.match(capability.hash, /^[a-f0-9]{64}$/); + assert.equal(verifyDropsBotWebhookCapability(capability.secret, capability.hash), true); + assert.equal(verifyDropsBotWebhookCapability(`${capability.secret}x`, capability.hash), false); + assert.doesNotMatch(capability.hash, new RegExp(capability.secret)); + + const redacted = redactDropsBotWebhookPayload({ + event: "wallet.swap", + wallet: "0x1111111111111111111111111111111111111111", + apiKey: "sk-provider-secret-material-that-must-not-be-stored", + nested: { + authorization: "Bearer provider-secret-material-that-must-not-be-stored", + providerApiKey: "provider-key-with-a-prefixed-field-name", + authorizationHeader: "provider-auth-with-a-suffixed-field-name", + botToken: "123456789:AAabcdefghijklmnopqrstuvwxyz0123456789", + token: { symbol: "ETH" }, + }, + }); + + assert.equal(redacted.apiKey, "[REDACTED]"); + assert.equal(redacted.nested.authorization, "[REDACTED]"); + assert.equal(redacted.nested.providerApiKey, "[REDACTED]"); + assert.equal(redacted.nested.authorizationHeader, "[REDACTED]"); + assert.equal(redacted.nested.botToken, "[REDACTED]"); + assert.equal(redacted.nested.token.symbol, "ETH"); + assert.equal(redacted.wallet, "0x1111111111111111111111111111111111111111"); +}); + +test("callback creation requires a signed project owner, same origin, and explicit consent", async () => { + await withLocalDropsBot(async () => { + const { POST } = await import("../app/api/dropsbot/webhooks/route.ts"); + const { NextRequest } = await import("next/server.js"); + const { account, cookie } = signedAccount(firstSubject); + seedProject(account.identity); + + const anonymous = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://drops.example" }, + body: JSON.stringify({ projectId, consent: true }), + })); + assert.equal(anonymous.status, 401); + + const noConsent = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://drops.example", + }, + body: JSON.stringify({ projectId, consent: false }), + })); + assert.equal(noConsent.status, 400); + assert.equal((await noConsent.json()).code, "DROPSBOT_CONSENT_REQUIRED"); + + const crossOrigin = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://attacker.example", + }, + body: JSON.stringify({ projectId, consent: true }), + })); + assert.equal(crossOrigin.status, 403); + + const missingOrigin = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + }, + body: JSON.stringify({ projectId, consent: true }), + })); + assert.equal(missingOrigin.status, 403); + + const jsonLikeType = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { + "content-type": "application/jsonp", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + origin: "https://drops.example", + }, + body: JSON.stringify({ projectId, consent: true }), + })); + assert.equal(jsonLikeType.status, 415); + + const ownerWithoutProject = signedAccount(secondSubject); + const foreign = await POST(new NextRequest("https://drops.example/api/dropsbot/webhooks", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${ownerWithoutProject.cookie}`, + origin: "https://drops.example", + }, + body: JSON.stringify({ projectId, consent: true }), + })); + assert.equal(foreign.status, 404); + + const { response, payload } = await createConnection(cookie); + assert.equal(response.status, 201); + assert.equal(payload.projectId, projectId); + assert.match(payload.connectionId, /^[a-f0-9-]{36}$/); + assert.match( + payload.callbackUrl, + /^https:\/\/drops\.example\/api\/dropsbot\/webhooks\/[a-f0-9-]{36}\/[A-Za-z0-9_-]{43}$/, + ); + assert.equal(payload.registration.mode, "manual-in-@drops"); + assert.equal(payload.registration.claimedConfigured, false); + assert.equal(payload.callbackEvidence.status, "pending"); + assert.equal(payload.callbackEvidence.providerVerified, false); + assert.equal(payload.callbackEvidence.providerSignatureVerified, false); + }); +}); + +test("callback creation accepts the browser-visible forwarded origin", async () => { + await withLocalDropsBot(async () => { + const { POST } = await import("../app/api/dropsbot/webhooks/route.ts"); + const { NextRequest } = await import("next/server.js"); + const { account, cookie } = signedAccount(firstSubject); + seedProject(account.identity); + + const response = await POST(new NextRequest( + "http://internal:3000/api/dropsbot/webhooks", + { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}`, + host: "drops.example", + origin: "https://drops.example", + "x-forwarded-proto": "https", + }, + body: JSON.stringify({ projectId, consent: true }), + }, + )); + + assert.equal(response.status, 201); + }); +}); + +test("callback creation reports fixed storage capacity separately from transient storage failures", async () => { + await withLocalDropsBot(async () => { + const { account, cookie } = signedAccount(firstSubject); + seedProject(account.identity); + globalThis.__DROPS_STUDIO_LOCAL_DROPSBOT_WEBHOOKS__ = { + schemaVersion: 1, + connections: Array.from({ length: 500 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + ownerIdentity: index.toString(16).padStart(64, "0"), + projectId: `capacity-${index}`, + capabilityHash: "a".repeat(64), + createdAt: "2026-07-30T00:00:00.000Z", + consentedAt: "2026-07-30T00:00:00.000Z", + callbackReceivedAt: null, + lastEventReceivedAt: null, + lastEventContentHash: null, + events: [], + })), + }; + + const { response, payload } = await createConnection(cookie); + assert.equal(response.status, 507); + assert.equal(payload.code, "DROPSBOT_CALLBACK_CAPACITY_REACHED"); + assert.equal(response.headers.get("retry-after"), "3600"); + }); +}); + +test("the signed owner explicitly rotates or revokes a callback and every old capability stops working", async () => { + await withLocalDropsBot(async () => { + const owner = signedAccount(firstSubject); + const foreign = signedAccount(secondSubject); + seedProject(owner.account.identity); + seedProject(foreign.account.identity); + + const created = await createConnection(owner.cookie); + assert.equal(created.response.status, 201); + const original = callbackParts(created.payload.callbackUrl); + + const deniedRotation = await mutateConnection(owner.cookie, "PUT", false); + assert.equal(deniedRotation.response.status, 400); + assert.equal(deniedRotation.payload.code, "DROPSBOT_CONSENT_REQUIRED"); + + const foreignRotation = await mutateConnection(foreign.cookie, "PUT"); + assert.equal(foreignRotation.response.status, 404); + + const rotated = await mutateConnection(owner.cookie, "PUT"); + assert.equal(rotated.response.status, 200); + assert.equal(rotated.payload.connectionId, original.connectionId); + assert.equal(rotated.payload.callbackEvidence.status, "pending"); + assert.equal(rotated.payload.registration.claimedConfigured, false); + const replacement = callbackParts(rotated.payload.callbackUrl); + assert.notEqual(replacement.capability, original.capability); + + const callback = await import("../app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts"); + const { NextRequest } = await import("next/server.js"); + const oldCapability = await callback.POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: "wallet.swap", generation: "old" }), + }), { params: Promise.resolve(original) }); + assert.equal(oldCapability.status, 404); + + const replacementCapability = await callback.POST(new NextRequest(rotated.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: "wallet.swap", generation: "replacement" }), + }), { params: Promise.resolve(replacement) }); + assert.equal(replacementCapability.status, 202); + assert.equal((await replacementCapability.json()).callbackEvidence.status, "callback-received"); + + const deniedRevocation = await mutateConnection(owner.cookie, "DELETE", false); + assert.equal(deniedRevocation.response.status, 400); + + const revoked = await mutateConnection(owner.cookie, "DELETE"); + assert.equal(revoked.response.status, 200); + assert.equal(revoked.payload.revoked, true); + + const revokedCapability = await callback.POST(new NextRequest(rotated.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: "wallet.swap", generation: "revoked" }), + }), { params: Promise.resolve(replacement) }); + assert.equal(revokedCapability.status, 404); + + const recreated = await createConnection(owner.cookie); + assert.equal(recreated.response.status, 201); + assert.notEqual(recreated.payload.connectionId, original.connectionId); + }); +}); + +test("callback ingress enforces JSON and byte limits before recording callback evidence", async () => { + await withLocalDropsBot(async () => { + const { account, cookie } = signedAccount(firstSubject); + seedProject(account.identity); + const created = await createConnection(cookie); + assert.equal(created.response.status, 201); + const { connectionId, capability } = callbackParts(created.payload.callbackUrl); + const { POST } = await import("../app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts"); + const { NextRequest } = await import("next/server.js"); + const { + DROPSBOT_WEBHOOK_BODY_LIMIT_BYTES, + } = await import("../lib/dropsbot-webhook.ts"); + + const wrongCapability = await POST(new NextRequest( + `https://drops.example/api/dropsbot/webhooks/${connectionId}/${"x".repeat(43)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }, + ), { params: Promise.resolve({ connectionId, capability: "x".repeat(43) }) }); + assert.equal(wrongCapability.status, 404); + + const wrongType = await POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }), { params: Promise.resolve({ connectionId, capability }) }); + assert.equal(wrongType.status, 415); + + const jsonLikeType = await POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/jsonp" }, + body: "{}", + }), { params: Promise.resolve({ connectionId, capability }) }); + assert.equal(jsonLikeType.status, 415); + + const malformed = await POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not-json", + }), { params: Promise.resolve({ connectionId, capability }) }); + assert.equal(malformed.status, 400); + + const oversized = await POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(DROPSBOT_WEBHOOK_BODY_LIMIT_BYTES + 1), + }, + body: "{}", + }), { params: Promise.resolve({ connectionId, capability }) }); + assert.equal(oversized.status, 413); + + const actualOversized = await POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ data: "x".repeat(DROPSBOT_WEBHOOK_BODY_LIMIT_BYTES) }), + }), { params: Promise.resolve({ connectionId, capability }) }); + assert.equal(actualOversized.status, 413); + + const { GET } = await import("../app/api/dropsbot/events/route.ts"); + const listed = await GET(new NextRequest( + `https://drops.example/api/dropsbot/events?projectId=${projectId}`, + { headers: { cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}` } }, + )); + const listedPayload = await listed.json(); + assert.equal(listed.status, 200); + assert.equal(listedPayload.events.length, 0); + assert.equal(listedPayload.callbackEvidence.status, "pending"); + assert.equal(listedPayload.registration.claimedConfigured, false); + }); +}); + +test("callback ingress rate limits a valid capability before body or event storage", async () => { + await withLocalDropsBot(async () => { + const { account, cookie } = signedAccount(firstSubject); + seedProject(account.identity); + const created = await createConnection(cookie); + assert.equal(created.response.status, 201); + const { connectionId, capability } = callbackParts(created.payload.callbackUrl); + const callback = await import("../app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts"); + const { NextRequest } = await import("next/server.js"); + const windowMs = 60_000; + const bucket = Math.floor(Date.now() / windowMs); + globalThis.__DROPS_STUDIO_LOCAL_RATE_LIMITS__ = new Map( + [bucket, bucket + 1].map((candidate) => [ + `dropsbot-callback:${candidate}:${connectionId}:null`, + { count: 120, expiresAt: (candidate + 1) * windowMs }, + ]), + ); + + const limited = await callback.POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ event: "wallet.swap", shouldNotPersist: true }), + }), { params: Promise.resolve({ connectionId, capability }) }); + + assert.equal(limited.status, 429); + assert.equal(limited.headers.get("retry-after"), "60"); + assert.equal((await limited.json()).code, "DROPSBOT_WEBHOOK_RATE_LIMITED"); + + const { GET } = await import("../app/api/dropsbot/events/route.ts"); + const listed = await GET(new NextRequest( + `https://drops.example/api/dropsbot/events?projectId=${projectId}`, + { headers: { cookie: `${STUDIO_ACCOUNT_COOKIE}=${cookie}` } }, + )); + const listedPayload = await listed.json(); + assert.equal(listedPayload.events.length, 0); + assert.equal(listedPayload.callbackEvidence.status, "pending"); + + const routeSource = await readFile( + new URL("../app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts", import.meta.url), + "utf8", + ); + const limitAt = routeSource.indexOf("consumeRequestLimit({"); + const bodyAt = routeSource.indexOf("readDropsBotWebhookBody(request)"); + assert.ok( + limitAt >= 0 && bodyAt >= 0, + "both callback guards must be present", + ); + assert.ok( + limitAt < bodyAt, + "callback rate limiting must run before the body is read or storage is mutated", + ); + }); +}); + +test("accepted callbacks are content-hash idempotent, redacted, and owner-scoped", async () => { + await withLocalDropsBot(async () => { + const first = signedAccount(firstSubject); + const second = signedAccount(secondSubject); + seedProject(first.account.identity); + seedProject(second.account.identity); + const created = await createConnection(first.cookie); + assert.equal(created.response.status, 201); + const { connectionId, capability } = callbackParts(created.payload.callbackUrl); + const callback = await import("../app/api/dropsbot/webhooks/[connectionId]/[capability]/route.ts"); + const events = await import("../app/api/dropsbot/events/route.ts"); + const { NextRequest } = await import("next/server.js"); + const body = JSON.stringify({ + event: "wallet.swap", + wallet: "0x2222222222222222222222222222222222222222", + callbackUrl: created.payload.callbackUrl, + apiKey: "sk-provider-secret-material-that-must-not-be-stored", + details: { + authorization: "Bearer provider-secret-material-that-must-not-be-stored", + token: { symbol: "ETH", amount: "1.25" }, + }, + }); + const context = { params: Promise.resolve({ connectionId, capability }) }; + + const accepted = await callback.POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }), context); + const acceptedPayload = await accepted.json(); + assert.equal(accepted.status, 202); + assert.equal(acceptedPayload.accepted, true); + assert.equal(acceptedPayload.duplicate, false); + assert.match(acceptedPayload.contentHash, /^[a-f0-9]{64}$/); + assert.equal(acceptedPayload.callbackEvidence.status, "callback-received"); + assert.equal(acceptedPayload.callbackEvidence.providerVerified, false); + assert.equal(acceptedPayload.callbackEvidence.providerSignatureVerified, false); + + const duplicate = await callback.POST(new NextRequest(created.payload.callbackUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }), { params: Promise.resolve({ connectionId, capability }) }); + const duplicatePayload = await duplicate.json(); + assert.equal(duplicate.status, 200); + assert.equal(duplicatePayload.accepted, false); + assert.equal(duplicatePayload.duplicate, true); + assert.equal(duplicatePayload.eventId, acceptedPayload.eventId); + + const listed = await events.GET(new NextRequest( + `https://drops.example/api/dropsbot/events?projectId=${projectId}`, + { headers: { cookie: `${STUDIO_ACCOUNT_COOKIE}=${first.cookie}` } }, + )); + const listedPayload = await listed.json(); + assert.equal(listed.status, 200); + assert.equal(listedPayload.events.length, 1); + assert.equal(listedPayload.events[0].payload.apiKey, "[REDACTED]"); + assert.equal(listedPayload.events[0].payload.callbackUrl, "[REDACTED]"); + assert.equal(listedPayload.events[0].payload.details.authorization, "[REDACTED]"); + assert.equal(listedPayload.events[0].payload.details.token.symbol, "ETH"); + assert.equal(listedPayload.callbackEvidence.status, "callback-received"); + assert.equal(listedPayload.callbackEvidence.providerVerified, false); + assert.equal(listedPayload.callbackEvidence.providerSignatureVerified, false); + assert.equal(listedPayload.registration.claimedConfigured, false); + assert.equal("capabilityHash" in listedPayload, false); + assert.doesNotMatch(JSON.stringify(listedPayload), new RegExp(capability)); + + const foreign = await events.GET(new NextRequest( + `https://drops.example/api/dropsbot/events?projectId=${projectId}`, + { headers: { cookie: `${STUDIO_ACCOUNT_COOKIE}=${second.cookie}` } }, + )); + assert.equal(foreign.status, 404); + }); +}); + +test("D1 duplicate callbacks report the latest stored connection evidence", async (t) => { + const { + acceptDropsBotWebhookEvent, + } = await import("../db/dropsbot-webhooks.ts"); + const { + createDropsBotWebhookCapability, + } = await import("../lib/dropsbot-webhook.ts"); + const capability = createDropsBotWebhookCapability(); + const connectionId = "11111111-2222-4333-8444-555555555555"; + const duplicateHash = "c".repeat(64); + const latestHash = "d".repeat(64); + const duplicateRow = { + id: "evt_duplicate", + content_hash: duplicateHash, + received_at: "2026-07-30T00:01:00.000Z", + payload_json: JSON.stringify({ event: "wallet.swap", sequence: 1 }), + }; + const latestConnection = { + capability_hash: capability.hash, + last_event_received_at: "2026-07-30T00:02:00.000Z", + last_event_content_hash: latestHash, + }; + + function fakeDatabase(existingBeforeBatch) { + let eventReads = 0; + let batchCalls = 0; + const db = { + prepare(sql) { + return { + sql, + args: [], + bind(...args) { + this.args = args; + return this; + }, + async run() { + return { + meta: { + changes: /UPDATE dropsbot_webhook_connections/i.test(sql) ? 1 : 0, + }, + }; + }, + async first() { + if (/SELECT capability_hash(?:, last_event_received_at)?/i.test(sql)) { + return latestConnection; + } + if (/SELECT id, content_hash, received_at, payload_json/i.test(sql)) { + eventReads += 1; + return existingBeforeBatch || eventReads > 1 ? duplicateRow : null; + } + if (/SELECT COUNT\(\*\) AS count/i.test(sql)) return { count: 1 }; + return null; + }, + async all() { + return { results: [] }; + }, + }; + }, + async batch() { + batchCalls += 1; + return [{ meta: { changes: 0 } }, { meta: { changes: 1 } }]; + }, + }; + return { db, batchCalls: () => batchCalls }; + } + + for (const scenario of [ + { name: "existing duplicate branch", existingBeforeBatch: true, expectedBatchCalls: 0 }, + { name: "insert-race duplicate branch", existingBeforeBatch: false, expectedBatchCalls: 1 }, + ]) { + await t.test(scenario.name, async () => { + const previousEnvironment = globalThis.__DROPS_STUDIO_ENV__; + const previousLocalStore = process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + const previousVercel = process.env.VERCEL; + const fake = fakeDatabase(scenario.existingBeforeBatch); + globalThis.__DROPS_STUDIO_ENV__ = { DB: fake.db }; + globalThis.__DROPS_STUDIO_LOCAL_DROPSBOT_WEBHOOKS__ = undefined; + delete process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + delete process.env.VERCEL; + try { + const result = await acceptDropsBotWebhookEvent({ + connectionId, + capabilityHash: capability.hash, + event: { + id: "evt_duplicate", + contentHash: duplicateHash, + receivedAt: "2026-07-30T00:03:00.000Z", + payload: { event: "wallet.swap", sequence: 1 }, + }, + }); + + assert.equal(result.status, "duplicate"); + assert.equal(result.event.contentHash, duplicateHash); + assert.equal(result.callbackEvidence.status, "callback-received"); + assert.equal(result.callbackEvidence.receivedAt, latestConnection.last_event_received_at); + assert.equal(result.callbackEvidence.contentHash, latestHash); + assert.equal(fake.batchCalls(), scenario.expectedBatchCalls); + } finally { + globalThis.__DROPS_STUDIO_ENV__ = previousEnvironment; + globalThis.__DROPS_STUDIO_LOCAL_DROPSBOT_WEBHOOKS__ = undefined; + if (previousLocalStore === undefined) delete process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + else process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE = previousLocalStore; + if (previousVercel === undefined) delete process.env.VERCEL; + else process.env.VERCEL = previousVercel; + } + }); + } +}); + +test("D1 migrates legacy global event ids to connection-scoped uniqueness without dropping rows or indexes", async () => { + const { acceptDropsBotWebhookEvent } = await import("../db/dropsbot-webhooks.ts"); + const operations = []; + const migrationBatches = []; + const legacyEventSchema = `CREATE TABLE dropsbot_webhook_events ( + id TEXT NOT NULL UNIQUE, + connection_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + received_at TEXT NOT NULL, + payload_json TEXT NOT NULL, + PRIMARY KEY (connection_id, content_hash) + )`; + const db = { + prepare(sql) { + return { + sql, + bind() { + return this; + }, + async run() { + operations.push(sql); + return { meta: { changes: 0 } }; + }, + async first() { + operations.push(sql); + if (/FROM sqlite_master/i.test(sql)) return { sql: legacyEventSchema }; + return null; + }, + async all() { + return { results: [] }; + }, + }; + }, + async batch(statements) { + const sql = statements.map((statement) => statement.sql); + migrationBatches.push(sql); + operations.push(...sql); + return statements.map(() => ({ meta: { changes: 0 } })); + }, + }; + const previousEnvironment = globalThis.__DROPS_STUDIO_ENV__; + const previousLocalStore = process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + const previousVercel = process.env.VERCEL; + globalThis.__DROPS_STUDIO_ENV__ = { DB: db }; + delete process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + delete process.env.VERCEL; + try { + const result = await acceptDropsBotWebhookEvent({ + connectionId: "11111111-2222-4333-8444-555555555555", + capabilityHash: "a".repeat(64), + event: { + id: "evt_migration_probe", + contentHash: "b".repeat(64), + receivedAt: "2026-07-30T00:00:00.000Z", + payload: { event: "migration.probe" }, + }, + }); + assert.equal(result.status, "not-found"); + + const currentSchema = operations.find((sql) => + /CREATE TABLE IF NOT EXISTS dropsbot_webhook_events/i.test(sql)); + assert.match(currentSchema, /UNIQUE\s*\(\s*connection_id\s*,\s*id\s*\)/i); + assert.doesNotMatch(currentSchema, /id\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i); + + const migration = migrationBatches.find((batch) => + batch.some((sql) => /ALTER TABLE .* RENAME TO dropsbot_webhook_events/i.test(sql))); + assert.ok(migration); + assert.ok(migration.some((sql) => + /INSERT INTO [\s\S]*?\(id, connection_id, content_hash, received_at, payload_json\)[\s\S]*SELECT id, connection_id, content_hash, received_at, payload_json\s+FROM dropsbot_webhook_events/i.test(sql))); + assert.ok(migration.some((sql) => /DROP TABLE dropsbot_webhook_events/i.test(sql))); + const renamedAt = operations.findIndex((sql) => + /ALTER TABLE .* RENAME TO dropsbot_webhook_events/i.test(sql)); + const indexAt = operations.findIndex((sql) => + /CREATE INDEX IF NOT EXISTS dropsbot_webhook_event_time_idx/i.test(sql)); + assert.ok(renamedAt >= 0 && indexAt > renamedAt); + } finally { + globalThis.__DROPS_STUDIO_ENV__ = previousEnvironment; + if (previousLocalStore === undefined) delete process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + else process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE = previousLocalStore; + if (previousVercel === undefined) delete process.env.VERCEL; + else process.env.VERCEL = previousVercel; + } +}); + +test("the Vercel Blob fallback retries a concurrent first-writer conflict", async () => { + const { createDropsBotWebhookConnection } = await import("../db/dropsbot-webhooks.ts"); + let stored = null; + let etag = 0; + let puts = 0; + const competingConnection = { + id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + ownerIdentity: "b".repeat(64), + projectId: "competing-project", + capabilityHash: "c".repeat(64), + createdAt: "2026-07-30T00:00:00.000Z", + consentedAt: "2026-07-30T00:00:00.000Z", + callbackReceivedAt: null, + lastEventReceivedAt: null, + lastEventContentHash: null, + events: [], + }; + const storage = { + async get() { + if (!stored) return null; + return { + statusCode: 200, + blob: { etag: `etag-${etag}` }, + stream: new Response(stored).body, + }; + }, + async put(pathname, body, options) { + puts += 1; + if (puts === 1) { + assert.equal(options.allowOverwrite, false); + stored = JSON.stringify({ schemaVersion: 1, connections: [competingConnection] }); + etag = 1; + throw new Error("Vercel Blob: blob already exists"); + } + assert.equal(options.allowOverwrite, true); + assert.equal(options.ifMatch, "etag-1"); + stored = String(body); + etag += 1; + return { pathname }; + }, + }; + + const created = await createDropsBotWebhookConnection({ + id: "11111111-2222-4333-8444-555555555555", + ownerIdentity: "a".repeat(64), + projectId, + capabilityHash: "d".repeat(64), + createdAt: "2026-07-30T00:01:00.000Z", + consentedAt: "2026-07-30T00:01:00.000Z", + }, storage); + + assert.equal(created.status, "created"); + assert.equal(puts, 2); + assert.deepEqual( + JSON.parse(stored).connections.map((connection) => connection.projectId).sort(), + ["competing-project", projectId].sort(), + ); +}); + +test("the Vercel Blob fallback keeps one private CAS-protected webhook state", async () => { + const { + acceptDropsBotWebhookEvent, + createDropsBotWebhookConnection, + listDropsBotWebhookProject, + } = await import("../db/dropsbot-webhooks.ts"); + const { + createDropsBotWebhookCapability, + } = await import("../lib/dropsbot-webhook.ts"); + let stored = null; + let etag = 0; + const writes = []; + const storage = { + async get(pathname, options) { + assert.equal(options.access, "private"); + assert.equal(options.useCache, false); + if (!stored) return null; + return { + statusCode: 200, + blob: { etag: `etag-${etag}` }, + stream: new Response(stored).body, + }; + }, + async put(pathname, body, options) { + writes.push({ pathname, options }); + assert.equal(options.access, "private"); + if (stored === null) assert.equal(options.allowOverwrite, false); + else assert.equal(options.ifMatch, `etag-${etag}`); + stored = String(body); + etag += 1; + return { pathname }; + }, + }; + const capability = createDropsBotWebhookCapability(); + const connectionId = "11111111-2222-4333-8444-555555555555"; + const ownerIdentity = "a".repeat(64); + + const created = await createDropsBotWebhookConnection({ + id: connectionId, + ownerIdentity, + projectId, + capabilityHash: capability.hash, + createdAt: "2026-07-30T00:00:00.000Z", + consentedAt: "2026-07-30T00:00:00.000Z", + }, storage); + assert.equal(created.status, "created"); + + const accepted = await acceptDropsBotWebhookEvent({ + connectionId, + capabilityHash: capability.hash, + event: { + id: "evt_test", + contentHash: "b".repeat(64), + receivedAt: "2026-07-30T00:01:00.000Z", + payload: { event: "wallet.swap", wallet: "0xabc" }, + }, + }, storage); + assert.equal(accepted.status, "accepted"); + assert.ok(writes.every((write) => write.pathname === "drops-studio/dropsbot/webhook-state-v1.json")); + assert.equal(JSON.parse(stored).connections[0].capabilityHash, capability.hash); + assert.doesNotMatch(stored, new RegExp(capability.secret)); + + const project = await listDropsBotWebhookProject(ownerIdentity, projectId, storage); + assert.equal(project.events.length, 1); + assert.equal(project.callbackEvidence.status, "callback-received"); + assert.equal(project.callbackEvidence.providerVerified, false); +}); diff --git a/tests/generated-products.test.mjs b/tests/generated-products.test.mjs index d98bc50..43c0991 100644 --- a/tests/generated-products.test.mjs +++ b/tests/generated-products.test.mjs @@ -17,6 +17,30 @@ const presetIds = [ "crypto-siri", ]; +test("prepared browser tests cannot inherit the production DropsTab API key", async () => { + const [packageJson, workflow] = await Promise.all([ + readFile(new URL("../package.json", import.meta.url), "utf8").then(JSON.parse), + readFile( + new URL("../.github/workflows/ui-quality.yml", import.meta.url), + "utf8", + ), + ]); + + assert.match( + packageJson.scripts["serve:test"], + /(?:^|\s)DROPSTAB_API_KEY=(?:\s|$)/, + ); + assert.match( + packageJson.scripts["serve:lighthouse"], + /(?:^|\s)DROPSTAB_API_KEY=(?:\s|$)/, + ); + assert.match( + packageJson.scripts["serve:storybook:test"], + /(?:^|\s)DROPSTAB_API_KEY=(?:\s|$)/, + ); + assert.match(workflow, /\n\s+DROPSTAB_API_KEY:\s*["']{2}\s*\n/); +}); + test("the compiler contains a distinct runnable product for every preset", async () => { const [compiler, presets] = await Promise.all([ readFile(new URL("../lib/project-compiler.ts", import.meta.url), "utf8"), @@ -94,12 +118,13 @@ test("publishing recompiles validated specs and persists recoverable public buil }); test("professional editing and category direction apply to every product", async () => { - const [types, validator, director, compiler, studio] = await Promise.all([ + const [types, validator, director, compiler, studio, workspaceDialog] = await Promise.all([ readFile(new URL("../lib/project-types.ts", import.meta.url), "utf8"), readFile(new URL("../lib/project-validator.ts", import.meta.url), "utf8"), readFile(new URL("../lib/project-director.ts", import.meta.url), "utf8"), readFile(new URL("../lib/project-compiler.ts", import.meta.url), "utf8"), readFile(new URL("../components/project-studio.tsx", import.meta.url), "utf8"), + readFile(new URL("../components/project-workspace-dialog.tsx", import.meta.url), "utf8"), ]); assert.match(types, /interface ProjectExperienceDirection/); @@ -137,8 +162,15 @@ test("professional editing and category direction apply to every product", async assert.match(studio, /categoryPrompts/); assert.match(studio, /Apply changes/); assert.match(studio, /Reordered product modules/); - assert.match(studio, /Owned source workspace/); - assert.match(studio, /Validate & apply/); + assert.match(studio, /ProjectWorkspaceDialog/); + assert.match(studio, /StudioAccountTeamPanel/); + assert.match(workspaceDialog, /Owned source workspace/); + assert.match(workspaceDialog, /AI source change/); + assert.match(workspaceDialog, /Generate & apply/); + assert.match(workspaceDialog, /Validate & apply/); + assert.match(workspaceDialog, /Verified sandbox receipt/); + assert.match(studio, /\/api\/workspace\/patch/); + assert.match(studio, /AI workspace revision/); assert.match(studio, /Release checks/); assert.doesNotMatch(studio, /\{game &&