Skip to content

feat(monetization): debit org credit balance + rebuild app-auth consent flow#474

Merged
lalalune merged 34 commits intodevelopfrom
feat/org-balance-monetization-and-app-auth-rebuild
Apr 27, 2026
Merged

feat(monetization): debit org credit balance + rebuild app-auth consent flow#474
lalalune merged 34 commits intodevelopfrom
feat/org-balance-monetization-and-app-auth-rebuild

Conversation

@NubsCarson
Copy link
Copy Markdown
Contributor

Re-opened from #473 after a branch-rename closed the original. Same commits, same content.

TL;DR

Two intertwined cleanups for the app-monetization story plus the OAuth consent flow:

  1. Reworks appCreditsService so monetized apps debit the user's organization credit balance instead of the per-app app_credit_balances pool. Any signed-in cloud user can use any monetized app without first topping up a per-app pot. Creator markup share + redeemable_earnings flow still fires through recordCreatorEarnings().
  2. Rebuilds /app-auth/authorize UX. Old code auto-redirected on isAuthenticated and bailed silently when useAuth().user hadn't hydrated, which left users staring at a Cancel-only screen. New flow shows an explicit "Authorize " button and a checkmark "Signed in" status. Standard OAuth consent UX, no silent failure modes.

Plus the smaller fixes that fell out of testing:

  • COOP same-origin-allow-popups on /app-auth/* so OAuth popups can call window.closed without throwing.
  • Connect-src CSP allows localhost:3200 when NEXT_PUBLIC_STEWARD_AUTH_ENABLED=true (so a local Steward dev server is reachable from a browser running against next start).
  • Drop LandingHeader from the consent screen — its server/client auth-state mismatch ("Log in" vs "Dashboard") was throwing a hydration error that remounted the React tree mid-flight and prevented validateApp's effect from completing.
  • Drop the dead <video src="/videos/Hero...mp4"> element (asset isn't checked in; was 404'ing in 60s+ on every page load).
  • Truncate the user-menu dropdown email so long addresses stop overflowing the w-56 container.

Why this matters

The org-balance rework is the load-bearing piece. Before this PR a user had to discover per-app credits as a separate concept, top up each app individually, and the in-app deduct path went through a different service from the rest of cloud's billing. After this PR, the marketplace promise is honest: one balance, any monetized app, creator + affiliate still earn their cut.

What's verified

End-to-end against a local cloud-fork + Steward + agent-home eDad reference app:

```
ORG balance: 999.999147 → 999.999467 (debited $0.000277 = base + 100% markup)
APP pool: 100.04 → 100.04 (untouched ✅ — no per-app deduction)
CREATOR earnings: 0.000000 → 0.000229 (creator markup share)
PLATFORM revenue: 0.000000 → 0.000229 (base cost)
REDEEMABLE_EARNINGS: 0 → 0.0002 (creator's withdrawable balance)
```

New-user JIT sync also exercised: brand-new Steward JWT → syncUserFromSteward provisions cloud user + org with $5 welcome credit → chat charges $0.000588 → creator earnings tick up. Same pattern as existing user.

Migration notes for redeploy

  • No DB migration. The existing app_credit_balances table is left in place — no data loss, just no longer the deduction target. Per-app pools you've sold remain readable; new chats just don't decrement them.
  • No env changes required. All existing config still works.
  • App-creator UI changes (the per-app credit pool view in the dashboard) may now show stale values vs. activity. Worth a follow-up to repurpose that view as "earnings from this app" instead.
  • Pricing math is unchanged: baseCost * (1 + inference_markup_percentage / 100) debited atomically from organizations.credit_balance via creditsService.reserveAndDeductCredits (same row-lock pattern used by every other org-balance debit in cloud).

Code-quality notes

  • Audited per the 5-rule no-slop pass: dropped redundant intermediate variables, inlined single-call helpers, removed defensive fallbacks for cases that can't happen.
  • All new auth-page components are local to authorize-content.tsx (Frame, AppHeader, PermissionsList, SignedInActions, SignedOutActions) — no new shared abstractions.

File-by-file

  • packages/lib/services/app-credits.ts — checkBalance / deductCredits / reconcileCredits all reach into organizations.credit_balance via creditsService. Refund path uses refundCredits.
  • app/api/v1/messages/route.ts — for the app-credits flow, estimatedBaseCost is now 0 so reconcile charges the full actual cost as the diff (the anonymous reservation never debited upfront).
  • packages/ui/src/components/auth/authorize-content.tsx — full rewrite of the consent screen.
  • packages/ui/src/components/layout/user-menu.tsxtruncate + min-w-0 + title=... on the email row.
  • next.config.ts — COOP for /app-auth/*, CSP gate for local Steward.

What I want maintainer eyes on

  1. The org-balance debit semantics in reconcileCredits — I assume the messages-route flow is the only caller that uses estimatedBaseCost: 0. If anything else still passes a real estimate, the "diff" math will under-charge.
  2. Whether the per-app credit pool concept should be removed entirely (deprecate app_credit_balances table, drop the dashboard UI for it, drop processPurchase for app credits) in a follow-up. This PR keeps it readable but inert.
  3. The force-dynamic page /app-auth/authorize is heavier in dev-mode webpack compile after this rewrite (it pulls <StewardLogin> from @stwd/react) — if Vercel cold-starts feel slow, consider generateStaticParams or splitting StewardLogin to client-only chunk.

Test plan

  • Sign in to https://app.elizacloud.ai/app-auth/authorize via passkey/email/OAuth — should reach explicit "Authorize " + Cancel
  • Click Authorize — redirect back to app's redirect_uri with token+state
  • Send a message via a monetized app — confirm organizations.credit_balance decrements by base*(1+markup)
  • Confirm apps.total_creator_earnings and the creator's redeemable_earnings rise by base*markup
  • Confirm an unrelated user's app_credit_balances row for the same app stays untouched

The OAuth popup-callback flow opens a provider popup, polls
window.closed to detect completion or cancellation, and then exchanges
the returned code/token. Default Cross-Origin-Opener-Policy of
'same-origin' (or any CORP-isolated default) makes that closed-check
throw, so the parent window never notices the popup finishing and the
user is stuck in a re-prompt loop.

'same-origin-allow-popups' keeps the page cross-origin-isolated for
its own resources but exempts popups it opened, which is the contract
the Steward + Privy SDKs are coded against.

Scoped to /app-auth/* so dashboard / app pages keep the stricter
default. No other change to the existing CSP/security header set.
reworks AppCreditsService.checkBalance / deductCredits / reconcileCredits
to read and write organizations.credit_balance via creditsService instead
of the per-app app_credit_balances table. signed-in cloud users can now
spend their org balance on any monetized app without pre-purchasing a
per-app pool. app developers still earn the markup % via the existing
recordCreatorEarnings -> redeemableEarnings path.

messages route now passes estimatedBaseCost=0 so reconcile charges the
full actual cost as the diff (the anonymous reservation never debited
upfront, so there is nothing to refund).

verified against local cloud: chat call dropped org balance from
1000.000000 to 999.999744 (actual 0.000256), per-app pool unchanged,
and a "App reconciliation charge (eDad)" debit row landed in
credit_transactions.
…e user menu

prior auth flow auto-redirected on isAuthenticated and bailed silently
when useAuth().user was null (session loaded but user record not yet
hydrated). result: signed-in users saw only a Cancel button with no way
to continue. fixed by switching to standard oauth consent ux:
- signed out: <StewardLogin> form + Cancel
- signed in:  "Signed in as <email>" + "Authorize <app>" + Cancel

also fetches the email from /api/v1/user when the steward jwt omits it,
so the consent label shows the real address instead of "your account".

user dropdown in the top-right was overflowing on long emails; added
truncate + min-w-0 + title for hover-to-see-full so it stays inside the
w-56 menu.

minor: dropped a redundant intermediate `result` var in
deductCredits — references orgDeduct.newBalance directly.
- inline single-call-site redirectWithError helper into handleCancel
- drop "wide" prop from Frame (only ever passed one value)
- narrow AppHeader's appInfo to non-null via early-return guard;
  remove the impossible "?? "A"" / "?? "Application"" / "App logo"
  defensive fallbacks (AppInfo.name is required by the cloud schema)

no behaviour change. types tightened, dead defensive code removed.
…ader

CSP previously only included loopback origins (localhost:3000/3200) when
NODE_ENV=development. Running `next start` against a local Steward
instance with NEXT_PUBLIC_STEWARD_AUTH_ENABLED=true sets NODE_ENV to
production, so the CSP stripped the Steward origin and the browser
refused @stwd/react's fetch to localhost:3200/auth/providers — the
StewardLogin form rendered blank. Widen the gate to also include local
loopback when STEWARD_AUTH_ENABLED is true.

Authorize page: dropped LandingHeader. The header renders different
markup on server vs client based on auth state ("Log in" vs "Dashboard"),
which threw a React hydration error and remounted the whole tree —
that prevented validateApp's effect from completing, leaving the page
stuck on "Verifying application...". Standard OAuth consent UIs
(Google, GitHub) keep this screen header-less for the same reason.

Also dropped a dead <video> tag pointing at an asset not in the repo
(was 404'ing in 60s+ on every page load) and inlined its 1-line
wrapper, replaced with a static gradient.
Re-add the /videos/Hero* asset on the OAuth consent screen layered over
the existing gradient. Browsers fire onError on 404, which we use to
display:none the <video> element so the gradient underneath stays
visible. Net effect:

- prod (asset present): plays the hero loop, same as before
- local / self-hosted (asset missing): silently falls back to gradient

No conditional fetch or env check — the browser's own load behaviour
decides at runtime, so the same component works for both deploys.
Removed two non-essential pieces from the consent screen:

1. The email-fetch effect + accountEmail state. We were chasing
   `user.email` from useAuth, then falling back to a /api/v1/user
   round-trip when the Steward session didn't surface it. The label
   now just reads "Signed in" with the green check — same intent,
   no network dependency, no slow path on cloud cold-start.

2. The <video> hero element. Local dev doesn't ship the asset and
   the resulting 404 was adding noticeable load time. Solid gradient
   stays.

Net: -55 lines, simpler component, faster page.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 26, 2026

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

Project Deployment Actions Updated (UTC)
eliza-cloud-v2 Error Error Apr 27, 2026 1:55am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 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: 39c6c8b6-ceb9-4b61-8616-56d32de6cfec

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 feat/org-balance-monetization-and-app-auth-rebuild

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.

@NubsCarson
Copy link
Copy Markdown
Contributor Author

Follow-up idea (out of scope for this PR)

While testing this in dev, the browser console was noisy with:

GET https://auth.privy.io/api/v1/apps/cm00000000000000000000000 400 (Bad Request)
PrivyApiError: Invalid Privy app ID

This happens because PrivyProvider.tsx always wraps children in <PrivyProviderReactAuth> (with a dummy app ID when Privy isn't configured) so child components calling usePrivy() get a valid context. With NEXT_PUBLIC_STEWARD_AUTH_ENABLED=true, that dummy init is pure noise — no real Privy traffic, but a 400 fires on every page load.

Idea: when STEWARD_AUTH_ENABLED is true, skip the Privy provider wrap entirely and provide a minimal mock context that returns sensible defaults for the existing usePrivy() callers (8 files: app/page.tsx, app/login/privy-login-section.tsx, app/auth/error/page.tsx, packages/ui/src/components/settings/crypto-payment-modal.tsx, packages/ui/src/components/layout/user-menu.tsx, packages/ui/src/components/settings/tabs/account-tab.tsx, packages/ui/src/components/auth/cli-login-content.tsx, packages/ui/src/components/chat/email-capture-modal.tsx).

Why I didn't do it in this PR: safe execution requires per-component verification that each usePrivy() callsite tolerates the mock (most check authenticated/user, but a few call login()/logout() which would need to no-op cleanly). That's a separate ~1-2hr investigation worth its own focused PR.

Just flagging in case a maintainer wants to take it on or batch it with another auth-cleanup pass.

@lalalune lalalune merged commit a6e247e into develop Apr 27, 2026
3 of 4 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.

3 participants