Ship account-aware v0-style Studio flow - #23
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 18 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (6)
📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe PR adds Google OIDC sign-in, encrypted account-scoped connection storage, remembered credentials, project deletion synchronization, account controls, and Director workspace progress reporting. ChangesAccount-backed Studio workflows
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33714797f8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| await fetch("/api/account/connections?provider=openrouter", { | ||
| method: "DELETE", | ||
| credentials: "same-origin", | ||
| headers: { accept: "application/json" }, | ||
| }).catch(() => undefined); |
There was a problem hiding this comment.
Check vault deletion before confirming OpenRouter disconnect
When a signed-in user has a remembered OpenRouter key and the DELETE request returns 503/non-2xx or encounters a network failure, this catch discards the result and the function clears local state and reports “OpenRouter disconnected.” The encrypted credential therefore remains in the account vault and will be restored on the next load; only clear the connection and confirm disconnection after an ok response.
AGENTS.md reference: AGENTS.md:L9-L10
Useful? React with 👍 / 👎.
| void fetch("/api/account/connections?provider=telegram", { | ||
| method: "DELETE", | ||
| credentials: "same-origin", | ||
| headers: { accept: "application/json" }, | ||
| }).catch(() => undefined); |
There was a problem hiding this comment.
Await Telegram vault deletion before clearing the session
For a signed-in user whose Telegram token was saved in the account vault, any failed or non-2xx DELETE is ignored while the wizard immediately clears its local state and presents the phone sign-in flow. The supposedly disconnected credential remains server-side and the next wizard mount can silently restore it, so this action must await and validate deletion before changing the connected state.
AGENTS.md reference: AGENTS.md:L9-L10
Useful? React with 👍 / 👎.
| setToast( | ||
| "Custom API configured for this tab. It will be called directly by your browser when you plan.", | ||
| remembered | ||
| ? "Custom API verified for this tab and encrypted in your Studio account vault." | ||
| : "Custom API configured for this tab. Sign in to remember it across sessions.", |
There was a problem hiding this comment.
Verify custom providers before marking them connected
When a signed-in user enters an unreachable endpoint, invalid key, or nonexistent model, this path performs only local syntax checks and stores the credential through /api/account/connections; neither that endpoint nor this branch contacts the provider. A successful vault write is therefore reported as “Custom API verified” and later restored as a connected provider despite having no provider evidence, so test the endpoint before persisting/claiming verification or label it as unverified configuration.
AGENTS.md reference: AGENTS.md:L9-L10
Useful? React with 👍 / 👎.
| if (projectSyncAvailable) { | ||
| await deleteProjectV2FromCloud(project.id); | ||
| } | ||
| if (memberConnected) { | ||
| await deleteMemberProjectFromCloud(project.id); |
There was a problem hiding this comment.
Roll back V2 deletion when legacy cloud cleanup fails
For a signed-in project with both sync paths enabled, deleteProjectV2FromCloud permanently destroys the Sandbox, release receipts, and V2 snapshot before deleteMemberProjectFromCloud runs. If the latter then returns a conflict or transient error, the catch restores only the browser record, leaving the operation reported as failed while the V2 cloud state has already been deleted; restore that remote state as part of rollback or sequence the operation through an atomic server-side deletion.
AGENTS.md reference: AGENTS.md:L76-L76
Useful? React with 👍 / 👎.
| const activeProvider = useMemo(() => { | ||
| if (!project) return "free" as ProjectProvider; | ||
| return (window.sessionStorage?.getItem("drops-studio:active-brain") || | ||
| project.spec.brain.provider || | ||
| "free") as ProjectProvider; | ||
| }, [project]); | ||
| if (accountBrain) return accountBrain; | ||
| return project?.spec.brain.provider || "free"; |
There was a problem hiding this comment.
Honor an explicit Free Auto selection in Project Studio
When drops-studio:active-brain is explicitly set to free, it is excluded from providers, so accountBrain remains null, while the later if (current) return prevents account hydration from choosing anything else. activeProvider then falls back to the project’s historical provider, causing a Builder run to use a paid remembered provider or fail for missing credentials even though the user selected Free Auto; preserve free as a valid session selection.
AGENTS.md reference: AGENTS.md:L85-L85
Useful? React with 👍 / 👎.
| if (account && "accountToken" in result && typeof result.accountToken === "string") { | ||
| await saveStudioConnection(account.identity, { | ||
| provider: "telegram", | ||
| credential: result.accountToken, | ||
| label: "Telegram account session", | ||
| }).catch(() => undefined); |
There was a problem hiding this comment.
Surface Telegram account-vault save failures
For a signed-in Studio account, a missing vault key, storage outage, or conflicting account-state write is swallowed here and the route returns the normal successful Telegram sign-in payload. The wizard explicitly promises signed-in users encrypted cross-session restore, but after this path the session exists only in the current tab and disappears on reload; include the vault-save result in the response and present the connection as session-only when persistence fails.
AGENTS.md reference: AGENTS.md:L9-L10
Useful? React with 👍 / 👎.
| <strong>{memberConnected ? "Encrypted account vault." : "Session-only storage."}</strong>{" "} | ||
| The key is never written to project files, logs, ZIPs or checkpoints. | ||
| {memberConnected | ||
| ? " After verification it is encrypted server-side and can be removed here." | ||
| : " Sign in to remember it across sessions."} |
There was a problem hiding this comment.
Expose vault removal for every remembered provider
For signed-in users with remembered DropsTab, OpenAI, Anthropic, Kimi, or custom credentials, this text promises that the key “can be removed here,” but the dialog exposes a delete action only for OpenRouter; the other action buttons merely re-test or overwrite the connection and require another key. These users therefore have no UI path to revoke credentials stored by this same screen, so provide a confirmed DELETE control for every persisted provider.
AGENTS.md reference: AGENTS.md:L9-L10
Useful? React with 👍 / 👎.
| onAgentEvent?.({ | ||
| phase: "sandbox", | ||
| status: "done", | ||
| message: "Project files are running inside the isolated Node 24 Sandbox.", | ||
| }); |
There was a problem hiding this comment.
Mark the Sandbox done only after successful runtime evidence
Whenever /api/builder/agent returns any result, this records “Project files are running inside the isolated Node 24 Sandbox” before checking its status or release gate. A 422 blocked result can represent an unconfigured fallback, Sandbox startup failure, or gate failure, yet the persisted Director history retains this phase as “Verified” even after verification is marked blocked; emit the completed Sandbox event only when the result contains successful runtime evidence.
AGENTS.md reference: AGENTS.md:L83-L85
Useful? React with 👍 / 👎.
| 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| window.sessionStorage.setItem( | ||
| `drops-studio:${connection.provider}:model`, | ||
| connection.model, | ||
| ); |
There was a problem hiding this comment.
Persist model changes made after connecting
The vault stores the model selected during the initial connection, but subsequent picker changes update only sessionStorage; there is no account-state update for the new selection. On the next browser session this hydration unconditionally writes the stale vault model back into sessionStorage, silently reverting the user’s choice and sending later remembered-provider requests to the old model, so persist model selection changes or stop restoring this stale metadata.
AGENTS.md reference: AGENTS.md:L85-L85
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
components/telegram-channel-wizard.tsx (1)
195-210: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winHandle the DELETE failure before clearing local Telegram state.
disconnectfiresDELETE /api/account/connections?provider=telegramand immediately clears local session state regardless of the outcome (.catch(() => undefined)swallows any failure). If the account-scoped deletion fails silently, the encrypted Telegram connection stays stored server-side while the UI shows "disconnected." On the next load, the always-on status check (lines 92-113) can restore that stale connection from the signed-in account, silently undoing the user's disconnect action with no error shown.Check the response status and surface an error (and keep the connected UI state) when the deletion fails, instead of unconditionally clearing local state.
🤖 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 `@components/telegram-channel-wizard.tsx` around lines 195 - 210, Update disconnect so it awaits the DELETE request, validates the response status, and only clears session and Telegram UI state after a successful deletion. On request or non-success response failure, surface an error to the user and preserve the connected state instead of swallowing the failure.
🧹 Nitpick comments (14)
lib/studio-account-profile.ts (1)
5-14: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winMake the display-name truncation and the initial locale-independent.
Line 14 calls
toLocaleUpperCase()with no locale argument, so the result depends on the ambient default locale of the runtime. The server runtime locale and the browser locale can differ, anddrops-studio.tsxrenders this initial in markup, so the values can disagree during hydration. Line 6 also truncates by UTF-16 code unit, which can split a surrogate pair at position 160.♻️ Proposed change
- const normalized = value?.trim().replace(/\s+/g, " ") ?? ""; - return normalized ? normalized.slice(0, 160) : fallback; + const normalized = value?.trim().replace(/\s+/g, " ") ?? ""; + return normalized ? Array.from(normalized).slice(0, 160).join("") : fallback; } export function studioAccountInitial( value: string | null | undefined, fallback = "D", ): string { const displayName = studioAccountDisplayName(value, ""); - return Array.from(displayName)[0]?.toLocaleUpperCase() ?? fallback; + return Array.from(displayName)[0]?.toLocaleUpperCase("en-US") ?? fallback; }🤖 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 `@lib/studio-account-profile.ts` around lines 5 - 14, Update studioAccountDisplayName and studioAccountInitial to make normalization deterministic: truncate the normalized display name by Unicode code points without splitting surrogate pairs, and uppercase the selected initial with an explicit locale-independent locale. Preserve the existing fallback behavior for empty or missing display names.app/api/auth/google/callback/route.ts (1)
60-62: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAvoid the dynamic re-import of
@/lib/access-tier.
createStudioAccountCookie,resolveAccountCookieSecret, andSTUDIO_ACCOUNT_COOKIEare already statically imported from@/lib/access-tierat the top of the file. AddreadStudioAccountCookieto that same static import instead of dynamically re-importing the module here.♻️ Proposed fix
import { createStudioAccountCookie, + readStudioAccountCookie, resolveAccountCookieSecret, STUDIO_ACCOUNT_COOKIE, } from "`@/lib/access-tier`"; @@ - const account = await import("`@/lib/access-tier`").then(({ readStudioAccountCookie }) => - readStudioAccountCookie(accountCookie, signingSecret), - ); + const account = readStudioAccountCookie(accountCookie, signingSecret);🤖 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/auth/google/callback/route.ts` around lines 60 - 62, Update the existing static import from "`@/lib/access-tier`" to include readStudioAccountCookie, then call it directly in the account initialization instead of dynamically importing the module. Preserve the existing arguments and behavior.db/studio-account-state.ts (1)
180-189: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy liftAdd a repair path for an unparsable stored record.
readStoredthrowsStudioAccountStateUnavailableErrorwhenparseStaterejects the stored JSON.mutateStatereads before every write. One malformed record therefore blocks read, save, and delete for that account forever, and the operator has no recovery route through the API.Keep the strict read for the account API, but give writes a bounded recovery: copy the unreadable record to a quarantine path, then continue from
emptyState(). Record the event so the loss is visible.🤖 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 `@db/studio-account-state.ts` around lines 180 - 189, Add bounded recovery in mutateState for StudioAccountStateUnavailableError from readStored: copy the unreadable record to a quarantine path, record the recovery event, and continue mutation from emptyState(). Keep readStored strict for account API reads, and ensure quarantine or logging failures do not silently overwrite the original record.app/api/account/connections/route.ts (1)
28-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove
sameOrigininto one shared module.This function repeats the logic in
app/api/agent/plan/route.tslines 61-75 and the builder variant. The copies already differ: this version rejects a request with nooriginheader, the plan route accepts it outside production. Divergent copies of a cross-site guard become a security defect over time.Extract one helper, expose the "missing origin" behavior as an option, and call it from every route.
🤖 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 28 - 45, Extract the duplicated sameOrigin logic into a shared helper module, preserving its existing host, protocol, origin, and sec-fetch-site validation. Add an option controlling whether a missing Origin header is accepted, then update the connections route, plan route, and builder variant to call the shared helper with their intended missing-origin behavior.lib/studio-account-state.ts (1)
67-79: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winDerive the vault key with a KDF, not a single SHA-256 pass.
resolveConnectionVaultKeyhashes the configured passphrase one time. The result has no salt and no work factor.DROPS_CONNECTION_VAULT_KEYonly needs 32 bytes of text, so an operator can supply a low-entropy passphrase. If a stored record leaks, an offline guess of the passphrase decrypts every remembered credential.Use
hkdfSyncwith a fixed context string, orscryptSyncfor passphrase input. Record the derivation version insideEncryptedStudioConnectionso existing records stay readable during migration.🔐 Suggested derivation change
-import { - createCipheriv, - createDecipheriv, - createHash, - randomBytes, -} from "node:crypto"; +import { + createCipheriv, + createDecipheriv, + hkdfSync, + randomBytes, +} from "node:crypto"; @@ - return createHash("sha256").update(configured, "utf8").digest(); + return Buffer.from(hkdfSync( + "sha256", + Buffer.from(configured, "utf8"), + Buffer.from("drops-studio-connection-vault:v1", "utf8"), + Buffer.from("drops-studio-connection-key", "utf8"), + 32, + ));🤖 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 `@lib/studio-account-state.ts` around lines 67 - 79, Update resolveConnectionVaultKey to derive the vault key with a password-appropriate KDF such as scryptSync, or hkdfSync with the specified fixed context, instead of a single unsalted SHA-256 pass. Add a derivation-version field to EncryptedStudioConnection and use it when reading records so existing encrypted records remain decryptable during migration.tests/studio-account-state.test.mjs (1)
8-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the blob persistence layer.
These tests cover the vault primitives well. The persistence layer in
db/studio-account-state.tshas no test, and it holds the riskiest logic: the conditional-write retry loop, theparseStaterejection path, the 96 KiB bound, and the pre-v1 envelope fallback inreadStudioConnectionSecret.Every exported function accepts
storageOverride, so a fakeBlobStoragewithgetandputcan drive these paths without network access. Add cases for a first write with no ETag, a concurrent ETag mismatch followed by a successful retry, and a stored record that fails validation.🤖 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 `@tests/studio-account-state.test.mjs` around lines 8 - 72, Add tests for the persistence functions in db/studio-account-state.ts using a fake BlobStorage supplied through storageOverride. Cover an initial write without an ETag, a conditional-write ETag mismatch followed by a successful retry, parseState rejection for invalid stored data, the 96 KiB size limit, and pre-v1 envelope fallback in readStudioConnectionSecret; verify the expected get/put behavior and outcomes without network access.app/api/builder/shared.ts (1)
264-271: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReplace the repeated provider list and the cast with one typed constant.
The supported provider names appear at line 254 and again at line 265, and line 270 casts the value to a union that no check guarantees. A typed constant plus a type guard removes the cast and keeps the two lists in sync.
♻️ Suggested refactor
+const REMEMBERED_PROVIDERS = ["openai", "anthropic", "openrouter", "kimi", "custom"] as const; +type RememberedProvider = (typeof REMEMBERED_PROVIDERS)[number]; + +function isRememberedProvider(value: string): value is RememberedProvider { + return (REMEMBERED_PROVIDERS as readonly string[]).includes(value); +} @@ - const provider = selection.provider; - if (!["openai", "anthropic", "openrouter", "kimi", "custom"].includes(provider)) { - return { credentials, selection }; - } - const remembered = await readStudioConnectionSecret( - account.identity, - provider as "openai" | "anthropic" | "openrouter" | "kimi" | "custom", - ).catch(() => null); + const provider = selection.provider; + if (!isRememberedProvider(provider)) return { credentials, selection }; + const remembered = await readStudioConnectionSecret(account.identity, provider) + .catch(() => null);🤖 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/builder/shared.ts` around lines 264 - 271, Define one typed supported-provider constant near the existing provider definitions and reuse it for validation in the selection flow. Replace the inline provider list and the cast passed to readStudioConnectionSecret with a type guard that narrows provider to the supported-provider union before the call, keeping both checks synchronized.components/drops-studio-dialogs.tsx (1)
168-192: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueGive the account card an accessible name.
The
sectionelement has no accessible name, so assistive technology announces it as a generic region. Addaria-labelto describe it.♿ Proposed change
- <section className={`studio-account-card ${accountProfile ? "connected" : ""}`}> + <section + aria-label="Studio account" + className={`studio-account-card ${accountProfile ? "connected" : ""}`} + >🤖 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 `@components/drops-studio-dialogs.tsx` around lines 168 - 192, Add an aria-label to the account card section in the account profile UI so assistive technology announces a descriptive accessible name. Update the section element containing the account avatar, profile details, and sign-in/sign-out button; keep its existing class and content unchanged.lib/member-project-sync-client.ts (1)
212-234: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAccept an optional
expectedRevisionto avoid one full listing per delete.
deleteMemberProjectFromCloudcallslistMemberProjectsFromCloud()on every invocation. The bulk deletion loop incomponents/drops-studio.tsx(lines 1982-1994) calls it once per project, so deleting N projects issues N full listings plus N deletes. Let callers pass a known revision and fall back to the listing only when the revision is absent.♻️ Proposed refactor
export async function deleteMemberProjectFromCloud( projectId: string, + expectedRevision?: number, ): Promise<void> { - const listing = await listMemberProjectsFromCloud(); - const current = listing.projects.find((project) => project.id === projectId); - if (!current) return; + let revision = expectedRevision; + if (revision === undefined) { + const listing = await listMemberProjectsFromCloud(); + const current = listing.projects.find((project) => project.id === projectId); + if (!current) return; + revision = current.revision; + } const response = await fetch("/api/projects", { method: "DELETE", credentials: "same-origin", headers: { accept: "application/json", "content-type": "application/json", }, body: JSON.stringify({ id: projectId, - expectedRevision: current.revision, + expectedRevision: revision, }), });🤖 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 `@lib/member-project-sync-client.ts` around lines 212 - 234, Update deleteMemberProjectFromCloud to accept an optional expectedRevision parameter and use it directly when provided. Only call listMemberProjectsFromCloud and resolve the project revision when the parameter is absent, preserving the existing not-found behavior and DELETE request handling.components/drops-studio.tsx (1)
1982-1994: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winContinue the bulk deletion after a single project fails.
deleteAllProjectsFromLibraryawaitsdeleteProjectRecordinside the loop. The first rejection exits the loop, so the remaining projects stay in place even when they could be deleted. Collect the failures and report them after the loop.♻️ Proposed change
- try { - for (const project of [...projects]) await deleteProjectRecord(project); - setProjects([]); - setToast("All projects deleted."); - } catch (error) { - setToast(error instanceof Error ? error.message : "Some projects could not be deleted."); - } + const failures: string[] = []; + for (const project of [...projects]) { + try { + await deleteProjectRecord(project); + } catch { + failures.push(project.spec.name); + } + } + setToast( + failures.length + ? `These projects could not be deleted: ${failures.join(", ")}.` + : "All projects deleted.", + );
deleteProjectRecordalready updates the project list after each successful deletion, so the explicitsetProjects([])is no longer required.🤖 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 `@components/drops-studio.tsx` around lines 1982 - 1994, Update deleteAllProjectsFromLibrary to attempt every project even when an individual deleteProjectRecord call fails, collecting failed deletions and reporting them after the loop. Remove the redundant setProjects([]), preserve successful deletion updates, and show an appropriate success or failure summary in the toast based on the collected failures.e2e/fixtures/ui-test.ts (1)
59-63: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDocument why the reset runs only once per session.
The
addInitScriptcallback runs for every document in the context. The new marker makes the storage reset run only on the first document, so state that a test creates during later navigations survives. Add a short comment so a later reader does not remove the guard. Note the behavior change: a secondprepareHomePagecall in the same context no longer clears storage.♻️ Proposed change
+ // This init script runs on every document. Reset storage only on the + // first one so state created during the flow survives later navigations. const seedKey = "drops-studio:e2e-home-seeded"🤖 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 `@e2e/fixtures/ui-test.ts` around lines 59 - 63, Add a concise comment immediately above the seedKey/sessionStorage guard in the addInitScript callback explaining that the marker limits storage clearing to the first document per session, preserving state across later navigations and repeated prepareHomePage calls; do not change the existing reset behavior.components/project-studio.tsx (2)
2014-2019: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLog the discarded error before showing the generic fallback message.
void error;drops the original failure with no logging, so there is no trace to diagnose why the connected model failed. Keep the generic user-facing message (it intentionally avoids leaking provider-specific text), but log the error for diagnostics.♻️ Proposed fix
- } catch (error) { - void error; + } catch (error) { + console.error("Project V2 agent edit failed", 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 `@components/project-studio.tsx` around lines 2014 - 2019, Replace the discarded `error` expression in the failure-handling path with diagnostic logging before constructing the generic `assistant` fallback message. Preserve the existing user-facing message and avoid exposing provider-specific error details in it.
1013-1018: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueRemove or consume the
autobuildquery parameter.
autobuild=1has no runtime consumer. Project V2 auto-build uses a session-storage key instead. Remove the parameter and update the URL assertions, or implement URL handling.🤖 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 `@components/project-studio.tsx` around lines 1013 - 1018, Update the project initialization logic near requestedPanel in project-studio.tsx to remove or consume the obsolete autobuild query parameter; preserve Project V2 auto-build through its existing session-storage mechanism, and update any related URL assertions to match the parameter’s removal if it is no longer handled.components/telegram-channel-wizard.tsx (1)
92-113: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider rendering the phone-entry form immediately while the account check runs in the background.
This effect now always calls
/api/telegram/account/status, even when there is no savedaccountToken, to support cross-session restore for signed-in accounts. First-time users (no saved token, not signed in) now wait for this round trip before the phone-entry form appears, instead of seeing it immediately as before.Consider showing the phone phase optimistically by default, and only switching to "connected" if the status check confirms a restored account, so first-time users are not delayed by a network round trip that will return nothing for them.
🤖 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 `@components/telegram-channel-wizard.tsx` around lines 92 - 113, Initialize the Telegram wizard in the phone-entry phase with checking disabled so first-time users see the form immediately, while keeping the status request in the existing useEffect for background restoration. When the response confirms a valid account, continue switching to the connected phase and populating the account state; preserve the existing fallback cleanup and phone-phase behavior for failed or missing restoration.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@app/api/account/connections/route.ts`:
- Around line 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.
In `@app/api/account/route.ts`:
- Around line 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.
In `@app/api/agent/plan/route.ts`:
- Around line 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.
In `@app/api/auth/google/callback/route.ts`:
- Around line 34-88: Update the successful Google callback flow in GET so an
existing valid Studio account cookie is preserved when available instead of
always creating a new Google-scoped account identity. Reuse the existing account
identity for the Google provider association, or migrate its connections to the
new identity before setting STUDIO_ACCOUNT_COOKIE, ensuring /api/account
continues to expose previously saved connections.
In `@app/api/auth/openrouter/exchange/route.ts`:
- Around line 95-131: Update saveStudioConnection handling in
app/api/auth/openrouter/exchange/route.ts lines 95-131 and
app/api/telegram/account/sign-in/route.ts lines 36-45 to log persistence
failures instead of silently swallowing them. In the OpenRouter route,
distinguish the session key being returned from the credential being
successfully remembered rather than always reporting connected: true; keep
telegramAccountJson(result) behavior unchanged apart from adding failure
logging.
In `@app/api/builder/shared.ts`:
- Around line 280-286: Constrain remembered connection values before merging
them in app/api/builder/shared.ts lines 280-286: limit remembered.model to 192
characters and remembered.endpoint to 2,000 characters. In
app/api/builder/agent/route.ts lines 155-163, revalidate the merged selection
with requestSchema.shape.provider before assigning it to agentRequest.provider.
In `@app/styles/drops-studio.shell.css`:
- Line 32: Update .account-profile-button in app/styles/drops-studio.shell.css
at lines 32-32 to use min-height: 44px, add min-width: 44px, and set font-size:
14px; update .studio-account-card > button in
app/styles/drops-studio.dialogs.css at lines 5-5 with the same three sizing
changes.
In `@app/styles/project-studio.chrome.css`:
- Around line 36-39: Update the .workspace-account-action rule to enforce a
minimum 44px height, ensuring the account button meets the 44×44 CSS-pixel
interactive-target requirement at all viewport widths, including 1440px.
In `@components/drops-studio.tsx`:
- Around line 1265-1276: Update closeConnectionsHub to clear connectionReturnTo
before navigating, then use router.push(connectionReturnTo) for the validated
local destination instead of window.location.assign; preserve the existing
query-cleanup behavior when no return path is set.
In `@components/project-studio.tsx`:
- Around line 601-607: Update the account hydration logic in project-studio.tsx
to set accountProfile whenever payload.profile exists, regardless of whether
profile.name is empty or absent. Preserve the existing profile fields and allow
studioAccountDisplayName and studioAccountInitial to provide the fallback
display values used by the signed-in topbar and Drops Studio dialog flow.
- Around line 3277-3289: Update openConnectionsHub to call commitPendingSpec()
before window.location.assign, matching the behavior of openRuntime,
openPublish, and downloadSource so pending quiet-spec edits are persisted before
navigation.
- Around line 1487-1532: The recordBuilderAgentEvent path should update local
state and persist the conversation through the V1 wrapper only; remove its
persistProject call so conversation-only builder events do not upload projectV2
or advance storageRevision. Keep the existing conversation construction and
setProject behavior unchanged, and avoid invoking saveProjectV2ToCloud from this
callback.
In `@db/studio-account-state.ts`:
- Around line 191-236: Update writeStored and readStored to pass bounded abort
signals to Blob get and put operations, using a timeout and bounded backoff
between mutateState retries. Classify BlobPreconditionFailedError as the only
retryable write conflict; propagate or map token, rate-limit, network, and
timeout failures instead of converting every write error to false. Preserve
cacheControlMaxAge at the SDK-supported minimum of 60 seconds.
---
Outside diff comments:
In `@components/telegram-channel-wizard.tsx`:
- Around line 195-210: Update disconnect so it awaits the DELETE request,
validates the response status, and only clears session and Telegram UI state
after a successful deletion. On request or non-success response failure, surface
an error to the user and preserve the connected state instead of swallowing the
failure.
---
Nitpick comments:
In `@app/api/account/connections/route.ts`:
- Around line 28-45: Extract the duplicated sameOrigin logic into a shared
helper module, preserving its existing host, protocol, origin, and
sec-fetch-site validation. Add an option controlling whether a missing Origin
header is accepted, then update the connections route, plan route, and builder
variant to call the shared helper with their intended missing-origin behavior.
In `@app/api/auth/google/callback/route.ts`:
- Around line 60-62: Update the existing static import from "`@/lib/access-tier`"
to include readStudioAccountCookie, then call it directly in the account
initialization instead of dynamically importing the module. Preserve the
existing arguments and behavior.
In `@app/api/builder/shared.ts`:
- Around line 264-271: Define one typed supported-provider constant near the
existing provider definitions and reuse it for validation in the selection flow.
Replace the inline provider list and the cast passed to
readStudioConnectionSecret with a type guard that narrows provider to the
supported-provider union before the call, keeping both checks synchronized.
In `@components/drops-studio-dialogs.tsx`:
- Around line 168-192: Add an aria-label to the account card section in the
account profile UI so assistive technology announces a descriptive accessible
name. Update the section element containing the account avatar, profile details,
and sign-in/sign-out button; keep its existing class and content unchanged.
In `@components/drops-studio.tsx`:
- Around line 1982-1994: Update deleteAllProjectsFromLibrary to attempt every
project even when an individual deleteProjectRecord call fails, collecting
failed deletions and reporting them after the loop. Remove the redundant
setProjects([]), preserve successful deletion updates, and show an appropriate
success or failure summary in the toast based on the collected failures.
In `@components/project-studio.tsx`:
- Around line 2014-2019: Replace the discarded `error` expression in the
failure-handling path with diagnostic logging before constructing the generic
`assistant` fallback message. Preserve the existing user-facing message and
avoid exposing provider-specific error details in it.
- Around line 1013-1018: Update the project initialization logic near
requestedPanel in project-studio.tsx to remove or consume the obsolete autobuild
query parameter; preserve Project V2 auto-build through its existing
session-storage mechanism, and update any related URL assertions to match the
parameter’s removal if it is no longer handled.
In `@components/telegram-channel-wizard.tsx`:
- Around line 92-113: Initialize the Telegram wizard in the phone-entry phase
with checking disabled so first-time users see the form immediately, while
keeping the status request in the existing useEffect for background restoration.
When the response confirms a valid account, continue switching to the connected
phase and populating the account state; preserve the existing fallback cleanup
and phone-phase behavior for failed or missing restoration.
In `@db/studio-account-state.ts`:
- Around line 180-189: Add bounded recovery in mutateState for
StudioAccountStateUnavailableError from readStored: copy the unreadable record
to a quarantine path, record the recovery event, and continue mutation from
emptyState(). Keep readStored strict for account API reads, and ensure
quarantine or logging failures do not silently overwrite the original record.
In `@e2e/fixtures/ui-test.ts`:
- Around line 59-63: Add a concise comment immediately above the
seedKey/sessionStorage guard in the addInitScript callback explaining that the
marker limits storage clearing to the first document per session, preserving
state across later navigations and repeated prepareHomePage calls; do not change
the existing reset behavior.
In `@lib/member-project-sync-client.ts`:
- Around line 212-234: Update deleteMemberProjectFromCloud to accept an optional
expectedRevision parameter and use it directly when provided. Only call
listMemberProjectsFromCloud and resolve the project revision when the parameter
is absent, preserving the existing not-found behavior and DELETE request
handling.
In `@lib/studio-account-profile.ts`:
- Around line 5-14: Update studioAccountDisplayName and studioAccountInitial to
make normalization deterministic: truncate the normalized display name by
Unicode code points without splitting surrogate pairs, and uppercase the
selected initial with an explicit locale-independent locale. Preserve the
existing fallback behavior for empty or missing display names.
In `@lib/studio-account-state.ts`:
- Around line 67-79: Update resolveConnectionVaultKey to derive the vault key
with a password-appropriate KDF such as scryptSync, or hkdfSync with the
specified fixed context, instead of a single unsalted SHA-256 pass. Add a
derivation-version field to EncryptedStudioConnection and use it when reading
records so existing encrypted records remain decryptable during migration.
In `@tests/studio-account-state.test.mjs`:
- Around line 8-72: Add tests for the persistence functions in
db/studio-account-state.ts using a fake BlobStorage supplied through
storageOverride. Cover an initial write without an ETag, a conditional-write
ETag mismatch followed by a successful retry, parseState rejection for invalid
stored data, the 96 KiB size limit, and pre-v1 envelope fallback in
readStudioConnectionSecret; verify the expected get/put behavior and outcomes
without network access.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 34532287-e049-4365-9cc8-15e6d8fb1e45
⛔ Files ignored due to path filters (15)
docs/design/current-home-actual.pngis excluded by!**/*.pngdocs/design/current-studio-actual.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/home-builder-chromium-1024-linux-system.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/home-builder-chromium-1024-linux.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/home-builder-chromium-1440-linux-system.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/home-builder-chromium-1440-linux.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/home-builder-chromium-390-linux-system.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/home-builder-chromium-390-linux.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux-system.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1024-linux.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux-system.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-1440-linux.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-390-linux-system.pngis excluded by!**/*.pnge2e/visual/home.spec.ts-snapshots/studio-crypto-game-chromium-390-linux.pngis excluded by!**/*.pngpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (48)
.env.exampleREADME.mdapp/api/account/connections/route.tsapp/api/account/route.tsapp/api/agent/plan/route.tsapp/api/auth/google/callback/route.tsapp/api/auth/google/start/route.tsapp/api/auth/openrouter/exchange/route.tsapp/api/builder/agent/route.tsapp/api/builder/shared.tsapp/api/dropstab/route.tsapp/api/telegram/account/create-channel/route.tsapp/api/telegram/account/sign-in/route.tsapp/api/telegram/account/status/route.tsapp/styles/drops-studio.dialogs.cssapp/styles/drops-studio.responsive.cssapp/styles/drops-studio.shell.cssapp/styles/project-studio.chrome.cssapp/styles/project-studio.responsive.cssapp/styles/project-studio.runtime.csscomponents/drops-studio-dialogs.tsxcomponents/drops-studio.tsxcomponents/project-studio.tsxcomponents/project-v2-studio-surface.tsxcomponents/project-workspace-dialog.tsxcomponents/telegram-channel-wizard.tsxdb/studio-account-state.tsdocs/BUILDER_V2.mddocs/V2_SECURITY_MODEL.mde2e/contracts/member-access.spec.tse2e/contracts/member-project-cloud.spec.tse2e/contracts/v0-studio-flow.spec.tse2e/fixtures/ui-test.tse2e/products/free-prompt-game.spec.tslib/access-tier.tslib/enterprise-platform/oidc-provider.tslib/google-oidc.tslib/member-project-sync-client.tslib/project-store.tslib/safe-return-to.tslib/studio-account-profile.tslib/studio-account-state.tslib/telegram-account-request.tspackage.jsontests/google-oidc.test.mjstests/project-store.test.mjstests/studio-account-profile.test.mjstests/studio-account-state.test.mjs
| 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, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔒 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
| if (!account) { | ||
| return NextResponse.json( | ||
| { authenticated: false, profile: null, connections: [] }, | ||
| { headers: { "cache-control": "no-store" } }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🗄️ 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 /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.
| 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 }); |
There was a problem hiding this comment.
🩺 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.
| 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.
| const existingAccount = resolveStudioAccount( | ||
| request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value, | ||
| ); | ||
| const accountCookie = existingAccount | ||
| ? request.cookies.get(STUDIO_ACCOUNT_COOKIE)?.value ?? "" | ||
| : createStudioAccountCookie({ provider: "openrouter", subject: payload.user_id }, secret); | ||
| const account = existingAccount ?? resolveStudioAccount(accountCookie); | ||
| if (account) { | ||
| await saveStudioConnection(account.identity, { | ||
| provider: "openrouter", | ||
| credential: payload.key, | ||
| model: "openrouter/free", | ||
| label: "OpenRouter OAuth", | ||
| }).catch(() => undefined); | ||
| } | ||
| // The API key is returned once to the initiating browser. For a signed-in | ||
| // Studio profile it is also stored only as an AES-GCM encrypted vault entry. | ||
| const result = NextResponse.json( | ||
| { | ||
| key: payload.key, | ||
| account: { | ||
| provider: "openrouter", | ||
| provider: account?.provider ?? "openrouter", | ||
| connected: true, | ||
| projectSync: memberProjectSyncReadiness(), | ||
| }, | ||
| }, | ||
| { headers: { "cache-control": "no-store" } }, | ||
| ); | ||
| result.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, { | ||
| httpOnly: true, | ||
| sameSite: "lax", | ||
| secure: process.env.NODE_ENV === "production", | ||
| maxAge: 60 * 60 * 24 * 90, | ||
| path: "/", | ||
| }); | ||
| if (!existingAccount) { | ||
| result.cookies.set(STUDIO_ACCOUNT_COOKIE, accountCookie, { | ||
| httpOnly: true, | ||
| sameSite: "lax", | ||
| secure: process.env.NODE_ENV === "production", | ||
| maxAge: 60 * 60 * 24 * 90, | ||
| path: "/", | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Swallowed connection-persistence failures with no logging or client signal. Both routes call saveStudioConnection(...) and discard failures via .catch(() => undefined), then return a success response regardless of whether the write succeeded. The shared root cause is a fire-and-forget persistence call with no error visibility.
app/api/auth/openrouter/exchange/route.ts#L95-L131: log thesaveStudioConnectionfailure at lines 103-109 (for example withconsole.error), and consider adding a field to the response distinguishing "session key returned" from "credential remembered for next time," since line 117 currently reportsconnected: trueunconditionally.app/api/telegram/account/sign-in/route.ts#L36-L45: log thesaveStudioConnectionfailure at lines 40-44, sincetelegramAccountJson(result)at line 46 is returned unchanged whether or not persistence succeeded.
📍 Affects 2 files
app/api/auth/openrouter/exchange/route.ts#L95-L131(this comment)app/api/telegram/account/sign-in/route.ts#L36-L45
🤖 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/auth/openrouter/exchange/route.ts` around lines 95 - 131, Update
saveStudioConnection handling in app/api/auth/openrouter/exchange/route.ts lines
95-131 and app/api/telegram/account/sign-in/route.ts lines 36-45 to log
persistence failures instead of silently swallowing them. In the OpenRouter
route, distinguish the session key being returned from the credential being
successfully remembered rather than always reporting connected: true; keep
telegramAccountJson(result) behavior unchanged apart from adding failure
logging.
| function closeConnectionsHub() { | ||
| setConnectionOpen(false); | ||
| if (connectionReturnTo) { | ||
| window.location.assign(connectionReturnTo); | ||
| return; | ||
| } | ||
| const url = new URL(window.location.href); | ||
| for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) { | ||
| url.searchParams.delete(key); | ||
| } | ||
| window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Clear connectionReturnTo after the return navigation.
closeConnectionsHub reads connectionReturnTo but never resets it. The state is set once during initialization from the returnTo query parameter (lines 564-571). After the first close navigates to that path, any later open and close of the Connections Hub navigates to the same stale path again. Reset the state before navigating. Use router.push to keep client-side navigation, because the destination is already validated as a same-origin local path.
🐛 Proposed fix
function closeConnectionsHub() {
setConnectionOpen(false);
if (connectionReturnTo) {
- window.location.assign(connectionReturnTo);
+ setConnectionReturnTo(null);
+ router.push(connectionReturnTo);
return;
}📝 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.
| function closeConnectionsHub() { | |
| setConnectionOpen(false); | |
| if (connectionReturnTo) { | |
| window.location.assign(connectionReturnTo); | |
| return; | |
| } | |
| const url = new URL(window.location.href); | |
| for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) { | |
| url.searchParams.delete(key); | |
| } | |
| window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); | |
| } | |
| function closeConnectionsHub() { | |
| setConnectionOpen(false); | |
| if (connectionReturnTo) { | |
| setConnectionReturnTo(null); | |
| router.push(connectionReturnTo); | |
| return; | |
| } | |
| const url = new URL(window.location.href); | |
| for (const key of ["connections", "provider", "flow", "project", "returnTo", "auth"]) { | |
| url.searchParams.delete(key); | |
| } | |
| window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); | |
| } |
🤖 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 `@components/drops-studio.tsx` around lines 1265 - 1276, Update
closeConnectionsHub to clear connectionReturnTo before navigating, then use
router.push(connectionReturnTo) for the validated local destination instead of
window.location.assign; preserve the existing query-cleanup behavior when no
return path is set.
| const [accountProfile, setAccountProfile] = useState<{ | ||
| name: string; | ||
| email?: string; | ||
| } | null>(null); | ||
| const [accountBrain, setAccountBrain] = useState<ProjectProvider | null>( | ||
| null, | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Confirm the signed-in state is set for accounts with an empty display name.
accountProfile is only set if (payload.profile?.name) is truthy. If the account API returns a profile without a name (or an empty one), accountProfile stays null, and the topbar keeps showing "Sign in" for an already-authenticated user. studioAccountDisplayName already provides a "Drops Studio member" fallback for this exact case, but it is never reached because accountProfile is never set without a name.
Set accountProfile whenever payload.profile exists, and let studioAccountDisplayName/studioAccountInitial handle the empty-name fallback, the same way the account hydration in the Drops Studio dialog flow does.
🐛 Proposed fix
- if (payload.profile?.name) {
- setAccountProfile({
- name: payload.profile.name,
- ...(payload.profile.email ? { email: payload.profile.email } : {}),
- });
- }
+ if (payload.profile) {
+ setAccountProfile({
+ name: payload.profile.name ?? "",
+ ...(payload.profile.email ? { email: payload.profile.email } : {}),
+ });
+ }Also applies to: 1358-1424
🤖 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 `@components/project-studio.tsx` around lines 601 - 607, Update the account
hydration logic in project-studio.tsx to set accountProfile whenever
payload.profile exists, regardless of whether profile.name is empty or absent.
Preserve the existing profile fields and allow studioAccountDisplayName and
studioAccountInitial to provide the fallback display values used by the
signed-in topbar and Drops Studio dialog flow.
| const recordBuilderAgentEvent = useCallback( | ||
| (event: { | ||
| phase: "snapshot" | "sandbox" | "verification" | "preview"; | ||
| status: "active" | "done" | "blocked"; | ||
| message: string; | ||
| }) => { | ||
| const current = projectRef.current; | ||
| if (!current) return; | ||
| const eventId = `builder-${current.id}-${event.phase}`; | ||
| const content = | ||
| event.status === "active" | ||
| ? `Working · ${event.message}` | ||
| : event.status === "done" | ||
| ? `Verified · ${event.message}` | ||
| : `Paused · ${event.message}`; | ||
| const existing = current.conversation ?? []; | ||
| if (existing.some((item) => item.id === eventId && item.content === content)) { | ||
| return; | ||
| } | ||
| const conversation = existing.some((item) => item.id === eventId) | ||
| ? existing.map((item) => | ||
| item.id === eventId | ||
| ? { ...item, content, createdAt: new Date().toISOString() } | ||
| : item, | ||
| ) | ||
| : [ | ||
| ...existing, | ||
| { | ||
| id: eventId, | ||
| role: "assistant" as const, | ||
| content, | ||
| createdAt: new Date().toISOString(), | ||
| }, | ||
| ]; | ||
| const next: GeneratedProject = { | ||
| ...current, | ||
| conversation, | ||
| updatedAt: new Date().toISOString(), | ||
| }; | ||
| projectRef.current = next; | ||
| setProject(next); | ||
| void persistProject(next, current.updatedAt); | ||
| }, | ||
| [persistProject], | ||
| ); | ||
|
|
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: confirm whether saveProjectV2ToCloud enforces optimistic-concurrency conflicts on stale revisions.
rg -n -A 15 'function saveProjectV2ToCloud|PROJECT_V2_REVISION_CONFLICT' libRepository: svg8bit/drops-studio
Length of output: 1444
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- project-studio.tsx relevant symbols ---'
rg -n -A 35 -B 15 'recordBuilderAgentEvent|persistProject|adoptProjectV2|projectV2CloudRevisionRef|cloudSyncAvailableRef' components/project-studio.tsx
printf '%s\n' '--- project-v2-studio-surface.tsx relevant symbols ---'
rg -n -A 30 -B 15 'storageRevision|saveSnapshot|mutateFiles|stopSandbox|restoreCheckpoint|onProjectChange|runBuilder' components/project-v2-studio-surface.tsx
printf '%s\n' '--- V2 sync client ---'
cat -n lib/project-v2-sync-client.ts | sed -n '1,130p'
printf '%s\n' '--- V2 API routes and conflict handling ---'
rg -n -A 35 -B 15 'expectedStorageRevision|PROJECT_V2_REVISION_CONFLICT|storageRevision' app lib componentsRepository: svg8bit/drops-studio
Length of output: 50377
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- persistProject exact implementation ---'
sed -n '661,805p' components/project-studio.tsx
printf '%s\n' '--- surface syncSnapshot and saveSnapshot ---'
sed -n '358,447p' components/project-v2-studio-surface.tsx
sed -n '692,735p' components/project-v2-studio-surface.tsx
printf '%s\n' '--- surface runBuilder event sequence ---'
sed -n '543,670p' components/project-v2-studio-surface.tsx
printf '%s\n' '--- all V2 sync API files ---'
git ls-files | rg '(^|/)(route|project-v2|projects).*(ts|tsx|js)$|api/projects/v2'
printf '%s\n' '--- conflict implementation ---'
rg -n -A 45 -B 20 'PROJECT_V2_REVISION_CONFLICT|expectedStorageRevision' --glob '*.ts' --glob '*.tsx' app libRepository: svg8bit/drops-studio
Length of output: 26822
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- V2 snapshot write semantics ---'
rg -n -A 80 -B 20 'writeProjectV2Snapshot' db lib app --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- child-owned cloud writes and revision updates ---'
rg -n -A 18 -B 12 'saveProjectV2ToCloud|setStorageRevision|onProjectChange' components/project-v2-studio-surface.tsx
printf '%s\n' '--- parent callback call sites and event count ---'
rg -n -A 12 -B 8 'onAgentEvent|onProjectChange' components/project-v2-studio-surface.tsxRepository: svg8bit/drops-studio
Length of output: 50376
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- exact revision increment and conflict path ---'
sed -n '452,510p' db/project-v2-snapshots.ts
printf '%s\n' '--- child refresh effect and save closure ---'
sed -n '448,464p' components/project-v2-studio-surface.tsx
sed -n '692,727p' components/project-v2-studio-surface.tsx
printf '%s\n' '--- read-only structural verifier ---'
python3 - <<'PY'
from pathlib import Path
surface = Path("components/project-v2-studio-surface.tsx").read_text()
parent = Path("components/project-studio.tsx").read_text()
db = Path("db/project-v2-snapshots.ts").read_text()
events = surface[surface.index("const runBuilder"):surface.index("useEffect", surface.index("const runBuilder"))]
print("successful runBuilder onAgentEvent calls:", events.count("onAgentEvent?.({"))
print("parent recordBuilderAgentEvent calls persistProject:", parent[parent.index("const recordBuilderAgentEvent"):parent.index("const replaceProject")].count("persistProject(next, current.updatedAt)"))
print("child syncSnapshot effect depends on syncSnapshot:", "}, [syncSnapshot]);" in surface)
print("child saveSnapshot sends storageRevision:", "saveProjectV2ToCloud(next, currentRevision)" in surface)
print("snapshot write increments current revision:", "createEnvelope(currentRevision + 1)" in db)
print("snapshot write rejects mismatched revision:", "currentRevision !== expectedStorageRevision" in db)
PYRepository: svg8bit/drops-studio
Length of output: 4500
Do not cloud-save Project V2 for conversation-only events.
recordBuilderAgentEvent calls persistProject, which uploads the unchanged projectV2 and advances storageRevision for each builder phase. ProjectV2StudioSurface tracks that revision separately. Its asynchronous refresh can leave saveSnapshot using a stale revision and produce PROJECT_V2_REVISION_CONFLICT. Persist the conversation in the V1 wrapper only, without calling saveProjectV2ToCloud from this path.
🤖 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 `@components/project-studio.tsx` around lines 1487 - 1532, The
recordBuilderAgentEvent path should update local state and persist the
conversation through the V1 wrapper only; remove its persistProject call so
conversation-only builder events do not upload projectV2 or advance
storageRevision. Keep the existing conversation construction and setProject
behavior unchanged, and avoid invoking saveProjectV2ToCloud from this callback.
| const openConnectionsHub = (provider?: string) => { | ||
| const returnTo = safeSameOriginReturnPath( | ||
| `${window.location.pathname}${window.location.search}`, | ||
| window.location.origin, | ||
| ); | ||
| const search = new URLSearchParams({ | ||
| connections: "1", | ||
| returnTo, | ||
| }); | ||
| if (provider) search.set("provider", provider); | ||
| window.location.assign(`/?${search.toString()}`); | ||
| }; | ||
|
|
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Flush pending spec edits before navigating to the Connections Hub.
openConnectionsHub navigates away with window.location.assign but, unlike openRuntime, openPublish, and downloadSource, it does not call commitPendingSpec() first. A pending quiet-spec edit (still inside its debounce window) then depends solely on the beforeunload handler's fire-and-forget persistProject call, which is not guaranteed to finish before the browser navigates away.
🐛 Proposed fix
const openConnectionsHub = (provider?: string) => {
+ commitPendingSpec();
const returnTo = safeSameOriginReturnPath(📝 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.
| const openConnectionsHub = (provider?: string) => { | |
| const returnTo = safeSameOriginReturnPath( | |
| `${window.location.pathname}${window.location.search}`, | |
| window.location.origin, | |
| ); | |
| const search = new URLSearchParams({ | |
| connections: "1", | |
| returnTo, | |
| }); | |
| if (provider) search.set("provider", provider); | |
| window.location.assign(`/?${search.toString()}`); | |
| }; | |
| const openConnectionsHub = (provider?: string) => { | |
| commitPendingSpec(); | |
| const returnTo = safeSameOriginReturnPath( | |
| `${window.location.pathname}${window.location.search}`, | |
| window.location.origin, | |
| ); | |
| const search = new URLSearchParams({ | |
| connections: "1", | |
| returnTo, | |
| }); | |
| if (provider) search.set("provider", provider); | |
| window.location.assign(`/?${search.toString()}`); | |
| }; |
🤖 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 `@components/project-studio.tsx` around lines 3277 - 3289, Update
openConnectionsHub to call commitPendingSpec() before window.location.assign,
matching the behavior of openRuntime, openPublish, and downloadSource so pending
quiet-spec edits are persisted before navigation.
| async function writeStored( | ||
| identity: string, | ||
| stored: StoredState, | ||
| state: StudioAccountState, | ||
| storage: BlobStorage, | ||
| ): Promise<boolean> { | ||
| const serialized = JSON.stringify(state); | ||
| if (new TextEncoder().encode(serialized).byteLength > 96 * 1_024) { | ||
| throw new StudioAccountStateUnavailableError("Studio account state exceeded its bounded size."); | ||
| } | ||
| try { | ||
| await storage.put(blobPath(identity), serialized, { | ||
| access: "private", | ||
| addRandomSuffix: false, | ||
| allowOverwrite: Boolean(stored.etag), | ||
| contentType: "application/json; charset=utf-8", | ||
| cacheControlMaxAge: 60, | ||
| ...(stored.etag ? { ifMatch: stored.etag } : {}), | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| async function mutateState( | ||
| identity: string, | ||
| mutate: (state: StudioAccountState, now: string) => StudioAccountState, | ||
| storageOverride?: BlobStorage, | ||
| ): Promise<StudioAccountState> { | ||
| validIdentity(identity); | ||
| if (!storageOverride && localStoreEnabled()) { | ||
| const now = new Date().toISOString(); | ||
| const current = structuredClone(localStore().get(identity) ?? emptyState()); | ||
| const next = mutate(current, now); | ||
| localStore().set(identity, structuredClone(next)); | ||
| return structuredClone(next); | ||
| } | ||
| if (!storageOverride && !durableBlobConfigured()) throw new StudioAccountStateUnavailableError(); | ||
| const storage = await blobClient(storageOverride); | ||
| for (let attempt = 0; attempt < 6; attempt += 1) { | ||
| const stored = await readStored(identity, storage); | ||
| const next = mutate(stored.state, new Date().toISOString()); | ||
| if (await writeStored(identity, stored, next, storage)) return structuredClone(next); | ||
| } | ||
| throw new StudioAccountStateUnavailableError("Studio account storage stayed busy after safe retries."); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Confirm the `@vercel/blob` surface used by db/studio-account-state.ts.
set -euo pipefail
fd -H -t f 'package.json' -d 2 --exec rg -n '"`@vercel/blob`"' {} \;
fd -t d 'blob' node_modules/@vercel 2>/dev/null || true
rg -n 'BlobPreconditionFailedError|abortSignal|useCache|cacheControlMaxAge|ifMatch' \
--iglob '**/@vercel/blob/**/*.d.ts' -m 40 || echo "types not vendored in sandbox"Repository: svg8bit/drops-studio
Length of output: 248
🏁 Script executed:
set -euo pipefail
printf '%s\n' '--- dependency and lockfile entries ---'
rg -n -C 3 '"`@vercel/blob`"|`@vercel/blob`' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true
printf '%s\n' '--- installed package files ---'
find node_modules/@vercel/blob -maxdepth 2 -type f -print 2>/dev/null | sort | head -80
printf '%s\n' '--- relevant declarations and implementation symbols ---'
rg -n -C 4 'BlobPreconditionFailedError|abortSignal|ifMatch|cacheControlMaxAge|put\(|get\(' \
node_modules/@vercel/blob db/studio-account-state.ts 2>/dev/null | head -240
printf '%s\n' '--- account-state implementation and imports ---'
sed -n '1,280p' db/studio-account-state.tsRepository: svg8bit/drops-studio
Length of output: 28835
🏁 Script executed:
set -euo pipefail
printf '%s\n' '--- public type declarations ---'
rg -n -C 8 'class BlobPreconditionFailedError|BlobPreconditionFailedError|interface PutCommandOptions|type PutCommandOptions|interface GetCommandOptions|type GetCommandOptions|abortSignal|ifMatch|cacheControlMaxAge' \
node_modules/@vercel/blob/dist/*.d.ts | head -320
printf '%s\n' '--- error definitions and response mapping ---'
rg -n -C 12 'BlobPreconditionFailedError|BlobServiceRateLimited|status === 412|statusCode === 412|412|PRECONDITION|precondition' \
node_modules/@vercel/blob/dist/*.js node_modules/@vercel/blob/dist/*.cjs | head -360
printf '%s\n' '--- request timeout and option propagation ---'
rg -n -C 10 'function requestApi|const requestApi|requestApi =|abortSignal|signal:' \
node_modules/@vercel/blob/dist/index.js node_modules/@vercel/blob/dist/client.js | head -360
printf '%s\n' '--- runtime and package constraints ---'
cat node_modules/@vercel/blob/package.json | sed -n '1,180p'Repository: svg8bit/drops-studio
Length of output: 50376
🏁 Script executed:
set -euo pipefail
printf '%s\n' '--- get and put option declarations ---'
sed -n '1,115p' node_modules/@vercel/blob/dist/index.d.ts
sed -n '1,85p' node_modules/@vercel/blob/dist/create-folder-DAlHaCQ2.d.ts
sed -n '220,245p' node_modules/@vercel/blob/dist/create-folder-DAlHaCQ2.d.ts
printf '%s\n' '--- error response mapping and request behavior ---'
sed -n '642,780p' node_modules/@vercel/blob/dist/chunk-CIIQSN42.js
printf '%s\n' '--- package metadata ---'
node -e 'const p=require("./node_modules/@vercel/blob/package.json"); console.log(JSON.stringify({version:p.version,engines:p.engines,exports:p.exports},null,2))'Repository: svg8bit/drops-studio
Length of output: 14465
Classify Blob write failures and bound Blob operations.
@vercel/blob 2.6.1 exports BlobPreconditionFailedError, and abortSignal is supported by get and put. Retry only ETag conflicts. Propagate or classify token, rate-limit, network, and timeout errors. Add bounded backoff and a timeout to both Blob operations. Do not set cacheControlMaxAge to 0; the SDK requires at least 60 seconds.
🤖 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 `@db/studio-account-state.ts` around lines 191 - 236, Update writeStored and
readStored to pass bounded abort signals to Blob get and put operations, using a
timeout and bounded backoff between mutateState retries. Classify
BlobPreconditionFailedError as the only retryable write conflict; propagate or
map token, rate-limit, network, and timeout failures instead of converting every
write error to false. Preserve cacheControlMaxAge at the SDK-supported minimum
of 60 seconds.
Summary
Verification
Release
Ready for main and production after required checks pass.
Summary by CodeRabbit