Skip to content

D614: deal-room join checks the deal, a Free investor can leave, co-invest is Institutional, create refuses an unknown status (#1307) - #1325

Merged
guillaumelauzier merged 1 commit into
mainfrom
agent/claude-b/1307-dealroom-gates
Oct 6, 2026
Merged

guillaumelauzier merged 1 commit into
mainfrom
agent/claude-b/1307-dealroom-gates

Conversation

@guillaumelauzier

Copy link
Copy Markdown
Contributor

Objective

This fixes four gates on deal rooms and co-invest, found by #1165's audit (area 6). Decision D614. No migration.

Closes #1307

Implementation

  1. Join. POST /api/deals/:id/dealroom/join now reads the deal first. If no deal has that id, it answers 404 deal_not_found and writes nothing. Before, a bad id inserted a membership row that counted against investor_dealroom_max.
  2. Leave. The Professional gate in index.ts (INVESTOR_PRO_PREFIXES) is now requireInvestorTierExcept('professional'). It skips the requests listed in INVESTOR_PRO_EXEMPT, which has one entry: DELETE /api/deals/:id/dealroom/leave.
    • Listing deals, joining a room, and any other method on the leave path stay Professional.
    • To check: the exemption lives in middleware/requireInvestorTier.ts, beside the gate it wraps. That file is not on the issue's owned list. Putting it there lets the test mount the gate exactly as index.ts does. index.ts itself changes only the gate's mount lines.
  3. Co-invest. GET /api/funds/syndication now calls userMeetsInvestorTier(user, 'institutional'), the same check requireInvestorTier makes, and refuses with investorUpsell('institutional').
    • That 402 body (error: 'investor_tier_required', required: 'institutional') is the one PaywallModal already opens on.
    • Admins and partners pass. A founder is still refused by role first (D370).
  4. Create. POST /api/deals refuses a status outside DEAL_STATUSES with a 400 unknown_status and an allowed list.
    • DEAL_STATUSES is the list PUT /:id already validates against, and the test checks it equals the CHECK in schema_baseline.sql.
    • No status, or an empty one, still defaults to applied.

Left open, on purpose. POST /api/deals still accepts rejected, because the table allows it and the issue asked only to refuse unknown statuses. /draft and PUT /:id both send a pass through POST /:id/pass, which requires a reason (task #127). Whether create should do the same is a separate call. D614 records it as not changed.

Files changed

  • cloudflare-worker/src/routes/deals.ts: the join deal check; the create status check
  • cloudflare-worker/src/index.ts: the investor gate's mounts use requireInvestorTierExcept
  • cloudflare-worker/src/middleware/requireInvestorTier.ts: INVESTOR_PRO_EXEMPT, requireInvestorTierExcept
  • cloudflare-worker/src/routes/funds.ts: the syndication tier check
  • cloudflare-worker/test/dealroom_coinvest_gates_d614.test.ts: new
  • cloudflare-worker/test/lp_money_authz_d370.test.ts: its free-tier LP now expects the 402; the listings are read by the Institutional GP
  • documentation/architecture/decisions/D614.md

Testing

  • npm run build: exit 0.
  • npm run test:drift > drift.log 2>&1; echo EXIT=$?: EXIT=0.
  • New test (5 tests, real node:sqlite). Each gate is asserted from both sides:
    • join: an unknown deal is refused and no quota slot is used; the next real join counts as the first;
    • leave: a Free investor can leave through the gate mounted as index.ts mounts it, and is still refused listing, joining and a POST to the leave path;
    • co-invest: 402 with required: 'institutional' for Free and Professional investors; 200 for Institutional, admin and partner; 403 for a founder;
    • create: a bad status is a 400 that writes nothing, its allowed equals the baseline CHECK, and an allowed or missing status still creates.
  • 13 mutations, all caught, across deals.ts, funds.ts, the middleware and index.ts.
  • On main, the new test fails.

Risks

  • Ships to production: the Worker. No migration, no wrangler.toml change, no frontend change.
  • Who loses access. Professional and Free investors lose co-invest listings, which matches what the pricing page sells. The client already handles this 402.
  • Who gains access. Free investors can call one more route: leaving a room they are in. That route only deletes the caller's own membership row.
  • Security: no secret is in the diff. Auth is unchanged: every route still calls requireAuth.
  • Rollback: revert the PR.

Dependencies

Found by #1165. Touches D370's syndication route (its role gate is unchanged).

Agent

S07 · Claude Code

Review requested

guillaumelauzier (owner)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q6MrbLAt5ETsDnEUFNzVSQ


Generated by Claude Code

…nvest is Institutional, create refuses an unknown status (#1307)

- POST /api/deals/:id/dealroom/join answers 404 deal_not_found for a
  deal that does not exist, and writes no membership, so no quota slot
  is used.
- The Professional gate on /api/deals/* (and the other investor-pro
  prefixes) is now requireInvestorTierExcept('professional'), which
  skips INVESTOR_PRO_EXEMPT: DELETE /api/deals/:id/dealroom/leave only.
- GET /api/funds/syndication requires Institutional for investors, with
  the paywall's own 402 body (required: 'institutional'); admins and
  partners pass.
- POST /api/deals refuses a status outside DEAL_STATUSES with a 400 that
  lists the allowed ones, instead of a 500 from the CHECK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6MrbLAt5ETsDnEUFNzVSQ
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Preview: https://studioos-pr-1325.guillaumelauzier.workers.dev (built from d15c12c)

The pull request's SPA build on a Worker with no bindings: pages and deep links work, /api/* is a 404, and nothing here can reach production data. Redeployed on every push; deleted when the PR closes.

@guillaumelauzier
guillaumelauzier marked this pull request as ready for review October 6, 2026 23:20
@guillaumelauzier
guillaumelauzier merged commit 4c1e7f8 into main Oct 6, 2026
21 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.

Deal rooms and co-invest: join checks the deal, a Free investor can still leave, and co-invest is Institutional as the pricing page says (D614)

2 participants