Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions .github/workflows/ui-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
env:
CI: "true"
TZ: UTC
DROPSTAB_API_KEY: ""
steps:
- name: Check out repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
Expand Down
21 changes: 18 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Comment on lines +102 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -e css . app/styles --exec ls -l
rg -n "project-studio\.(chrome|workspace|runtime)\.css" -g '!**/node_modules/**'
rg -n "48 ?KiB|49152|4 ?KiB|4096" scripts/check-ui-guardrails.mjs tests/ui-css-policy.test.mjs

Repository: svg8bit/drops-studio

Length of output: 1778


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== DESIGN.md mentions ==\n'
rg -n "project-studio\.(chrome|workspace|runtime)\.css|globals\.css|48 ?KiB|4 ?KiB|manual style block|guardrail|exception" DESIGN.md app/styles scripts tests || true

printf '\n== Style imports / references ==\n'
rg -n "project-studio\.(chrome|workspace|runtime)\.css" app scripts tests DESIGN.md || true

printf '\n== Guardrail / policy files ==\n'
fd -e mjs -e js -e ts -e md . scripts tests app | sed -n '1,120p'

Repository: svg8bit/drops-studio

Length of output: 6246


Document project-studio.chrome.css and project-studio.workspace.css in the CSS policy. DESIGN.md only grants an exception to project-studio.runtime.css, but app/studio/project-studio.css also imports project-studio.chrome.css and project-studio.workspace.css. Add those files here with their intended bounds, or update the guardrail so the policy matches the shipped CSS surface.

🧰 Tools
🪛 LanguageTool

[style] ~107-~107: This is not the usual sequence for adjectives that have no special emphasis.
Context: ...ual manual stylesheet above 48 KiB so a new monolithic CSS surface cannot silently return. `a...

(EN_ADJ_ORDER)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DESIGN.md` around lines 102 - 115, Update the CSS policy in DESIGN.md to
explicitly cover project-studio.chrome.css and project-studio.workspace.css,
including their intended legacy bounds and the existing 48 KiB cap.
Alternatively, adjust the release guardrail to enforce the same policy across
these imported stylesheets, ensuring the documented rules match the shipped
imports.

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
Expand Down
57 changes: 50 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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/<safe-name>` 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.

Expand Down Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions app/api/access/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {
accessMetadata,
GUEST_IDENTITY_COOKIE,
GUEST_USAGE_COOKIE,
MEMBER_DAILY_LIMIT,
memberProjectSyncReadiness,
platformAiReadiness,
resolveFundedBuildQuota,
resolveGuestAccess,
resolveStudioAccount,
STUDIO_ACCOUNT_COOKIE,
Expand All @@ -18,23 +18,27 @@ 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 };
const platformAvailable = readiness.available && quota.status !== "unavailable" && quota.count !== null;
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,
},
Expand Down
36 changes: 21 additions & 15 deletions app/api/agent/plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
102 changes: 102 additions & 0 deletions app/api/billing/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading