Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ CRON_SECRET=
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.
# Set by `npm run test:live:sandbox`. Never enable it in the standard unit suite;
# the test creates a billable external Sandbox and requires runtime credentials.
DROPS_STUDIO_LIVE_SANDBOX=
# Set to "1" only to opt into the full live install/build/preview/browser/checkpoint flow.
# Set by `npm run test:live:builder`. The test creates billable external runtime
# resources and also requires a configured browser snapshot.
DROPS_STUDIO_LIVE_BUILDER=

# Agent Intelligence v2. The JSON flag object contains booleans only; no credentials.
Expand Down
51 changes: 48 additions & 3 deletions app/api/access/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import {
GUEST_USAGE_COOKIE,
memberProjectSyncReadiness,
platformAiReadiness,
projectV2SyncReadiness,
resolveFundedBuildQuota,
resolveGuestAccess,
resolveStudioAccount,
resolveStudioProjectActor,
STUDIO_ACCOUNT_COOKIE,
} from "../../../lib/access-tier.ts";
import {
PROJECT_STORE_SCOPE_COOKIE,
projectStoreScopeCookieValue,
type ProjectStoreScope,
} from "../../../lib/project-store.ts";
import { readRequestLimitState } from "../../../lib/request-rate-limit.ts";

export const runtime = "nodejs";
Expand All @@ -21,6 +28,23 @@ function requestOidcToken(request: NextRequest): string | undefined {
: undefined;
}

function setProjectStoreScope(
response: NextResponse,
scope: ProjectStoreScope,
): void {
response.cookies.set(
PROJECT_STORE_SCOPE_COOKIE,
projectStoreScopeCookieValue(scope),
{
httpOnly: false,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
path: "/",
},
);
}

export async function GET(request: NextRequest) {
const oidcToken = requestOidcToken(request);
const readinessEnvironment = oidcToken
Expand All @@ -42,19 +66,27 @@ export async function GET(request: NextRequest) {
})
: { status: "unavailable" as const, count: null, remaining: null };
const platformAvailable = readiness.available && quota.status !== "unavailable" && quota.count !== null;
return NextResponse.json(
const projectStoreScope = {
kind: "member" as const,
identity: account.identity,
};
const response = NextResponse.json(
{
access: accessMetadata({
tier: platformAvailable ? memberTier : "fallback",
used: quota.count ?? 0,
account,
projectSyncAvailable: memberProjectSyncReadiness(readinessEnvironment),
projectSyncAvailable: projectV2SyncReadiness(readinessEnvironment),
accountProjectSyncAvailable: memberProjectSyncReadiness(readinessEnvironment),
platformLimit: memberLimit,
}),
projectStoreScope,
quotaSigningConfigured: readiness.signingConfigured,
},
{ headers: { "cache-control": "no-store" } },
);
setProjectStoreScope(response, projectStoreScope);
return response;
}
const context = resolveGuestAccess({
identityCookie: request.cookies.get(GUEST_IDENTITY_COOKIE)?.value,
Expand All @@ -66,11 +98,23 @@ export async function GET(request: NextRequest) {
tier: context.configured && readiness.available ? "guest" : "fallback",
used: context.used,
projectSyncAvailable: context.configured
&& memberProjectSyncReadiness(readinessEnvironment),
&& projectV2SyncReadiness(readinessEnvironment),
});
const signedGuestCookie = context.identityCookie
?? request.cookies.get(GUEST_IDENTITY_COOKIE)?.value;
const actor = signedGuestCookie
? resolveStudioProjectActor(
{ guestCookie: signedGuestCookie },
readinessEnvironment,
)
: null;
const projectStoreScope = actor?.kind === "guest"
? { kind: "guest" as const, identity: actor.identity }
: null;
const response = NextResponse.json(
{
access,
...(projectStoreScope ? { projectStoreScope } : {}),
Comment on lines +101 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether guest cookie signing is documented as required and whether a fallback exists.
set -euo pipefail

rg -n 'DROPS_GUEST_COOKIE_SECRET' --glob '!node_modules' -C3
rg -n -C4 'resolveGuestCookieSecret' lib/access-tier.ts

Repository: svg8bit/drops-studio

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(app/api/access/route\.ts|lib/access-tier\.ts|.*project.*|.*store.*|.*studio.*)$' | head -200

printf '%s\n' '--- relevant symbols and configuration references ---'
rg -n -C4 \
  'resolveStudioProjectActor|resolveGuestCookieSecret|DROPS_GUEST_COOKIE_SECRET|drops_project_scope|readProjectsFromStore|saveProjectSafely|GUEST_IDENTITY_COOKIE|legacy namespace|Project storage is waiting for a signed actor scope' \
  --glob '!node_modules' --glob '!dist' --glob '!build' .

printf '%s\n' '--- access route ---'
cat -n app/api/access/route.ts | sed -n '70,145p'

Repository: svg8bit/drops-studio

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- access route ---'
cat -n app/api/access/route.ts | sed -n '1,155p'

printf '%s\n' '--- access-tier actor and guest access logic ---'
cat -n lib/access-tier.ts | sed -n '80,130p'
cat -n lib/access-tier.ts | sed -n '250,365p'

printf '%s\n' '--- project-store scope and migration logic ---'
cat -n lib/project-store.ts | sed -n '1,90p'
cat -n lib/project-store.ts | sed -n '175,310p'
cat -n lib/project-store.ts | sed -n '381,410p'

printf '%s\n' '--- access and deployment documentation ---'
cat -n docs/ACCESS_TIERS.md | sed -n '1,45p'
cat -n docs/V2_SECURITY_MODEL.md | sed -n '30,55p'
cat -n .env.example | sed -n '1,15p'

Repository: svg8bit/drops-studio

Length of output: 29632


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- scope bootstrap implementation and callers ---'
rg -n -C8 \
  'browserProjectStoreScope|establishActorScope|readProjectsAfterScopeBootstrap|PROJECT_STORE_SCOPE_COOKIE|parseProjectStoreScopeCookieValue|/api/access' \
  lib components app tests --glob '!node_modules'

printf '%s\n' '--- focused project-store tests ---'
rg -n -C8 \
  'legacy|scope|signed actor|Project storage is waiting|DROPS_GUEST_COOKIE_SECRET|no secret|production' \
  tests/project-store.test.mjs tests/*access* tests/*security* 2>/dev/null || true

printf '%s\n' '--- project-store implementation around browser scope ---'
cat -n lib/project-store.ts | sed -n '85,175p'

printf '%s\n' '--- access bootstrap in the main Studio callers ---'
cat -n components/drops-studio.tsx | sed -n '600,735p'
cat -n components/project-studio.tsx | sed -n '900,1060p'

Repository: svg8bit/drops-studio

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- drops-studio bootstrap and save handling ---'
cat -n components/drops-studio.tsx | sed -n '634,690p'
cat -n components/drops-studio.tsx | sed -n '1868,1935p'

printf '%s\n' '--- project-studio bootstrap and save handling ---'
cat -n components/project-studio.tsx | sed -n '944,1015p'
cat -n components/project-studio.tsx | sed -n '1628,1665p'

printf '%s\n' '--- focused migration tests ---'
cat -n tests/project-store.test.mjs | sed -n '395,445p'
cat -n tests/project-store.test.mjs | sed -n '130,172p'

printf '%s\n' '--- deterministic source-level verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

access = Path("lib/access-tier.ts").read_text()
route = Path("app/api/access/route.ts").read_text()
store = Path("lib/project-store.ts").read_text()
builder = Path("components/drops-studio.tsx").read_text()
studio = Path("components/project-studio.tsx").read_text()

checks = {
    "production_without_guest_secret_returns_empty_secret":
        'return env.NODE_ENV === "production" ? ""' in access,
    "project_actor_requires_guest_secret":
        'const guest = secret\n    ? readGuestIdentityCookie' in access
        and 'if (!guest) return null;' in access,
    "guest_scope_only_when_guest_actor_exists":
        'const projectStoreScope = actor?.kind === "guest"' in route,
    "browser_read_without_scope_returns_empty":
        'if (browserDefault && !scope) return [];' in store,
    "browser_save_without_scope_throws":
        'if (browserDefault && !scope) {\n    throw new Error("Project storage is waiting for a signed actor scope.");' in store,
    "legacy_migration_requires_scope":
        'if (browserDefault && !scope) {\n    throw new Error("Project storage is waiting for a signed actor scope.");' in store
        and 'await claimLegacyProjectsSafely(options);' in store,
    "builder_bootstraps_before_project_read":
        'readProjectsAfterScopeBootstrap(async () =>' in builder
        and 'fetch("/api/access"' in builder,
    "studio_bootstraps_before_project_read":
        'readProjectsAfterScopeBootstrap(async () =>' in studio
        and 'fetch("/api/access"' in studio,
}

for name, result in checks.items():
    print(f"{name}={result}")
if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: svg8bit/drops-studio

Length of output: 15145


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- drops-studio bootstrap and save handling ---'
cat -n components/drops-studio.tsx | sed -n '634,690p'
cat -n components/drops-studio.tsx | sed -n '1868,1935p'

printf '%s\n' '--- project-studio bootstrap and save handling ---'
cat -n components/project-studio.tsx | sed -n '944,1015p'
cat -n components/project-studio.tsx | sed -n '1628,1665p'

printf '%s\n' '--- focused migration tests ---'
cat -n tests/project-store.test.mjs | sed -n '395,445p'
cat -n tests/project-store.test.mjs | sed -n '130,172p'

printf '%s\n' '--- deterministic source-level verifier ---'
python3 - <<'PY'
from pathlib import Path

access = Path("lib/access-tier.ts").read_text()
route = Path("app/api/access/route.ts").read_text()
store = Path("lib/project-store.ts").read_text()
builder = Path("components/drops-studio.tsx").read_text()
studio = Path("components/project-studio.tsx").read_text()

checks = {
    "production_without_guest_secret_returns_empty_secret":
        'return env.NODE_ENV === "production" ? ""' in access,
    "project_actor_requires_guest_secret":
        'const guest = secret\n    ? readGuestIdentityCookie' in access
        and 'if (!guest) return null;' in access,
    "guest_scope_only_when_guest_actor_exists":
        'const projectStoreScope = actor?.kind === "guest"' in route,
    "browser_read_without_scope_returns_empty":
        'if (browserDefault && !scope) return [];' in store,
    "browser_save_without_scope_throws":
        'if (browserDefault && !scope) {\n    throw new Error("Project storage is waiting for a signed actor scope.");' in store,
    "legacy_migration_requires_scope":
        'if (browserDefault && !scope) {\n    throw new Error("Project storage is waiting for a signed actor scope.");' in store
        and 'await claimLegacyProjectsSafely(options);' in store,
    "builder_bootstraps_before_project_read":
        'readProjectsAfterScopeBootstrap(async () =>' in builder
        and 'fetch("/api/access"' in builder,
    "studio_bootstraps_before_project_read":
        'readProjectsAfterScopeBootstrap(async () =>' in studio
        and 'fetch("/api/access"' in studio,
}

for name, result in checks.items():
    print(f"{name}={result}")
if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: svg8bit/drops-studio

Length of output: 15145


Configure DROPS_GUEST_COOKIE_SECRET in every production deployment. Without it, /api/access omits projectStoreScope; guest browser reads cannot migrate legacy projects, and saveProjectSafely fails, breaking guest project creation and editing. If unsigned guests are supported, add an explicit compatibility scope.

🤖 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/access/route.ts` around lines 101 - 117, Configure
DROPS_GUEST_COOKIE_SECRET in every production deployment so
resolveStudioProjectActor recognizes signed guests and /api/access includes
projectStoreScope for guest requests. If unsigned guests remain supported,
update the actor/projectStoreScope handling to emit an explicit compatibility
scope instead of omitting it, preserving guest project migration and
saveProjectSafely behavior.

Source: Coding guidelines

quotaSigningConfigured: readiness.signingConfigured,
},
{ headers: { "cache-control": "no-store" } },
Expand All @@ -84,5 +128,6 @@ export async function GET(request: NextRequest) {
path: "/",
});
}
if (projectStoreScope) setProjectStoreScope(response, projectStoreScope);
return response;
}
149 changes: 132 additions & 17 deletions app/api/agent/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { generateText, type LanguageModel } from "ai";
import { generateText, streamText, type LanguageModel } from "ai";
import { NextRequest } from "next/server.js";
import { z } from "zod";

Expand All @@ -12,6 +12,7 @@ import type {
import { ProjectRuntimeProviderError } from "../../../../lib/project-runtime-adapter.ts";
import {
builderActor,
BUILDER_NO_STORE_HEADERS,
builderJson,
builderRouteError,
consumeBuilderLimit,
Expand Down Expand Up @@ -53,6 +54,12 @@ interface GenerateChatInput {
abortSignal: AbortSignal;
}

interface StreamChatResult {
toTextStreamResponse(init?: ResponseInit): Response;
}

const MAX_STREAM_LINE_CHARACTERS = 16_384;

export interface AgentChatRouteDependencies {
modelResolver?: BuilderModelResolver;
rememberConnection?: (
Expand All @@ -63,6 +70,14 @@ export interface AgentChatRouteDependencies {
selection: BuilderProviderSelection;
}>;
generate?: (input: GenerateChatInput) => Promise<{ text: string }>;
stream?: (input: GenerateChatInput) => StreamChatResult;
}

function acceptsTextStream(request: NextRequest): boolean {
if (request.headers.get("x-drops-stream")?.trim() === "1") return true;
return (request.headers.get("accept") ?? "")
.split(",")
.some((value) => value.trim().split(";", 1)[0]?.toLowerCase() === "text/plain");
}

function safeProviderFailure(error: unknown): ProjectRuntimeProviderError {
Expand All @@ -72,6 +87,80 @@ function safeProviderFailure(error: unknown): ProjectRuntimeProviderError {
);
}

function secretSafeTextStreamResponse(response: Response): Response {
if (!response.body) return response;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let pending = "";
let scanContext = "";
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
while (true) {
const { done, value } = await reader.read();
pending += decoder.decode(value, { stream: !done });
let enqueued = false;
let newline = pending.indexOf("\n");
while (newline >= 0) {
const line = pending.slice(0, newline + 1);
pending = pending.slice(newline + 1);
if (
line.length > MAX_STREAM_LINE_CHARACTERS
|| findArtifactSecrets(`${scanContext}${line}`, "agent chat stream").length
) {
throw new ProjectRuntimeProviderError(
"The selected AI model returned an unsafe streaming response. The project was not changed.",
);
}
scanContext = `${scanContext}${line}`.slice(-512);
controller.enqueue(encoder.encode(line));
enqueued = true;
newline = pending.indexOf("\n");
}
if (pending.length > MAX_STREAM_LINE_CHARACTERS) {
throw new ProjectRuntimeProviderError(
"The selected AI model returned an unsafe or oversized streaming response. The project was not changed.",
);
}
if (done) {
if (
pending
&& findArtifactSecrets(`${scanContext}${pending}`, "agent chat stream").length
) {
throw new ProjectRuntimeProviderError(
"The selected AI model returned an unsafe streaming response. The project was not changed.",
);
}
if (pending) controller.enqueue(encoder.encode(pending));
pending = "";
controller.close();
reader.releaseLock();
return;
}
if (enqueued) return;
}
} catch (error) {
await reader.cancel().catch(() => undefined);
try {
reader.releaseLock();
} catch {
// The reader can already be detached after cancellation.
}
controller.error(safeProviderFailure(error));
}
},
async cancel(reason) {
await reader.cancel(reason).catch(() => undefined);
},
});
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

export async function handleAgentChatRequest(
request: NextRequest,
dependencies: AgentChatRouteDependencies = {},
Expand Down Expand Up @@ -117,8 +206,47 @@ export async function handleAgentChatRequest(
remembered.selection,
remembered.credentials,
);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
const abortSignal = AbortSignal.any([
request.signal,
AbortSignal.timeout(30_000),
]);
const generationInput: GenerateChatInput = {
model: resolved.model,
system:
"You are Drops Agent inside one existing crypto product project. Answer in the language of the latest user message. Use only the supplied project context. Be concise and practical. Never claim a file edit, build, deployment, Telegram delivery, connection, or external action unless the context explicitly proves it. Never request or repeat API keys, tokens, private keys, or credentials. If the user asks for a change, explain that a change request will run through the verified file-edit flow; this endpoint is conversation-only.",
prompt: JSON.stringify({
project: parsed.data.context,
latestUserMessage: parsed.data.message,
}),
abortSignal,
};

if (acceptsTextStream(request)) {
try {
const result = (dependencies.stream ?? ((input) => streamText({
model: input.model,
system: input.system,
prompt: input.prompt,
abortSignal: input.abortSignal,
maxOutputTokens: 1_200,
maxRetries: 1,
onError: ({ error }) => {
console.error(
"[agent-chat] provider stream failed",
error instanceof Error ? error.name : "unknown",
);
},
})))(generationInput);
return secretSafeTextStreamResponse(
result.toTextStreamResponse({
headers: BUILDER_NO_STORE_HEADERS,
}),
);
} catch (error) {
throw safeProviderFailure(error);
}
}

let result: { text: string };
try {
result = await (dependencies.generate ?? (async (input) => generateText({
Expand All @@ -128,22 +256,9 @@ export async function handleAgentChatRequest(
abortSignal: input.abortSignal,
maxOutputTokens: 1_200,
maxRetries: 1,
})))(
{
model: resolved.model,
system:
"You are Drops Agent inside one existing crypto product project. Answer in the language of the latest user message. Use only the supplied project context. Be concise and practical. Never claim a file edit, build, deployment, Telegram delivery, connection, or external action unless the context explicitly proves it. Never request or repeat API keys, tokens, private keys, or credentials. If the user asks for a change, explain that a change request will run through the verified file-edit flow; this endpoint is conversation-only.",
prompt: JSON.stringify({
project: parsed.data.context,
latestUserMessage: parsed.data.message,
}),
abortSignal: controller.signal,
},
);
})))(generationInput);
} catch (error) {
throw safeProviderFailure(error);
} finally {
clearTimeout(timer);
}
const reply = result.text.trim();
if (!reply || findArtifactSecrets(reply, "agent chat response").length) {
Expand Down
18 changes: 18 additions & 0 deletions app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
GOOGLE_OIDC_TRANSACTION_COOKIE,
readGoogleOidcTransaction,
} from "@/lib/google-oidc";
import {
PROJECT_STORE_SCOPE_COOKIE,
projectStoreScopeCookieValue,
} from "@/lib/project-store";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";
Expand Down Expand Up @@ -78,6 +82,20 @@ export async function GET(request: NextRequest) {
maxAge: 60 * 60 * 24 * 90,
path: "/",
});
response.cookies.set(
PROJECT_STORE_SCOPE_COOKIE,
projectStoreScopeCookieValue({
kind: "member",
identity: account.identity,
}),
{
httpOnly: false,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
path: "/",
},
);
clearTransaction(response);
response.headers.set("cache-control", "no-store");
return response;
Expand Down
20 changes: 20 additions & 0 deletions app/api/auth/openrouter/exchange/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
} from "../../../../../lib/access-tier.ts";
import { saveStudioConnection } from "../../../../../db/studio-account-state.ts";
import { consumeRequestLimit, requestIdentity } from "../../../../../lib/request-rate-limit.ts";
import {
PROJECT_STORE_SCOPE_COOKIE,
projectStoreScopeCookieValue,
} from "../../../../../lib/project-store.ts";

export const runtime = "nodejs";

Expand Down Expand Up @@ -139,6 +143,22 @@ export async function POST(request: NextRequest) {
path: "/",
});
}
if (account) {
result.cookies.set(
PROJECT_STORE_SCOPE_COOKIE,
projectStoreScopeCookieValue({
kind: "member",
identity: account.identity,
}),
{
httpOnly: false,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 90,
path: "/",
},
);
}
return result;
} catch (error) {
console.error("[openrouter-auth] key exchange failed", error);
Expand Down
Loading
Loading