fix(advertising): enforce ad-account approval + campaign status gate (#11364) - #11516
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
[cloud-security] review — the PR correctly does what it claims (pending-by-default connect, admin-only approve/reject failing closed via requireAdmin, create/start gated before any credit charge; no conflict with the merged #11369 refund logic or open #11497). Two adjacent money gaps worth a fast-follow (not blocking this merge):
I'll ship these two as a fast-follow PR referencing this one. Money-path merge → @lalalune per charter. |
NubsCarson
left a comment
There was a problem hiding this comment.
[cloud-audit] COMMENT — approval gate verified correct; suspension enforcement is incomplete (non-blocking follow-up).
Invariant checked: a never-approved account can never spend or serve — HOLDS on every path I traced.
createCampaigngatesaccount.status !== "active"atpackages/cloud/shared/src/lib/services/advertising/index.ts:507before the firstcreditsService.deductCredits(:522) — fail-closed, no charge, no refund path needed.startCampaigngates at :808 beforeprovider.activateCampaign(:817).- Creatives transitively require a campaign of the same org (:1050-1052), and a campaign can only exist if the account was active at creation — so pending accounts cannot reach
createCreative's deduct either. requireAdmin(packages/cloud/shared/src/lib/auth/workers-hono-auth.ts:306-336) resolves platform admin viaadminService.getAdminStatusForUserand fails closed on lookup error — it is not an org-role check, so an org owner genuinely cannot self-approve. ✓- "Looks right but won't apply" class: clean.
statusis a plaintextcolumn and theAdAccountStatusunion already declaredpending/suspended— no enum/CHECK, no migration needed. The route file is already mounted (app.route("/api/v1/advertising/accounts/:id", …)in_router.generated.ts), and Hono.route()flattens sub-paths, soapp.post("/approve")servesPOST …/accounts/:id/approvewith:idaccessible (same param mechanics as the existingx402/requests/[id]/settleroute). No competing mount shadows it. - Tests exercise the real service with repository-boundary spies only; the 12 cases cover the state machine + both enforcement points including the disconnected status. ✓
Caveats (suspension direction — the approval gate is fine, but rejectAccount's claimed ToS-suspension coverage is partial):
rejectAccountdoesn't stop a running campaign (index.ts:273-287): it only flips account status. The account's already-active campaigns keep serving and spending — both on the external platform (never paused) and in the internal SSP, whose eligibility queryfindEligibleAd(packages/cloud/shared/src/db/repositories/ad-slots.ts:110-134) filters onlycampaign.status/creative.statuswith no join toad_accounts.status.updateCampaignhas no status gate (index.ts:670-717): a suspended account can still push a live budget increase (deduct +provider.updateCampaigngoes live). Not reachable from pending accounts, so not an approval bypass — but it lets a ToS-suspended advertiser escalate live spend.createCreative(index.ts:1049-1070) deducts credits with no account gate — same suspended-only reachability.
None of these lets an unapproved account spend, so I'm not blocking on them — but since the PR body claims reject "covers suspending an active account for ToS", please either add the status gate to updateCampaign/createCreative + account-status filter to findEligibleAd (and ideally pause active campaigns in rejectAccount) in this PR, or file a follow-up issue so the gap is tracked. Minor style note: sub-actions elsewhere use directories ([id]/start/route.ts); [id]/approve/route.ts would match convention, though the in-file registration does mount correctly.
Policy note for the merger: this makes new ad-account connection operator-gated (pending by default) — intended per #11364, flagged by the author, and consistent with the payout posture. Existing accounts are grandfathered active (no backfill), which is the sane default.
…11364) Ad spend is money movement, but any connected ad account was immediately active and campaigns never checked account status — a stolen/abusive account could spend with zero review, and a suspended account's campaigns could still be created/started (the declared pending/suspended states were never used). - connectAccount now creates accounts as 'pending' (was hardcoded 'active'). - approveAccount (pending->active) / rejectAccount (->suspended) — operator transitions, admin-gated at the route (requireAdmin), the same operator-executes posture as fiat payouts: an org owner can never self-approve their own ad account. - POST /api/v1/advertising/accounts/:id/approve|reject (requireAdmin). - createCampaign + startCampaign now refuse any account whose status !== active. Tests: 12/12 pass (bun test ad-account-approval.test.ts) — approval state machine (transitions, idempotency, non-pending guard, unknown account) and campaign spend blocked for pending/suspended/disconnected. Money-path — flagged for maintainer review; NOT self-merged. [cloud-security]
8025a17 to
a7f9057
Compare
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
lalalune
left a comment
There was a problem hiding this comment.
Reviewed after rebasing onto current origin/develop and pushed an additional API-level route test proving the admin gate.\n\nLocal verification after final push:\n- bun test src/lib/services/tests/ad-account-approval.test.ts (packages/cloud/shared): 12 pass / 0 fail\n- bun test tests/advertising-account-approval-route.test.ts (packages/cloud/api): 3 pass / 0 fail\n- bunx @biomejs/biome@2.5.2 check packages/cloud/api/v1/advertising/accounts/[id]/route.ts packages/cloud/api/tests/advertising-account-approval-route.test.ts packages/cloud/shared/src/lib/services/advertising/index.ts packages/cloud/shared/src/lib/services/tests/ad-account-approval.test.ts\n- bun run --cwd packages/cloud/shared typecheck && bun run --cwd packages/cloud/api typecheck\n- git diff --check origin/develop...HEAD\n\nBehavior reviewed: newly connected ad accounts start pending; only admin routes can approve/reject; create/start campaign reject pending/suspended/disconnected accounts before spend.
… suspended-account spend leg (#11364) (#11619) #11516 gated createCampaign and startCampaign on account.status === "active" but left updateCampaign ungated: a suspended (or still-pending) account could PATCH a campaign budget increase, which deducts credits and pushes the change live to the ad platform — the exact spend the approval workflow exists to stop. Add the same fail-closed gate after the account lookup in updateCampaign (mirrors the createCampaign/startCampaign wording), and extend the #11364 suite with updateCampaign-blocked tests across pending/suspended/disconnected. Verification: ad-account-approval.test.ts 15 pass / 0 fail with the gate; reverting the source change alone fails exactly the 3 new tests (proves the tests pin the hole). cloud/shared tsgo --noEmit clean. Co-authored-by: lalalune <shaw.nicola.walters@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Problem (#11364)
Ad spend is money movement, but the approval workflow + status field were entirely unenforced:
connectAccounthardcodedstatus: "active"— every connected account was immediately usable, no review.pending/suspendedstates were never set anywhere.createCampaignandstartCampaignnever checkedaccount.status— so a stolen/abusive account could spend with zero review, and even a suspended account's campaigns could be created/started.Fix
Mirrors the codebase's existing money-safety posture (fiat payouts/redemptions are
requireAdminoperator-executed steps — a user can never self-trigger money movement):connectAccountnow creates accountspending.approveAccount(pending→active) /rejectAccount(→suspended) service transitions — idempotent, with a guard that onlypendingcan be approved./api/v1/advertising/accounts/:id/approve|/reject— bothrequireAdmin, so an org owner can never self-approve their own ad account (platform operator executes).createCampaign+startCampaignnow refuse any account whosestatus !== "active".Evidence
bun test ad-account-approval.test.ts→ 12/12 pass, 0 fail:Tests the real
advertisingService; only the repository boundary is spied (nomock.module).Behavior-change note for reviewers
New ad accounts now require a platform operator to approve before running campaigns (default
pending). This is the intended anti-abuse posture per #11364, consistent with the admin-gated payout flow — but it does introduce an operator step. If you'd prefer to keepactive-by-default and only enforce the suspend path, that's a one-line change toconnectAccount; flagging the policy choice for your call.Money-path → please review + merge; not self-merged.
[cloud-security]