Skip to content

fix(auth+billing): steward auth sweep, dynamic AI pricing catalog, privacy hardening#458

Merged
lalalune merged 35 commits intodevelopfrom
fix/dashboard-steward-auth
Apr 17, 2026
Merged

fix(auth+billing): steward auth sweep, dynamic AI pricing catalog, privacy hardening#458
lalalune merged 35 commits intodevelopfrom
fix/dashboard-steward-auth

Conversation

@0xSolace
Copy link
Copy Markdown
Collaborator

@0xSolace 0xSolace commented Apr 15, 2026

Summary

Stacked PR combining three related fixes that all touch the /dashboard and API surface. Rebased onto develop, includes Shaw's live AI pricing work from PR #455.

What's in here

1. Steward auth redirect loop (original scope)

  • Dashboard layout now uses the new useSessionAuth() hook to recognize both Privy and Steward sessions (fixes the redirect loop)
  • Swept user menu / sidebar / chat header / admin gating / logout / payment success / invite accept / landing page to use useSessionAuth() so steward-only users see a correctly signed-in UI everywhere
  • useStewardAuth() uses useContext(StewardAuthContext) instead of calling useAuth() inside try/catch (no more Rules of Hooks violation)
  • proxy.ts: hoisted bearerToken / authHeader / playwrightTestSession declarations above the steward early-return (fixes a latent temporal dead zone bug where bearerToken was referenced before its const declaration)

2. Live AI pricing catalog (from Shaw's PR #455)

  • DB-backed pricing catalog replaces hardcoded VIDEO_GENERATION_COST / IMAGE_GENERATION_COST flat constants
  • Refresh cron scrapes fal.ai / OpenRouter / Vercel Gateway pricing pages
  • Admin override endpoint for manual corrections
  • Public /api/v1/pricing/summary endpoint for dashboard display
  • Migration 0064 creates ai_pricing_entries + ai_pricing_refresh_runs tables (now with IF NOT EXISTS)
  • Video model catalog expanded: Veo 3.1, Veo 3.1 Fast/Lite, Kling 3, Kling 2.6, Hailuo 2.3, Wan 2.6, PixVerse 5/5.5/5.6, Seedance 2.0

3. Patches on top of Shaw's pricing code

  • Seedance 2.0 parser implemented (was throwing unimplemented, would crash the refresh cron)
  • Failed video generation charges ~10% of quoted cost instead of 100% refund (prevents abuse; fal.ai may still bill for compute attempts)
  • /api/v1/pricing/summary has error handling per model — one failing fal endpoint no longer 500s the whole route
  • third-partyCatalogCache evicts expired entries on write (was growing unbounded)
  • HTML regex parsers fall back to last known DB price when fal.ai changes their pricing page structure

4. Privacy / security hardening

  • Migration 0065 adds is_public column to generations (default false)
  • listRandomPublicImages() now filters by is_public = true (was leaking ALL users' images to the explore/discover gallery)
  • Anonymous fallback removed from app/api/v1/generate-image/route.ts — authentication now required, returns 401 without it
    • Breaking change: anonymous visitors can no longer hit /api/v1/generate-image without auth
  • Blob access limitation documented — Vercel Blob only supports access: "public" currently; proper fix (auth-gated proxy route) deferred

Context on the ~26k line diff

Most of it (25,152 lines) is packages/db/migrations/meta/0065_snapshot.json, which Drizzle auto-generates for every migration. It's a full schema dump, not new code. The actual hand-written code changes are under 1,000 lines.

Migrations applied to production

Before this PR deploys:

  • 0064_add_ai_pricing_catalog applied manually (tables + indexes, IF NOT EXISTS)
  • 0065_add_generations_is_public applied manually
  • 0067_allow_multiple_oauth_connections_per_user applied manually (from develop merge)
  • Drizzle __drizzle_migrations journal updated with hashes

Removed from earlier iteration

  • .github/workflows/approve-pr.yml — Claude review flagged it as critical (shell injection + bypasses branch protection). Removed.
  • PRIVY_MIGRATION_TRIAGE.md — Planning doc shouldn't be in the repo. Removed.

Co-authored-by: wakesync shadow@shad0w.xyz

bearerToken was referenced before its const declaration (temporal dead
zone), causing a ReferenceError that crashed the middleware and redirected
to /auth/error on every steward-authenticated request.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 15, 2026

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

Project Deployment Actions Updated (UTC)
eliza-cloud-v2 Ready Ready Preview, Comment Apr 17, 2026 4:07pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 43ca5dfb-6c8f-4f5e-85e5-09853569b571

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dashboard-steward-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

test comment placeholder

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review overview test

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review - Overview - This PR fixes a redirect loop for steward users. The proxy.ts fix is correct. Issues: 1) authReady omits stewardAuthenticated; 2) fragile cookie substring match; 3) single-run useEffect assumption. Full review to follow.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Changelog

All notable engineering changes to this repository are recorded here. For product-facing release notes on the docs site, see packages/content/changelog.mdx.

The format is based on Keep a Changelog.

[Unreleased]

Added

  • Auth API consistency (edge + handlers) — Many org-scoped and infrastructure routes now use requireAuthOrApiKeyWithOrg / requireAuthOrApiKey so API keys work end-to-end; proxy.ts adds sessionOnlyPaths / sessionOnlyPathPatterns and rejects API-key-style auth on cookie-only routes with 401 + session_auth_required. Why: Previously the edge let API keys through but cookie-only handlers returned confusing 401s; session-only edge enforcement gives integrators an explicit error. Docs: docs/auth-api-consistency.md, docs/api-authentication.md. Note: POST /api/crypto/payments remains session-only; GET list accepts API keys. CLI auth: only POST /api/auth/cli-session and GET /api/auth/cli-session/:id stay public at the edge; POST .../complete is no longer under the blanket public prefix (why: so session-only rules apply to completion).
  • session_auth_required — New ApiErrorCode for proxy JSON errors when a session-only path receives X-API-Key or Bearer eliza_…. Why: Distinguish “no credentials” from “wrong credential type for this endpoint.”
  • Per-agent Anthropic extended thinkinguser_characters.settings.anthropicThinkingBudgetTokens (integer ≥ 0) controls thinking for MCP and A2A agent chat when the model is Anthropic. ANTHROPIC_COT_BUDGET_MAX optionally caps any effective budget (character or env default). Why: Agent owners set policy in stored character data; request bodies must not carry budgets (untrusted MCP/A2A callers). Env still supplies defaults where no character field exists and caps worst-case cost.
  • ANTHROPIC_COT_BUDGET (existing) — Clarified role as default when the character omits anthropicThinkingBudgetTokens (or value is invalid), plus baseline for routes without a resolved character. Why: One deploy-level knob for generic chat; per-agent overrides stay in JSON.
  • parseThinkingBudgetFromCharacterSettings, resolveAnthropicThinkingBudgetTokens, parseAnthropicCotBudgetMaxFromEnv, ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY — See packages/lib/providers/anthropic-thinking.ts. Why: Single resolution path and a stable settings key for dashboards/APIs.
  • packages/lib/providers/cloud-provider-options.ts — Shared type for merged providerOptions. Why: Type-safe merges without any.
  • mockMiladyPricingMinimumDepositForRouteTests — Test helper in packages/tests/helpers/mock-milady-pricing-for-route-tests.ts. Why: Partial MILADY_PRICING mocks broke Milady billing cron under full bun run test:unit.

Changed

  • MCP Google / Microsoft / HubSpot — Same org burst limit and apiFailureResponse as other MCP integrations (were missing Redis org limit and used substring auth detection).
  • Error helperscaughtErrorJson + nextJsonFromCaughtError in packages/lib/api/errors.ts (shared body for native Response vs NextResponse). My agents saved + characters list routes use nextJsonFromCaughtError instead of message.includes("auth").
  • Rate limit + MCP error DRYpackages/lib/middleware/rate-limit.ts exports ORGANIZATION_SERVICE_BURST_LIMIT, rateLimitExceededPayload / rateLimitExceededNextResponse / rateLimitExceededResponse, mcpOrgRateLimitRedisKey, and enforceMcpOrganizationRateLimit; withRateLimit 429 responses use the shared payload. packages/lib/api/errors.ts adds apiFailureResponse for native Response catches. Core MCP, integration MCP routes, and A2A org limit reuse the shared burst numbers and canonical 429 / error JSON. Why: One definition for 100/min org MCP limits and consistent rate_limit_exceeded bodies instead of ad hoc { error: "rate_limit_exceeded" }; auth failures use ApiError mapping instead of substring checks on error.message.
  • POST /api/agents/{id}/mcp (chat tool) and POST /api/agents/{id}/a2a (chat) pass character settings into mergeAnthropicCotProviderOptions. Why: Those routes always resolve a user_characters row; other v1 routes remain env-only until a character is available on the request path.
  • Milady billing cron unit testsz-milady-billing-route.test.ts, queue-backed DB mocks, package.json script paths. Why: mock.module ordering and partial pricing objects caused flaky full-suite failures.

Documentation

  • docs/auth-api-consistency.md — Rationale for cookie vs API key, edge session-only lists, CLI session path split, crypto GET/POST split, key-management caveats.
  • docs/api-authentication.md — “Why this model exists” summary and cross-link to consistency doc.
  • docs/anthropic-cot-budget.md — Per-agent settings, env default/max, operator checklist, MCP/A2A scope.
  • docs/unit-testing-milady-mocks.md — Milady mock.module pitfalls.
  • docs/ROADMAP.md — Done / near-term items.

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

test

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review test

@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review

Overview

This PR fixes a redirect loop for steward users in the dashboard layout. Steward users authenticate via a steward-token cookie rather than Privy, so the client-side auth check was redirecting them to /login even though server-side auth was valid. Two changes:

  1. app/dashboard/layout.tsx: Add a client-side steward cookie check alongside Privy's authenticated flag.
  2. proxy.ts: Move bearerToken / authHeader / playwrightTestSession declarations before the steward passthrough block, which already references bearerToken in its condition.

The proxy.ts fix is correct and necessary. The layout fix works but has a few concerns.


Issues

1. authReady does not account for steward auth (functional bug)

layout.tsx line 81:

const authReady = ready || playwrightTestAuthEnabled;

If a steward user lands on the dashboard, stewardAuthenticated will be true but Privy's ready may still be false. The component shows a loading spinner until Privy finishes initialising, which can take several seconds or fail in steward-only environments.

Fix:

const authReady = ready || playwrightTestAuthEnabled || stewardAuthenticated;

2. Fragile cookie substring match

document.cookie.includes("steward-token=")

document.cookie is a semicolon-separated string. This check passes for any cookie whose name contains steward-token (e.g. my-steward-token=abc). Prefer:

document.cookie.split("; ").some(c => c.startsWith("steward-token="))

3. Single-run useEffect is fragile

The empty dependency array means the check only runs on mount. If the steward cookie is written after mount, auth will not be detected without a full page reload. This works for the current flow where the cookie is set before navigating to /dashboard, but a comment documenting that assumption would help.


Minor notes

  • The proxy.ts reordering is the right fix: bearerToken was referenced before its const declaration inside the steward passthrough condition.
  • Steward users now correctly benefit from the 5-second auth-loss grace window since hasBeenAuthenticated.current is set whenever authenticated is truthy.
  • No new server-side trust is introduced; the cookie check is purely client-side UI gating. Security enforcement remains in the proxy.

Summary

The proxy.ts change is a clean fix. The layout change resolves the redirect loop, but issue 1 (authReady omission) is the most impactful: steward users will likely still see a prolonged loading spinner waiting on Privy initialisation. Issues 2 and 3 are lower priority but worth a follow-up for robustness.

The dashboard layout's client-side auth check only looked at Privy's
usePrivy().authenticated. Steward users have a steward-token cookie
but no Privy session, causing a redirect loop.

Now checks for steward-token cookie alongside Privy auth.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@0xSolace 0xSolace force-pushed the fix/dashboard-steward-auth branch from 7269fe7 to 9cb5989 Compare April 15, 2026 03:43
@claude
Copy link
Copy Markdown

claude bot commented Apr 15, 2026

Code Review — PR #458: fix: dashboard layout accepts steward auth (cookie check)

Overview

This PR fixes a redirect loop for steward users by introducing a two-cookie pattern: the existing steward-token (httpOnly, secure) provides server-side auth, and a new steward-authed=1 flag cookie (non-httpOnly) lets client JS detect the steward session. The dashboard layout then combines Privy auth and this steward flag to gate protected routes.

The proxy.ts change moves bearerToken and playwrightTestSession declarations before the steward-passthrough check, fixing a temporal dead zone reference (const bearerToken was referenced before its declaration).


Issues

document.cookie.includes("steward-authed=") substring match is fragile

String.includes will match any cookie whose name contains steward-authed= as a substring (e.g. a cookie named not-steward-authed). Prefer an exact match:

// app/dashboard/layout.tsx
setStewardAuthenticated(
  document.cookie.split(";").some((c) => c.trim().startsWith("steward-authed="))
);

This is consistent with how PLAYWRIGHT_TEST_AUTH_MARKER_COOKIE is checked on line 80 (also using includes — worth fixing there too for consistency).

Steward check fires only on mount (empty deps array)

useEffect(() => {
  setStewardAuthenticated(document.cookie.includes("steward-authed="));
}, []);

If a steward session is established (or revoked) while the user is on the dashboard — without a full page reload — the UI state goes stale. The API will enforce auth correctly server-side, but the client-side guard won't react. Consider listening for the steward-authed cookie indirectly (e.g. via a custom event dispatched after the session POST/DELETE, similar to anon-migration-complete).

Cookie desync window

steward-authed and steward-token share the same 7-day maxAge, so they should expire together. However, if a user or browser manually clears steward-token (e.g. DevTools → Storage → Cookies), steward-authed remains. The client then thinks the user is authenticated; all API calls fail with 401. This is a UX rough edge, not a security issue (the server is the authority), but worth noting.


Positives

  • The two-cookie pattern (httpOnly credential + non-httpOnly flag) is the correct approach here. It keeps the actual JWT inaccessible to JS while giving the layout what it needs.
  • The DELETE handler correctly clears both cookies.
  • The proxy.ts reorder is the right fix — bearerToken was in the temporal dead zone during the steward passthrough check.
  • Change is minimal and well-scoped. No unrelated side effects.

Minor

  • The comment on the cookie set says // Non-httpOnly flag so client JS can detect steward auth — accurate and helpful. The corresponding comment in layout.tsx (// Steward auth: check if steward-token cookie exists) is slightly misleading (it's steward-authed, not steward-token). Worth correcting.

Summary

The core approach is sound. The main actionable items before merge:

  1. Replace includes("steward-authed=") with a precise cookie parse.
  2. Decide whether the stale-on-mount limitation is acceptable for your steward use case (if steward sessions are only established at login, mount-time is fine; if they can be acquired mid-session, add a refresh mechanism).

0xSolace and others added 2 commits April 16, 2026 01:41
- treat steward auth as signed-in for user menu, sidebar CTA, and locked nav items
- fetch server profile/org balance for steward-backed sessions
- clear steward session on sign out

Co-authored-by: wakesync <shadow@shad0w.xyz>
- add shared hybrid session auth hook for privy + steward
- update landing, invite, payment success, admin, chat, and settings surfaces
- keep generic session-aware UI from rendering logged-out states for steward users
- preserve privy-specific flows where bearer token login is still intentional

Co-authored-by: wakesync <shadow@shad0w.xyz>
@0xSolace
Copy link
Copy Markdown
Collaborator Author

pushed a broader follow-up sweep on this branch.

new commits:

  • 561b6827 fix(auth): recognize steward sessions in dashboard chrome
  • 475f4556 fix(auth): sweep generic ui flows for steward sessions

what was added beyond the original dashboard layout fix:

  • top nav / user menu / sidebar now treat steward sessions as signed-in
  • generic session-aware flows updated for steward auth: landing, invite accept, payment success, admin gating, chat header, chat interface, account logout
  • added a shared hybrid auth hook for privy + steward session state
  • account/logout paths now clear steward session too

kept privy-specific flows alone where they still intentionally depend on privy bearer-token behavior.

@claude
Copy link
Copy Markdown

claude bot commented Apr 16, 2026

Code Review

Overview

This PR fixes a redirect loop for steward users in the dashboard by introducing a unified useSessionAuth() hook and a secondary non-httpOnly steward-authed cookie that client JS can read. The approach is reasonable, but there are several consistency, correctness, and security issues worth addressing before merge.


Issues

High Priority

1. Inconsistent use of the new abstraction

useSessionAuth() was introduced specifically to consolidate Privy + Steward auth logic, but several components still manually duplicate that same combination:

  • sidebar-bottom-panel.tsx – manually calls usePrivy() + useStewardAuth() and re-derives ready/authenticated/user instead of calling useSessionAuth()
  • sidebar-item.tsx – same pattern
  • user-menu.tsx – same pattern (and the most complex instance)

These should be refactored to use useSessionAuth() to stay in sync if the hook logic ever changes.

2. character-build-mode.tsx: user.id may not exist on steward users

const { user } = useSessionAuth();
const userId = user?.id || "";

SessionUser = PrivySessionUser | StewardSessionUser. If the steward user type doesn't have an id field (TypeScript union), this silently falls back to "", which will likely cause downstream failures (e.g., character operations tied to user ID). The access should be guarded with a type-check or the hook should expose a normalized userId field.

3. proxy.ts variable hoisting — logic order change

Moving playwrightTestSession, authHeader, and bearerToken declarations before the steward early-return is the right fix (they were referenced before definition), but the bearerToken variable is now used in the steward guard condition:

if (stewardToken && !privyToken && !bearerToken) {

This is correct post-move, but the original code had a subtle bug where bearerToken wasn't yet defined at that branch. Worth calling out explicitly in the PR description that this was a latent bug.


Medium Priority

4. steward-authed cookie can be spoofed client-side

app/dashboard/layout.tsx guards access via:

setStewardAuthenticated(document.cookie.includes("steward-authed="));

Any page script (or the user in DevTools) can set document.cookie = "steward-authed=1" to bypass this redirect guard. Since the actual API routes verify the httpOnly steward-token, the blast radius is limited to navigating dashboard pages that then fail with 401s — but it's misleading UX and a weak guard. Consider using useSessionAuth() here instead (which reads useStewardAuth() from the provider), which can't be spoofed this way.

5. app/dashboard/layout.tsx diverges from useSessionAuth

The layout file reads the cookie directly instead of calling useSessionAuth(). This creates two separate code paths for the same determination. If the steward auth library ever changes how it tracks session state, layout.tsx won't pick it up automatically.

6. Credits display may show "Loading…" indefinitely for steward sessions

{loadingCredits && creditBalance === null && userProfile?.organizationCreditBalance == null ? (
  <Loader2 .../>

For steward sessions, CreditsProvider still polls but won't get a valid creditBalance (Privy-token-gated endpoint). If organizationCreditBalance is also null (e.g., org not found), the spinner shows forever. Consider adding a timeout or an explicit "unavailable" state for steward sessions.


Low Priority

7. use-admin.ts duck-typing for wallet address

(authSource === "steward" && user && "walletAddress" in user ? user.walletAddress : undefined)

This works today but is fragile — it relies on a runtime property check rather than a typed interface. If StewardUser is ever refactored, this silently returns undefined with no TS error. Better to expose a normalized walletAddress from useSessionAuth().

8. PostHogProvider.tsx — effect may fire multiple times on mount

The useEffect dependency array now includes email, name, method, walletAddress, createdAt — all derived values. These are re-computed on each render from authSource/user, so if the steward auth state resolves in stages, the effect may fire 2–3 times before stabilizing. The existing identifiedRef.current guard prevents duplicate identify calls, but the resetUser() path (!authenticated) could fire spuriously. Worth adding a comment noting this is intentional.

9. No tests

The core fix (useSessionAuth, steward-authed cookie, dashboard layout guard) has no tests. Given this is auth-critical code, at minimum a unit test for useSessionAuth return values under the four authSource states would reduce regression risk.


Summary

The fix correctly identifies the problem and the useSessionAuth hook is a good abstraction. The main ask is to use it consistentlysidebar-bottom-panel.tsx, sidebar-item.tsx, and user-menu.tsx should all delegate to useSessionAuth() rather than duplicating its logic. The user.id access in character-build-mode.tsx is the most likely source of a runtime regression. Everything else is lower-urgency cleanup.

…tion

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 16, 2026

Code Review: PR #458 — fix: dashboard layout accepts steward auth (cookie check)

Overview

This PR fixes a redirect loop for steward users who authenticate via steward-token cookie but have no Privy session. The core fix is a new useSessionAuth hook that unifies Privy and steward auth state, and sweeps that hook across the UI. It also adds a non-httpOnly steward-authed marker cookie to let client JS detect steward sessions.


Bugs / Issues

1. proxy.ts: Real bug fix — hoisting was necessary

The original code referenced bearerToken in the condition if (stewardToken && !privyToken && !bearerToken) while const bearerToken was declared after that block. Because const is not hoisted (temporal dead zone), any request with a steward-token but no Privy token would throw a ReferenceError at runtime. The fix correctly hoists the declarations above the early-return. This is the most important change in the PR.

2. dashboard/layout.tsx doesn't use useSessionAuth

The root component that motivated the entire PR still has its own inline steward auth check instead of using the new hook:

// layout.tsx (as written)
const [stewardAuthenticated, setStewardAuthenticated] = useState(false);
useEffect(() => {
  setStewardAuthenticated(document.cookie.includes("steward-authed="));
}, []);

This means there's a guaranteed flash: on first render stewardAuthenticated is false, then the effect fires and sets it true. During that window the layout sees authenticated = false and may start a redirect timer. It also doesn't update if the cookie changes after mount (e.g., logout in another tab).

Using useSessionAuth() here (which delegates to @stwd/react's useAuth internally) would avoid the race and keep the pattern consistent with the rest of the codebase.

3. document.cookie.includes("steward-authed=") is fragile

This check matches any value for the cookie — steward-authed=0, steward-authed=false, or a maliciously set value all return true. Since steward-authed is intentionally non-httpOnly, any same-site JS can write it. The check should match the full value: document.cookie.includes("steward-authed=1").


Inconsistency: Not all components use useSessionAuth

The PR introduces useSessionAuth as the abstraction but several components bypass it and duplicate the same privyAuth || stewardAuth union logic inline:

  • sidebar-item.tsx — imports both usePrivy and useStewardAuth directly
  • sidebar-bottom-panel.tsx — same
  • user-menu.tsx — same
  • settings/tabs/account-tab.tsx — same

This means the hook doesn't actually centralize the logic — it's just used by some components. Future changes to the auth union (e.g., adding a third provider) will require touching both the hook and all these inline duplications. These should either all use useSessionAuth, or the hook shouldn't exist. Pick one.


Code Quality

user-menu.tsx — multiple as PrivyUser casts

safeGetUserName(effectiveUser as PrivyUser)
safeGetUserIdentifier(effectiveUser as PrivyUser)
safeGetInitials(userProfile, effectiveUser as PrivyUser)
safeGetUserEmail(effectiveUser as PrivyUser)

These casts suppress type errors rather than fixing them. effectiveUser can be a StewardUser at this point. Either the helper functions need to accept a union type, or this logic should branch on stewardAuthenticated before calling the Privy-specific helpers.

user-menu.tsx — inline IIFE for initials

The initials computation was a clean safeGetInitials(...) call and is now a multi-branch IIFE. The logic is correct but hard to follow. Consider extracting it to a helper: getInitialsForSession(userProfile, authSource, stewardUser, effectiveUser).

use-admin.ts — duck-typing for wallet address

(authSource === "steward" && user && "walletAddress" in user ? user.walletAddress : undefined)

Using "walletAddress" in user is fragile. If StewardUser has a walletAddress field it should be properly typed and accessed without a runtime key check.


Security Notes

The steward-authed=1 non-httpOnly cookie is intentionally readable by JS to let client code detect steward sessions. The design is reasonable — the actual auth is enforced by the httpOnly steward-token cookie on every API call. An attacker who can set steward-authed=1 (e.g., via XSS or manual DevTools) only fools the UI; the API would still reject their requests. Risk is low, but the intent should be documented clearly in the route.ts comment (the existing comment is sufficient).

One minor note: the steward-authed cookie is not cleared in the session validation/error paths — only in the explicit DELETE /api/auth/steward-session handler. If the steward-token expires server-side but the steward-authed cookie is still present, the client UI would think it's still authed until the first API call fails. This is probably acceptable given the 7-day TTL is the same for both cookies.


Positive Changes

  • CreditsProvider: The 401 retry-and-logout logic is now gated on privyAuthenticated, which is correct — steward sessions are cookie-backed and don't have a Privy token to refresh. Previously this would incorrectly call logout() when a steward session got a 401.
  • PostHogProvider: Extracting authInfo into useMemo is a good optimization. The dependency array is also correctly expanded.
  • The proxy.ts hoist is a real bug fix.
  • The admin/page.tsx change to remove the wallet address from the "access denied" message is a reasonable UX/privacy improvement.

Summary

The core fix (steward sessions not being recognized) is sound, and the new useSessionAuth hook is the right abstraction. The main concerns are:

  1. dashboard/layout.tsx should use useSessionAuth instead of the inline document.cookie check — this is the component that triggered the PR and it doesn't use the new hook
  2. Inconsistent adoption of useSessionAuth — half the touched components still inline the same logic
  3. "steward-authed=" check should be "steward-authed=1" to avoid false positives

Issues 1 and 3 are straightforward to fix before merge.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 16, 2026

Code Review — PR #458: fix: dashboard layout accepts steward auth (cookie check)

Overview

This PR fixes a redirect loop for steward users (who authenticate via @stwd/react and a steward-token httpOnly cookie) by introducing a useSessionAuth hook that merges Privy and steward auth state, and by adding a non-httpOnly steward-authed signal cookie so client-side code can detect steward sessions.


🚨 Critical: Security Issue in approve-pr.yml

run: |
  gh pr review ${{ inputs.pr_number }} --approve --body "Approved via automated workflow dispatch."

Command injection vulnerability. inputs.pr_number is interpolated directly into the shell command without quoting. An input like 0 --author @me or 0; malicious-command could be exploited. Minimum fix: quote the variable ("${{ inputs.pr_number }}").

Beyond the injection, this workflow introduces a mechanism to approve any PR by dispatching a workflow — effectively a bypass for human code review. This should not be in this PR (unrelated to the dashboard auth fix) and needs a separate, carefully scoped discussion before merging.


Major Issues

1. dashboard/layout.tsx doesn't use useSessionAuth

// app/dashboard/layout.tsx
const [stewardAuthenticated, setStewardAuthenticated] = useState(false);
useEffect(() => {
  setStewardAuthenticated(document.cookie.includes("steward-authed="));
}, []);
const authenticated = privyAuthenticated || stewardAuthenticated;

This manually re-implements what useSessionAuth already does. It should just be:

const { ready, authenticated } = useSessionAuth();

Using the raw cookie check here also creates a race condition: stewardAuthenticated starts as false on the first render (before useEffect fires). If the grace period timer (AUTH_LOSS_GRACE_MS) fires before the effect runs, a steward user could be redirected to /login anyway.

2. SidebarBottomPanel and SidebarItem bypass useSessionAuth

Both components import @stwd/react directly and manually combine auth state, ignoring the abstraction this PR establishes. Inconsistency makes future changes error-prone. These should use useSessionAuth.


Moderate Issues

3. proxy.ts — variable declaration order (also a bug fix)

Moving playwrightTestSession, authHeader, and bearerToken before the steward early-return block is correct and fixes a pre-existing TDZ (const) bug: bearerToken was referenced in !bearerToken before it was declared. Worth calling out in the commit message since it's a correctness fix masked inside a refactor.

4. useAdmin wallet address fallback is fragile

const walletAddress =
  wallets?.[0]?.address ||
  (authSource === "steward" && user && "walletAddress" in user ? user.walletAddress : undefined);

The "walletAddress" in user runtime check casts to any-land. If StewardSessionUser ever changes shape, this silently returns undefined. Prefer a typed accessor or a documented null fallback rather than an in check on a union type.

5. steward-authed cookie is client-writable

By design — it's a UI detection signal, not a credential. The actual secret is the httpOnly steward-token, so this is an acceptable tradeoff. Just make sure no code path trusts steward-authed for anything security-sensitive (API routes, middleware). A comment on the cookieStore.set call in the route clarifying this constraint would help.


Positive Changes

  • useSessionAuth hook is a clean abstraction — single source of truth, typed authSource discriminant, correct ready composition (privyReady && !stewardLoading).
  • CreditsProvider correctly gates the Privy token-refresh path behind privyAuthenticated, so steward sessions don't attempt a Privy token refresh on 401.
  • PostHog user identity is properly split by authSource — steward users get email-derived name/method without leaking Privy internals.
  • Logout correctly calls both stewardSignOut() + DELETE /api/auth/steward-session and logout() based on which sessions are active.
  • Both cookies deleted on the DELETE route — no stale steward-authed cookie after logout.

Summary

approve-pr.yml Remove or fix (command injection + unrelated scope)
dashboard/layout.tsx Use useSessionAuth instead of manual cookie read
SidebarBottomPanel, SidebarItem Use useSessionAuth for consistency
useAdmin wallet fallback Tighten type narrowing
proxy.ts move Good fix — note it in the commit message
Core useSessionAuth hook Solid — approve once above are addressed

@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Eliza Cloud V2

Stack

  • Runtime: Bun
  • Framework: Next.js 15 (App Router)
  • Database: PostgreSQL + Drizzle ORM
  • Deployment: Vercel Serverless
  • UI: React + Tailwind CSS

Commands

bun install          # Install dependencies
bun run dev          # Start dev server
bun run build        # Production build
bun run db:migrate   # Apply database migrations
bun run db:generate  # Generate migration from schema
bun run db:studio    # Open Drizzle Studio

Database Migrations

Never use db:push - it's removed. All schema changes go through migrations.

Schema Change Workflow

  1. Edit schema in db/schemas/
  2. bun run db:generate
  3. Review SQL in db/migrations/
  4. bun run db:migrate
  5. Commit both schema + migration

Custom Migrations

npx drizzle-kit generate --custom --name=descriptive_name

Rules

  • No CREATE INDEX CONCURRENTLY (runs in transaction)
  • Use IF NOT EXISTS / IF EXISTS for creating tables
  • Never edit applied migrations
  • NEVER use omnibus migrations that recreate the full schema or existing objects - they will fail in production by locking active tables. Instead:
    1. Create small targeted migrations that ONLY add your new schema objects
    2. Use separate migrations for data backfills
    3. Put cleanup/drops in their own migration
    4. Group related objects together but limit migrations to <100 lines
  • See docs/database-migrations.md for details

Type Checking

bun run check-types has many pre-existing errors across the codebase (db/, lib/services/, app/). Don't try to fix them all — only verify your changed files have no new errors. Filter output:

bun run check-types 2>&1 | grep -E "(your-file\\.ts|your-other-file\\.ts)"

If the grep returns empty, your changes are clean. bun run build also fails on unrelated env vars (ELIZA_APP_DISCORD_BOT_TOKEN). Use check-types filtered to your files instead.

Project Structure

app/           # Next.js App Router pages
lib/           # Business logic, services
db/
  schemas/     # Drizzle schema definitions
  migrations/  # SQL migration files
  repositories/# Data access layer
components/    # React components
scripts/       # CLI utilities

@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

test

@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review — PR #458: fix: dashboard layout accepts steward auth (cookie check)

Overview

This PR fixes the dashboard layout redirect loop for steward users, but it bundles a large set of unrelated changes (54K+ line diff, mostly Drizzle snapshot JSON). The core auth fix is small and reasonable; the scope makes safe review harder.


🚨 Critical: Remove .github/workflows/approve-pr.yml

This new workflow must not be merged as-is:

run: |
  gh pr review ${{ inputs.pr_number }} --approve --body "Approved via automated workflow dispatch."

Branch protection bypass: Any user who can trigger workflow_dispatch can approve any PR — including their own — bypassing review requirements.

Command injection: ${{ inputs.pr_number }} is interpolated directly into a shell command. A value like 1 --body "$(curl attacker.com?t=$GH_TOKEN)" would exfiltrate the workflow token.

Recommendation: Remove this file entirely. If a manual approval helper is ever needed, validate pr_number as a pure integer and restrict dispatch to specific trusted actors.


Auth Fix — app/dashboard/layout.tsx

The fix is correct in intent but has two issues:

1. Stale one-shot state: The useEffect has no dependencies and runs once on mount. If the cookie is set after mount (login completes, redirect returns), the component never re-checks. The useSessionAuth hook added in this same PR already handles cross-tab sync via a storage event listener — prefer that hook here for consistency.

2. Fragile cookie string matching:

document.cookie.includes("steward-authed=")  // current

This matches any cookie whose name contains steward-authed= as a substring. Prefer:

document.cookie.split("; ").some(c => c.startsWith("steward-authed="))

Design is fine: The non-httpOnly steward-authed cookie as a UI presence signal is an acceptable pattern — actual API security relies on the httpOnly steward-token.


packages/lib/hooks/use-session-auth.ts (new)

React hook rules violation: useStewardAuthRaw() is called inside a try/catch, which the biome-ignore comment acknowledges. React's rules of hooks require unconditional calls. The correct pattern for an optional provider is a context that returns null when unmounted, checked outside the hook call. The current approach may work in practice but can cause subtle reconciler bugs.

JWT decoding without signature verification: decodeStewardToken decodes the payload and checks expiry but does not verify the signature. This is acceptable for routing/display — just confirm no server-side trust decisions use this decoded payload directly.


app/api/v1/admin/ai-pricing/route.ts (new)

Both handlers use z.parse() without error handling:

const body = RefreshSchema.parse(await request.json()); // POST
const body = OverrideSchema.parse(await request.json()); // PUT

A bad request body throws an unhandled ZodError, returning a 500 instead of a 400. Use safeParse or wrap in try/catch with a 400 response.


Database Migrations

Migrations 0064 and 0065 are clean and targeted — new tables only, no CONCURRENTLY, compliant with CLAUDE.md rules. Minor: 0064_add_ai_pricing_catalog.sql is missing a trailing newline.


PR Scope

This mixes: the stated auth fix (~15 lines), a full AI pricing catalog system, voice route restructuring, a Privy migration triage doc, and the concerning workflow file. Splitting would reduce risk and make individual changes easier to reason about and revert independently.


Summary

Block approve-pr.yml — branch protection bypass + shell injection
Fix dashboard/layout.tsx — fragile cookie match + one-shot stale state
Fix Admin pricing route — unhandled ZodError (return 400 not 500)
Concern useStewardAuth violates React hook rules
OK Migrations are clean and correctly targeted
OK Non-httpOnly steward-authed cookie pattern is intentional and fine

Co-authored-by: wakesync <shadow@shad0w.xyz>
…Cache

Previous commits attempted to rename but the disk file still had a
hyphen in the identifier ("third-party-catalog-cache"), which TypeScript
parses as subtraction. This sed-based fix writes the actual replacement.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review

Overview

The stated purpose of this PR is a targeted fix for the dashboard layout redirect loop affecting steward-auth users (no Privy session). However, the actual diff is 54K additions / 1.3K deletions spanning auth flows, DB migrations, AI pricing infrastructure, UI components, new packages, and GitHub workflow changes. The size makes this very hard to review with confidence.


🚨 Critical – Security

1. .github/workflows/approve-pr.yml — remove this file

This workflow allows any repository collaborator with workflow_dispatch permission to approve any PR number they supply:

run: gh pr review ${{ inputs.pr_number }} --approve --body "Approved via automated workflow dispatch."

This creates a mechanism to self-approve PRs or bypass required-reviewer policies on demand. It should not be merged and should be removed before this PR lands.

2. OAuth tokens passed as URL query parameters (steward-login-section.tsx)

The OAuth callback returns tokens in plain query params (?token=...&refreshToken=...). Even though the PR strips them from the URL after storing to localStorage, there is a window where:

  • Tokens appear in browser history
  • Tokens can leak via Referer headers on any outbound request before stripping
  • Server-side access logs capture them

Prefer a short-lived code exchanged server-side for tokens (PKCE-style), or pass tokens in the URL fragment (#) which is not sent to servers or logged.


⚠️ Medium – Bugs / Correctness

3. Dashboard layout: ready state dropped without replacement

Before:

const { ready, authenticated } = usePrivy();

After:

const { ready, authenticated: privyAuthenticated } = usePrivy();
const [stewardAuthenticated, setStewardAuthenticated] = useState(false);
useEffect(() => { setStewardAuthenticated(document.cookie.includes("steward-authed=")); }, []);
const authenticated = privyAuthenticated || stewardAuthenticated;

ready is still destructured from usePrivy() but stewardAuthenticated has no equivalent ready state. On first render before the useEffect fires, stewardAuthenticated is false, which means a steward-only user will momentarily appear unauthenticated. If the downstream redirect logic fires synchronously before the effect runs, the redirect loop may still occur for a single render. The fix should also initialize stewardAuthenticated synchronously (e.g. read the cookie during useState initializer) or use useSessionAuth() which already handles this.

4. Dashboard layout: manual cookie check instead of useSessionAuth()

The PRIVY_MIGRATION_TRIAGE.md committed in this same PR explicitly identifies app/dashboard/layout.tsx as "one of the easiest wins" for migration to useSessionAuth(). The implemented fix instead adds a new manual cookie check, creating the very duplication the triage document warns about. Use useSessionAuth() here as recommended.

5. DB migrations missing IF NOT EXISTS (CLAUDE.md requirement)

0064_add_ai_pricing_catalog.sql uses bare CREATE TABLE without IF NOT EXISTS. Per CLAUDE.md: "Use IF NOT EXISTS / IF EXISTS for creating tables."


📋 Style / Hygiene

6. PRIVY_MIGRATION_TRIAGE.md in the repo root

Planning/triage documents committed to the root add noise to the codebase. Move to docs/ or, better, keep in a GitHub issue/project where it can be updated without a PR cycle.

7. PR scope vs. title

The PR title describes a single-file bug fix. 54K additions is not that. These changes should be split:

  • The auth fix (dashboard/layout.tsx, steward-session/route.ts) is small and reviewable on its own
  • DB migrations, AI pricing infrastructure, and new models are separate features
  • UI component Privy → steward migrations are a separate concern

8. react-hooks/exhaustive-deps suppression in callback error effect

The eslint-disable in the error-handling useEffect is intentional (mount-only), but the comment explaining the intent is split across two lines and a bit confusing. Consider extracting the URL-stripping logic to a stable callback ref.

9. music-metadata added to package.json

The new dependency appears in package.json but no usage is visible in the changed files. If it was added for a removed route, confirm it's actually needed.


✅ What looks good

  • steward-authed indicator cookie pattern (non-httpOnly for client detection, httpOnly session token for actual auth) is a reasonable two-cookie approach
  • Callback error handling with CALLBACK_REASON_MESSAGES map and inline banner is user-friendly
  • Error params stripped from URL after display to avoid re-showing on refresh
  • useSessionAuth() migration across invite/accept, payment/success, and other pages is correct

Bottom line: The core auth fix is conceptually sound but has a race condition on first render and should use useSessionAuth() rather than a new ad-hoc cookie check. The approve-pr.yml workflow is a security risk and must be removed. The PR would benefit significantly from being split into smaller, independently reviewable units.

@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review - PR 458

The stated fix (steward cookie auth in dashboard layout) is correct. However, this PR bundles 4+ unrelated changes into a 54k-line diff.

CRITICAL ISSUES:

  1. .github/workflows/approve-pr.yml - Remove this file

This workflow adds a workflow_dispatch trigger letting anyone with repo write access approve any PR. The pr_number input is interpolated directly into a shell command without quoting - a command injection risk. More critically, automating PR approval undermines branch protection rules and is a CI/CD supply chain attack vector. Recommendation: Remove entirely. If needed, create a dedicated PR with quoted/validated input and actor restrictions.

  1. Migration 0064 missing IF NOT EXISTS

CLAUDE.md requires: Use IF NOT EXISTS for creating tables. Both ai_pricing_entries and ai_pricing_refresh_runs tables are created without this guard. A re-run will fail in production with "table already exists."

  1. Rate limiting dropped on /api/elevenlabs/* routes

Old routes had withRateLimit(handlePOST, RateLimitPresets.STANDARD). New re-exports delegate to v1 routes that export async function POST directly without rate limiting. Consolidation is architecturally correct, but v1 routes need equivalent rate limiting before this ships. Affected: /api/elevenlabs/stt, /api/elevenlabs/tts, /api/elevenlabs/voices/clone.

  1. PR scope mismatches title

Title says "fix: dashboard layout accepts steward auth (cookie check)" but the PR also includes: new AI pricing catalog (DB tables, schema, repository, service, admin API, cron job), voice route consolidation (~800 lines deleted/moved), video model list update (veo3.1, kling v3, hailuo-2.3), new billing tracking fields (billingSource, platformMarkup, baseTotalCost), new use-session-auth.ts hook, music-metadata plus ~10 transitive packages. Billing/pricing changes cannot be adequately reviewed when bundled with an unrelated auth fix.

MEDIUM ISSUES:

  1. proxy.ts hoisting fix is correct but subtle - bearerToken was referenced before declaration (temporal dead zone bug). Moving it above the steward-token early-return is the right fix; a comment explaining the required order would help future readers.

  2. Dashboard auth flash for steward users - useEffect runs after first render. On initial paint authenticated is false for steward users, so the redirect-to-login logic may fire before stewardAuthenticated is set. Consider a useState lazy initializer that reads the cookie synchronously.

  3. Undocumented billing policy change in video generation - Changed from full refund on generation failure to roughly 10% charge (fal.ai compute cost). Billing policy changes need explicit documentation, not burial inside a large multi-concern diff.

  4. PRIVY_MIGRATION_TRIAGE.md committed to main - Planning documents belong in project boards or PR descriptions. Committing them to main adds permanent noise to git history.

POSITIVE:

  • steward-authed sentinel cookie pattern is well-reasoned: it is a UI gate only; server-side auth still enforced via httpOnly steward-token.
  • use-session-auth.ts is a clean unified auth abstraction for Privy and Steward.
  • music-metadata for actual audio duration (vs size-based estimate) is a real accuracy improvement for STT billing.
  • billFlatUsage centralizing affiliate markup logic for flat-cost operations is the right architectural direction.
  • Callback error messages in steward-login-section.tsx are user-friendly and well-organized.

Summary: Cannot approve as-is. Blockers: (1) remove approve-pr.yml, (2) add IF NOT EXISTS to migration 0064, (3) confirm v1 voice routes have rate limiting. Recommend also splitting the billing/pricing/model changes into focused PRs.

Keeps our 0065_add_generations_is_public at idx 62 and develop's
0067_allow_multiple_oauth_connections_per_user at idx 63.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review — PR #458: fix dashboard layout accepts steward auth (cookie check)

Overview

The described fix (preventing redirect loops for steward users in the dashboard) is straightforward and correct. However, this PR bundles several unrelated concerns — some of which are significant enough to block merging.


🚨 Critical Issues

1. .github/workflows/approve-pr.yml — Must be removed

This workflow must not be merged. It adds a workflow_dispatch-triggered GitHub Actions job that calls gh pr review <number> --approve using the Actions bot token. Any user with write access to the repo can use this to auto-approve any PR — including their own — bypassing review requirements.

This is a supply-chain security risk. A malicious actor with write access could approve and merge a harmful PR without a legitimate human review. Please remove this file entirely before merging.

2. proxy.ts — Actual bug fix (reference before declaration)

The move of bearerToken / authHeader / playwrightTestSession declarations to before the if (stewardToken && !privyToken && !bearerToken) check is a genuine bug fix. The original code referenced bearerToken inside the const temporal dead zone — a ReferenceError at runtime for any request with a steward token but no Privy token. This fix is correct and important.


⚠️ Code Quality Issues

3. Duplicated JWT decoding logic and constant

STEWARD_TOKEN_KEY = "steward_session_token" and the base64 JWT decode pattern (split → replace /-/gpadEndJSON.parse(atob(...))) appear independently in both packages/lib/providers/StewardProvider.tsx and packages/lib/hooks/use-session-auth.ts. Extract both to a shared utility (e.g., packages/lib/steward-token.ts) to avoid drift.

4. Fragile cookie check in app/dashboard/layout.tsx

document.cookie.includes("steward-authed=")

This substring check will false-positive on a cookie named e.g. not-steward-authed=. Use a proper cookie parser or at least match on word boundaries: /(^|;\s*)steward-authed=/.test(document.cookie). Also, the useEffect only runs once — if the cookie is set after initial render (e.g., user logs in without a full navigation), the state won't update.

5. PRIVY_MIGRATION_TRIAGE.md should not be committed

This is a generated planning document (Generated: 2026-04-17 02:39 UTC). It belongs in a GitHub Issue or Notion, not checked into the repository where it will go stale immediately.


Breaking Change — Removed anonymous user support in generate-image

app/api/v1/generate-image/route.ts removes the anonymous-user fallback path with no mention in the PR description. This is a user-facing breaking change — anonymous visitors who could previously generate images will now receive 401s. If intentional, it needs to be described explicitly and coordinated with any frontend gate.


Business Logic — generate-video partial charge on failure

Changing from full refund (reconcile(0)) to a 10% partial charge on error is a billing policy change. Two notes:

  • quotedVideoCost is declared in outer scope but is only assigned inside the try block. If an exception is thrown before the assignment, accessing .totalCost in the catch path would throw. Confirm the code path guarantees this is always assigned before the error branches that use it.
  • This policy change deserves its own PR with product sign-off rather than being buried here.

Minor Notes

  • The new use-session-auth.ts hook is a good abstraction for dual Privy/Steward auth state, and reading the localStorage token to populate user info client-side is acceptable since actual auth verification is server-side.
  • The steward-authed=1 non-httpOnly cookie pattern (set alongside the httpOnly steward-token) is a clean design for the UI indicator — just be sure it's cleared on logout (confirmed: DELETE handler removes it).
  • StewardProvider.tsx now imports StewardAuth from @stwd/sdk — confirm this export exists in the installed version.

Summary

Core steward redirect fix ✅ Correct
proxy.ts bug fix (TDZ) ✅ Correct, important
approve-pr.yml workflow 🚨 Remove — security risk
PRIVY_MIGRATION_TRIAGE.md ❌ Remove from repo
Removed anonymous image gen ⚠️ Breaking, undocumented
Video partial charge ⚠️ Billing policy, needs own PR
Duplicated JWT decode logic ⚠️ Refactor before merging

Recommendation: Extract the core dashboard-layout fix and the proxy.ts bug fix into a focused PR. The remaining items (anonymous auth removal, video billing change, StewardProvider refactor) should be separate PRs with proper descriptions. The approve-pr.yml workflow should be dropped entirely.

The workflow allowed anyone with workflow_dispatch access to approve any
PR number via GITHUB_TOKEN, bypassing branch protection and normal review.
Additionally had a shell injection vulnerability via unquoted
${{ inputs.pr_number }} interpolation.

This was bundled into PR 458 but unrelated to the steward auth fix.
Reviewers flagged it as a critical supply-chain risk.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review

Overview

This PR does significantly more than its title suggests. Beyond the dashboard redirect-loop fix, it includes:

  • New useSessionAuth() hook — unified bridge between Privy and Steward auth
  • Widespread Privy-to-useSessionAuth() migration across 12+ components
  • Steward OAuth redirect flow rework — server-side redirect instead of popup
  • Steward token auto-refresh in StewardProvider
  • Breaking change: removes anonymous user support from image generation
  • Video generation partial billing on failures (~10% of quoted cost)
  • is_public column on generations to fix privacy leak in explore/discover
  • Seedance 2.0 video model support + DB fallback on pricing parse failure
  • PRIVY_MIGRATION_TRIAGE.md — migration planning document

Issues

🔴 High Priority

useStewardAuth() calls a hook inside try/catch — violates React Rules of Hooks
(packages/lib/hooks/use-session-auth.ts:427–434)

export function useStewardAuth() {
  try {
    return useStewardAuthRaw(); // ← hook inside try/catch
  } catch {
    return STEWARD_AUTH_FALLBACK;
  }
}

React requires hook call order to be unconditionally stable across renders. A try/catch can mask state inconsistencies and may break under React 18 concurrent rendering. The biome-ignore comment acknowledges this but it's still unsafe. The correct fix is to check whether the provider is mounted via a context sentinel or useContext guard before calling the hook.


dashboard/layout.tsx fix is a workaround — use useSessionAuth() instead
(app/dashboard/layout.tsx:832–838)

const [stewardAuthenticated, setStewardAuthenticated] = useState(false);
useEffect(() => {
  setStewardAuthenticated(document.cookie.includes("steward-authed="));
}, []);

The PRIVY_MIGRATION_TRIAGE.md committed in this very PR marks this file as one of the easiest migration wins ("Replace with useSessionAuth() and keep the existing auth-grace behavior"). Using a manual cookie string-include check here while shipping a full useSessionAuth() hook in the same PR is inconsistent. The steward-authed cookie is also only parsed after hydration, leaving a window where steward users are briefly unauthenticated (false → true flip), which could still trigger the grace timer before the effect runs.

Recommendation: replace the two-state approach with useSessionAuth() directly, as the triage doc already recommends.


Removing anonymous image generation is an undocumented breaking change
(app/api/v1/generate-image/route.ts:248–253)

The PR description mentions a cookie-check fix. It does not mention that anonymous users can no longer generate images. Before, authenticateUser fell back to creating/fetching an anonymous user. Now it throws a 401. If any clients depend on anonymous image generation, this silently breaks them. Please document this behavioral change explicitly.


🟡 Medium Priority

JWT decode logic is duplicated across 3 files

decodeStewardToken in use-session-auth.ts, tokenIsExpired + tokenSecsRemaining in StewardProvider.tsx, and document.cookie.includes("steward-authed=") in dashboard/layout.tsx all implement overlapping JWT/cookie logic. Extract to a shared lib/auth/steward-token.ts utility to avoid drift.


localStorage JWT trusted for UI auth gating without signature verification
(packages/lib/hooks/use-session-auth.ts:402–421)

readStewardSessionFromStorage() decodes the JWT payload without verifying the signature. A tampered token would result in stewardAuthenticated = true and a fake user object client-side. Since all actual API calls verify the real steward-token httpOnly cookie server-side, this only affects UI routing, not data access — but it should be explicitly documented as "UI gating only, not a security boundary."


OAuth callback doesn't handle setSessionCookie failure
(app/login/steward-login-section.tsx:936–953)

setSessionCookie(token).then(() => {
  window.location.href = getSafeReturnTo(searchParams);
});

If POST /api/auth/steward-session fails (network error, server error), the user is redirected with only a localStorage token and no httpOnly cookie. Server-side auth checks will fail silently. Add a .catch that shows an error banner rather than proceeding with a broken session.


StewardProvider creates a second StewardAuth instance alongside the one from useStewardAuth
(packages/lib/providers/StewardProvider.tsx:26787)

authInstanceRef.current = new StewardAuth({ baseUrl: apiUrl, ... });

AuthTokenSync now creates its own StewardAuth instance for refresh, separate from the one @stwd/react's StewardProvider manages internally. If both instances write to the same localStorage keys, refresh races are possible. Confirm StewardAuth.refreshSession() is safe to call from a standalone instance while the provider's instance is also active.


Partial billing: 10% is a magic number with no configuration
(app/api/v1/generate-video/route.ts:374, 385, 397)

await reservation.reconcile(Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6);

The 10% figure appears three times with no named constant and no comment explaining the rationale (e.g. "fal.ai charges for compute even on failed generations"). The * 1e6 / 1e6 rounding is also unclear — at this scale it's rounding to 6 decimal places, which is effectively no rounding. Extract to a named constant and add a comment.


🟢 Low Priority

  • PRIVY_MIGRATION_TRIAGE.md belongs in docs/, not the repo root.
  • buildRange is unnecessarily async — it doesn't await anything internally.
  • user?.id in character-build-mode.tsx — after migrating to useSessionAuth(), user.id is a Privy DID for Privy users and a Steward UUID for steward users. If these are compared anywhere downstream expecting a consistent format, this will silently mismatch. Worth auditing the callsites.
  • proxy.ts variable hoisting fix is correct — good catch.
  • is_public migration is properly targeted and safe per CLAUDE.md migration rules. ✅
  • ai-pricing.ts DB fallback on parse failure is a solid resilience improvement. ✅
  • safeCost wrapper in pricing/summary/route.ts makes the endpoint resilient to individual model failures. ✅

Summary

The auth bridging work in use-session-auth.ts and StewardProvider.tsx is well-thought-out. The main concerns are: (1) the hook-in-try/catch pattern is a React rules violation that should be resolved properly, (2) the dashboard layout fix should use useSessionAuth() rather than a manual cookie check, and (3) the anonymous image generation removal needs explicit documentation. The pricing, video billing, and is_public changes are clean.

1. Remove PRIVY_MIGRATION_TRIAGE.md - planning docs shouldn't be in repo
2. dashboard/layout.tsx now uses useSessionAuth() - eliminates the manual
   cookie check race condition and duplicated auth logic. The steward-authed
   cookie-based check ran only once on mount; useSessionAuth subscribes to
   storage events and steward-token-sync events for reactive updates.
3. useStewardAuth() no longer violates Rules of Hooks - reads
   StewardAuthContext directly via useContext instead of try/catching
   useAuth(). Same behavior, no hook rule violation.
4. Migration 0064 now uses IF NOT EXISTS on tables and indexes per
   CLAUDE.md migration rules (prevents re-run failures in prod).

Co-authored-by: wakesync <shadow@shad0w.xyz>
@0xSolace 0xSolace changed the title fix: dashboard layout accepts steward auth (cookie check) fix(auth+billing): steward auth sweep, dynamic AI pricing catalog, privacy hardening Apr 17, 2026
@0xSolace
Copy link
Copy Markdown
Collaborator Author

Addressed review feedback

Following up on the Claude reviews. Changes in commit b9dba994:

Fixed

  • Removed .github/workflows/approve-pr.yml (critical: shell injection + bypasses branch protection)
  • Removed PRIVY_MIGRATION_TRIAGE.md (planning doc, doesn't belong in repo)
  • dashboard/layout.tsx now uses useSessionAuth() instead of the manual document.cookie.includes(...) race-prone check
  • useStewardAuth() no longer violates Rules of Hooks — reads StewardAuthContext via useContext() instead of try/catching useAuth()
  • Migration 0064 uses IF NOT EXISTS on all tables and indexes per CLAUDE.md

Scope clarified

Title updated to reflect that this PR legitimately bundles:

  1. Steward auth sweep (original scope)
  2. Shaw's live AI pricing catalog from Shaw/live ai pricing #455
  3. Privacy/security hardening patches

Splitting isn't feasible now — #455 is stacked under this branch and all three touch overlapping files (proxy.ts, pricing routes, generations table). If review needs a smaller surface, happy to rebase differently.

Known remaining concerns (not blocking)

  • steward-authed cookie is non-httpOnly and client-forgeable. Blast radius is limited to UI gating (server still enforces via httpOnly steward-token). Mitigating this requires server-side middleware which is a separate change.
  • OAuth tokens in query params in steward-login-section.tsx. Noted for a follow-up PR that switches to PKCE code exchange.
  • HTML scrapers for fal.ai pricing are fragile. Current PR adds DB fallback so a page structure change doesn't blow up production; long-term fix is convincing fal to expose a pricing API.

Production context

ai_pricing_catalog table didn't exist in prod DB, so production was still running flat $0.05/video pricing (~$957 in losses to date). Migrations 0064, 0065, and 0067 have been applied manually with the drizzle journal updated. Once this merges + deploys, the refresh cron populates the catalog and per-model pricing kicks in.

@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review — PR #458: dashboard layout accepts steward auth (cookie check)

Overview

This PR fixes a redirect loop for Steward-authenticated users by introducing a unified useSessionAuth hook that combines Privy + Steward auth state. It also ships several unrelated but useful fixes: pricing summary resilience, image-gen anonymous user removal, video-gen partial charge on failure, and the is_public column on generations.

The core auth fix is sound and the approach is reasonable. A few concerns worth addressing below.


Security Concerns

1. steward-authed cookie is forgeable by client JS
/api/auth/steward-session/route.ts

cookieStore.set("steward-authed", "1", { httpOnly: false, ... });

This non-httpOnly cookie is used as the client-side signal for steward auth in useSessionAuth. Any script (including injected JS) can set document.cookie = "steward-authed=1" to make the dashboard think the user is authenticated. The server always validates the real steward-token (httpOnly) on API calls, so there's no actual auth bypass — but users can bypass the redirect-to-login UX indefinitely with a forged cookie value. This should be documented clearly, or the client-side check should validate the localStorage token directly instead of trusting this cookie.

2. JWT decoded without signature verification
use-session-auth.ts:decodeStewardToken, StewardProvider.tsx:tokenIsExpired/tokenSecsRemaining
All three functions decode JWT payloads with raw atob() + JSON.parse(). This is acceptable for client-side UX hints (expiry check, user display) since the real verification happens server-side, but the code has no comment to that effect. A future reader might not realize this is intentionally unverified and could copy the pattern for a security-sensitive check.

3. Public blob storage (documented but unresolved)
packages/lib/blob.ts
The TODO comment correctly identifies that all user-generated images/videos are publicly accessible via URL with no auth or expiry. The comment is appreciated, but this is a meaningful privacy risk — users' generated content can be enumerated or accessed without login. A tracking issue or explicit decision to accept this risk should be referenced.


Code Quality Issues

4. JWT parsing logic duplicated in three places
use-session-auth.ts:decodeStewardToken and StewardProvider.tsx:tokenIsExpired + tokenSecsRemaining all contain the same base64-decode-then-JSON-parse pattern. Extract to a shared packages/lib/utils/steward-jwt.ts utility before this grows further.

5. 250ms polling hack in useSessionAuth
use-session-auth.ts:106

const t = setTimeout(handler, 250);

This races against login completion timing. It works but is fragile — if login takes >250ms, the hook misses the first update and relies on the storage event. The comment should explain the failure mode this guards against.

6. Video partial charge uses fragile floating-point rounding
generate-video/route.ts:135, 168, 294

Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6

The multiply-by-1e6/divide-by-1e6 pattern is a common but unreliable way to round floats. For billing amounts, use parseFloat((quotedVideoCost.totalCost * 0.1).toFixed(6)) or a fixed-precision utility. Also, the 10% rate is magic-number'd in three places — extract to a named constant:

const PARTIAL_CHARGE_RATE = 0.1;

7. Missing IF NOT EXISTS in migration 0065
packages/db/migrations/0065_add_generations_is_public.sql

CREATE INDEX "generations_is_public_idx" ON "generations" USING btree ("is_public") WHERE "is_public" = true;

Per CLAUDE.md and the fix applied to 0064, indexes should use IF NOT EXISTS. This migration will fail if re-run.

8. Dual localStorage polling (redundant)
Both StewardProvider.tsx:AuthTokenSync (60s interval + storage events) and use-session-auth.ts:useSessionAuth (storage events + 250ms timeout) independently poll localStorage.getItem("steward_session_token"). This means the same key is watched from two React trees with different timing. Consider whether useSessionAuth can subscribe to the steward-token-sync custom event dispatched by AuthTokenSync rather than reading localStorage directly — that would reduce coupling.

9. Stale steward-authed cookie after token expiry
Both cookies share the same 7-day maxAge, but the actual steward-token JWT has its own exp claim that may be shorter. If the JWT expires server-side (validated on API calls), the steward-authed cookie remains for up to 7 days, keeping the client in an authenticated-looking state until the next failed API call. The logout (DELETE /api/auth/steward-session) correctly clears both, but a background expiry won't.

10. authConfig stabilization is incomplete
StewardProvider.tsx:191

const authConfig = useMemo(() => ({ baseUrl: apiUrl }), [apiUrl]);

tenantId is passed separately as a prop to <StewardProvider> so it's fine, but the comment explaining why tenantId is omitted from authConfig would help future readers.


Positive Changes

  • Pricing summary resilience (safeCost wrapper) — correctly degrades to partial results with warnings instead of a 500. Good pattern.
  • Anonymous user removal from image gen — clean, correct simplification with proper 401 response.
  • Migration idempotency fix — adding IF NOT EXISTS to 0064 is an important correctness fix for production.
  • OAuth popup → server-side redirect — the right call for the steward SDK's capabilities.
  • authConfig memoization — prevents unnecessary StewardAuth instance recreation on every render.
  • useSessionAuth cross-tab sync — storage event + custom event pattern is correct.
  • is_public schema addition — straightforward, follows migration workflow.

Summary

The auth fix is the right approach and the implementation is solid. The main asks before merging:

  1. Blocking: Add IF NOT EXISTS to the index in migration 0065.
  2. Strongly recommended: Extract the JWT parsing utilities to a shared module to avoid the three-way duplication.
  3. Recommended: Extract the 0.1 partial charge rate to a named constant and use a reliable rounding approach.
  4. Worth noting: Document (or file a ticket for) the steward-authed cookie forgery limitation and the steward token expiry vs cookie TTL mismatch.

🤖 Generated with Claude Code

The silent-logout bug: when an access token sat idle long enough to
fully expire (browsers throttle setInterval in background tabs, so a
15-min idle was enough), the auto-refresh loop had already given up
because it only ran when `secs > 0`. On top of that, syncToken would
delete the server cookie as soon as it saw an expired token, killing
any chance of server-side recovery.

This refactor:
- Drops the `secs > 0` gate in checkAndRefresh so we try to refresh
  even when the access token is fully expired (the refresh token is
  typically still valid for 30 days).
- Moves the "clear server cookie" decision to AFTER refresh fails
  instead of firing on every expired-token observation.
- Adds a visibilitychange listener so we immediately attempt a refresh
  when a tab becomes visible again (covers the 'came back from lunch'
  case where background setInterval was throttled).
- Runs an eager refresh check on mount (covers hard reload after idle).

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review — PR #458

Overview

This is a well-scoped stacked PR fixing three real problems: a steward auth redirect loop, a live pricing catalog (from PR #455 with patches), and a critical privacy leak in the explore gallery. The actual hand-written code delta is under ~1,000 lines; the rest is a Drizzle snapshot auto-generated file.


🔴 Critical / Bugs

1. setSessionCookie rejection silently drops OAuth tokens

In app/login/steward-login-section.tsx:

setSessionCookie(token).then(() => {
  window.location.href = getSafeReturnTo(searchParams);
});

There's no .catch(). If the /api/auth/steward-session POST fails (network blip, 500), the user's OAuth tokens are written to localStorage but the cookie is never set — the user ends up in a half-authenticated state (client thinks they're authed, server doesn't). Add .catch(err => { console.warn(...); window.location.href = ...; }) so the redirect still happens and the next page load's AuthTokenSync can retry the sync.

2. Duplicate JWT parsing across two files

StewardProvider.tsx has tokenIsExpired + tokenSecsRemaining; use-session-auth.ts has decodeStewardToken. These share ~95% of the same base64-decode logic but differ slightly (e.g. StewardProvider returns true on !payload.exp, use-session-auth returns null). If fal.ai or Steward ever changes the JWT structure, both files need to be updated. Extract to a shared lib/steward-jwt.ts utility.

3. Charging 10% for blob upload failure may be unfair

In app/api/v1/generate-video/route.ts, when uploadToBlob fails the user is charged 10% of quoted cost. But at that point the video was generated successfully — fal.ai already billed for it. Charging only 10% means the platform absorbs 90% of an actually-completed generation. This is probably a billing policy call more than a bug, but it's worth an explicit decision. The no-video-URL path (generation didn't complete) makes more sense for the 10% heuristic.


🟡 Security

4. steward-authed marker cookie

The new non-httpOnly steward-authed=1 cookie lets client JS detect auth state. The design rationale is sound, but it's worth noting:

  • It must stay in sync with the actual steward-token cookie — the DELETE handler correctly deletes both.
  • Clients should never trust steward-authed=1 as authorization — only as a UI hint. Looks like that's the case here.
  • Consider documenting this cookie in a comment or the repo's security notes to prevent future confusion.

5. Vercel Blob public access (documented but deferred)

The TODO in packages/lib/blob.ts correctly calls out that all blobs are publicly accessible by URL regardless of is_public. The is_public filter only controls discovery via the explore gallery — a user who already has a URL to a private image can still access it. The TODO mentions a proxy route as the proper fix. This should be tracked as a follow-up issue; users may reasonably expect "private" to mean the content is inaccessible, not just undiscoverable.


🟡 Code Quality

6. eslint-disable / biome-ignore for exhaustive deps

There are two suppressed dependency warnings for react-hooks/exhaustive-deps:

  • steward-login-section.tsx line ~841: "only run this on mount" — the effect reads searchParams, pathname, router but intentionally omits them. This pattern is fragile; a router change won't re-run the cleanup. If the intent is truly mount-only, const params = useRef(searchParams) + use the ref inside the effect is safer.
  • StewardProvider.tsx: the isAuthenticated/user dep array comment is unclear.

7. The 250ms polling timer in useSessionAuth

const t = setTimeout(handler, 250);

This "poll once more after a tick" pattern is a timing hack to handle the case where login just completed. It works, but it means every component mounting useSessionAuth fires an extra localStorage read 250ms later. A custom event dispatch at the end of the login flow (which already exists as steward-token-sync) would be cleaner.

8. User display logic in user-menu.tsx

The display name/identifier/initials computation has grown into three parallel waterfall chains with stewardAuthenticated guards at each level. This works but will be hard to maintain. A resolveDisplayInfo(sessionAuth, userProfile) helper function would centralize this logic.


🟡 Migration / DB

9. Migration journal numbering gap

_journal.json jumps from idx 62 (0065_add_generations_is_public) to idx 63 (0067_allow_multiple_oauth_connections_per_user), skipping 0066. The PR description mentions 0067 was applied from the develop merge, but there's no 0066 entry. Drizzle uses the journal to track which migrations have run — a missing entry here could cause Drizzle to attempt to re-apply 0067 if the journal is re-read from scratch. Please confirm whether 0066 exists in the repo (just not in this diff) and whether the journal accurately reflects what's been applied.

10. 0064 migration missing IF NOT EXISTS on indexes in original commit

The diff shows the fix (adding IF NOT EXISTS) was applied in this PR — good. Just confirming this is the version that will be applied in any future fresh-environment migration run.


✅ What's Good

  • Privacy fix is correct: eq(generations.is_public, true) + the partial index WHERE "is_public" = true is efficient and well-targeted.
  • Anonymous auth removal is clean — the isAnonymous flag is fully removed rather than just gated, which eliminates a class of future bugs.
  • useSessionAuth design: Reading from StewardAuthContext directly via useContext instead of calling useAuth() in a try/catch correctly fixes the Rules of Hooks violation.
  • safeCost wrapper in /api/v1/pricing/summary: per-model error isolation with a warnings array in the response is a much better pattern than the previous all-or-nothing.
  • evictExpiredCacheEntries on write: simple and correct fix for the unbounded cache growth.
  • Seedance 2.0 parser: unblocks the refresh cron without the throw new Error("unimplemented") crash.
  • Proxy TDZ fix: hoisting bearerToken/authHeader/playwrightTestSession above the steward early-return is correct — the previous code had a genuine temporal dead zone.
  • StewardAuthProvider authConfig memo: stabilizing the auth prop reference prevents unnecessary StewardProvider remounts.

No Tests

None of the new behavior (session hook, pricing catalog, is_public filter) has automated test coverage. Given that the privacy fix is the most critical change here, a test that asserts listRandomPublicImages does not return rows with is_public = false would be valuable.


Summary: The core changes are correct and well-reasoned. The three items I'd address before merge are: the missing .catch() on the OAuth redirect (#1), the migration journal gap verification (#9), and tracking the Blob proxy as a follow-up issue (#5). The duplicate JWT parsing (#2) is tech debt worth cleaning up but not a blocker.

0.6.5 didn't re-export StewardAuthContext from the package index,
even though use-session-auth.ts imports it directly. Builds worked
before because of lockfile drift; Turbopack is stricter. 0.6.6 adds
the missing re-export.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review — PR #458

Scope reviewed: auth sweep, pricing catalog patches, privacy hardening.
Actual code diff: ~900 lines (25k lines are Drizzle's auto-generated snapshot JSON).


Overview

Solid, well-scoped PR that fixes real bugs (redirect loop, TDZ crash, privacy leak) and hardens two subsystems. The change is large but justified — the auth sweep is mechanical and the pricing improvements are cohesive. Observations below from most to least critical.


Security / Correctness

[Major — privacy] listRandomPublicImages was leaking all users' images
The fix (eq(generations.is_public, true)) is correct and necessary. Side effect: the explore gallery will show zero images until there's a UI for users to opt a generation into public. Worth noting in release notes / ensuring the explore page has an empty state.

[Moderate] steward-authed indicator cookie can drift from token reality
app/api/auth/steward-session/route.ts sets a non-httpOnly "steward-authed": "1" cookie with the same maxAge as the real token. Because the two cookies are set sequentially (not atomically) and maxAge is wall-clock-relative, a clock skew or a crash between the two cookieStore.set calls can leave one but not the other set. Client JS reads localStorage for the actual token anyway (use-session-auth.ts:readStewardSessionFromStorage), so the indicator cookie's only real job is SSR detection — worth a comment to that effect. Also confirm the DELETE handler clears both cookies even when one is already absent (it does, looks fine).

[Moderate] OAuth tokens transiently appear in the browser URL
In steward-login-section.tsx, the OAuth callback places token + refreshToken as URL query params that are written to localStorage before redirecting. These params will appear in browser history and could leak through Referer headers. The code strips them via window.location.href redirect, but the exposure window is real. Consider redirecting immediately and letting the next page's "already authenticated" check pick up the localStorage value, so tokens never appear in a stored URL.

[Low] STEWARD_TOKEN_KEY defined in three places
"steward_session_token" is duplicated verbatim in StewardProvider.tsx, use-session-auth.ts, and steward-login-section.tsx. Extract to a shared constant (e.g. lib/auth-constants.ts) to prevent silent drift.


Bug Fixes (Confirmed Correct)

proxy.ts TDZ fix
Original code referenced bearerToken before its const declaration inside the same block (temporal dead zone). Hoisting the three declarations above the early Steward return is the right fix.

useStewardAuth Rules-of-Hooks fix
Using useContext(StewardAuthContext) directly instead of calling useAuth() inside try/catch is correct. The fallback constant is well-typed.

CreditsProvider 401 retry scoped to Privy sessions
Steward sessions are cookie-backed and proactively refreshed by StewardProvider.tsx's interval, so calling getAccessToken() on 401 for a Steward user would be wrong. Gating on privyAuthenticated is correct.


Pricing Catalog Patches

Seedance parser
/\$([\d.]+)\/second/ is simple and sufficient. If fal.ai changes their copy, the DB fallback catches it.

DB fallback on HTML parse failure
Good resilience. One note: fetchFalCatalogEntries now issues DB queries inside the Promise.all fan-out — if all models fail simultaneously (e.g. fal.ai is unreachable), this will fire N DB queries in parallel. Acceptable for now, but consider a single bulk query by billing_source = 'fal' in the fallback path to reduce round trips.

Cache eviction
evictExpiredCacheEntries() runs on every cache-miss write. Expired entries are only cleaned up on the write path, not on reads — meaning a stale entry will accumulate until a miss triggers a new write. This is fine in practice since the TTL mechanism prevents stale entries from being served, and the in-memory map is small.

safeCost in /api/v1/pricing/summary
Per-model error isolation is correct. Empty pricing sections are omitted rather than returning null ranges. The warnings array in the response is a nice debugging affordance.

10% partial charge on failed video generation
The math (Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6) is correct. The guard if (reservation && quotedVideoCost) handles the case where calculateVideoGenerationCostFromCatalog threw before returning. One question: does the user get any notification they were charged for a failed generation? If the error response is a 500, they'll just see a failure with no explanation of the deduction.


Code Quality

Duplicate JWT decode logic
StewardProvider.tsx and use-session-auth.ts both implement identical base64-URL decode + exp check functions (~30 lines each). Extract to lib/steward-jwt.ts.

// lib/steward-jwt.ts
export function decodeStewardToken(token: string): { id: string; email: string; exp?: number; walletAddress?: string } | null { ... }
export function tokenSecsRemaining(token: string): number | null { ... }
export function tokenIsExpired(token: string): boolean { ... }

useSessionAuth polling timeout

const t = setTimeout(handler, 250);

This fires a localStorage re-read 250 ms after mount on every component that uses useSessionAuth. If many components mount simultaneously (e.g. dashboard load), these timers batch naturally, but it's worth noting this was a deliberate workaround for provider initialization lag. A comment explaining why would help future readers.

signInHref in cli-login/page.tsx

const signInHref = useMemo(() => {
    if (typeof window === "undefined") return "/login";
    ...
}, []);

Empty dependency array means this only computes once. window.location.search is read inside the memo with no reactive dep — correct for a one-shot value, but the SSR guard (typeof window === "undefined") is unnecessary in a "use client" component (Next.js client components still SSR their initial HTML, so the guard is actually correct). A comment clarifying this would prevent future removal.


Migrations

0064 (pricing catalog)IF NOT EXISTS added correctly ✓

0065 (is_public on generations)

  • ADD COLUMN ... DEFAULT false NOT NULL is safe on a non-empty table ✓
  • Partial index WHERE is_public = true is efficient (will be tiny since default is false) ✓
  • Index and column lack IF NOT EXISTS — inconsistent with 0064's pattern, but fine since this is a new column. Low risk.

Summary

Area Status
Privacy leak fix (explore gallery) ✓ Correct
Auth redirect loop ✓ Correct
TDZ fix in proxy.ts ✓ Correct
Pricing resilience patches ✓ Correct
steward-authed cookie durability ⚠ Minor drift risk
JWT token in OAuth callback URL ⚠ Brief exposure
Shared STEWARD_TOKEN_KEY constant ⚠ Duplication
JWT decode duplication ⚠ Refactor opportunity
Partial charge user notification ❓ Worth checking

The blocking items are low severity — recommend merging after confirming:

  1. The explore gallery has a sensible empty state while is_public = true images are zero.
  2. Users who get a partial charge on failed video generation see some feedback (even just a different error message).

Shadow reported still getting logged out of elizacloud. Traced the
bug: when the client-side refresh loop hasn't fired yet (e.g. came
back after server-rendered navigation with expired access cookie),
the middleware redirects to /login. The login page calls
`auth.getSession()` \u2014 which returns null if expired \u2014 and shows
the login UI even though a 30-day refresh token is still sitting in
localStorage.

Now the login page tries `auth.refreshSession()` before falling back
to the UI. If it succeeds, sets the cookie and redirects the user
back to where they were going. Fast-path for the "valid session"
case is preserved.

Co-authored-by: wakesync <shadow@shad0w.xyz>
@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Code Review

Overview

This PR bundles three scopes: Steward auth redirect loop fix (widespread useSessionAuth sweep), live AI pricing catalog (from PR #455 + patches), and privacy/security hardening. The actual hand-written code is under 1,000 lines; the 25k-line diff is Drizzle's auto-generated snapshot.


Bugs Fixed — Confirmed Good

  • TDZ bug in proxy.ts: bearerToken / authHeader / playwrightTestSession were referenced after the steward early-return but declared below it (const TDZ). Hoisting them above the early-return is correct.
  • Rules of Hooks violation: useStewardAuth() previously called useAuth() inside a try/catch, which conditionally invoked a hook. Using useContext(StewardAuthContext) directly fixes this properly.
  • Privacy data leak: listRandomPublicImages() was returning all users' images. Now correctly gates on is_public = true. This is the most important fix in the PR.

Issues

Duplicate JWT decoding logic

decodeStewardToken() in use-session-auth.ts and tokenIsExpired() / tokenSecsRemaining() in StewardProvider.tsx all do the same base64url-decode-and-JSON.parse of the JWT payload. Any change to the Steward token structure (new claim names, different encoding) must be updated in three places. These should be consolidated into a single parseStewardJwt(token) utility, ideally in a shared module both files import.

Double JSDoc block on useStewardAuth() (use-session-auth.ts ~line 26376)

There are two consecutive JSDoc blocks on the same function — an old one-liner that was left in alongside the replacement. The first block should be removed.

Explore gallery will be empty after deploy

listRandomPublicImages() now filters is_public = true, and is_public defaults to false. There is no UI or API endpoint that lets users mark a generation public, so the explore/discover gallery will render zero items until a follow-up ships that mechanism. This should be tracked.

steward-authed cookie rationale

The new non-httpOnly steward-authed=1 cookie lets client JS detect steward auth state without having to parse cookies manually. The value is just "1" so no token is exposed, but it does broadcast auth state to any script running on the page. The approach is reasonable given the constraint; a code comment explaining why the non-httpOnly flag is intentional (vs. an accidental security regression) would prevent future reviewers from questioning it.

Partial-charge arithmetic precision

Math.ceil(quotedVideoCost.totalCost * 0.1 * 1e6) / 1e6

This ceiling-rounds to 6 decimal places. When quotedVideoCost.totalCost is very small (e.g. a sub-cent model), floating-point multiply before Math.ceil can accumulate error. Using Math.round would be safer for edge cases, or storing costs as integer microdollars and doing integer arithmetic throughout. Not a production blocker, but worth noting.


Minor Notes

  • safeCost() wrapper in /api/v1/pricing/summary is clean. Per-model isolation is the right design.
  • Cache eviction on write for externalCatalogCache is correct; the prior unbounded growth was a latent memory leak in long-running processes.
  • DB fallback for fal.ai parse failures is a good resilience pattern, though it silently serves stale prices without surfacing staleness to callers.
  • The // eslint-disable-next-line react-hooks/exhaustive-deps on the callback-error useEffect in steward-login-section.tsx is well-justified by its comment.
  • Migration 0065 uses a partial index (WHERE is_public = true), which is efficient given the expected low selectivity.

Summary

The privacy fix and TDZ/hooks fixes are high-value. Main asks before merge: consolidate the duplicate JWT parsing into a shared utility, remove the stale JSDoc, and file a follow-up ticket for the explore gallery becoming empty post-deploy. Everything else is polish.

@lalalune lalalune merged commit 9ea79eb into develop Apr 17, 2026
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants