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 @@ -56,6 +56,9 @@ AGENT_BROWSER_SNAPSHOT_ID=

# Vercel Cron authenticates scheduled idle cleanup with this 32+ character server secret.
CRON_SECRET=
# Optional independently rotatable operator secret for an immediate provider
# health receipt after a release. It never enters browser bundles or generated apps.
DROPS_PLATFORM_HEALTH_OPERATOR_SECRET=
# Optional integer from 5 through 240; defaults to 20 minutes.
DROPS_STUDIO_SANDBOX_IDLE_MINUTES=
# Set to "1" only to opt into the live Sandbox contract test.
Expand Down Expand Up @@ -110,6 +113,9 @@ DROPS_TEAM_INVITE_SECRET=
# DROPS_MANAGED_DATA_PROVIDER accepts only "d1" or "postgres" in adapter wiring.
DROPS_MANAGED_DATA_PROVIDER=
DATABASE_URL=
# Vercel Marketplace Neon uses these prefixed server-only values.
DROPS_MANAGED_DATABASE_URL=
DROPS_MANAGED_POSTGRES_URL=
DROPS_COLLABORATION_TRANSPORT_URL=

# Generic enterprise OIDC. Values stay server-only and external login remains
Expand Down
2 changes: 2 additions & 0 deletions app/api/access/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export async function GET(request: NextRequest) {
const access = accessMetadata({
tier: context.configured && readiness.available ? "guest" : "fallback",
used: context.used,
projectSyncAvailable: context.configured
&& memberProjectSyncReadiness(readinessEnvironment),
Comment on lines +68 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Advertise guest sync for every supported Blob auth mode

In a Vercel deployment with BLOB_STORE_ID and the platform-provided VERCEL=1 marker but no literal VERCEL_OIDC_TOKEN environment variable, db/project-v2-snapshots.ts considers durable Project V2 storage configured and can service the route, while memberProjectSyncReadiness() returns false. This newly added guest response consequently tells the client that sync is unavailable, so guest builds never upload or reopen their otherwise supported private snapshots. Derive this flag from the same storage readiness predicate used by the Project V2 route.

Useful? React with 👍 / 👎.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
const response = NextResponse.json(
{
Expand Down
4 changes: 2 additions & 2 deletions app/api/platform/capabilities/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server.js";

import { platformCapabilitySnapshot } from "@/lib/platform-capabilities";
import { platformCapabilitySnapshotWithHealth } from "@/lib/platform-capabilities";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET() {
return NextResponse.json(platformCapabilitySnapshot(), {
return NextResponse.json(await platformCapabilitySnapshotWithHealth(), {
status: 200,
headers: {
"cache-control": "private, no-store, max-age=0",
Expand Down
41 changes: 41 additions & 0 deletions app/api/platform/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server.js";
import { createHash, timingSafeEqual } from "node:crypto";

import { runPlatformProviderHealthChecks } from "@/lib/platform-provider-health";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;

function digest(value: string): Buffer {
return createHash("sha256").update(value).digest();
}

function authorized(request: NextRequest): boolean {
const authorization = request.headers.get("authorization")?.trim();
if (!authorization) return false;
const secrets = [
process.env.CRON_SECRET?.trim(),
process.env.DROPS_PLATFORM_HEALTH_OPERATOR_SECRET?.trim(),
].filter((value): value is string => Boolean(value));
Comment on lines +17 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject weak health-trigger secrets in production

In production, any nonempty CRON_SECRET or DROPS_PLATFORM_HEALTH_OPERATOR_SECRET is accepted, whereas the existing Sandbox cleanup route rejects secrets shorter than 32 characters. A weak operator secret can be guessed against this unaudited endpoint to repeatedly trigger costly Sandbox creation, Blob mutations, provider requests, and GitHub token issuance. Enforce the same production secret-strength boundary before authorizing the health run.

AGENTS.md reference: AGENTS.md:L109-L109

Useful? React with 👍 / 👎.

const presented = digest(authorization);
return secrets.some((secret) => timingSafeEqual(
presented,
digest(`Bearer ${secret}`),
));
}

export async function GET(request: NextRequest) {
if (!authorized(request)) {
return NextResponse.json(
{ error: "Platform health authorization is required." },
{ status: 401, headers: { "cache-control": "private, no-store" } },
);
}
const receipt = await runPlatformProviderHealthChecks();
return NextResponse.json(receipt, {
headers: { "cache-control": "private, no-store" },
});
}

export const POST = GET;
Comment on lines +28 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound the external side effects of this endpoint.

Each authorized request runs the full provider suite. That suite creates a persistent Vercel Sandbox, writes and deletes private Blob objects, writes and deletes real project-data rows, mints a GitHub installation token, and overwrites the shared health receipt. Two concurrent requests, for example the cron invocation plus one operator call, run all of that twice in parallel and race on the single receipt path. Add a single-flight guard plus a minimum interval between runs, and return the last stored receipt when a run is already in progress.

Also note that GET and POST share one non-idempotent, state-mutating handler.

As per coding guidelines: "Every external or destructive tool must have explicit approval, timeout, quota, audit record, bounded output, and idempotency behavior."

🤖 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 `@app/api/platform/health/route.ts` around lines 28 - 41, Bound the side
effects in GET by guarding runPlatformProviderHealthChecks with a shared
single-flight lock and minimum-run interval. When a run is active or the
interval has not elapsed, return the last persisted health receipt without
starting provider checks; otherwise execute once, persist the receipt, and
release the guard reliably. Ensure POST, currently aliased to GET, uses the same
guard and cannot trigger a concurrent duplicate run.

Source: Coding guidelines

59 changes: 41 additions & 18 deletions app/api/project-data/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
ProjectDataError,
ProjectDataStore,
authorizeProjectDataCapability,
createDurableProjectDataBackend,
verifyProjectDataCapability,
type ProjectDataBackend,
type ProjectDataCapabilityPayload,
type ProjectDataPermission,
} from "../../../lib/project-data/index.ts";
Expand All @@ -25,22 +27,30 @@ function json(payload: Record<string, unknown>, status = 200): NextResponse {
return NextResponse.json(payload, { status, headers: NO_STORE_HEADERS });
}

function backend() {
let backendPromise: Promise<ProjectDataBackend> | null = null;

async function backend() {
if (globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__) {
return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__;
}
if (process.env.DROPS_STUDIO_LOCAL_PROJECT_DATA !== "1") {
backendPromise ??= (async () => {
const durable = await createDurableProjectDataBackend();
if (durable) return durable;
if (process.env.DROPS_STUDIO_LOCAL_PROJECT_DATA === "1") {
return new MemoryProjectDataBackend();
}
throw new ProjectDataError(
"storage_unavailable",
"Project data storage is not configured. The generated app can continue with its labelled browser-local fallback.",
);
}
globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = new MemoryProjectDataBackend();
return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__;
})();
const resolved = await backendPromise;
globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = resolved;
return resolved;
}
Comment on lines +30 to 50

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reset backendPromise when initialization fails.

??= caches the rejected promise. After one failed createDurableProjectDataBackend() call, every later request awaits the same rejection and returns 503 for the lifetime of the process. PostgresProjectDataBackend.#ensureSchema already clears its cached promise on failure. Apply the same handling here.

🐛 Proposed fix to clear the cache on failure
-  const resolved = await backendPromise;
-  globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = resolved;
-  return resolved;
+  try {
+    const resolved = await backendPromise;
+    globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = resolved;
+    return resolved;
+  } catch (error) {
+    backendPromise = null;
+    throw error;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let backendPromise: Promise<ProjectDataBackend> | null = null;
async function backend() {
if (globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__) {
return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__;
}
if (process.env.DROPS_STUDIO_LOCAL_PROJECT_DATA !== "1") {
backendPromise ??= (async () => {
const durable = await createDurableProjectDataBackend();
if (durable) return durable;
if (process.env.DROPS_STUDIO_LOCAL_PROJECT_DATA === "1") {
return new MemoryProjectDataBackend();
}
throw new ProjectDataError(
"storage_unavailable",
"Project data storage is not configured. The generated app can continue with its labelled browser-local fallback.",
);
}
globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = new MemoryProjectDataBackend();
return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__;
})();
const resolved = await backendPromise;
globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = resolved;
return resolved;
}
let backendPromise: Promise<ProjectDataBackend> | null = null;
async function backend() {
if (globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__) {
return globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__;
}
backendPromise ??= (async () => {
const durable = await createDurableProjectDataBackend();
if (durable) return durable;
if (process.env.DROPS_STUDIO_LOCAL_PROJECT_DATA === "1") {
return new MemoryProjectDataBackend();
}
throw new ProjectDataError(
"storage_unavailable",
"Project data storage is not configured. The generated app can continue with its labelled browser-local fallback.",
);
})();
try {
const resolved = await backendPromise;
globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ = resolved;
return resolved;
} catch (error) {
backendPromise = null;
throw error;
}
}
🤖 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 `@app/api/project-data/route.ts` around lines 30 - 50, Update the backend()
initialization flow so backendPromise is reset to null when the cached
initialization promise rejects, allowing later requests to retry
createDurableProjectDataBackend(). Preserve successful caching and
globalThis.__DROPS_STUDIO_PROJECT_DATA_BACKEND_V2__ assignment, and mirror the
failure-cache clearing behavior used by
PostgresProjectDataBackend.#ensureSchema.


function store(): ProjectDataStore {
return new ProjectDataStore(backend());
async function store(): Promise<ProjectDataStore> {
return new ProjectDataStore(await backend());
}

function bearer(request: NextRequest): string {
Expand Down Expand Up @@ -97,7 +107,20 @@ function requireSameOrigin(request: NextRequest): void {
const origin = request.headers.get("origin");
if (!origin) throw new ProjectDataError("forbidden", "A same-origin project data mutation is required.");
try {
if (new URL(origin).origin !== request.nextUrl.origin) throw new Error("origin mismatch");
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
? new URL(`${protocol}://${host}`).origin
: request.nextUrl.origin;
const parsedOrigin = new URL(origin).origin;
if (
parsedOrigin !== request.nextUrl.origin
&& parsedOrigin !== visibleOrigin
) {
throw new Error("origin mismatch");
}
} catch {
throw new ProjectDataError("forbidden", "Cross-origin project data mutation rejected.");
}
Expand Down Expand Up @@ -170,12 +193,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
exactFields(query, ["projectId", "namespace", "id"]);
const { projectId, namespace } = scope(authorization, query, "read");
if (query.id) {
const document = await store().get(projectId, namespace, query.id);
const document = await (await store()).get(projectId, namespace, query.id);
if (!document) throw new ProjectDataError("not_found", "Project data document was not found.");
return json({ document, persistence: backend().kind });
return json({ document, persistence: (await backend()).kind });
}
const documents = await store().list(projectId, namespace);
return json({ documents, persistence: backend().kind });
const documents = await (await store()).list(projectId, namespace);
return json({ documents, persistence: (await backend()).kind });
} catch (error) {
return responseError(error);
}
Expand All @@ -189,8 +212,8 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
const input = await requestBody(request);
exactFields(input, ["projectId", "namespace", "id", "data"]);
const { projectId, namespace } = scope(authorization, input, "write");
const document = await store().create({ projectId, namespace, id: input.id, data: input.data });
return json({ document, persistence: backend().kind }, 201);
const document = await (await store()).create({ projectId, namespace, id: input.id, data: input.data });
return json({ document, persistence: (await backend()).kind }, 201);
} catch (error) {
return responseError(error);
}
Expand All @@ -204,14 +227,14 @@ export async function PUT(request: NextRequest): Promise<NextResponse> {
const input = await requestBody(request);
exactFields(input, ["projectId", "namespace", "id", "expectedRevision", "data"]);
const { projectId, namespace } = scope(authorization, input, "write");
const document = await store().update({
const document = await (await store()).update({
projectId,
namespace,
id: input.id,
expectedRevision: input.expectedRevision,
data: input.data,
});
return json({ document, persistence: backend().kind });
return json({ document, persistence: (await backend()).kind });
} catch (error) {
return responseError(error);
}
Expand All @@ -225,8 +248,8 @@ export async function DELETE(request: NextRequest): Promise<NextResponse> {
const input = await requestBody(request);
exactFields(input, ["projectId", "namespace", "id", "expectedRevision"]);
const { projectId, namespace } = scope(authorization, input, "delete");
await store().delete(projectId, namespace, input.id, input.expectedRevision);
return json({ deleted: true, persistence: backend().kind });
await (await store()).delete(projectId, namespace, input.id, input.expectedRevision);
return json({ deleted: true, persistence: (await backend()).kind });
} catch (error) {
return responseError(error);
}
Expand Down
16 changes: 15 additions & 1 deletion app/api/projects/v2/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,21 @@ function requireSameOrigin(request: NextRequest): void {
const origin = request.headers.get("origin");
if (!origin && process.env.NODE_ENV !== "production") return;
try {
if (!origin || new URL(origin).origin !== request.nextUrl.origin) throw new Error();
const host = request.headers.get("host")?.split(",")[0]?.trim();
const protocol =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().replace(/:$/, "")
|| request.nextUrl.protocol.replace(/:$/, "");
const visibleOrigin = host ? `${protocol}://${host}` : request.nextUrl.origin;
const parsedOrigin = origin ? new URL(origin).origin : "";
if (
!parsedOrigin
|| (
parsedOrigin !== request.nextUrl.origin
&& parsedOrigin !== visibleOrigin
)
) {
throw new Error();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
throw new RouteError(403, { error: "A same-origin Project V2 mutation is required." });
}
Expand Down
6 changes: 3 additions & 3 deletions app/platform/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { Boxes, ShieldCheck } from "lucide-react";
import { PlatformOverview } from "@/components/platform/platform-overview";
import { PlatformShell } from "@/components/platform/platform-shell";
import { PageIntro, StatusBadge } from "@/components/platform/platform-ui";
import { platformCapabilitySnapshot } from "@/lib/platform-capabilities";
import { platformCapabilitySnapshotWithHealth } from "@/lib/platform-capabilities";

export const dynamic = "force-dynamic";

export default function PlatformPage() {
const snapshot = platformCapabilitySnapshot();
export default async function PlatformPage() {
const snapshot = await platformCapabilitySnapshotWithHealth();
return <PlatformShell active="Platform"><PageIntro eyebrow="Drops platform" title="A crypto builder that shows its evidence." description="See what works locally, what needs provider configuration, and what remains feature-gated across Project V2, Sandbox, Drops intelligence, delivery, and the managed platform rollout." receipt={<><div className="flex items-center gap-3"><span className="grid size-11 place-items-center rounded-xl bg-[#eef4ff] text-[#245fe5]"><Boxes className="size-5" aria-hidden="true" /></span><div><strong className="text-sm">Capability-aware UI</strong><p className="mt-1 text-xs text-[#52617a]">Working, local, and setup states stay distinct</p></div></div><div className="mt-5 flex items-center justify-between gap-3"><StatusBadge status="working">Truthful states</StatusBadge><ShieldCheck className="size-5 text-[#139a62]" aria-hidden="true" /></div></>} /><PlatformOverview snapshot={snapshot} /></PlatformShell>;
}
10 changes: 7 additions & 3 deletions app/styles/drops-studio.builder.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading