Skip to content

feat: Grida AI Credit (v1) + AI seam consolidation#711

Merged
softmarshmallow merged 21 commits into
mainfrom
canary
May 12, 2026
Merged

feat: Grida AI Credit (v1) + AI seam consolidation#711
softmarshmallow merged 21 commits into
mainfrom
canary

Conversation

@softmarshmallow

@softmarshmallow softmarshmallow commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

Two cohesive halves under one PR:

  1. Grida AI Credit (v1) — Metronome-backed prepaid credit ledger with Stripe-charged top-ups and threshold-based auto-reload.
  2. AI seam consolidation — collapse the fan of app/(api)/private/ai/* route handlers into a single lib/ai/{server,actions,credits} module that goes through the new credit gate; ship a public /ai chat page on top.

Webhook receivers (Stripe + new Metronome) live under the (ingest) route group introduced in this PR's first commit (6604d6c43) — see GRIDA-SEC-001 in SECURITY.md for the trust-boundary contract.

Roles in the final architecture:

  • Metronome = source of truth for credit balance + drain order
  • Stripe = source of truth for money
  • Our DB = gate-decision cache (sub-100ms reads on customer_entitled)
  • lib/ai/server.ts = the only file allowed to import provider SDKs; every call goes through withAiAuth (membership + entitlement + balance gate)

What ships — billing v1

Area What
Schema grida_billing.account gains Metronome ids + entitlement / cache / auto-reload columns; new metronome_event dedup table; new projector RPC (fn_billing_apply_metronome_event) — multi-tier-alert aware (only depletion-tier flips entitlement off; warning tiers refresh balance only).
Service layer editor/lib/billing/metronome.ts (~1400 LOC) — provision, commits, ingest, auto-reload, gate, reconcile, invoices, refund. Flat markup envelope ceil((credit + 30) / 0.95) in fees.ts, verified safe across every Stripe card type.
Webhooks New /webhooks/metronome (HMAC + dedup + projector). Existing /webhooks/stripe extended with two AI-credit branches (post-Checkout reconciliation + subscription-cancel → disable auto-reload).
Cron Hourly /internal/cron/billing-reconcile sweeps only provisioned orgs.
User UI Settings → Billing → Grida AI Credit panel: balance, two-step Buy Credit dialog, subscription-gated auto-reload, recent activity. Return page settles on entitlement, not a delta, so the webhook race no longer triggers the toast.
Insiders harness /insiders/billing exercises every Metronome primitive directly. Gated by GRIDA-SEC-002 (proxy 404 + layout notFound()) so the dev-only mutators can never reach prod.
CLI 27 ad-hoc scripts → 6 files under editor/scripts/billing/cli.ts: setup:stripe, setup:metronome, ping, smoke:{topup,auto-reload,webhook}, backfill, markup-sim.
Tests New headless e2e (ai-credit-topup.test.ts) drives handleAiCreditCheckoutCompleted directly against the Metronome sandbox — no browser, no real Checkout, no dev server.
Docs docs/wg/platform/billing/ai-credits.md (master plan), metronome.md (integration), known-issues.md (living KI-BILL register). Contributor setup at docs/contributing/billing.md rewritten for the CLI + locally-configured cloudflared tunnel.

What ships — AI seam + /ai page

Area What
GRIDA-SEC-003 New editor/lib/auth/organization.ts — single producer of verified organizationId for AI callers. Resolves route slug → header → explicit input → session-derived org, with membership established at every step. The session resolver intersects user_project_access_state with public.get_organizations_for_user (the SECURITY DEFINER membership primitive), so a stale access-state row left over after a membership revocation can never escalate into another org's billing context. SECURITY.md documents the boundary.
AI seam editor/lib/ai/server.ts — single seam entry point, the only file allowed to import provider SDKs (Vercel AI SDK / Replicate / OpenAI / Anthropic). Enforced by oxlint no-restricted-imports + editor/scripts/audit-ai-seam.ts (CI). Wraps every call in withAiAuth (membership + entitlement + balance gate). editor/lib/ai/error.ts — typed AiActionError envelope shared by every action.
Server actions editor/lib/ai/actions/{chat,audio,image,image-generate,forms-schema,models}.ts replace the deleted route handlers under app/(api)/private/ai/*. None re-implements auth — all go through withAiAuth.
Credits module editor/lib/ai/credits/ — controller (refresh / debit / format), <AiCredits.Provider> + useAiCredits() hook, server actions (preloadAiCredits, resolveInitialAiCredits, refreshAiCredits). Replaces ad-hoc use-credits / use-generate-* hooks. Backed by tests for the controller state machine and the cents formatter.
Consumer migration Every AI consumer migrated off the deleted routes: canvas tools (ai/generate, use-models), playground/image, tools/remove-bg, ai/music, scaffolds/ai (form-field schema assistant), scaffolds/playground-forms, grida-canvas-hosted (server-agent + canvas-use), starterkit image toolbar.
Public /ai chat New app/(www)/(ai)/{layout,ai/page,ai/_page}.tsx — minimal user-facing chat surface that flexes the new seam end to end. Auth gate at submit time (intentionally not pre-gated); credit chip + balance preload via the route-group layout's <AiCredits.Provider>; session-resolved org for the credit context.
Test infra editor/lib/__tests__/server-only.shim.ts + vitest.config.ts change so server-only modules load under vitest without import \"server-only\" blowing up. New tests: lib/ai/__tests__/{error,server}.test.ts, lib/ai/credits/__tests__/{controller,format}.test.ts.

Commits

# Commit Subject
1 697185a1c feat(billing): add Metronome AI credit service layer
2 e79107b48 feat(billing): Metronome + Stripe ai-credit webhooks and reconcile cron
3 2b535c695 feat(billing): user-facing AI credit page (top-up + auto-reload)
4 ec9a643b2 feat(insiders): billing QA harness + GRIDA-SEC-002 dev-only gate
5 b4d31f199 chore(scripts/billing): consolidate dev/QA scripts into one CLI
6 ec1bf8b23 test(billing): headless e2e for AI-credit topup post-Checkout flow
7 6c3cb1679 docs(billing): AI credit design, Metronome integration, known issues
8 fe5256973 refactor(docs): group billing WG docs under wg/platform/billing/
9 36e98cb4c docs(wg/billing): fix bullet rendering in index.md
10 2a603fc1f docs(wg/billing): escape \<100 ms` to fix MDX build`
11 863c0ef09 docs(billing): add known issue for plan-included credit not granted
12 c34b205b2 feat(security): GRIDA-SEC-003 — verified org-id resolver
13 3837b06f2 refactor(ai): consolidate API routes into lib/ai server actions + credits module
14 1e4134eb8 feat(ai): public /ai chat page

Money model & known issues

  • Markup envelope (lib/billing/fees.ts): flat 5% + $0.30 gross-up, single safe envelope across US / intl / AmEx cards + 1% FX. Applied to manual top-up Checkout, initial auto-reload setup, and all silent recharges (payment_type: INVOICE). Audit trail: cli.ts markup-sim.
  • Stripe Invoices: all three flows (top-up, auto-reload setup, silent recharge) generate Stripe Invoices that appear in "Past Invoices". Enabled via invoice_creation: { enabled: true } on Checkout sessions and payment_type: \"INVOICE\" on Metronome's threshold config.
  • KI-BILL-001 (mitigated): silent auto-recharge runs at-cost on the unfixed Metronome primitive — mitigation is gating auto-reload behind an active paid subscription. Bounded loss; manual top-up unaffected.
  • KI-BILL-002 (accepted): concurrent subscribe Checkouts can produce an orphan Stripe sub. Manual refund; tracked.

Full register: docs/wg/platform/billing/known-issues.md.

Verification

  • `pnpm exec tsc --noEmit` — exit 0
  • `pnpm exec oxlint` over the billing + ai surface — 0 warnings, 0 errors
  • pgTAP (`supabase test db`) — 135/135 pass on a clean DB after `supabase db reset`
  • Billing e2e (`BILLING_E2E=1 vitest run lib/billing/tests/e2e`) — 4/4 pass (lifecycle, idempotency, tampered-signature, ai-credit-topup)
  • AI seam tests (`vitest run lib/ai`) — 52/52 pass across 4 test files
  • Manual: subscription upgrade + manual top-up flows exercised against Stripe test mode + Metronome sandbox; live /ai chat exercised end-to-end with credit chip refresh

Operational checklist (for prod cutover)

  • Run `cli.ts setup:stripe` against prod Stripe live key (one-time)
  • Run `cli.ts setup:metronome` against prod Metronome tenant (one-time)
  • Register `/webhooks/metronome` endpoint in Metronome prod dashboard → set `METRONOME_WEBHOOK_SECRET`
  • Confirm Stripe live webhook endpoint includes `customer.subscription.deleted` + `checkout.session.completed`
  • Set `CRON_SECRET`, `METRONOME_API_TOKEN`, `METRONOME_WEBHOOK_SECRET` in Vercel env (prod)
  • Close Metronome pricing (Q-AI-14 in `ai-credits.md`) before opening AI to free tier

Test plan

  • Apply migration via `supabase db reset` on a clean local DB
  • Subscribe to Pro plan with test card → confirm "Past Invoices" populates
  • Buy $10 of AI credit → confirm credit lands within 30s, Stripe Invoice appears
  • Enable auto-reload at $5 / $25 → confirm initial $25 lands as paid invoice
  • Cancel subscription → confirm auto-reload is automatically disabled on Metronome side
  • `/insiders/billing` is 404 when `NODE_ENV !== "development"`
  • Visit `/ai` while logged out → chat UI renders; submit gates to auth
  • Visit `/ai` while logged in (member of one org) → credit chip shows balance, no "no-org" banner
  • Send a chat message → assistant streams response; credit chip refreshes

Summary by CodeRabbit

  • New Features

    • AI Credits: org-level balances, Checkout top-ups, auto-reload, entitlement checks, purchase UI, and consumption across chat/image/audio.
    • Billing platform: Stripe ↔ Metronome integration, webhook receivers under /webhooks, provisioning, live balance sync, reconcile cron, and billing dev harness.
  • Documentation

    • New billing/metronome/AI-credits playbooks, contributor guides, known-issues, and security trust-boundary registry.
  • Tests / Tools

    • E2E and unit tests for billing/AI flows and new billing CLI & audit scripts.

Review Change Stack

- Add SECURITY.md as the registry for prevented-vulnerability ids
- Allocate GRIDA-SEC-001 for the ingest trust boundary (webhook receivers
  exposed on a static URL must HMAC-verify before any business logic)
- Add (ingest) route group with README codifying the rules
- Move Stripe webhook receiver from (api)/private/webhooks/stripe to
  (ingest)/webhooks/stripe; URL changes to /webhooks/stripe
- Add /webhooks/* bypass in proxy.ts (skips tenant routing + session refresh)
- Add .agents/skills/security/SKILL.md — auto-loads on any GRIDA-SEC mention,
  mandates security review before commit on tagged files
- Update E2E fixtures and .env.example to the new path
@vercel

vercel Bot commented May 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment May 12, 2026 10:49am
grida Ready Ready Preview, Comment May 12, 2026 10:49am
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored May 12, 2026 10:49am
legacy Ignored Ignored May 12, 2026 10:49am
backgrounds Skipped Skipped May 12, 2026 10:49am
blog Skipped Skipped May 12, 2026 10:49am
viewer Skipped Skipped May 12, 2026 10:49am

Request Review

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1fa2a997-0a9b-4a0e-bcfb-3bcbf859a986

📥 Commits

Reviewing files that changed from the base of the PR and between 6cee53a and b9ed333.

📒 Files selected for processing (6)
  • editor/scripts/billing/README.md
  • editor/scripts/billing/_env.ts
  • editor/scripts/billing/cli.ts
  • editor/scripts/billing/ops.ts
  • editor/scripts/billing/setup.ts
  • editor/scripts/billing/smoke.ts

Walkthrough

Adds Metronome-backed AI credits, an AI usage seam with org gating and metered ingestion, signed webhook receivers, DB migrations/RPCs, UI/Checkout flows, CLI tooling, lint/audit enforcement, and tests.

Changes

AI Credits and AI Seam Integration

Schema & DB RPCs

Layer / File(s) Summary
Data Shape & Migrations
supabase/migrations/*, supabase/schemas/grida_billing.sql
Adds account provisioning UID, Metronome linkage columns, metronome_event table, ai_credit_processed_at marker, and SECURITY DEFINER RPCs for applying events, reading/writing cache and auto-reload, resolving org by customer, listing provisioned orgs/events, and optimistic debit.
Generated DB types
database/database-generated.types.ts
Adds TypeScript RPC function typings for the new billing RPCs (apply event, debit cache, get/set account, resolve org, list events/orgs).

Server seam & billing service

Layer / File(s) Summary
AI seam & provider
editor/lib/ai/server.ts, editor/lib/ai/ai.ts, editor/lib/ai/models.ts
Introduces a server-only AI seam: org verification, gating, withTransaction ingestion, cost calculators, Grida provider wrapper/middlewares, Replicate integration, and seam methods.
Metronome billing service
editor/lib/billing/metronome.ts, editor/lib/billing/fees.ts
Adds Metronome SDK client, provisioning, commit/top-up APIs, auto-reload enable/disable, reconciliation, entitlement gate, transactions/invoices listing, invoice PDF retrieval, and fee math/guardrails.

Ingress, proxy, cron

Layer / File(s) Summary
Webhook ingest & reconcile
editor/app/(ingest)/webhooks/stripe/route.ts, editor/app/(ingest)/webhooks/metronome/route.ts, editor/app/(api)/internal/cron/billing-reconcile/route.ts
Adds signed Stripe and Metronome webhook receivers under (ingest) with raw-body verification, idempotency marker handling for AI-credit post-processing, best-effort balance refresh on triggers, and an hourly reconcile cron endpoint.
Proxy & deployment
editor/proxy.ts, editor/vercel.json
Proxy bypass for /webhooks/*, production blocking for /insiders, and a vercel cron entry for reconcile.

Server actions & client credits

Layer / File(s) Summary
Server Actions
editor/lib/ai/actions/*, editor/lib/ai/actions/image-generate.ts, editor/lib/ai/actions/image.ts
Adds server actions for chat, image generation/upload, audio, image upscaling/remove-background, forms-schema, and models listing; each returns typed AiActionResult envelopes.
AiCredits client & error seam
editor/lib/ai/credits/*, editor/lib/ai/error.ts
Adds AiCredits controller/provider/format, preload/refresh server actions, client hook, and shared AiErrorResponse envelope + client resolver for redirect vs toast handling.

Product surfaces

Layer / File(s) Summary
UI & Checkout
editor/app/(site)/.../billing/*, editor/app/(www)/(ai)/**
Adds Grida AI Credit UI in org billing settings, Checkout session creators for top-up/auto-reload, AI chat page/layout preloading, and tool integrations consuming credits.
Dev harness
editor/app/(insiders)/*
Adds a development billing harness with provisioning, top-up, ingest simulation, and observability controls guarded to development-only.

Security, docs, lint, audit

Layer / File(s) Summary
Security registry & skills
SECURITY.md, .agents/skills/*, docs/wg/platform/billing/*
Defines GRIDA-SEC boundaries (webhooks, insiders, AI seam), adds skills docs, and extensive billing/metronome design and known-issues docs.
Lint & audit
editor/.oxlintrc.jsonc, editor/scripts/audit-ai-seam.ts
Blocks runtime imports of provider SDKs outside seam via ESLint and CI audit script; allowlist overrides for seam files.

Tooling & tests

Layer / File(s) Summary
CLI & scripts
editor/scripts/billing/*, editor/scripts/audit-ai-seam.ts
Billing CLI (setup/metronome), env loader, smoke/ops/backfill scripts, and audit script for import rules.
Tests & config
editor/vitest.config.ts, editor/lib/**/__tests__/*
Vitest env updates and shims, many new unit and E2E tests covering AI seam, billing flows, and format/controller logic.
Misc
lefthook.yml, editor/package.json
Pre-commit glob update and added dependency on @metronome/sdk plus audit script entry.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant UI as Org Billing UI
  participant Stripe
  participant Ingest as /webhooks/stripe
  participant DB as Supabase RPC
  participant Billing as lib/billing/metronome.ts
  User->>UI: Start top-up (Checkout)
  UI->>Stripe: Create Checkout Session
  Stripe->>Ingest: webhook checkout.session.completed (raw body)
  Ingest->>DB: fn_billing_get_ai_credit_processed(event_id)
  DB-->>Ingest: processed?
  Ingest->>Billing: handleAiCreditCheckoutCompleted(session)
  Billing->>DB: fn_billing_set_balance_cache / addStripeChargedCommit
  Billing-->>Ingest: applied/noop
  UI->>Billing: refreshAiCreditsBalance()
  Billing-->>UI: { cents, allowed }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • gridaco/grida#276: Related AI image-generation surface refactor touching generation route and provider wiring.
  • gridaco/grida#709: Related DB/billing migrations and projector/security model changes.
  • gridaco/grida#703: Related audio/generate surface and server action changes.

Suggested labels

stripe

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch canary

Three subcommands under one entry point: `/database compact local
migration` for safely consolidating only unshipped migrations,
`/database rls scenarios` for writing pgTAP coverage where the
implementation mirrors the test (not the reverse), and `/database align`
for keeping `schemas/*.sql` in sync with the migrated state.

Encodes the discipline that applied migrations are immutable history —
classification heuristics are signals, not authority; user confirmation
is the only ground truth.
Schema: per-org Metronome customer/contract ids + entitlement-cache +
auto-reload columns on grida_billing.account, plus metronome_event dedup
table and the projector RPC. Multi-tier-alert aware (only depletion-tier
flips entitlement off; warning tiers refresh balance only).

Service: editor/lib/billing/metronome.ts — provisionOrg, addStripeChargedCommit,
addComplimentaryCommit, setAutoReload, ingestUsageEvent[Gated], getEntitlement,
getOrgBalance, getTransactions, getInvoices, refreshBalance, revokeUnusedOnCommit,
getAccountView, handleAiCreditCheckoutCompleted (post-Checkout reconciler).

Money model: fees.ts ships a flat 5% + \$0.30 gross-up markup envelope
verified safe across every Stripe card type at \$10-\$500 (audit via
cli.ts markup-sim). AI_GATE_FLOOR_CENTS is the single source for the 25c
gate floor.
Metronome receiver: HMAC-SHA256 over Date+raw-body, 5-min freshness, dedup
via the projector's PK on event_id, payment_gate failure logged with reason,
post-projector refreshBalance for commit-affecting events.

Stripe receiver: extends the existing projector with two ai-credit branches —
checkout.session.completed (kind=ai_topup or ai_auto_reload_enable) routes
to handleAiCreditCheckoutCompleted; customer.subscription.deleted disables
auto-reload (KI-BILL-001 mitigation: never silent-recharge a canceled org).
Errors return 500 so Stripe retries instead of leaving the customer charged
with no credit.

Reconcile cron: hourly /internal/cron/billing-reconcile sweeps only
provisioned orgs (via fn_billing_list_provisioned_orgs) with concurrency 4.
Settings → Billing now hosts a Grida AI Credit panel: balance card with
two-step Buy Credit dialog ($X-of-credit picker → line-item breakdown
showing markup as "Payment Processing Fee"), collapsible auto-reload
configuration (subscription-gated per KI-BILL-001), and a recent-activity
feed.

Server actions in _actions.ts: getAiCreditsSummary (lazy-provisions only
when not yet wired — skips on every steady-state poll), Stripe Checkout
launchers for top-up and auto-reload-enable with payment_intent_data +
invoice_creation so post-payment Stripe Invoices appear in Past Invoices.
Edit-in-place auto-reload mutators don't re-Checkout.

Return page (post-Stripe-Checkout interstitial) settles on
`entitled === true` rather than a delta vs baseline — kills a race where
the webhook landed before the page mounted and polling never settled.

Polling pauses when the tab is hidden and stops entirely once state is
steady, so an open billing tab doesn't hammer Stripe + Metronome.
The insiders harness drives every Metronome/Stripe lifecycle step manually
(provision, add commit, ingest, alerts, refund, invoices, ...) — useful
for debugging the live system without writing scripts. The harness's
server actions intentionally skip org-membership checks, so the route
group MUST never be reachable outside local development.

GRIDA-SEC-002 enforces that contract at two layers:
- editor/proxy.ts — 404 every /insiders/* request when NODE_ENV != "development",
  including Next-Action POSTs.
- editor/app/(insiders)/layout.tsx — defense-in-depth notFound() in the
  layout if any request slips past the proxy.

SECURITY.md documents both layers and lists every file bound by the id.
27 ad-hoc scripts → 6 files:

  cli.ts        single entry point with subcommands
  _env.ts       shared env loader (.env.test.local > .env.test > .env.local)
  setup.ts      setup:stripe, setup:metronome
  smoke.ts      ping, smoke:topup, smoke:auto-reload, smoke:webhook
  ops.ts        backfill, markup-sim
  README.md

Drops:
- All `_*` underscore-prefix diagnostics (transient debug artifacts).
- All mid-development spike scripts (overage-test, spike-experiments,
  stripe-poc, refire-test, empty-window, drift-test, multi-tier-alerts,
  optimistic-debit) — proven and folded into code/docs.
- tunnel.sh — locally-configured cloudflared per docs/contributing/billing.md.
Drives `handleAiCreditCheckoutCompleted` directly with a synthetic Stripe
Checkout session — no browser, no real Checkout UI, no dev server. The
test asserts:
- the handler returns `applied`
- DB customer_entitled flips to true synchronously (proves the inline
  reconcile closes Gap B for the happy path)
- DB cached_balance_cents reflects the new credit
- the transaction's `at` field is wall-clock-fresh (Commit.created_at,
  not the hour-floored schedule_items[0].starting_at)

vitest.config.ts now also falls back to .env.local for env vars, mirroring
what the cli.ts scripts already do — a token kept there for the dev server
no longer has to be duplicated into .env.test.local.
Three working-group docs under docs/wg/platform/:
- ai-credits.md   the master design (constraints, flows, drain order, refunds)
- metronome.md    the Metronome integration playbook
- billing-known-issues.md   living register; KI-BILL-001 (silent auto-recharge
  markup gap, mitigated by subscription gate) + KI-BILL-002 (concurrent
  subscribe-Checkout race, accepted for v1).

Contributor setup (docs/contributing/billing.md) is rewritten for the
consolidated CLI (`cli.ts setup:stripe`, `setup:metronome`, etc.) and the
locally-configured cloudflared tunnel (no longer ships a wrapper script).

editor/.env.example documents METRONOME_API_TOKEN, METRONOME_WEBHOOK_SECRET,
and WEBHOOK_TUNNEL_HOSTNAME alongside the existing Stripe vars.
Three flat docs under wg/platform/ are now grouped:

  wg/platform/ai-credits.md             → wg/platform/billing/ai-credits.md
  wg/platform/billing-known-issues.md   → wg/platform/billing/known-issues.md
  wg/platform/metronome.md              → wg/platform/billing/metronome.md

Plus a billing/index.md and _category_.json so Docusaurus renders the
group as a labeled section. wg/platform/index.md links to the new
section. Inside known-issues.md, the redundant `billing-` prefix is
dropped since the directory now provides the namespace.

All external references updated to the new paths: docs/contributing/billing.md,
service module + fees comment headers, the consolidated migration,
_actions.ts, the Stripe webhook receiver, and the scripts CLI README.
Verified zero stale references via grep across docs/, editor/, supabase/.
The formatter parsed "(Stripe + Metronome ..." as a sub-bullet because
of the leading hyphen. Reword to a single-bullet phrase.
@softmarshmallow softmarshmallow added the enhancement New feature or request label May 11, 2026
@softmarshmallow softmarshmallow changed the title AI billing feat(billing): Grida AI Credit — Metronome integration (v1) May 11, 2026
@softmarshmallow softmarshmallow added migration documentation Improvements or additions to documentation insiders It can be challenging for casual contributors to tackle labels May 11, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e4134eb83

ℹ️ 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".

Comment on lines +92 to +94
if (result.result === "replayed") {
return NextResponse.json({ received: true, replayed: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run AI-credit post-processor on replayed Stripe events

dispatchStripeEvent records/deduplicates the Stripe event before handleAiCreditCheckoutCompleted runs, but this early return exits on result === "replayed" before the AI-credit branch executes. If the first delivery fails in the post-processor (returns 500), Stripe retries with the same event id; on retry this branch returns early and the credit/top-up side effect is skipped forever, leaving a paid checkout without credited balance. This affects checkout.session.completed retries specifically.

Useful? React with 👍 / 👎.

Comment thread editor/lib/ai/server.ts Outdated
typeof g.costMills === "number" && g.costMills >= 0
? g.costMills
: undefined,
awaitIngest: g.awaitIngest === true,

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 Preserve default await-ingest behavior in AI seam context

extractContext currently forces awaitIngest to false unless callers explicitly pass true (g.awaitIngest === true), which overrides the intended default in withTransaction (ctx.awaitIngest ?? true). As a result, normal non-streaming AI calls become fire-and-forget ingest by default, so withAiAuth can return a stale post-call balance before usage debit is ingested/reconciled.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
editor/scaffolds/playground-forms/playground.tsx (1)

109-128: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing .catch will leave the UI stuck in a "Generating…" state.

With organizationId now passed as undefined, generate(...) is expected to throw a 400 server-side. The current .then(...) chain has no .catch/.finally, so on rejection generating.current stays true and busy stays true forever — the spinner never clears and the user can't retry. Even if the public playground is intended to be temporarily unusable until the sign-in gate lands, the failure mode should be a recoverable error toast rather than a frozen header.

🛠️ Proposed fix
-    generate(undefined, prompt, initial?.slug).then(async ({ output }) => {
-      for await (const delta of readStreamableValue(output)) {
-        // setData(delta as JSONForm);
-        __set_schema_txt(JSON.stringify(delta, null, 2));
-      }
-      generating.current = false;
-      setBusy(false);
-    });
+    generate(undefined, prompt, initial?.slug)
+      .then(async ({ output }) => {
+        for await (const delta of readStreamableValue(output)) {
+          __set_schema_txt(JSON.stringify(delta, null, 2));
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+        toast.error("Generation failed. Please sign in to use this feature.");
+      })
+      .finally(() => {
+        generating.current = false;
+        setBusy(false);
+      });
🤖 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 `@editor/scaffolds/playground-forms/playground.tsx` around lines 109 - 128,
streamGeneration currently calls generate(...).then(...) but has no rejection
handler, so on error (e.g., generate(undefined,...)) generating.current and busy
never reset; add error handling to the promise: attach a .catch that sets
generating.current = false and setBusy(false) and surfaces an error toast, and a
.finally that ensures both flags are reset even if streaming started;
alternatively convert to async/await with try/catch/finally around generate and
the for-await loop (referencing streamGeneration, generate, readStreamableValue,
generating.current, setBusy, and __set_schema_txt) so failures always clear the
spinner and allow retry.
editor/app/(tools)/(playground)/playground/image/_page.tsx (1)

107-127: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Orphaned image node on generation/credit failure.

onCommit inserts the image node before kicking off the transition. If generateAiImage throws or credits.consume returns undefined (gate redirect, blocked, internal error), the early return at line 124 leaves an empty image node in the document with no src. The user is also redirected away (e.g. next: "/playground/image") but on return they'll find a leftover broken node.

Either insert the node only after a successful result, or delete it in the failure paths (and on exception).

🔧 Proposed fix
   const onCommit = (value: { text: string }) => {
-    const id = editor.commands.insertNode({
-      type: "image",
-      name: value.text,
-      layout_target_width: model.width,
-      layout_target_height: model.height,
-      fit: "cover",
-    });
     startGenerate(async () => {
-      const env = await generateAiImage({
-        model: model.modelId,
-        width: model.width,
-        height: model.height,
-        aspect_ratio: model.aspect_ratio,
-        prompt: value.text,
-      });
-      const data = credits.consume(env, { next: "/playground/image" });
-      if (!data) return; // gate / redirect handled
-      editor.commands.changeNodePropertySrc(id, data.publicUrl);
+      const id = editor.commands.insertNode({
+        type: "image",
+        name: value.text,
+        layout_target_width: model.width,
+        layout_target_height: model.height,
+        fit: "cover",
+      });
+      try {
+        const env = await generateAiImage({
+          model: model.modelId,
+          width: model.width,
+          height: model.height,
+          aspect_ratio: model.aspect_ratio,
+          prompt: value.text,
+        });
+        const data = credits.consume(env, { next: "/playground/image" });
+        if (!data) {
+          editor.commands.deleteNode(id); // adjust to actual API
+          return;
+        }
+        editor.commands.changeNodePropertySrc(id, data.publicUrl);
+      } catch (e) {
+        editor.commands.deleteNode(id);
+        throw e;
+      }
     });
   };
🤖 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 `@editor/app/`(tools)/(playground)/playground/image/_page.tsx around lines 107
- 127, The onCommit handler currently calls editor.commands.insertNode (creating
an image node) before invoking startGenerate/generateAiImage and
credits.consume, which leaves an empty image node if generation or credit
consumption fails; update onCommit so the image node is only inserted after a
successful generateAiImage + credits.consume result (or, if you prefer minimal
change, capture the returned node id from editor.commands.insertNode and ensure
you remove that node in all failure paths and catch blocks), and then call
editor.commands.changeNodePropertySrc(id, data.publicUrl) only after a
successful data is obtained; reference functions: onCommit, startGenerate,
generateAiImage, credits.consume, editor.commands.insertNode,
editor.commands.changeNodePropertySrc.
editor/grida-canvas-hosted/ai/tools/canvas-use.ts (1)

126-146: ⚠️ Potential issue | 🔴 Critical

Add runtime validation for aspect_ratio format before type assertion.

The aspect_ratio parameter is cast to ${number}:${number} (line 138) using a TypeScript type assertion without runtime validation. TypeScript template literal types are compile-time constructs only; invalid formats like "16x9" or "wide" will pass the assertion. Since the aspect_ratio comes directly from user input, validate it against a list of allowed formats (similar to the validation pattern in use-image-model.ts lines 68–79) before calling generateAiImage.

🤖 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 `@editor/grida-canvas-hosted/ai/tools/canvas-use.ts` around lines 126 - 146,
The execute function is unsafely asserting aspect_ratio to `${number}:${number}`
before runtime validation; before calling generateAiImage validate the incoming
aspect_ratio against the allowed set/pattern (same approach used in
use-image-model.ts) and only pass a validated value (falling back to "1:1" if
invalid) to generateAiImage; update the validation logic inside execute
(referencing the execute method and generateAiImage call) to check the format
(e.g., regex or allowed list) and reject or normalize bad inputs prior to the
type assertion/call.
editor/app/(api)/private/ai/chat/route.ts (1)

98-100: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return the same AI error envelope on 500s.

Auth and organization failures now use aiErrorResponse, but the catch path falls back to plain text. That reintroduces a second error contract for the same endpoint and makes client-side error parsing inconsistent.

Consistent 500 response
   } catch (error) {
     console.error("Error in agent chat:", error);
-    return new Response("Internal error", { status: 500 });
+    return aiErrorResponse({
+      code: "internal_error",
+      status: 500,
+      message: "internal 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 `@editor/app/`(api)/private/ai/chat/route.ts around lines 98 - 100, The catch
block in the agent chat route currently returns a plain text Response; change it
to return the standardized AI error envelope using the existing aiErrorResponse
helper so 500 responses match auth/org failures. In the catch for the route
handler in editor/app/(api)/private/ai/chat/route.ts, replace the plain
Response("Internal error", { status: 500 }) with a call to
aiErrorResponse(error, 500) (or aiErrorResponse(new Error("Internal error"),
500) if the helper expects an Error) and ensure the caught error is passed into
aiErrorResponse so the endpoint always returns the same error contract.
🟠 Major comments (20)
editor/.env.example-82-84 (1)

82-84: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid committing concrete secret-like test keys in the env template.

Even for test mode, shipping an actual *_SECRET_KEY-looking value increases leakage/scanner risk. Use placeholders in .env.example and keep real values out of VCS.

Suggested fix
-INTEGRATIONS_TEST_TOSSPAYMENTS_CUSTOMER_KEY="test_ck_ORzdMaqN3w92ng0ALboPr5AkYXQG"
-INTEGRATIONS_TEST_TOSSPAYMENTS_SECRET_KEY="test_sk_6BYq7GWPVvyJpbAv24nw3NE5vbo1"
+INTEGRATIONS_TEST_TOSSPAYMENTS_CUSTOMER_KEY="test_ck_..."
+INTEGRATIONS_TEST_TOSSPAYMENTS_SECRET_KEY="test_sk_..."
🤖 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 `@editor/.env.example` around lines 82 - 84, Replace the concrete test keys in
.env.example with non-secret placeholders: remove the literal values assigned to
INTEGRATIONS_TEST_TOSSPAYMENTS_CUSTOMER_KEY and
INTEGRATIONS_TEST_TOSSPAYMENTS_SECRET_KEY and replace them with descriptive
placeholders (e.g., "YOUR_TOSSPAYMENTS_CUSTOMER_KEY" and
"YOUR_TOSSPAYMENTS_SECRET_KEY") so no secret-looking tokens are committed;
ensure the variable names remain unchanged so consumers know what to set.
editor/lib/billing/metronome.ts-1522-1588 (1)

1522-1588: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't mint credit from Checkout metadata without re-validating the payment.

Both branches grant addComplimentaryCommit() based on metadata alone. If a session is created with a mismatched amount_total or out-of-range *_cents, the webhook will still issue credit that Stripe never collected; in the auto-reload branch that happens before setAutoReload() can reject the config. Recompute the expected gross with totalChargeForCredit(...), enforce the same bounds/whole-dollar rules here, and only add the commit after those checks pass.

🤖 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 `@editor/lib/billing/metronome.ts` around lines 1522 - 1588, The code mints
credit from session.metadata without re-validating the actual charged amount;
before calling addComplimentaryCommit (in both TOPUP and AUTO_RELOAD_ENABLE
branches) recompute the expected gross using totalChargeForCredit(...) with the
same inputs (cents/threshold/recharge and any currency/fee params), verify it
matches session.amount_total and that the cents/threshold/recharge values pass
the same whole-dollar and min/max bounds enforced elsewhere, and only proceed to
call addComplimentaryCommit/refreshBalance/setAutoReload after those checks
pass; update the validation logic around parseInt(meta.cents),
parseInt(meta.threshold_cents), and parseInt(meta.recharge_to_cents) to use the
totalChargeForCredit result and existing AUTO_RELOAD_*/TOPUP bounds before
granting credit.
supabase/migrations/20260508130000_grida_billing_metronome.sql-84-86 (1)

