-
Notifications
You must be signed in to change notification settings - Fork 0
Ship account-aware v0-style Studio flow #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3371479
d90e4e5
2431713
339cc2b
71a039c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import { NextRequest, NextResponse } from "next/server.js"; | ||
|
|
||
| import { | ||
| deleteStudioConnection, | ||
| saveStudioConnection, | ||
| StudioAccountStateUnavailableError, | ||
| } from "@/db/studio-account-state"; | ||
| import { | ||
| resolveStudioAccount, | ||
| STUDIO_ACCOUNT_COOKIE, | ||
| } from "@/lib/access-tier"; | ||
| import { consumeRequestLimit, requestIdentity } from "@/lib/request-rate-limit"; | ||
| import { | ||
| connectionVaultConfigured, | ||
| isStudioConnectionProvider, | ||
| publicConnectionStatuses, | ||
| } from "@/lib/studio-account-state"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const runtime = "nodejs"; | ||
|
|
||
| const MAX_BODY_BYTES = 40 * 1_024; | ||
|
|
||
| function response(payload: unknown, status = 200) { | ||
| return NextResponse.json(payload, { status, headers: { "cache-control": "no-store" } }); | ||
| } | ||
|
|
||
| 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 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 = new URL(origin).origin; | ||
| return parsedOrigin === request.nextUrl.origin || parsedOrigin === visibleOrigin; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| async function body(request: NextRequest): Promise<Record<string, unknown> | null> { | ||
| const declared = Number(request.headers.get("content-length") ?? 0); | ||
| if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null; | ||
| const raw = await request.text().catch(() => ""); | ||
| if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) return null; | ||
| try { | ||
| const parsed = JSON.parse(raw) as unknown; | ||
| return parsed && typeof parsed === "object" && !Array.isArray(parsed) | ||
| ? parsed as Record<string, unknown> | ||
| : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function account(request: NextRequest) { | ||
| return resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); | ||
| } | ||
|
|
||
| export async function PUT(request: NextRequest) { | ||
| const actor = account(request); | ||
| if (!actor) return response({ error: "Sign in to remember this connection." }, 401); | ||
| if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403); | ||
| if (!connectionVaultConfigured()) return response({ error: "Encrypted connection vault is not configured." }, 503); | ||
| const limit = await consumeRequestLimit({ | ||
| identity: requestIdentity(request), | ||
| namespace: "account-connection-write", | ||
| max: 20, | ||
| windowMs: 10 * 60 * 1_000, | ||
| }).catch(() => "unavailable" as const); | ||
| if (limit === "limited") return response({ error: "Too many connection changes. Try again later." }, 429); | ||
| if (limit === "unavailable" && process.env.NODE_ENV === "production") { | ||
| return response({ error: "Connection write protection is temporarily unavailable." }, 503); | ||
| } | ||
| const input = await body(request); | ||
| if (!input || !isStudioConnectionProvider(input.provider) || typeof input.credential !== "string") { | ||
| return response({ error: "Connection payload is invalid." }, 400); | ||
| } | ||
| try { | ||
| const state = await saveStudioConnection(actor.identity, { | ||
| provider: input.provider, | ||
| credential: input.credential, | ||
| ...(typeof input.model === "string" ? { model: input.model } : {}), | ||
| ...(typeof input.endpoint === "string" ? { endpoint: input.endpoint } : {}), | ||
| ...(typeof input.label === "string" ? { label: input.label } : {}), | ||
| }); | ||
| return response({ saved: true, connections: publicConnectionStatuses(state) }); | ||
| } catch (error) { | ||
| return response( | ||
| { error: error instanceof Error ? error.message : "Connection could not be stored." }, | ||
| error instanceof StudioAccountStateUnavailableError ? 503 : 400, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export async function DELETE(request: NextRequest) { | ||
| const actor = account(request); | ||
| if (!actor) return response({ error: "Sign in to change remembered connections." }, 401); | ||
| if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403); | ||
| const provider = request.nextUrl.searchParams.get("provider"); | ||
| if (!isStudioConnectionProvider(provider)) return response({ error: "Connection provider is invalid." }, 400); | ||
| try { | ||
| const state = await deleteStudioConnection(actor.identity, provider); | ||
| return response({ deleted: true, connections: publicConnectionStatuses(state) }); | ||
| } catch (error) { | ||
| return response( | ||
| { error: error instanceof Error ? error.message : "Connection could not be removed." }, | ||
| error instanceof StudioAccountStateUnavailableError ? 503 : 400, | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+102
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Add a rate limit and an audit record to connection mutations.
Apply the same 🛡️ Suggested guard for DELETE if (!sameOrigin(request)) return response({ error: "Cross-origin connection storage rejected." }, 403);
+ const limit = await consumeRequestLimit({
+ identity: requestIdentity(request),
+ namespace: "account-connection-write",
+ max: 20,
+ windowMs: 10 * 60 * 1_000,
+ }).catch(() => "unavailable" as const);
+ if (limit === "limited") return response({ error: "Too many connection changes. Try again later." }, 429);
+ if (limit === "unavailable" && process.env.NODE_ENV === "production") {
+ return response({ error: "Connection write protection is temporarily unavailable." }, 503);
+ }
const provider = request.nextUrl.searchParams.get("provider");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 AgentsSource: Coding guidelines |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { NextRequest, NextResponse } from "next/server.js"; | ||
|
|
||
| import { | ||
| readStudioAccountState, | ||
| StudioAccountStateUnavailableError, | ||
| } from "@/db/studio-account-state"; | ||
| import { | ||
| resolveStudioAccount, | ||
| STUDIO_ACCOUNT_COOKIE, | ||
| } from "@/lib/access-tier"; | ||
| import { | ||
| connectionVaultConfigured, | ||
| publicConnectionStatuses, | ||
| } from "@/lib/studio-account-state"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const runtime = "nodejs"; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| const account = resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); | ||
| if (!account) { | ||
| return NextResponse.json( | ||
| { authenticated: false, profile: null, connections: [] }, | ||
| { headers: { "cache-control": "no-store" } }, | ||
| ); | ||
| } | ||
|
Comment on lines
+21
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Locate clients of /api/account and check how they read the vault and account fields.
set -euo pipefail
rg -n --glob '!node_modules' -C4 '"/api/account"|`/api/account`|/api/account\b' \
--iglob '*.{ts,tsx,js,jsx}'
rg -n --glob '!node_modules' -C2 'vault\??\.available|\.account\??\.provider' \
--iglob '*.{ts,tsx,js,jsx}'Repository: svg8bit/drops-studio Length of output: 158 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- route candidates ---'
fd -t f 'route\.(ts|tsx|js|jsx)$' app | sort | grep -E 'account|connection|vault' || true
echo '--- route outline ---'
if [ -f app/api/account/route.ts ]; then
ast-grep outline app/api/account/route.ts
wc -l app/api/account/route.ts
cat -n app/api/account/route.ts
fi
echo '--- account/vault symbols ---'
rg -n --glob '!node_modules' --glob '!**/.next/**' \
'connectionVaultConfigured|DROPS_CONNECTION_VAULT_KEY|vault|authenticated|connections' \
app components lib src 2>/dev/null | head -n 300 || true
echo '--- endpoint callers and account field access ---'
rg -n --glob '!node_modules' --glob '!**/.next/**' --iglob '*.{ts,tsx,js,jsx}' \
'api/account|account\.(vault|available|provider)|data\.(vault|account)|\b(vault|account)\??\.(available|provider)' \
. 2>/dev/null | head -n 400 || trueRepository: svg8bit/drops-studio Length of output: 24509 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- components/drops-studio.tsx account fetch and types ---'
cat -n components/drops-studio.tsx | sed -n '120,150p;490,540p;680,710p;750,775p;1060,1095p'
echo '--- components/project-studio.tsx account fetch ---'
cat -n components/project-studio.tsx | sed -n '1350,1415p'
echo '--- account state implementation ---'
ast-grep outline lib/studio-account-state.ts
cat -n lib/studio-account-state.ts | sed -n '45,95p;100,180p'
echo '--- all vault.available consumers ---'
rg -n --glob '!node_modules' --glob '!**/.next/**' --iglob '*.{ts,tsx,js,jsx}' \
'vault\s*(\?|:)?\.\s*available|vault.*available|available.*vault' . 2>/dev/null || true
echo '--- account endpoint tests and mocked shapes ---'
cat -n e2e/contracts/member-access.spec.ts | sed -n '1,75p'
cat -n e2e/contracts/v0-studio-flow.spec.ts | sed -n '1,55p'Repository: svg8bit/drops-studio Length of output: 23160 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- readStudioAccountState definition ---'
rg -n -A45 -B8 'export async function readStudioAccountState|function readStudioAccountState' db lib app 2>/dev/null || true
echo '--- deterministic static contract verifier ---'
python3 - <<'PY'
from pathlib import Path
import re
route = Path("app/api/account/route.ts").read_text()
production = "\n".join(
p.read_text()
for root in ("app", "components", "lib")
if Path(root).exists()
for p in Path(root).rglob("*")
if p.suffix in {".ts", ".tsx", ".js", ".jsx"}
)
checks = {
"unauthenticated omits account": bool(
re.search(r'if\s*\(!account\).*?\{\s*authenticated:\s*false,\s*profile:\s*null,\s*connections:\s*\[\]', route, re.S)
),
"unauthenticated omits vault": bool(
re.search(r'if\s*\(!account\).*?connections:\s*\[\].*?\}', route, re.S)
and not re.search(r'if\s*\(!account\).*?vault\s*:', route, re.S)
),
"success derives vault from configuration": "vault: { available: connectionVaultConfigured() }" in route,
"error hard-codes vault false": "vault: { available: false }" in route,
"production reads vault.available": bool(
re.search(r'\b(?:payload|data|account)\??\.vault\??\.available\b', production)
),
}
for name, result in checks.items():
print(f"{name}: {'yes' if result else 'no'}")
for path in sorted(Path("app").rglob("*")) + sorted(Path("components").rglob("*")):
if path.suffix in {".ts", ".tsx", ".js", ".jsx"}:
text = path.read_text()
if re.search(r'\b(?:payload|data|account)\??\.vault\??\.available\b', text):
print("vault.available consumer:", path)
PYRepository: svg8bit/drops-studio Length of output: 4099 Align all Return 🤖 Prompt for AI Agents |
||
| try { | ||
| const state = await readStudioAccountState(account.identity); | ||
| return NextResponse.json( | ||
| { | ||
| authenticated: true, | ||
| account: { provider: account.provider }, | ||
| profile: state.profile ?? { | ||
| provider: account.provider, | ||
| name: account.provider === "google" ? "Google member" : "OpenRouter member", | ||
| }, | ||
| connections: publicConnectionStatuses(state), | ||
| vault: { available: connectionVaultConfigured() }, | ||
| }, | ||
| { headers: { "cache-control": "no-store" } }, | ||
| ); | ||
| } catch (error) { | ||
| const unavailable = error instanceof StudioAccountStateUnavailableError; | ||
| return NextResponse.json( | ||
| { | ||
| authenticated: true, | ||
| account: { provider: account.provider }, | ||
| profile: null, | ||
| connections: [], | ||
| vault: { available: false }, | ||
| error: unavailable ? error.message : "Studio account state could not be read.", | ||
| }, | ||
| { status: 503, headers: { "cache-control": "no-store" } }, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -26,6 +26,7 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| RequestBodyBoundaryError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "../../../../lib/http-request-boundary.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { secretFreeRuntimeMessage } from "../../../../lib/project-runtime-adapter.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { readStudioConnectionSecret } from "../../../../db/studio-account-state.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const runtime = "nodejs"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -481,7 +482,11 @@ export async function POST(request: NextRequest) { | |||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const account = resolveStudioAccount(request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const openRouterKey = requestCredential(request, "x-openrouter-key"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rememberedProvider = body?.provider === "openrouter" && account | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? await readStudioConnectionSecret(account.identity, "openrouter").catch(() => null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const openRouterKey = requestCredential(request, "x-openrouter-key") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| || rememberedProvider?.credential; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (openRouterKey) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const model = body?.model?.trim() || "openrouter/free"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -500,7 +505,11 @@ export async function POST(request: NextRequest) { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const directProvider = ["openai", "anthropic", "kimi"].includes(body?.provider ?? "") ? body?.provider as DirectProvider : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const directKey = requestCredential(request, "x-provider-key"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rememberedDirect = directProvider && account | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? await readStudioConnectionSecret(account.identity, directProvider).catch(() => null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const directKey = requestCredential(request, "x-provider-key") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| || rememberedDirect?.credential; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (directProvider && !directKey) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ error: `Connect ${directProvider} with an API key before using it.` }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+508
to
514
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Separate a storage outage from a missing credential.
Catch 🔧 Suggested handling- const rememberedDirect = directProvider && account
- ? await readStudioConnectionSecret(account.identity, directProvider).catch(() => null)
- : null;
+ let rememberedDirect: Awaited<ReturnType<typeof readStudioConnectionSecret>> = null;
+ if (directProvider && account) {
+ try {
+ rememberedDirect = await readStudioConnectionSecret(account.identity, directProvider);
+ } catch (error) {
+ if (error instanceof StudioAccountStateUnavailableError) {
+ return NextResponse.json(
+ { error: "Remembered connections are temporarily unavailable. Retry shortly." },
+ { status: 503, headers: { "cache-control": "no-store" } },
+ );
+ }
+ rememberedDirect = null;
+ }
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { NextRequest, NextResponse } from "next/server.js"; | ||
|
|
||
| import { saveStudioAccountProfile } from "@/db/studio-account-state"; | ||
| import { | ||
| createStudioAccountCookie, | ||
| resolveAccountCookieSecret, | ||
| STUDIO_ACCOUNT_COOKIE, | ||
| } from "@/lib/access-tier"; | ||
| import { | ||
| exchangeGoogleAuthorizationCode, | ||
| GOOGLE_OIDC_TRANSACTION_COOKIE, | ||
| readGoogleOidcTransaction, | ||
| } from "@/lib/google-oidc"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const runtime = "nodejs"; | ||
|
|
||
| function clearTransaction(response: NextResponse) { | ||
| response.cookies.set(GOOGLE_OIDC_TRANSACTION_COOKIE, "", { | ||
| httpOnly: true, | ||
| sameSite: "lax", | ||
| secure: process.env.NODE_ENV === "production", | ||
| expires: new Date(0), | ||
| path: "/api/auth/google", | ||
| }); | ||
| } | ||
|
|
||
| function redirectError(request: NextRequest, code: string) { | ||
| const response = NextResponse.redirect(new URL(`/?auth=${encodeURIComponent(code)}`, request.nextUrl.origin)); | ||
| clearTransaction(response); | ||
| return response; | ||
| } | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| const clientId = process.env.GOOGLE_CLIENT_ID?.trim() ?? ""; | ||
| const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim() ?? ""; | ||
| const signingSecret = resolveAccountCookieSecret(); | ||
| if (!clientId || !clientSecret || !signingSecret) return redirectError(request, "google-unavailable"); | ||
| const transaction = readGoogleOidcTransaction( | ||
| request.cookies.get(GOOGLE_OIDC_TRANSACTION_COOKIE)?.value, | ||
| signingSecret, | ||
| ); | ||
| const state = request.nextUrl.searchParams.get("state") ?? ""; | ||
| const code = request.nextUrl.searchParams.get("code") ?? ""; | ||
| if (!transaction || state !== transaction.state || !code || code.length > 4_096) { | ||
| return redirectError(request, "google-state-invalid"); | ||
| } | ||
| try { | ||
| const identity = await exchangeGoogleAuthorizationCode({ | ||
| code, | ||
| clientId, | ||
| clientSecret, | ||
| redirectUri: `${request.nextUrl.origin}/api/auth/google/callback`, | ||
| transaction, | ||
| }); | ||
| const accountCookie = createStudioAccountCookie( | ||
| { provider: "google", subject: identity.subject }, | ||
| signingSecret, | ||
| ); | ||
| const account = await import("@/lib/access-tier").then(({ readStudioAccountCookie }) => | ||
| readStudioAccountCookie(accountCookie, signingSecret), | ||
| ); | ||
| if (!account) throw new Error("The signed Studio account could not be created."); | ||
| await saveStudioAccountProfile(account.identity, { | ||
| provider: "google", | ||
| subject: identity.subject, | ||
| name: identity.name, | ||
| ...(identity.email ? { email: identity.email } : {}), | ||
| ...(identity.picture ? { picture: identity.picture } : {}), | ||
| }); | ||
| const returnTo = new URL(transaction.returnTo, request.nextUrl.origin); | ||
| returnTo.searchParams.set("auth", "google-connected"); | ||
| const response = NextResponse.redirect(returnTo); | ||
| response.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, { | ||
| httpOnly: true, | ||
| 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; | ||
| } catch (error) { | ||
| console.error("[google-auth] callback failed", error instanceof Error ? error.message : "unknown"); | ||
| return redirectError(request, "google-failed"); | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { NextRequest, NextResponse } from "next/server.js"; | ||
|
|
||
| import { resolveAccountCookieSecret } from "@/lib/access-tier"; | ||
| import { | ||
| createGoogleOidcTransaction, | ||
| GOOGLE_OIDC_TRANSACTION_COOKIE, | ||
| GOOGLE_OIDC_TRANSACTION_TTL_SECONDS, | ||
| googleAuthorizationUrl, | ||
| serializeGoogleOidcTransaction, | ||
| } from "@/lib/google-oidc"; | ||
| import { safeSameOriginReturnPath } from "@/lib/safe-return-to"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const runtime = "nodejs"; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| const clientId = process.env.GOOGLE_CLIENT_ID?.trim() ?? ""; | ||
| const secret = resolveAccountCookieSecret(); | ||
| if (!clientId || !secret) { | ||
| return NextResponse.redirect(new URL("/?auth=google-unavailable", request.nextUrl.origin)); | ||
| } | ||
| const returnTo = safeSameOriginReturnPath( | ||
| request.nextUrl.searchParams.get("returnTo"), | ||
| request.nextUrl.origin, | ||
| ); | ||
| const transaction = createGoogleOidcTransaction(returnTo, request.nextUrl.origin); | ||
| const redirectUri = `${request.nextUrl.origin}/api/auth/google/callback`; | ||
| const response = NextResponse.redirect(googleAuthorizationUrl({ clientId, redirectUri, transaction })); | ||
| response.cookies.set( | ||
| GOOGLE_OIDC_TRANSACTION_COOKIE, | ||
| serializeGoogleOidcTransaction(transaction, secret), | ||
| { | ||
| httpOnly: true, | ||
| sameSite: "lax", | ||
| secure: process.env.NODE_ENV === "production", | ||
| maxAge: GOOGLE_OIDC_TRANSACTION_TTL_SECONDS, | ||
| path: "/api/auth/google", | ||
| }, | ||
| ); | ||
| response.headers.set("cache-control", "no-store"); | ||
| response.headers.set("referrer-policy", "no-referrer"); | ||
| return response; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unlike PUT, every authenticated same-origin DELETE reaches
deleteStudioConnection, which reads and rewrites the private Blob state and increments its revision even when that provider is already absent. A buggy or malicious signed-in client can therefore generate unbounded storage writes and retry work through this endpoint; apply the same fail-closed connection-write quota before performing the mutation.AGENTS.md reference: AGENTS.md:L109-L109
Useful? React with 👍 / 👎.