diff --git a/src/routes/founders.js b/src/routes/founders.js index 1bdbfed..695d215 100644 --- a/src/routes/founders.js +++ b/src/routes/founders.js @@ -1,20 +1,21 @@ /** * GET /api/founders/count — the REAL founder-seat counter. * - * TRUTH LAW (Truth-Everywhere Part 2, item 2): the ClaimMeter used to render a - * hardcoded "47 / 100 CLAIMED". This counts ONLY real paying founders — the - * Stripe-synced subscription records in Supabase (`user_profiles` where - * founder_pricing = true AND subscription_status = 'active'). Counting the - * webhook-synced mirror, not the Stripe API, is the cache — we never hammer - * Stripe. Cached 5 min in Redis on top of that. + * TRUTH LAW: the ClaimMeter used to render a hardcoded "47 / 100 CLAIMED", then + * a DB-profile count that showed a phantom "1" (a comped/manually-tiered account + * with founder_pricing=true but no real payment). Security follow-up item 0: the + * count is now Stripe's OWN truth — the number of ACTIVE subscriptions on a + * founder price (`stripeService.countFounderSeats`), verified against + * subscription status, never a tier/founder_pricing field a profile can set + * without paying. Cached 5 min in Redis so we never hammer the API. * - * If the source is unavailable (Supabase unconfigured, query error, column not - * migrated), we return { available: false } and the UI HIDES the counter — we - * never fall back to a number. A low real count is fine; the truth is the feature. + * If Stripe / the founder prices aren't configured, or any call fails, we return + * { available: false } and the UI HIDES the counter — never a fabricated number. + * A count of 0 is shown honestly; the truth is the feature. */ const express = require('express'); -const { getSupabaseServiceClient } = require('../utils/supabase'); +const stripeService = require('../services/stripeService'); const { cacheGet, cacheSet } = require('../utils/redis'); const router = express.Router(); @@ -23,18 +24,18 @@ const TOTAL = Number(process.env.FOUNDER_SEATS_TOTAL || 100); const CACHE_KEY = 'founders:count'; const CACHE_TTL = 300; // 5 min — the founder count moves slowly -// Injectable for tests (never hits network in the unit suite). -let _getClient = getSupabaseServiceClient; +// Injectable for tests (never hits the network in the unit suite). +let _countSeats = stripeService.countFounderSeats; let _cacheGet = cacheGet; let _cacheSet = cacheSet; -function __setDeps({ getClient, cacheGet: cg, cacheSet: cs } = {}) { - _getClient = getClient || getSupabaseServiceClient; +function __setDeps({ countSeats, cacheGet: cg, cacheSet: cs } = {}) { + _countSeats = countSeats || stripeService.countFounderSeats; _cacheGet = cg || cacheGet; _cacheSet = cs || cacheSet; } router.get('/count', async (req, res) => { - // Cache first — don't recount on every landing hit. + // Cache first — don't recount (a Stripe API call) on every landing hit. try { const cached = await _cacheGet(CACHE_KEY); if (cached && typeof cached.claimed === 'number') { @@ -42,22 +43,11 @@ router.get('/count', async (req, res) => { } } catch { /* cache miss/degraded — fall through to a live count */ } - let supabase; try { - supabase = _getClient(); - } catch { - return res.json({ available: false }); // unconfigured → hide, never a number - } - if (!supabase) return res.json({ available: false }); - - try { - const { count, error } = await supabase - .from('user_profiles') - .select('id', { count: 'exact', head: true }) - .eq('founder_pricing', true) - .eq('subscription_status', 'active'); - if (error) return res.json({ available: false }); // column missing / query error → hide - const claimed = Math.max(0, Number(count) || 0); + const count = await _countSeats(); + // null = Stripe / founder prices unconfigured → hide, never a number. + if (count == null || !Number.isFinite(Number(count))) return res.json({ available: false }); + const claimed = Math.max(0, Number(count)); try { await _cacheSet(CACHE_KEY, { claimed }, CACHE_TTL); } catch { /* best-effort */ } return res.json({ available: true, claimed, total: TOTAL }); } catch { diff --git a/src/services/stripeService.js b/src/services/stripeService.js index 78edb81..d1abbd5 100644 --- a/src/services/stripeService.js +++ b/src/services/stripeService.js @@ -284,6 +284,28 @@ function constructWebhookEvent(body, signature) { return getStripe().webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET); } +// Security follow-up item 0 — the REAL founder-seat count is the number of +// ACTIVE Stripe subscriptions on a founder price. It counts Stripe's OWN truth +// (subscription status on the live account), NOT a tier/founder_pricing field on +// a DB profile — a comped or manually-tiered account can set those without ever +// paying, which is exactly the phantom "1" the ClaimMeter was showing. Returns +// null when Stripe or the founder prices aren't configured → the counter hides. +async function countFounderSeats() { + const founderPrices = [PRICE_MAP.analyst_founder, PRICE_MAP.desk_founder].filter(Boolean); + if (!process.env.STRIPE_SECRET_KEY || founderPrices.length === 0) return null; + const stripe = getStripe(); + let count = 0; + for (const price of founderPrices) { + // Only 'active' (paid + current) subscriptions are a claimed paid seat — + // trialing / past_due / canceled are not. Auto-pages the full list. + // eslint-disable-next-line no-await-in-loop + for await (const _sub of stripe.subscriptions.list({ price, status: 'active', limit: 100 })) { + count += 1; + } + } + return count; +} + module.exports = { createCheckoutSession, handleWebhookEvent, @@ -292,6 +314,7 @@ module.exports = { constructWebhookEvent, isFounderCodeValid, getPriceId, + countFounderSeats, // Session 14 — exposed so the route layer + tests can recognize // the "tier valid but Stripe price not provisioned" state. PRICE_UNCONFIGURED, diff --git a/tests/integration/foundersRoute.test.js b/tests/integration/foundersRoute.test.js index af0b1f3..10cc552 100644 --- a/tests/integration/foundersRoute.test.js +++ b/tests/integration/foundersRoute.test.js @@ -1,7 +1,9 @@ 'use strict'; -// Item 2 (Truth-Everywhere Part 2) — the founder-seat counter is REAL or hidden, -// never a fabricated "47 / 100". Redis mocked; the Supabase client injected. +// Founder-seat counter — the count is REAL active Stripe subscriptions or hidden, +// never a fabricated number and never a DB tier/founder_pricing field a comped +// profile can set without paying (security follow-up item 0). Redis mocked; the +// Stripe seat-count injected. const request = require('supertest'); @@ -17,60 +19,41 @@ jest.mock('../../src/utils/redis', () => ({ const app = require('../../src/app'); const foundersRouter = require('../../src/routes/founders'); -// A fake Supabase query builder that resolves to a fixed count/error. -function fakeClient({ count = 0, error = null } = {}) { - const b = { - from() { return b; }, - select() { return b; }, - eq() { return b; }, - then(resolve) { return Promise.resolve({ count, error }).then(resolve); }, - }; - return b; -} - beforeEach(() => { mockStore = {}; foundersRouter.__setDeps({}); }); describe('GET /api/founders/count', () => { - test('real active-founder count → { available, claimed, total }', async () => { - foundersRouter.__setDeps({ getClient: () => fakeClient({ count: 3 }) }); + test('real active Stripe founder subs → { available, claimed, total }', async () => { + foundersRouter.__setDeps({ countSeats: async () => 3 }); const res = await request(app).get('/api/founders/count'); expect(res.status).toBe(200); - expect(res.body.available).toBe(true); - expect(res.body.claimed).toBe(3); - expect(res.body.total).toBe(100); + expect(res.body).toEqual({ available: true, claimed: 3, total: 100 }); }); - test('a low real count is shown honestly (no fabricated floor)', async () => { - foundersRouter.__setDeps({ getClient: () => fakeClient({ count: 0 }) }); + test('ZERO real paid subs is shown honestly (a comped profile does not count)', async () => { + foundersRouter.__setDeps({ countSeats: async () => 0 }); const res = await request(app).get('/api/founders/count'); expect(res.body).toEqual({ available: true, claimed: 0, total: 100 }); }); - test('query error (column missing) → available:false, NEVER a number', async () => { - foundersRouter.__setDeps({ getClient: () => fakeClient({ error: { message: 'no column' } }) }); + test('Stripe / founder prices unconfigured (null) → available:false, hidden', async () => { + foundersRouter.__setDeps({ countSeats: async () => null }); const res = await request(app).get('/api/founders/count'); expect(res.body).toEqual({ available: false }); expect(res.body).not.toHaveProperty('claimed'); }); - test('unconfigured source (no client) → available:false, hidden', async () => { - foundersRouter.__setDeps({ getClient: () => null }); + test('Stripe throws → available:false, never fabricates', async () => { + foundersRouter.__setDeps({ countSeats: async () => { throw new Error('stripe down'); } }); const res = await request(app).get('/api/founders/count'); expect(res.body).toEqual({ available: false }); }); - test('client throws → available:false, never fabricates', async () => { - foundersRouter.__setDeps({ getClient: () => { throw new Error('down'); } }); - const res = await request(app).get('/api/founders/count'); - expect(res.body).toEqual({ available: false }); - }); - - test('served from cache when warm (no client call)', async () => { + test('served from cache when warm (no Stripe call)', async () => { mockStore['founders:count'] = { claimed: 7 }; - let clientCalled = false; - foundersRouter.__setDeps({ getClient: () => { clientCalled = true; return fakeClient({ count: 999 }); } }); + let called = false; + foundersRouter.__setDeps({ countSeats: async () => { called = true; return 999; } }); const res = await request(app).get('/api/founders/count'); expect(res.body).toEqual({ available: true, claimed: 7, total: 100 }); - expect(clientCalled).toBe(false); + expect(called).toBe(false); }); });