84-86: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Revoke PUBLIC before granting service_role.

This table stores raw billing webhook payloads. Only revoking anon and authenticated still leaves privilege safety dependent on default grants. Add an explicit REVOKE ALL ON TABLE grida_billing.metronome_event FROM PUBLIC; before the grant.

Proposed fix
 ALTER TABLE grida_billing.metronome_event ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON TABLE grida_billing.metronome_event FROM PUBLIC;
 REVOKE ALL ON TABLE grida_billing.metronome_event FROM anon, authenticated;
 GRANT  ALL ON TABLE grida_billing.metronome_event TO   service_role;

As per coding guidelines, Explicitly grant permissions only to required roles; avoid accidental PUBLIC access by using \REVOKE ALL FROM PUBLIC;` followed by specific role grants`.

🤖 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 `@supabase/migrations/20260508130000_grida_billing_metronome.sql` around lines
84 - 86, Add an explicit revoke for PUBLIC on the table
grida_billing.metronome_event before granting to service_role: call REVOKE ALL
ON TABLE grida_billing.metronome_event FROM PUBLIC prior to the GRANT, and
ensure the sequence is: ENABLE ROW LEVEL SECURITY, REVOKE ALL FROM PUBLIC,
REVOKE ALL FROM anon, authenticated, then GRANT ALL TO service_role so the table
is not accidentally accessible via default PUBLIC privileges.
editor/lib/billing/metronome.ts-1035-1044 (1)

