A directory of App Store apps whose revenue is read directly from their payment provider, never typed in by hand. Modelled on trustmrr.com, scoped entirely to iOS.
Every listing is an App Store app, so each one carries its real icon, rating, category, and version from Apple's public catalogue alongside verified MRR.
- Next.js 15 (App Router, RSC) + TypeScript
- Tailwind CSS v4 with a token system in
src/app/globals.css - Prettier (
npm run format) with the Tailwind class-sorting plugin - Supabase — Postgres, Auth (X + Google OAuth), RLS
- Drizzle ORM for queries and migrations
- Vercel for hosting and cron
npm install
cp .env.example .env.local # fill in the values, see below
npm run db:setup # migrate, apply policies, seed sample data
npm run devdb:setup runs three steps you can also run individually:
| Command | What it does |
|---|---|
npm run db:generate |
Generate a migration from src/db/schema.ts |
npm run db:migrate |
Apply migrations in supabase/migrations |
npm run db:policies |
Apply supabase/policies.sql — RLS, triggers, auth wiring. Idempotent; re-run after every migration |
npm run db:seed |
Insert sample apps and 180 days of revenue history |
See .env.example for the full list. Two are worth calling out:
CREDENTIALS_ENCRYPTION_KEY— 32 random bytes, base64. Encrypts every stored provider credential. Rotating it makes all of them unreadable, and founders must reconnect.CRON_SECRET— shared secret for the sync endpoints. Without it they'd be a public button for hammering provider APIs.
Generate both with:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"The app runs against plain Postgres for development — supabase/policies.sql
creates a minimal auth schema shim when it doesn't find Supabase's. Auth
itself still needs a real Supabase project; everything public works without one.
createdb trustmrr_dev
# DATABASE_URL=postgresql://you@localhost:5432/trustmrr_devDark only, monospace throughout — the interface is meant to read like a terminal, and tabular figures let every revenue column line up across rows.
Tokens live in src/app/globals.css and are exposed to Tailwind through
@theme inline. Use the token classes, never raw hex.
| Token | Class | Used for |
|---|---|---|
--bg #0a0a0a |
bg-bg |
Page background |
--surface #141414 |
bg-surface |
Cards, tables, panels |
--surface-2 #1c1c1c |
bg-surface-2 |
Hover and inset fills |
--border #262626 |
border-border |
Every divider and card edge |
--fg #ededed |
text-fg |
Primary text and figures |
--fg-muted #8f8f8f |
text-muted |
Secondary text |
--fg-dim #666 |
text-dim |
Labels, timestamps |
--accent #fafafa |
bg-accent |
The one primary action per view |
--green / --red |
text-green / text-red |
Direction of change, nothing else |
--blue |
text-blue |
Verified state and links |
--gold |
text-gold |
Status flags |
Colour is rationed on purpose: green always means "MRR is up on 30 days ago", so an app's own icon is the only other saturated thing in a row.
Two utility classes carry most of the typography:
.display— headline weight and tracking. Monospace at display size needs tighter tracking than its default..label— the 10px uppercase key that sits above every figure on the site.
Components worth reusing before writing new ones: Stat (components/ui/card)
for a label/value pair, AppRow + AppRowHeader for any table of apps,
AppCard + AppRail for a horizontal card rail.
- A founder pastes an App Store link. We fetch the public listing from Apple's iTunes lookup API and pre-fill the submission.
- They connect a read-only provider credential. We make one live call to confirm it works, and only store it if that call succeeds.
- The credential is encrypted with AES-256-GCM before it touches the database.
- A daily cron re-reads every active connection and appends a snapshot for that day.
app_metricsis rebuilt from those snapshots — every leaderboard and card reads that rollup, never the raw history.
An app is a private draft until a provider verifies it. Verification is the only thing that publishes a listing.
| Provider | Credential | Notes |
|---|---|---|
| RevenueCat | V2 secret key scoped to charts_metrics:overview:read, plus project ID |
Primary path. 25 req/min per key |
| App Store Connect | Issuer ID, key ID, .p8, vendor number |
Reports lag one day; UI shows a "data as of" date. The only source of daily installs — Apple's SALES report counts downloads, a payments ledger cannot |
| Stripe | Restricted key with read on Subscriptions | For apps that also bill on the web |
| Superwall | — | Not supported. Superwall issues public SDK keys only and publishes no metrics API, so there is no way to verify a figure through it |
Multiple providers on one app are summed once per day, never double-counted.
Every profile carries a 0–100 score for how well its App Store listing is built,
beside the revenue it earns. It is computed in src/lib/appstore/aso.ts from the
same daily lookup that refreshes the icon and rating, and stored on
app_store_metadata — no App Store Connect key, so the identical score can be
run for any app in the store.
| Signal | Weight | Measured against |
|---|---|---|
| Ratings | 25 | Average over a 3.5★ floor, plus volume on a log scale |
| Title | 20 | Use of the 30-character budget, and keywords past the brand name |
| Description | 15 | Length, the ~170 characters shown before "more", scannable structure |
| Screenshots | 15 | Count against the 5 that fill the gallery, out of Apple's cap of 10 |
| Update cadence | 15 | Days since the last release; full marks inside 30 |
| Icon & category | 10 | Icon present, primary genre set, second genre claimed |
It is listing quality, not rank. The subtitle, the 100-character keyword field, impressions, and install conversion are what actually decide search placement, and Apple publishes none of them. The panel says so on the page; calling this an "ASO rank" would be the same unverifiable claim about marketing that a typed-in MRR figure is about revenue.
The score appears once the metadata sync has run. npm run aso prints it for
every live app, and npm run aso -- --refresh re-reads Apple first rather than
waiting for the 04:30 cron.
Dodo Payments is the merchant of record for the two paid products:
| Product | Billing | Grants |
|---|---|---|
| Dofollow link | One-time | apps.website_dofollow, so the listing's website link drops rel="nofollow" |
| Sponsor slot | Monthly | A rotating slot in the side rails, using the app's own icon and tagline as the creative |
Both are bought from the app's edit screen, which is the only place that knows which listing a purchase belongs to.
Dodo replaced Paddle, whose live account never finished Paddle's own account review — checkout stayed dead with no ETA and no setting on our side to fix. Before that, Paddle had replaced Polar, which settles to sellers over Stripe Connect Express and does not support Georgia — the checkout worked, the payout did not. Dodo's payout eligibility for this seller's country was confirmed before switching.
Dodo hosts its own checkout page. createCheckout creates a checkout
session server-side and returns Dodo's checkout_url directly — callers just
redirect to it, no page of ours needs to draw a payment form.
Nothing is granted by the browser. The return URL proves nothing — anyone
can type it — so /api/webhooks/dodo is the only code that promotes a purchase
to active. It verifies Dodo's signature, and every handler is idempotent
because webhooks are delivered at least once. websiteDofollow is deliberately
absent from updateAppDetails, so no founder-facing form can set it.
Leave DODO_PAYMENTS_API_KEY or a product id unset and that product simply is
not offered — the UI says so rather than showing a button that throws.
To set it up:
- Create the two products in Dodo and copy their product ids (
pdt_…) intoDODO_PRODUCT_DOFOLLOWandDODO_PRODUCT_SPONSOR. The sponsor product must be a subscription;npm run dodo:productschecks that for you. - In Dodo's live mode, add a webhook endpoint pointing at
https://your-host/api/webhooks/dodo, subscribed topayment.succeeded,subscription.active,subscription.renewed,subscription.cancelled, andrefund.succeeded. Copy its signing key intoDODO_PAYMENTS_WEBHOOK_KEY. - In Dodo's test mode, add a second webhook endpoint the same way, but
pointed at
https://your-host/api/webhooks/dodo-teston the same deployment, and copy its signing key intoDODO_PAYMENTS_TEST_WEBHOOK_KEY. A live deployment can never verify a test-mode signature againstDODO_PAYMENTS_WEBHOOK_KEY, so test-mode purchases — including ones made fromlocalhost, which shares this database — need this second route and secret to still be granted automatically. - Keep
DODO_PAYMENTS_ENV=test_modelocally until you have tested end to end. Test mode is a separate catalogue — its keys, webhook secret and product ids do not work in live mode.
A webhook is a delivery, not a guarantee. If the endpoint is missing, misconfigured, or down when a payment succeeds, Dodo has nowhere to deliver to — and it retries only for a limited window, so that payment is stranded: charged, but never granted.
npm run dodo:reconcile asks Dodo the question the webhook would have
answered. For every purchase still pending it checks whether that checkout
session produced a completed payment, and grants what was bought:
npm run dodo:reconcile # report what would change
npm run dodo:reconcile -- --fix # apply itIt calls the same activatePurchase the webhook does rather than reimplementing
the grant, so the two cannot drift, and that function is idempotent — running it
repeatedly, or alongside a webhook that later arrives, is safe. Worth running
after any webhook outage, and worth checking if a founder reports paying for
something they did not receive.
Payouts. Dodo Payments is a merchant of record: it bills the customer, owns the tax, and pays the seller out on its own schedule. That is the reason it is here — the provider before it, Paddle, never finished its own account approval, and the one before that, Polar, settles over Stripe Connect Express, which does not cover Georgia.
Everything at /admin requires profiles.role = 'admin'. Roles are changed
only from the command line — there is no user-management screen, deliberately,
because handing out admin is rare and not worth a button that can be clicked by
mistake:
npm run role -- <handle> admin # promote
npm run role -- <handle> founder # demote
npm run role # usage, plus a list of handles and rolesThe change takes effect immediately: the role is read from the database on every request rather than baked into the session, so there is no need to sign in again.
The section is five screens:
| Screen | What it does |
|---|---|
| Overview | Counts, plus the two failures that are invisible on the public site: checkouts that never settled, and revenue connections that have stopped refreshing. |
| Apps | Every app including drafts and hidden ones. Search by name, slug, or founder handle. Turn a sponsor slot on or off per app, gift or withdraw a dofollow link, publish or hide a listing. |
| Purchases | The full ledger — paid and gifted, pending and revoked. Settle a stuck checkout or revoke a purchase after a refund. |
| Settings | How many sponsor slots exist to sell. Everything priced or worded stays in code, and the screen says which file. |
| Activity | The audit log of everything done from these screens. |
apps.is_verified is owned by the provider-connection flow: set when a founder
connects a source, cleared when they disconnect the last one. The admin screens
show it and never write it.
That is not squeamishness about a button. The public "Verified" badge is rendered
from the providers on app_metrics, not from this flag, so an admin toggle would
not have removed the badge it appeared to remove — it would only have changed the
/stats totals, silently. To take a listing down, use Hide, which does
exactly what it says.
A gift is written as a purchases row with source = 'admin', amount_cents = 0,
and no checkout id — not as a flipped flag on the app. So a gifted upgrade and a
bought one grant exactly the same thing through the same code, expire the same
way, and are withdrawn by the same path; source is the only thing that
separates them, which is what keeps the revenue figures honest.
Gifting a sponsor slot respects the slot cap, exactly as checkout does. If the rails are full, raise the count in Settings first — overselling would mean every paying sponsor gets less rotation than they bought.
Three rules, because a sponsor slot is a subscription and someone is paying for it:
An admin cannot switch off anything paid for. Not the sponsor slot, not the
dofollow link. Both end when Dodo says they end — subscription.cancelled and
a refund.succeeded event already withdraw them without anyone clicking
anything — so the button would exist only to make it possible to take away
something a founder is paying for. The Apps screen says who is paying instead
of offering a control.
revokeActivePurchasesForApp takes a source filter and the admin screens pass
'admin', so the restriction holds in the data layer rather than only in the UI.
A gift can only be given where nothing is already active. Gifting on top of a paid subscription is refused; there is nothing to give.
A founder can pay for a slot they were gifted, and the payment takes over.
Checkout treats an existing gift as an upgrade rather than a duplicate, and skips
the slot-cap check because the gift already occupies a slot. When the webhook
confirms payment, activatePurchase marks the gift superseded — not revoked,
since nothing was withdrawn; the founder started paying for what they had been
given. Without that the app would hold two live sponsor rows, appear in the rails
twice, and consume two of the slots on sale.
A superseded gift does not come back if the subscription later lapses. The slot then ends the way any sponsor's does, and an admin can gift again deliberately — a gift that silently resurrected months later would be impossible to reason about.
Slot counting and the rails both count distinct apps rather than purchase rows, so even the brief window where a gift and its replacement are both active cannot show one sponsor twice or report the rails as fuller than they are.
The number of slots on sale lives in site_settings, not in code, so it changes
without a deploy. src/lib/ads.ts holds only the value a fresh database starts
with; server code must read getSponsorSlots() instead. Lowering the count never
evicts a booked sponsor — it stops new checkouts and lets the number fall back as
slots lapse.
Every change made from these screens appends a row to admin_actions, with the
actor's handle snapshotted rather than joined so the entry still reads correctly
after an account is renamed or deleted. The log records before/after values,
because reversing a mistaken change by hand needs the exact previous value. There
is no way to edit or delete an entry from the site — that is the only thing that
makes it worth reading. npm run role writes to it too, as @cli.
revenue_connections holds provider credentials and has RLS enabled with no
policy at all. Anon and authenticated roles cannot read, insert, or update a
single row — including the founders who own them. Only server code holding the
service-role key can reach it. Do not add a policy to that table.
purchases, site_settings, and admin_actions are locked down the same way,
for the same reason: a client that could write to them could grant itself a paid
upgrade for free, put more inventory on sale, or erase the record of having done
either.
RLS decides which rows a client may write, not which columns — and
profiles_update_own / apps_update_own both grant a signed-in user write
access to their own row. Those rows hold the fields the site's claims rest on:
profiles.role, and apps.is_verified / apps.website_dofollow / apps.status
/ apps.founder_id. Without a further guard, anyone with the anon key and a
session could make themselves an admin, award their own app a verified badge, or
take the paid dofollow link for free — from a browser console, with no server
code involved.
supabase/policies.sql closes that two ways: column-level revoke update for
the anon and authenticated roles, and a deny_client_column_change trigger
that raises if a guarded column changes while current_user is one of those
roles. Server code connects as the database owner and is deliberately unaffected
— it checks the caller's admin role itself before granting anything.
Verify all of it at any time:
set role anon;
select count(*) from revenue_connections; -- must be 0
select count(*) from purchases; -- must be 0
select count(*) from site_settings; -- must be 0
select count(*) from admin_actions; -- must be 0
set role authenticated;
select set_config('request.jwt.claim.sub', '<a real profile id>', false);
update profiles set role = 'admin' where id = '<that id>'; -- must raise
update apps set is_verified = true where founder_id = '<that id>'; -- must raise
update apps set tagline = 'still editable' where founder_id = '<that id>'; -- must succeednpm test # unit tests: report parsers, MRR normalisation, crypto
npm run typecheck
npm run lint
npm run buildManual end-to-end check:
- Sign in, submit a real App Store URL, confirm the icon and metadata populate.
- Connect a RevenueCat key; confirm the app flips to live and a snapshot lands.
curl -H "Authorization: Bearer $CRON_SECRET" localhost:3000/api/cron/sync-revenuetwice — the snapshot count must not change (it upserts per day).- Enter a bad key; confirm a readable error and that existing snapshots survive.
src/
app/ routes; (public) pages use ISR, dashboard is dynamic
components/ UI — squircle icons, sparklines, sync tape
db/ Drizzle schema and client
lib/
appstore/ iTunes lookup client and URL parsing
providers/ one adapter per revenue provider, common interface
crypto/ credential encryption (server-only)
data/ queries and mutations
sync.ts the daily sync job
supabase/
migrations/ generated by drizzle-kit
policies.sql RLS, triggers, auth wiring — hand-written
scripts/ seed and policy-application CLIs
- Create a Supabase project; set the
NEXT_PUBLIC_SUPABASE_*, service role, andDATABASE_URL(transaction pooler) variables in Vercel. - Run
npm run db:migrate && npm run db:policiesagainst it. - Set
CREDENTIALS_ENCRYPTION_KEYandCRON_SECRET. - Deploy.
vercel.jsonregisters the daily revenue sync and the daily App Store metadata refresh.
Cron cadence is a plan limit, not a design choice. Vercel's Hobby plan only permits daily cron jobs and rejects a deployment whose schedule runs more often, so both jobs are daily and the site says "refreshed daily" everywhere.
Revenue genuinely wants to be read hourly. Two ways to get there: upgrade to Vercel Pro and restore
0 * * * *invercel.json, or leave the plan alone and call the endpoints from an external scheduler — they are plain authorisedGETs, so anything that can send a header works:curl -H "Authorization: Bearer $CRON_SECRET" \ https://your-domain/api/cron/sync-revenueWhichever you pick, the copy has to move with it. "Refreshed hourly" over a daily job is exactly the kind of unverifiable claim this site exists to replace.
Do not run npm run db:seed against production — it inserts fictional
revenue and refuses non-local databases unless forced.
The marketplace (buy/sell listings, asking price, buyer inquiries), the activity
feed, and co-founder matching. The schema reserves nullable columns on apps for
the marketplace phase.
There is no submission review queue: an app goes live when a provider connection
succeeds, not when someone approves it. /admin can hide a listing after the
fact, which is the moderation the site actually needs.