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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ DROPS_GUEST_COOKIE_SECRET=
# May equal DROPS_GUEST_COOKIE_SECRET during an MVP, but should be independently rotated in production.
DROPS_ACCOUNT_COOKIE_SECRET=

# Google OIDC profile sign-in. Register the exact callback
# https://<studio-origin>/api/auth/google/callback in Google Cloud Console.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Independent 32+ byte AES-GCM key for encrypted account-scoped connection envelopes.
# Never reuse a public capability secret and never expose this value to the browser.
DROPS_CONNECTION_VAULT_KEY=

# Stable 32+ byte HMAC secret for browser-held publish management capabilities.
# Rotating it makes existing managed public links read-only in their original browsers.
DROPS_PUBLISH_CAPABILITY_SECRET=
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ must not be used to restore an older editor layout.
- 12 distinct standalone crypto product runtimes
- platform-funded guest and signed-in member AI planning with explicit daily quotas and a deterministic no-key fallback compiler
- OpenRouter PKCE sign-in with an HttpOnly Studio identity while the provider key remains session-only
- Google OIDC profile sign-in with private project sync and opt-in encrypted,
account-scoped connection restoration; guests remain session-only
- bounded design enhancement through OpenAI, Anthropic, OpenRouter Free, Kimi or a custom OpenAI-compatible model
- universal Experience Director for every recipe: archetype, layout, data view,
engagement loop, audience, primary loop and editable product modules
Expand Down
117 changes: 117 additions & 0 deletions app/api/account/connections/route.ts
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);
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rate-limit account connection deletions

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 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

DELETE removes a persisted credential, so it is a destructive operation. It has no request limit, while PUT has one at lines 71-80. Neither method records an audit event for the credential change.

Apply the same consumeRequestLimit namespace budget to DELETE, and record an audit event for both save and delete. Log the account identity, the provider, and the outcome only. Do not log the credential.

🛡️ 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 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/account/connections/route.ts` around lines 102 - 117, Update the
connection mutation handlers, including DELETE and the existing PUT flow, to
consume the same request-limit namespace budget before changing credentials. Add
audit records for both save and delete that contain only the account identity,
provider, and outcome; never include credential data. Preserve existing
validation, response, and error behavior.

Source: Coding guidelines

56 changes: 56 additions & 0 deletions app/api/account/route.ts
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

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 | 🟡 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 || true

Repository: 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)
PY

Repository: svg8bit/drops-studio

Length of output: 4099


Align all /api/account response branches.

Return account: null and vault: { available: connectionVaultConfigured() } for unauthenticated responses. Use the same vault value in the 503 branch. Account-state read failures do not imply that DROPS_CONNECTION_VAULT_KEY is unavailable. Current clients do not read vault.available, so this is an API contract issue, not an observed TypeError.

🤖 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/account/route.ts` around lines 21 - 26, Update the unauthenticated
response in the `/api/account` handler to return account: null and vault: {
available: connectionVaultConfigured() }, matching the response contract used by
other branches. In the 503/error branch, reuse the same
connectionVaultConfigured() result for vault.available instead of deriving
availability from the account-state read failure.

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" } },
);
}
}
13 changes: 11 additions & 2 deletions app/api/agent/plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

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 | 🟡 Minor | ⚡ Quick win

Separate a storage outage from a missing credential.

readStudioConnectionSecret rejects when account storage is unavailable. .catch(() => null) maps that rejection to "no remembered credential". Two wrong outcomes follow:

  • The direct-provider path returns 400 with "Connect openai with an API key before using it.", although the credential is stored. The user is told to repeat work that is already done.
  • The OpenRouter path at lines 485-489 falls through to the platform quota path and consumes a member allowance instead of using the remembered key.

Catch StudioAccountStateUnavailableError separately and return 503 for that case.

🔧 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

‼️ 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
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 });
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;
}
}
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 });
🤖 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/agent/plan/route.ts` around lines 508 - 514, Update the credential
lookup around readStudioConnectionSecret to catch
StudioAccountStateUnavailableError separately and return a 503 response, rather
than converting storage outages to a missing credential. Preserve null handling
for an actually absent remembered credential so direct-provider validation and
the OpenRouter quota path continue to behave normally.

}
Expand Down
88 changes: 88 additions & 0 deletions app/api/auth/google/callback/route.ts
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");
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
43 changes: 43 additions & 0 deletions app/api/auth/google/start/route.ts
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;
}
Loading