1035-1044: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Compare balance drift at the same precision as the cache.

reconcileAccountFromLive() floors the live balance into integer cents before persisting it, but this comparison uses the raw fractional live.balanceCents. After any mills-based debit, drift.balanceCents will stay true even when the cache is perfectly synchronized.

Proposed fix
-    drift.balanceCents = db.cached_balance_cents !== live.balanceCents;
+    drift.balanceCents =
+      db.cached_balance_cents !== Math.floor(live.balanceCents);
🤖 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 `@editor/lib/billing/metronome.ts` around lines 1035 - 1044, drift.balanceCents
is being set by comparing db.cached_balance_cents to the raw fractional
live.balanceCents, but reconcileAccountFromLive() floors the live balance to
integer cents before persisting; update the comparison in
reconcileAccountFromLive() (or where drift.balanceCents is computed) to compare
db.cached_balance_cents against the floored live value (e.g.,
Math.floor(live.balanceCents)) so both sides use the same integer-cent precision
(handle null/undefined the same way as the other balance checks).
editor/.oxlintrc.jsonc-24-39 (1)

24-39: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict direct provider package subpaths to close security seam

Lines 24–39 block bare package imports (openai, replicate, @anthropic-ai/sdk), but the "name" field does not restrict subpath imports. Oxlint's no-restricted-imports requires "patterns" with wildcards to block subpaths like openai/resources/*. Add pattern restrictions to prevent GRIDA-SEC-003 bypass.

Suggested config hardening
         "patterns": [
+          {
+            "group": ["openai/*", "replicate/*", "@anthropic-ai/sdk/*"],
+            "message": "Use editor/lib/ai/server instead. GRIDA-SEC-003.",
+            "allowTypeImports": true
+          },
           {
             // Provider-specific AI SDK packages. Excludes `@ai-sdk/rsc`
             // (server-component streaming utilities) and `@ai-sdk/react`
🤖 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 `@editor/.oxlintrc.jsonc` around lines 24 - 39, The current
no-restricted-imports entries for "replicate", "openai", and "@anthropic-ai/sdk"
only block the bare package name and do not stop subpath imports; update each
restricted-import object (the entries whose "name" values are replicate, openai,
and `@anthropic-ai/sdk`) to include a "patterns" array with wildcard entries
(e.g., "<package>/*") that mirror the "name" block so any subpath like
openai/resources/* or `@anthropic-ai/sdk/dist/`* is rejected, keep
"allowTypeImports": true and reuse the same "message" text for consistency.
editor/grida-canvas-react-starter-kit/starterkit-toolbar/image-toolbar.tsx-309-321 (1)

309-321: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

TODO leaves Upscale/Remove-Background non-functional until host threads organizationId.

Per the AI summary and the inline TODO at lines 309-312, the server action returns 400 (GRIDA-SEC-003) without an organizationId. Since this toolbar doesn't yet thread one through, every click on Upscale (and Remove Background at lines 374-380) will fail at the seam and surface a generic toast/redirect via handleApiError. Consider one of:

  1. Plumb an organizationId (or resolver) prop into ImageToolbar now and forward it into the action calls.
  2. Disable/hide the buttons when no org context is available, so users don't hit guaranteed-400 paths.

Same applies to the removeBackgroundImage call at line 376.

Want me to draft a organizationId?: number prop on ImageToolbar and wire it through both action calls?

🤖 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 `@editor/grida-canvas-react-starter-kit/starterkit-toolbar/image-toolbar.tsx`
around lines 309 - 321, The toolbar currently calls upscaleImage and
removeBackgroundImage without an organization context so the server returns
GRIDA-SEC-003; add an optional prop to ImageToolbar (e.g., organizationId?:
number or organizationResolver?: ()=>number|Promise<number>), accept it in the
ImageToolbar props and thread it into both upscaleImage({ image, scale,
organizationId }) and removeBackgroundImage({ image, organizationId }), and as a
fallback disable or hide the Upscale/Remove Background buttons when no
organizationId/resolver is provided so users cannot trigger guaranteed-400
server actions.
editor/app/(api)/internal/cron/billing-reconcile/route.ts-27-34 (1)

27-34: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop ?secret= authentication for the cron endpoint.

Putting CRON_SECRET in the URL leaks it into browser history and request logs. Since this route already supports Authorization: Bearer, keeping auth header-only closes that exposure without affecting the scheduled caller.

Safer auth check
 function authorized(req: NextRequest): boolean {
   const secret = process.env.CRON_SECRET;
   if (!secret) return false;
   const header = req.headers.get("authorization");
-  if (header && header === `Bearer ${secret}`) return true;
-  const url = new URL(req.url);
-  if (url.searchParams.get("secret") === secret) return true;
-  return false;
+  return header === `Bearer ${secret}`;
 }
🤖 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 `@editor/app/`(api)/internal/cron/billing-reconcile/route.ts around lines 27 -
34, The authorized function currently accepts a secret via URL query which leaks
secrets; change authorized(req: NextRequest) so it only validates
process.env.CRON_SECRET via the Authorization header (check
req.headers.get("authorization") === `Bearer ${secret}`) and remove the
URL/searchParams check that reads req.url and compares "secret"; keep the early
false when secret is unset and ensure any callers/scheduled callers use the
Authorization: Bearer header exclusively.
editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx-63-72 (1)

63-72: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

topup settles too early for orgs that were already entitled.

For a user buying additional credit before hitting zero, a.entitled === true is already true on the first poll, so this page can redirect before the new balance or activity lands. That makes the return flow look complete while the paid top-up is still invisible.

This needs a settlement signal tied to the checkout flow itself — e.g. baseline cents, a new activity row, or the checkout session id — not entitlement alone.

🤖 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
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/return/_view.tsx
around lines 63 - 72, The current topup branch (case "topup") returns true based
solely on snap.ai.entitled which can be already true and causes premature
settlement; modify the logic in the "topup" case (where a = snap.ai) to detect a
change tied to the checkout flow instead of entitlement alone — for example,
compare a.cents against a baselineCents stored before checkout, or check for a
new activity entry or matching checkoutSessionId on the account activities list;
update the condition to return true only when the baseline-cents delta or a new
activity/checkoutSessionId indicating the just-completed purchase is observed
(and keep the null check for a).
editor/lib/ai/credits/actions.ts-35-43 (1)

35-43: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid forcing a live balance refresh during initial layout preload.

resolveInitialAiCredits() uses this to seed route-group layout state, so refreshBalance() turns ordinary page renders into a Metronome-dependent path. That makes first paint slower and lets transient billing outages break otherwise cacheable pages. This preload path should read the cached balance/entitlement, with force-sync reserved for refreshAiCredits()/webhook-reconcile flows.

🤖 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 `@editor/lib/ai/credits/actions.ts` around lines 35 - 43, preloadAiCredits
currently forces a live refresh via refreshBalance(orgId), which makes initial
layout preload Metronome-dependent; change preloadAiCredits to read the cached
balance/entitlement instead of calling refreshBalance — use the existing cached
getter (e.g., getCachedBalance or readBalance) or add a small
getBalanceCached(orgId) helper and call that along with getEntitlement(orgId) so
resolveInitialAiCredits and route-group layout use only cached data; keep
refreshBalance reserved for refreshAiCredits() and webhook-reconcile flows where
a live sync is appropriate.
editor/lib/ai/actions/image.ts-16-20 (1)

16-20: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate scale before forwarding it to the provider.

The public contract says scale maxes out at 10, but this path currently forwards any runtime value. Since this is a server action, a client can still send 0, NaN, or 100 and push that failure/cost downstream instead of getting a deterministic bad_request.

Suggested fix
 export async function upscaleImage(
   input: UpscaleImageInput
 ): Promise<UpscaleImageResponse> {
   if (!input.image) {
     return {
       success: false,
       code: "bad_request",
       message: "image is required",
       status: 400,
     };
   }
+  if (
+    input.scale !== undefined &&
+    (!Number.isInteger(input.scale) || input.scale < 1 || input.scale > 10)
+  ) {
+    return {
+      success: false,
+      code: "bad_request",
+      message: "scale must be an integer between 1 and 10",
+      status: 400,
+    };
+  }
   return withAiAuth("ai/image/upscale", input.organizationId, (orgId) =>
     methods.upscale(orgId, {
       image: ai.server.methods.toImageData(input.image),
       scale: input.scale,
     })

Also applies to: 26-42

🤖 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 `@editor/lib/ai/actions/image.ts` around lines 16 - 20, Validate the
UpscaleImageInput.scale inside the server action that handles the upscale
request (the function that receives UpscaleImageInput in this file) before
forwarding to the provider: coerce/parse the value, ensure it's a finite integer
between 1 and 10 (default to 4 when undefined), and throw/return a deterministic
bad_request error for values like 0, NaN, negatives, or >10; do this validation
in the handler that consumes UpscaleImageInput (not just in the type) and use
the validated value when calling the provider so untrusted client inputs cannot
be forwarded.
editor/app/(ingest)/webhooks/metronome/route.ts-62-68 (1)

62-68: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject future-dated webhook timestamps too.

This freshness check only blocks events older than five minutes. A request dated far in the future still passes, which weakens the replay window the signature is supposed to enforce. Bound the skew in both directions here.

Suggested fix
-  if (Date.now() - dateMs > FIVE_MINUTES_MS) {
+  if (Math.abs(Date.now() - dateMs) > FIVE_MINUTES_MS) {
     return NextResponse.json({ error: "stale event" }, { 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 `@editor/app/`(ingest)/webhooks/metronome/route.ts around lines 62 - 68, The
freshness check only rejects events older than FIVE_MINUTES_MS but allows
timestamps far in the future; update the validation around dateHeader/dateMs in
route.ts to enforce symmetric skew by also rejecting when dateMs - Date.now() >
FIVE_MINUTES_MS (i.e., if the timestamp is more than FIVE_MINUTES_MS ahead of
now). Return the same error response (NextResponse.json with status 400) for
future-dated headers so the replay window is bounded in both directions.
editor/lib/ai/actions/image-generate.ts-57-67 (1)

57-67: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hardcoded medium/ tier prefix prevents tier key lookups from ever matching.

The code in computeCostMills constructs tier keys as `medium/${w}x${h}` (line 60), but the pricing catalog defines all tiered models (DALLE3, DALLE3-turbo, DALLE3-mini) with keys like "low/1024x1024". This mismatch means the lookup always fails and falls through to card.avg_cost_usd, causing high-resolution generations to be undercharged.

Change "medium/" to match the actual prefix in the pricing tier definitions, or derive the tier dynamically from request dimensions.

🤖 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 `@editor/lib/ai/actions/image-generate.ts` around lines 57 - 67, The per-image
tier lookup in computeCostMills (in the "per_image_tiered" case) builds tierKey
as `medium/${w}x${h}` which never matches the pricing catalog keys; update the
tierKey construction used in the "per_image_tiered" branch (where tierKey and
perImage are computed) to use the correct prefix that matches card.pricing.tiers
(e.g., replace "medium/" with the actual prefix used like "low/" or determine
the tier prefix dynamically from request dimensions or card.pricing.tiers keys),
so that tierKey can find an entry in card.pricing.tiers and perImage is selected
from the tiered pricing instead of falling back to card.avg_cost_usd.
editor/lib/ai/actions/chat.ts-99-103 (1)

99-103: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

resolvedId and the actual model can drift.

When model_id is absent or invalid, the SDK model comes from model("mini") but resolvedId is hardcoded to the string literal "openai/gpt-5.4-mini". If model("mini") is ever remapped to a different catalog id, costMillsFromTokenUsage(resolvedId, ...) will silently compute against the wrong card — i.e., the meter and the actual model disagree. Derive resolvedId from the same source the model came from so they can't diverge.

♻️ Suggested fix
-    const requested = input.model_id;
-    const useModelId = requested && isCatalogId(requested) ? requested : null;
-    const languageModel = useModelId ? grida(useModelId) : model("mini");
-    const resolvedId = useModelId ?? catalog["openai/gpt-5.4-mini"].id;
+    const requested = input.model_id;
+    const useModelId = requested && isCatalogId(requested) ? requested : null;
+    const tierModel = useModelId ? null : model("mini");
+    const languageModel = useModelId ? grida(useModelId) : tierModel!;
+    // Pull catalog id from whichever path produced the model so cost
+    // metering and the actual call can't drift.
+    const resolvedId = useModelId ?? tierModel!.modelId; // or equivalent accessor

If model("mini") doesn't expose the catalog id, consider exporting a tier("mini") → CatalogId mapping from @/lib/ai/models and using that for both grida(...) and the cost call.

🤖 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 `@editor/lib/ai/actions/chat.ts` around lines 99 - 103, The resolvedId is
hardcoded to catalog["openai/gpt-5.4-mini"].id while the actual languageModel
may be produced by model("mini"), which lets the two drift; change the logic in
the withAiAuth block so resolvedId is derived from the same source as
languageModel: if input.model_id is a catalog id use that (useModelId),
otherwise derive the catalog id for the "mini" tier from the same place you call
model("mini") (e.g., export/use a tier→CatalogId mapping or expose an id
property from model("mini")), then pass that derived id to
costMillsFromTokenUsage; update references to grida(...), model("mini"),
resolvedId and any costMillsFromTokenUsage calls so they all use the same
canonical catalog id source.
editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts-862-873 (1)

862-873: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mirror the checkout validation in setAiAutoReload().

This edit-in-place mutation skips the whole-dollar, min/max, and threshold < recharge_to checks that startEnableAutoReloadCheckout() enforces. Since it writes an off-session charge configuration directly, a malformed call can store values the UI never permits. Reuse the same validation block here before setAutoReload().

🤖 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
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts
around lines 862 - 873, The setAiAutoReload function currently writes
auto-reload settings without running the same validation used by
startEnableAutoReloadCheckout; before calling setAutoReload(org_id,
threshold_cents, recharge_to_cents) add the same validation logic (or call a
shared validator) that enforces whole-dollar amounts, min/max bounds, and
threshold_cents < recharge_to_cents as implemented in
startEnableAutoReloadCheckout(), so malformed values the UI would reject cannot
be stored off-session.
editor/lib/ai/error.ts-63-70 (1)

63-70: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't echo raw exception messages back to the client.

Both mappers currently pass err.message through the AI error envelope. For unexpected org / Stripe / Metronome failures, that can leak upstream diagnostics or internal identifiers into a toast/JSON response. Log the original error, but return a fixed user-safe message for non-user-correctable failures.

Also applies to: 97-110

🤖 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 `@editor/lib/ai/error.ts` around lines 63 - 70, orgErrorToAiError currently
returns err.message to the client which can leak internal details; change it to
log the original err (using console/error logger) but return a user-safe message
instead of err.message — e.g., set message to a fixed string like "An internal
error occurred" for non-user-correctable failures while preserving code via
orgErrorCode(err) and status via extractStatus(err, 403); apply the same change
to the other mappers in this file (the other AiErrorResponse-returning functions
around the stripe/metronome mappers) so none of them echo err.message to
clients.
editor/lib/ai/error.ts-73-85 (1)

73-85: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't route org lookup failures into onboarding.

The default branch currently covers org_lookup_failed and every other unknown resolver error, but resolveAiError() only looks at code, not status. That means a transient 5xx during org resolution will hard-redirect users to /organizations/new instead of surfacing an internal failure. Keep the onboarding mapping to the true “no org” cases and send lookup/backend failures to "internal" instead.

🤖 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 `@editor/lib/ai/error.ts` around lines 73 - 85, The orgErrorCode function
currently sends all unknown codes to "no_organization"; change its mapping so
only true “no org” codes (e.g., "missing_organization_id", "org_not_found",
"not_member") return "no_organization", explicitly map transient resolver
failures like "org_lookup_failed" to "internal", and make the default branch
return "internal" so lookup/backend errors surface as internal failures rather
than redirecting to onboarding.
editor/lib/ai/server.ts-318-335 (1)

318-335: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve undefined for awaitIngest so the default still works.

extractContext() currently sets awaitIngest: g.awaitIngest === true, which turns an omitted flag into false. withTransaction() then never hits its ?? true default, so plain AI SDK calls silently fall back to fire-and-forget ingest and the post-call refreshBalance() path can observe stale credit. Only write awaitIngest when the caller actually provided a boolean.

Suggested fix
       costMills:
         typeof g.costMills === "number" && g.costMills >= 0
           ? g.costMills
           : undefined,
-      awaitIngest: g.awaitIngest === true,
+      awaitIngest:
+        typeof g.awaitIngest === "boolean" ? g.awaitIngest : undefined,
     };
   }
🤖 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 `@editor/lib/ai/server.ts` around lines 318 - 335, extractContext currently
forces awaitIngest to true/false by using g.awaitIngest === true; change it to
preserve undefined so the default in withTransaction (which uses ?? true) can
apply: when building the returned object in extractContext, set awaitIngest only
if the caller provided a boolean (e.g., check typeof g.awaitIngest === "boolean"
and use that value; otherwise leave awaitIngest undefined) — update the object
construction in function extractContext (and any related uses of g.awaitIngest)
so awaitIngest is omitted/undefined unless explicitly passed.
editor/lib/ai/error.ts-192-200 (1)

192-200: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate code against the known union before accepting the envelope.

isAiErrorResponse() treats any string as a valid code. A malformed payload like { success: false, code: "foo", ... } passes this guard, then resolveAiError() falls through and handleAiFetchErrorResponse() will blow up when it reads action.kind. Tighten the check to the actual AiErrorCode set.

Suggested fix
+const AI_ERROR_CODES = new Set<AiErrorCode>([
+  "unauthorized",
+  "no_organization",
+  "blocked",
+  "bad_request",
+  "internal",
+]);
+
 export function isAiErrorResponse(x: unknown): x is AiErrorResponse {
   if (!x || typeof x !== "object") return false;
   const o = x as Record<string, unknown>;
   return (
     o.success === false &&
-    typeof o.code === "string" &&
+    typeof o.code === "string" &&
+    AI_ERROR_CODES.has(o.code as AiErrorCode) &&
     typeof o.message === "string" &&
     typeof o.status === "number"
   );
 }
🤖 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 `@editor/lib/ai/error.ts` around lines 192 - 200, isAiErrorResponse currently
accepts any string for the code field; update it to validate that o.code is one
of the known AiErrorCode values so malformed payloads like { success: false,
code: "foo", ... } fail the guard. Modify isAiErrorResponse to import/use the
authoritative AiErrorCode set (or an isAiErrorCode helper) and replace the loose
typeof o.code === "string" check with a membership check against AiErrorCode;
this ensures downstream functions like resolveAiError and
handleAiFetchErrorResponse only see envelopes with a valid code.
editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts-837-850 (1)

837-850: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply the same top-up bounds to the direct charge path.

topUpAiCredits() only rejects non-positive values, so any caller that reaches this exported owner action can bypass the documented top-up min/max guardrails and create arbitrarily large direct charges. Reuse TOPUP_MIN_CENTS / TOPUP_MAX_CENTS here before addStripeChargedCommit().

Suggested fix
-  if (!Number.isFinite(amount_cents) || amount_cents <= 0) {
+  if (
+    !Number.isFinite(amount_cents) ||
+    amount_cents < TOPUP_MIN_CENTS ||
+    amount_cents > TOPUP_MAX_CENTS
+  ) {
     throw new BillingError("invalid amount", "invalid_amount", 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
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts
around lines 837 - 850, topUpAiCredits currently only rejects non-positive
amounts and therefore allows callers to create arbitrarily large direct charges;
update the function topUpAiCredits to validate amount_cents against the existing
TOPUP_MIN_CENTS and TOPUP_MAX_CENTS constants before calling
addStripeChargedCommit (reuse the same bounds-check logic used elsewhere),
throwing a BillingError with the same error codes/message when amount_cents is
out of range, so the direct charge path enforces the documented min/max
guardrails.
editor/app/(insiders)/insiders/billing/actions.ts-49-55 (1)

49-55: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a runtime dev-only guard inside wrap().

These actions intentionally skip membership checks and accept arbitrary organizationIds. The only current protection is route-group/proxy placement, but importing from a production code path would expose privileged Stripe/Metronome mutations immediately. A dev-only gate inside wrap() ensures the failure mode is "request rejected" instead of "silent privilege bypass".

🤖 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 `@editor/app/`(insiders)/insiders/billing/actions.ts around lines 49 - 55,
wrap() currently allows privileged actions unconditionally; add a runtime
dev-only guard at the top of wrap() that rejects calls when the code is running
outside a developer environment. Concretely: in the wrap(fn) function (which
returns ActionResult<T> and uses errorMessage()), check a runtime dev flag (e.g.
process.env.NODE_ENV === 'development' or a dedicated runtime flag like
globalThis.__INSIDERS_DEV__ or NEXT_PUBLIC_INSIDERS) and if the guard fails
return { ok: false, error: 'Insiders-only action attempted in non-dev runtime' }
(use errorMessage for consistency if desired) so the request is rejected instead
of silently bypassing membership checks. Ensure the check is simple, clearly
named, and documented so imports from production paths cannot execute privileged
Stripe/Metronome mutations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 08a192e3-e313-4ede-8949-a33a3a41b466

📥 Commits

Reviewing files that changed from the base of the PR and between f384d90 and 1e4134e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (101)
  • .agents/skills/database/SKILL.md
  • .agents/skills/security/SKILL.md
  • SECURITY.md
  • database/database-generated.types.ts
  • docs/contributing/billing.md
  • docs/wg/platform/billing/_category_.json
  • docs/wg/platform/billing/ai-credits.md
  • docs/wg/platform/billing/index.md
  • docs/wg/platform/billing/known-issues.md
  • docs/wg/platform/billing/metronome.md
  • docs/wg/platform/index.md
  • editor/.env.example
  • editor/.oxlintrc.jsonc
  • editor/app/(api)/internal/cron/billing-reconcile/route.ts
  • editor/app/(api)/private/ai/audio/actions.ts
  • editor/app/(api)/private/ai/audio/generate/route.ts
  • editor/app/(api)/private/ai/chat/route.ts
  • editor/app/(api)/private/ai/credits/route.ts
  • editor/app/(api)/private/ai/generate/image/route.ts
  • editor/app/(api)/private/ai/image/actions.ts
  • editor/app/(api)/private/ai/image/remove-background/route.ts
  • editor/app/(api)/private/ai/image/upscale/route.ts
  • editor/app/(api)/private/ai/models/openai/route.ts
  • editor/app/(api)/private/ai/models/route.ts
  • editor/app/(api)/private/ai/ratelimit.ts
  • editor/app/(api)/private/editor/ai/schema/route.ts
  • editor/app/(api)/private/webhooks/stripe/route.ts
  • editor/app/(canvas)/canvas/tools/ai/_hooks/use-models.ts
  • editor/app/(canvas)/canvas/tools/ai/generate.ts
  • editor/app/(ingest)/README.md
  • editor/app/(ingest)/webhooks/metronome/route.ts
  • editor/app/(ingest)/webhooks/stripe/route.ts
  • editor/app/(insiders)/insiders/billing/_view.tsx
  • editor/app/(insiders)/insiders/billing/actions.ts
  • editor/app/(insiders)/insiders/billing/page.tsx
  • editor/app/(insiders)/layout.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts
  • editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsx
  • editor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsx
  • editor/app/(tools)/(playground)/playground/image/_page.tsx
  • editor/app/(tools)/(playground)/playground/image/layout.tsx
  • editor/app/(tools)/tools/remove-bg/_page.tsx
  • editor/app/(www)/(ai)/ai/_page.tsx
  • editor/app/(www)/(ai)/ai/music/page.tsx
  • editor/app/(www)/(ai)/ai/music/playground/_page.tsx
  • editor/app/(www)/(ai)/ai/page.tsx
  • editor/app/(www)/(ai)/layout.tsx
  • editor/grida-canvas-hosted/ai/agent/server-agent.ts
  • editor/grida-canvas-hosted/ai/tools/canvas-use.ts
  • editor/grida-canvas-react-starter-kit/starterkit-toolbar/image-toolbar.tsx
  • editor/lib/__tests__/server-only.shim.ts
  • editor/lib/ai/__tests__/error.test.ts
  • editor/lib/ai/__tests__/server.test.ts
  • editor/lib/ai/actions/audio.ts
  • editor/lib/ai/actions/chat.ts
  • editor/lib/ai/actions/forms-schema.ts
  • editor/lib/ai/actions/image-generate.ts
  • editor/lib/ai/actions/image.ts
  • editor/lib/ai/actions/models.ts
  • editor/lib/ai/ai.ts
  • editor/lib/ai/credits/README.md
  • editor/lib/ai/credits/__tests__/controller.test.ts
  • editor/lib/ai/credits/__tests__/format.test.ts
  • editor/lib/ai/credits/actions.ts
  • editor/lib/ai/credits/controller.ts
  • editor/lib/ai/credits/format.ts
  • editor/lib/ai/credits/index.ts
  • editor/lib/ai/credits/provider.tsx
  • editor/lib/ai/error.ts
  • editor/lib/ai/hooks/index.ts
  • editor/lib/ai/hooks/use-credits.ts
  • editor/lib/ai/hooks/use-generate-audio.ts
  • editor/lib/ai/hooks/use-generate-image.ts
  • editor/lib/ai/models.ts
  • editor/lib/ai/server.ts
  • editor/lib/auth/organization.ts
  • editor/lib/billing/__tests__/e2e/fixtures/db.ts
  • editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts
  • editor/lib/billing/__tests__/e2e/fixtures/safety.ts
  • editor/lib/billing/__tests__/e2e/scenarios/ai-credit-topup.test.ts
  • editor/lib/billing/fees.ts
  • editor/lib/billing/metronome.ts
  • editor/lib/billing/plans.ts
  • editor/package.json
  • editor/proxy.ts
  • editor/scaffolds/ai/form-field-schema-assistant.tsx
  • editor/scaffolds/playground-forms/actions.ts
  • editor/scaffolds/playground-forms/playground.tsx
  • editor/scripts/audit-ai-seam.ts
  • editor/scripts/billing/README.md
  • editor/scripts/billing/_env.ts
  • editor/scripts/billing/cli.ts
  • editor/scripts/billing/ops.ts
  • editor/scripts/billing/setup-stripe-test.ts
  • editor/scripts/billing/setup.ts
  • editor/scripts/billing/smoke.ts
  • editor/vercel.json
  • editor/vitest.config.ts
  • supabase/migrations/20260508130000_grida_billing_metronome.sql
  • supabase/schemas/grida_billing.sql
💤 Files with no reviewable changes (18)
  • editor/lib/ai/hooks/index.ts
  • editor/app/(api)/private/ai/audio/actions.ts
  • editor/app/(api)/private/ai/models/openai/route.ts
  • editor/app/(api)/private/ai/models/route.ts
  • editor/lib/ai/hooks/use-credits.ts
  • editor/app/(api)/private/ai/image/remove-background/route.ts
  • editor/app/(api)/private/ai/audio/generate/route.ts
  • editor/app/(api)/private/ai/image/upscale/route.ts
  • editor/lib/ai/hooks/use-generate-image.ts
  • editor/lib/ai/hooks/use-generate-audio.ts
  • editor/lib/billing/plans.ts
  • editor/app/(api)/private/ai/credits/route.ts
  • editor/app/(api)/private/ai/ratelimit.ts
  • editor/app/(api)/private/editor/ai/schema/route.ts
  • editor/app/(api)/private/webhooks/stripe/route.ts
  • editor/app/(api)/private/ai/image/actions.ts
  • editor/scripts/billing/setup-stripe-test.ts
  • editor/app/(api)/private/ai/generate/image/route.ts

Comment thread editor/app/(ingest)/webhooks/stripe/route.ts Outdated
`service_role.<schema>` was constructed at module-eval time — eleven
`createClient` calls fired the moment anything imported
`lib/supabase/server.ts`. That made the import contingent on
`SUPABASE_SECRET_KEY` being present, which is true in dev (.env.local)
but false in CI. Any consumer that transitively reached `service_role`
inherited the brittleness: the failure surfaced as unrelated test
files crashing on import with `supabaseKey is required`. The recent
`lib/ai/credits/__tests__/controller.test.ts` hit it via the
`controller → actions → @/lib/ai/server → @/lib/supabase/server` chain.

Convert each `service_role.<schema>` to a memoized getter on a const
object. The call-site syntax is unchanged (`service_role.workspace.from(...)`),
so every consumer keeps working without modification, and the
"grep `service_role` to find every privileged DB touch" property is
preserved. First access constructs and caches; further accesses
return the same client.

Lock the contract in `lib/supabase/__tests__/server.test.ts`:
- import does not call `createClient`
- first property access constructs once
- repeat access does not reconstruct (memoized identity)
- per-schema construction is independent

Revert the test-side mock workaround in
`lib/ai/credits/__tests__/controller.test.ts` — the controller test is
back to the minimal form (no `vi.mock` for supabase/billing/auth) and
passes under empty env (`SUPABASE_SECRET_KEY=""` etc.) because the
import chain no longer touches the supabase factory.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
editor/lib/supabase/server.ts (1)

160-194: ⚡ Quick win

Harden service_role against runtime mutation.

At Line 160, service_role is exported as a plain object with configurable getters. Freezing it prevents accidental/intentional redefinition of privileged accessors.

Suggested patch
-export const service_role = {
+export const service_role = Object.freeze({
   get workspace() {
     return _workspace();
   },
@@
   get www() {
     return _www();
   },
-};
+});
🤖 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 `@editor/lib/supabase/server.ts` around lines 160 - 194, Wrap the exported
service_role object in Object.freeze to prevent runtime mutation of its accessor
properties (so the getters like workspace, ciam, library, forms, storage,
canvas, sites, commerce, west_referral, xsb, and www cannot be redefined);
replace the current plain object export of service_role with a frozen object
(e.g., export const service_role = Object.freeze({ ... })) so the property
descriptors become non-configurable and the object cannot be mutated at runtime.
🤖 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.

Nitpick comments:
In `@editor/lib/supabase/server.ts`:
- Around line 160-194: Wrap the exported service_role object in Object.freeze to
prevent runtime mutation of its accessor properties (so the getters like
workspace, ciam, library, forms, storage, canvas, sites, commerce,
west_referral, xsb, and www cannot be redefined); replace the current plain
object export of service_role with a frozen object (e.g., export const
service_role = Object.freeze({ ... })) so the property descriptors become
non-configurable and the object cannot be mutated at runtime.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b48654dd-5cf3-412e-ba95-8bcd07f4c259

📥 Commits

Reviewing files that changed from the base of the PR and between 1e4134e and 22dd673.

📒 Files selected for processing (2)
  • editor/lib/supabase/__tests__/server.test.ts
  • editor/lib/supabase/server.ts

The Stripe webhook receiver runs the projector RPC
(`fn_billing_apply_stripe_event`) FIRST, which records the event in
`grida_billing.stripe_event` and stamps `processed_at`. The AI-credit
Checkout post-processor (`handleAiCreditCheckoutCompleted`) ran AFTER.
If the post-processor 500'd (Metronome network blip, etc.), the request
returned 500 → Stripe retries → projector returns `replayed` → route
short-circuited before the post-processor ever ran again. Customer paid;
no balance.

The post-processor cannot fold into the SQL projector because it makes
HTTP calls to Metronome (`addStripeChargedCommit`, `setAutoReload`).
Add a separate per-event marker that the receiver consults independently
of the projector's replayed/handled distinction.

- Migration `20260512000000_grida_billing_stripe_event_ai_credit_marker.sql`
  adds `ai_credit_processed_at timestamptz` to `grida_billing.stripe_event`,
  plus `fn_billing_get_ai_credit_processed` (read) and
  `fn_billing_stamp_ai_credit_processed` (UPSERT) RPCs. Both
  SECURITY DEFINER, search_path locked, EXECUTE granted only to
  service_role. Schema mirror in `supabase/schemas/grida_billing.sql`
  updated to match.
- `lib/billing/index.ts` exposes `readAiCreditMarker` and
  `stampAiCreditMarker` helpers; `as never` casts mirror the existing
  `fn_billing_get_metronome_account` pattern (generated DB types lag
  newly-added RPCs until `pnpm typecheck` is re-run after the migration
  applies locally).
- `app/(ingest)/webhooks/stripe/route.ts` now runs the AI-credit branch
  on every `checkout.session.completed` delivery — gated on
  `readAiCreditMarker(event.id) === true`, not on `result.result`.
  Marker stamped after the post-processor returns successfully (including
  `noop`, so non-AI-credit checkouts also short-circuit cleanly on
  replay). On failure the marker is NOT stamped — Stripe retries, the
  replay sees marker NULL, re-enters the branch.
- `replayed` short-circuit moved AFTER the AI-credit branch so it still
  fires for non-checkout replays.
- Doc comment block at top updated to describe the split-marker design.

The existing e2e test (`lib/billing/__tests__/e2e/scenarios/ai-credit-topup.test.ts`)
still passes — it calls `handleAiCreditCheckoutCompleted` directly,
bypassing the receiver and the marker.
CodeRabbit + ChatGPT Codex flagged ~25 issues against `1e4134eb`. After
fact-checking each against current code (~9 turned out STALE/WRONG —
Bearer auth already supported, payment_status already validated, bounds
already enforced one layer down, etc.), 15 narrow fixes landed here.

**AI seam — error envelope (`lib/ai/error.ts`)**
- Strict `AiErrorCode` union: codes are now an `AI_ERROR_CODES as const`
  array with the union derived from it. Per-code JSDoc inline. Adding a
  new code requires touching the array, the typeguard, the switch, and
  the tests in lock-step (TS exhaustiveness + `assertNever` enforce it).
- Sanitize internal-path messages: `billingErrorToAiError` no longer
  echoes raw exception `.message` to clients on the `internal` path —
  replaced with a constant; raw error still goes to `console.error` for
  debugging. The `blocked` path is unchanged because its messages are
  curated for end-user display.
- Propagate real DB faults: `orgErrorToAiError` no longer collapses
  `org_lookup_failed` (and unknown codes) into `no_organization`.
  Recoverable codes keep their typed mapping; everything else re-throws
  so the caller's outer try/catch (route or framework) handles it as a
  generic 500. Per project policy: real DB exceptions should NOT be
  wrapped in a misleading user-facing redirect.
- Strict `isAiErrorResponse`: rejects envelopes whose `code` isn't in
  the union. Closes the previously silent fall-through where an unknown
  code returned `undefined` and callers treated it as "no action."
- `resolveAiError` switch is closed with an `assertNever` default.

**AI seam — server (`lib/ai/server.ts`)**
- `extractContext`: preserve `awaitIngest: undefined` so
  `withTransaction`'s `?? true` default applies. Coercing to `false`
  silently flipped non-streaming AI calls to fire-and-forget ingest,
  letting `withAiAuth`'s post-call balance read race the debit.

**AI seam — actions (`lib/ai/actions/*`, `app/(api)/private/ai/chat/route.ts`)**
- `chat.ts`: derive `resolvedId` from `catalog[tiers.mini].id` so a
  future `tiers.mini` remap can't desync the billed cost card from the
  SDK call (was hardcoded `"openai/gpt-5.4-mini"`).
- `image.ts (upscaleImage)`: bound-check `scale` (finite number, 1-10)
  before forwarding — Replicate's real-esrgan rejects out-of-range with
  cryptic errors.
- `chat/route.ts`: 500 path now returns the typed `AiActionError`
  envelope via `aiErrorResponse(...)` instead of plain `"Internal error"`
  text, so client-side `consume()` can route it.

**AI seam — credits (`lib/ai/credits/actions.ts`)**
- `preloadAiCredits`: cache-first read via `getEntitlement` (hits the
  local `grida_billing.account` cache) instead of also calling
  `refreshBalance` (live Metronome read on every layout render). Aligns
  with the team's "billing webhook is sole source of truth" rule —
  webhooks + the post-action `withAiAuth` envelope keep the cache fresh,
  and `useAiCredits().refresh()` covers manual re-sync.

**AI seam — lint (`.oxlintrc.jsonc`)**
- Extend `no-restricted-imports` `patterns` so subpath imports of
  provider SDKs (`openai/resources`, `@anthropic-ai/sdk/foo`,
  `replicate/lib/...`, `@ai-sdk/openai/internal`, `stripe/lib/...`) also
  fail the lint. Bare-name imports were already blocked via `paths`.
  Verified subpath blocking via a scratch file; zero false positives
  across the existing tree (1010 files).

**Billing — service layer + webhook**
- `billing/metronome.ts (getAccountView)`: compare
  `db.cached_balance_cents !== Math.floor(live.balanceCents)` — the
  cache is written floored, so a fractional live value triggered
  spurious "drift" warnings on every read.
- `webhooks/metronome/route.ts`: symmetric ±5 min freshness window. The
  previous `Date.now() - dateMs > FIVE_MINUTES_MS` rejected stale events
  but accepted arbitrary future timestamps (clock-skew / forgery defense).

**Insiders — security defense in depth**
- `(insiders)/insiders/billing/actions.ts (wrap)`: runtime
  `NODE_ENV !== "development"` guard. GRIDA-SEC-002 is enforced at the
  proxy + (insiders) layout, but server-action hashes are addressable
  from any importing page; the guard ensures the unauth mutators refuse
  to execute even if an import accidentally crosses the boundary.

**Hosted canvas — LLM tool input validation**
- `grida-canvas-hosted/ai/tools/canvas-use.ts`: tighten the LLM tool
  schema with `.regex(/^\d+:\d+$/)` on `aspect_ratio` so the AI SDK
  rejects malformed values at validation time with a clear error the
  LLM can self-correct, instead of forwarding garbage to the image
  provider and surfacing an opaque downstream failure.

**Starterkit — host-injected organization id**
- New `<StarterKitOrgIdProvider>` + `useStarterKitOrgId()` hook (mirrors
  the existing `PreviewProvider` / `usePreview` pattern in
  `starterkit-preview/`). The starterkit stays workspace-agnostic; the
  host (`CanvasPlayground`, slides page) injects the verified
  organization id. `<ImageToolbar />`'s upscale / remove-background
  buttons now thread it into the AI seam actions; null org → friendly
  toast ("Sign in to use AI tools") instead of letting the server
  return 400 (GRIDA-SEC-003).

**UX — orphan / stuck states**
- `scaffolds/playground-forms/playground.tsx`: restructure
  `generate(...).then(...)` to `.then().catch().finally()` so the
  "Generating…" state always clears, even when the action rejects.
- `app/(tools)/(playground)/playground/image/_page.tsx`: wrap the
  generation flow in try/catch and `editor.commands.delete([id])` on
  both gate-skip and thrown error — previously a failed generate left
  an empty image node on the canvas.

Tests: 70/70 in `lib/ai` + `lib/supabase` (added 11 new tests covering
`isAiErrorCode`, the re-throw semantics for `org_lookup_failed` /
unknown codes, `billingErrorToAiError`'s sanitization, and the strict
typeguard).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
editor/lib/ai/__tests__/error.test.ts (1)

10-20: ⚡ Quick win

Add focused tests for handleAiFetchErrorResponse branch behavior.

This file validates most seam helpers, but the fetch helper’s redirect-null short-circuit and malformed JSON fallback paths are still untested.

🧪 Suggested test additions
 import {
   aiErrorResponse,
   AI_ERROR_CODES,
   billingErrorToAiError,
+  handleAiFetchErrorResponse,
   isAiErrorCode,
   isAiErrorResponse,
   orgErrorToAiError,
   resolveAiError,
   type AiErrorResponse,
 } from "../error";
+describe("handleAiFetchErrorResponse", () => {
+  it("redirect action returns null after navigation", async () => {
+    const origWindow = globalThis.window;
+    Object.defineProperty(globalThis, "window", {
+      configurable: true,
+      value: { location: { href: "" } },
+    });
+
+    const res = new Response(
+      JSON.stringify({
+        success: false,
+        code: "unauthorized",
+        message: "login required",
+        status: 401,
+      }),
+      { status: 401, headers: { "content-type": "application/json" } }
+    );
+
+    const out = await handleAiFetchErrorResponse(res);
+    expect(out).toBeNull();
+    expect(globalThis.window.location.href).toContain("/sign-in?next=");
+
+    if (origWindow === undefined) {
+      delete (globalThis as { window?: unknown }).window;
+    } else {
+      Object.defineProperty(globalThis, "window", {
+        configurable: true,
+        value: origWindow,
+      });
+    }
+  });
+
+  it("falls back to internal message when payload is not JSON", async () => {
+    const res = new Response("not-json", { status: 500 });
+    const out = await handleAiFetchErrorResponse(res);
+    expect(out).toEqual({
+      message: "Something went wrong. Please try again.",
+    });
+  });
+});

Also applies to: 291-329

🤖 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 `@editor/lib/ai/__tests__/error.test.ts` around lines 10 - 20, Add two focused
tests in editor/lib/ai/__tests__/error.test.ts exercising
handleAiFetchErrorResponse: one that stubs a fetch Response with redirected=true
and url set to the literal "null" to verify the function takes the redirect-null
short-circuit path and returns the expected AiErrorResponse shape; and a second
that stubs a Response whose json() either throws or returns malformed data to
verify handleAiFetchErrorResponse falls back to the generic error branch (using
response.status/statusText) rather than blowing up. Use the existing
aiErrorResponse, isAiErrorResponse, and AI_ERROR_CODES utilities to assert the
returned objects.
🤖 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 `@editor/lib/ai/error.ts`:
- Around line 298-299: The current return uses (data as { message?: string
}).message without type checking, which can leak non-string values to callers;
update the fallback logic in this function to read data.message, verify typeof
message === "string" (and optionally non-empty), and only return it if valid,
otherwise return INTERNAL_ERROR_MESSAGE; reference the existing
variable/assignment for fallback and the final returned object { message: ... }
so you replace the cast with a guarded check against data.message and fallback
to INTERNAL_ERROR_MESSAGE when the type is not a string.
- Line 165: Replace the raw error dump at the console.error call that currently
logs `[${scope}]` and the entire err object with a sanitized log containing only
scope and selected harmless fields (e.g., err.code and err.status); locate the
console.error(`[${scope}]`, err) usage in editor/lib/ai/error.ts and change it
to log a structured message like scope plus { code, status } (avoid logging
err.stack or the whole err object) so provider/internal details are not
persisted.

In `@editor/lib/billing/index.ts`:
- Around line 453-462: The function stampAiCreditMarker currently only logs RPC
errors (in stampAiCreditMarker) which swallows failures; change it to propagate
the error so callers can handle retries/failures: after calling
service_role.workspace.rpc in stampAiCreditMarker, if the returned error is
truthy, throw that error (or throw a new Error with contextual message that
includes error.message) instead of only console.warn, ensuring the dedup marker
failure bubbles up to the caller for explicit handling.

---

Nitpick comments:
In `@editor/lib/ai/__tests__/error.test.ts`:
- Around line 10-20: Add two focused tests in
editor/lib/ai/__tests__/error.test.ts exercising handleAiFetchErrorResponse: one
that stubs a fetch Response with redirected=true and url set to the literal
"null" to verify the function takes the redirect-null short-circuit path and
returns the expected AiErrorResponse shape; and a second that stubs a Response
whose json() either throws or returns malformed data to verify
handleAiFetchErrorResponse falls back to the generic error branch (using
response.status/statusText) rather than blowing up. Use the existing
aiErrorResponse, isAiErrorResponse, and AI_ERROR_CODES utilities to assert the
returned objects.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03e9cffd-ceef-40fb-9789-e2b1277d3dd7

📥 Commits

Reviewing files that changed from the base of the PR and between 22dd673 and 6cee53a.

📒 Files selected for processing (23)
  • editor/.oxlintrc.jsonc
  • editor/app/(api)/private/ai/chat/route.ts
  • editor/app/(canvas)/canvas/slides/page.tsx
  • editor/app/(ingest)/webhooks/metronome/route.ts
  • editor/app/(ingest)/webhooks/stripe/route.ts
  • editor/app/(insiders)/insiders/billing/actions.ts
  • editor/app/(tools)/(playground)/playground/image/_page.tsx
  • editor/grida-canvas-hosted/ai/tools/canvas-use.ts
  • editor/grida-canvas-hosted/playground/playground.tsx
  • editor/grida-canvas-react-starter-kit/starterkit-host/org-id-provider.tsx
  • editor/grida-canvas-react-starter-kit/starterkit-toolbar/image-toolbar.tsx
  • editor/lib/ai/__tests__/error.test.ts
  • editor/lib/ai/actions/chat.ts
  • editor/lib/ai/actions/image.ts
  • editor/lib/ai/credits/actions.ts
  • editor/lib/ai/error.ts
  • editor/lib/ai/server.ts
  • editor/lib/billing/index.ts
  • editor/lib/billing/metronome.ts
  • editor/scaffolds/playground-forms/playground.tsx
  • lefthook.yml
  • supabase/migrations/20260512000000_grida_billing_stripe_event_ai_credit_marker.sql
  • supabase/schemas/grida_billing.sql
✅ Files skipped from review due to trivial changes (1)
  • lefthook.yml
🚧 Files skipped from review as they are similar to previous changes (14)
  • editor/app/(api)/private/ai/chat/route.ts
  • editor/grida-canvas-hosted/ai/tools/canvas-use.ts
  • editor/lib/ai/credits/actions.ts
  • editor/app/(ingest)/webhooks/stripe/route.ts
  • editor/lib/ai/actions/chat.ts
  • editor/scaffolds/playground-forms/playground.tsx
  • editor/app/(ingest)/webhooks/metronome/route.ts
  • editor/app/(tools)/(playground)/playground/image/_page.tsx
  • editor/.oxlintrc.jsonc
  • editor/lib/ai/actions/image.ts
  • editor/grida-canvas-react-starter-kit/starterkit-toolbar/image-toolbar.tsx
  • editor/lib/billing/metronome.ts
  • editor/app/(insiders)/insiders/billing/actions.ts
  • editor/lib/ai/server.ts

Comment thread editor/lib/ai/error.ts
message: err instanceof Error ? err.message : "blocked",
};
}
console.error(`[${scope}]`, err);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging raw billing errors directly.

Line 165 logs the full err object, which can persist provider/internal details in logs. Prefer logging sanitized fields only (scope, code, status) and avoid raw stack/object dumps by default.

🔧 Suggested change
-  console.error(`[${scope}]`, err);
+  console.error(`[${scope}] billing_error`, {
+    code: errorCode(err) ?? "unknown",
+    status: extractStatus(err, 500),
+    kind: err instanceof Error ? err.name : typeof err,
+  });
🤖 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 `@editor/lib/ai/error.ts` at line 165, Replace the raw error dump at the
console.error call that currently logs `[${scope}]` and the entire err object
with a sanitized log containing only scope and selected harmless fields (e.g.,
err.code and err.status); locate the console.error(`[${scope}]`, err) usage in
editor/lib/ai/error.ts and change it to log a structured message like scope plus
{ code, status } (avoid logging err.stack or the whole err object) so
provider/internal details are not persisted.

Comment thread editor/lib/ai/error.ts
Comment on lines +298 to +299
const fallback = (data as { message?: string }).message;
return { message: fallback ?? INTERNAL_ERROR_MESSAGE };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate fallback message type before returning UI text.

Line 298 currently trusts data.message via a cast; non-string payloads can bleed through to callers expecting a string.

🔧 Suggested change
-  const fallback = (data as { message?: string }).message;
-  return { message: fallback ?? INTERNAL_ERROR_MESSAGE };
+  const fallback = (data as { message?: unknown }).message;
+  return {
+    message:
+      typeof fallback === "string" && fallback.length > 0
+        ? fallback
+        : INTERNAL_ERROR_MESSAGE,
+  };
📝 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 fallback = (data as { message?: string }).message;
return { message: fallback ?? INTERNAL_ERROR_MESSAGE };
const fallback = (data as { message?: unknown }).message;
return {
message:
typeof fallback === "string" && fallback.length > 0
? fallback
: INTERNAL_ERROR_MESSAGE,
};
🤖 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 `@editor/lib/ai/error.ts` around lines 298 - 299, The current return uses (data
as { message?: string }).message without type checking, which can leak
non-string values to callers; update the fallback logic in this function to read
data.message, verify typeof message === "string" (and optionally non-empty), and
only return it if valid, otherwise return INTERNAL_ERROR_MESSAGE; reference the
existing variable/assignment for fallback and the final returned object {
message: ... } so you replace the cast with a guarded check against data.message
and fallback to INTERNAL_ERROR_MESSAGE when the type is not a string.

Comment on lines +453 to +462
export async function stampAiCreditMarker(
eventId: string,
eventType: string
): Promise<void> {
const { error } = await service_role.workspace.rpc(
"fn_billing_stamp_ai_credit_processed" as never,
{ p_event_id: eventId, p_event_type: eventType } as never
);
if (error) console.warn("[billing] stamp_ai_credit_marker:", error.message);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not swallow AI-credit marker stamp failures.

On Line 461, warning-only handling can acknowledge the flow without persisting the dedup marker, which can re-trigger post-processing on replay paths. Bubble this failure so the caller can decide retry/fail behavior explicitly.

Suggested fix
 export async function stampAiCreditMarker(
   eventId: string,
   eventType: string
 ): Promise<void> {
   const { error } = await service_role.workspace.rpc(
     "fn_billing_stamp_ai_credit_processed" as never,
     { p_event_id: eventId, p_event_type: eventType } as never
   );
-  if (error) console.warn("[billing] stamp_ai_credit_marker:", error.message);
+  if (error) {
+    throw new Error(`stamp_ai_credit_marker: ${error.message}`);
+  }
 }
🤖 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 `@editor/lib/billing/index.ts` around lines 453 - 462, The function
stampAiCreditMarker currently only logs RPC errors (in stampAiCreditMarker)
which swallows failures; change it to propagate the error so callers can handle
retries/failures: after calling service_role.workspace.rpc in
stampAiCreditMarker, if the returned error is truthy, throw that error (or throw
a new Error with contextual message that includes error.message) instead of only
console.warn, ensuring the dedup marker failure bubbles up to the caller for
explicit handling.

The billing CLI is for one-off, human-driven runs against real external
services. Auto-loading `.env.test.local` / `.env.test` / `.env.local`
from cwd as a side-effect import made it too easy to fire commands
against the wrong tenant — the prod cutover for PR #711 is the
forcing function to fix this.

Three guards now:

1. **`--env=<spec>` is required.** No env file is auto-discovered. The
   loader is a function (`loadEnvSpec`) called explicitly by `cli.ts`
   after flag parsing, not a top-level side-effect import. Sibling
   modules (`setup.ts`, `smoke.ts`, `ops.ts`) drop their `import "./_env"`
   and trust the caller to populate `process.env`.
       --env=dev                  load editor/.env.test.local etc.
       --env=/abs/path/to/file    load exactly that file
       --env=~/foo/file           leading `~/` is expanded to $HOME
                                  (shells only expand `~` at the start of
                                  unquoted words, so this matters)
2. **Typed confirmation prompt.** After loading, the CLI prints the
   loaded env file paths, every relevant env var (redacted with length —
   `sk_test_*** (len=107)` so the user can sanity-check it's the right
   token), and the action that's about to run. The user then types the
   exact command name (`ping`, `setup:metronome`, etc.) to proceed.
   Empty / mismatched input aborts.
3. **Live-Stripe override.** If `STRIPE_SECRET_KEY` starts with
   `sk_live_`, the prompt requires typing `PROD` instead of the command
   name and prints a "STRIPE LIVE MODE detected" warning. Belt-and-
   suspenders — `requireStripeTestKey()` already refuses live keys for
   Stripe-touching commands; this is the catch-all for non-Stripe
   commands (Metronome, backfill) that nonetheless mutate prod state.

Failure modes are explicit:
- Missing `--env` → exit 2 with usage.
- Bad env path → "file not found".
- Unknown command → exit 2 with usage.
- Confirmation mismatch → exit 1, no command runs.

README rewritten to document the new contract + a recommended prod file
layout (env file outside the repo, `chmod 600`, no `STRIPE_SECRET_KEY`
since the CLI refuses live keys anyway).

Smoke-tested manually: `--help`, missing `--env`, bad path, dev-target
prompt, leading `~/` expansion — all behave as expected.
`pnpm exec tsc --noEmit` clean.
@vercel vercel Bot temporarily deployed to Preview – viewer May 12, 2026 10:42 Inactive
@vercel vercel Bot temporarily deployed to Preview – backgrounds May 12, 2026 10:42 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog May 12, 2026 10:42 Inactive
@softmarshmallow softmarshmallow merged commit e078f15 into main May 12, 2026
12 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request insiders It can be challenging for casual contributors to tackle migration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant