Building Dictare in Public: Week 5 — I Almost Shipped a Security Hole (And a Broken Business Model)

There are weeks where you make visible progress — you ship a feature, close a punch list item, feel the momentum. And then there are weeks like this one, where you spend three days staring at things that were already built and discovering that some of them were quietly wrong.

This was that kind of week. By the end of it, Dictare is in better shape than it’s ever been. But getting here involved finding out that I’d nearly launched with broken economics and a backend vulnerability that two code reviews had walked right past.

The Moment I Realised the Math Didn’t Work

It started with a cost model. Before launch, I needed to actually sit down and work out whether Dictare made money — not “in theory it should be fine” but “here are the real API costs per user per month, here is the subscription price, here is the margin.”

I ran the numbers. The results were not encouraging.

A moderate user — someone translating for 15 minutes a day — was costing around $12.90 per month in API costs. The Pro subscription was $7.99. That’s a $5 loss per paying customer before a single penny of infrastructure overhead.

The culprit was DeepL. Translation is expensive, DeepL charges per character, and a moderate translator burns through roughly 270,000 characters a month. On the plan I’d been assuming ($5.49 base + $25 per million characters), translate was 78% of the total API cost per user.

I spent a while sitting with that number before I found the silver lining.

DeepL’s pricing had quietly changed. The $5.49 API Pro plan is no longer available to new customers — the only upgrade now is their Growth tier at €29.75/month. That sounds worse. It’s actually much better. The Growth plan includes 1 million characters per month in the flat fee. At launch volume — where I’d have a handful of users, not hundreds — that 1 million character pool is shared across the whole user base, and marginal cost per user drops to near zero.

The economics only break when aggregate translation exceeds 1 million characters a month, which is roughly 2-3 moderate translators. That’s a problem for when there are 2-3 moderate translators, not before launch.

I also made pricing decisions I’d been avoiding. Two tiers: Base at $7.99/month (dictate + polish, effectively unlimited), Pro at $14.99/month (adds translation, capped at 300 minutes per month). Top-ups at $5.99 for 100 minutes, $12.99 for 250. Both tiers are margin-positive from the first subscriber. The Founding Member tier ($4.99 locked for life with translation) got dropped — it was an unfixable loss leader.

Break-even: approximately 27 paying subscribers covers all fixed costs including the tools I use to build the thing.

Two Code Reviews Missed What the QA Agent Caught in 30 Seconds

While I was doing the cost model work, I also ran a proper security audit. Pre-launch, with a paid consumer app touching Stripe payments, Supabase auth, microphone access, and clipboard paste-at-cursor — a formal review was non-negotiable.

The audit found eight issues. Three were critical.

The most embarrassing one was C2: privilege escalation via the quota RPCs.

The way the usage tracking works, there are database functions that increment counters — trial seconds used, translation characters used. These functions are marked SECURITY DEFINER, meaning they run with elevated permissions. The intention was that only the server side could call them.

What actually happened: PostgreSQL’s default grants gave the `anon` role execute permission on every new function in the public schema. The anon key ships inside the app. Anyone who decompiled the binary had what they needed to make direct API calls and set their own trial counter to any value they wanted — including negative, which would give them effectively unlimited trial time.

I had a static reviewer agent look at this code. It missed it. I had a second pass. That missed it too. The fix I wrote also had a subtle gap — I revoked the anon and authenticated roles, but not the PUBLIC grant, and Supabase’s default grants mean role-specific grants survive even after you revoke from PUBLIC.

The adversarial QA agent caught it. Not by reading the code. By actually running the exploit.

It fired 100 concurrent requests at the quota RPC with a negative delta. Every single one went through. The victim user’s trial counter dropped to -99,940. Clean HTTP 204 response. No errors.

The lesson I’m carrying forward: static review is necessary but not sufficient for anything touching permissions or concurrency. You have to run the attack, not just look at the code.

The fix — revoking from `public, anon, authenticated` explicitly, then granting only to `service_role` — is four SQL words longer than what I’d written before. The difference between “looks right” and “is right” was four words.

All eight issues are now fixed, deployed, and smoke-tested against the live backend. Real voice-to-text, real voice-to-translate, both working.

Where Things Stand

The punch list is shorter than it’s been. The economics are confirmed-positive. The security surface is tighter than most apps that ship without an audit at all.

What remains before launch: the DeepL upgrade (deferred until there are real translating users — no point paying €30/month before anyone has signed up), a Stripe dummy run to verify the full billing flow end-to-end, and the marketing work I keep calling Phase 8 without actually starting.

That last one is next.

This Week in Numbers

  • Security findings: 8 (3 critical, 5 important), all resolved
  • Lines of code reviewed by two separate AI agents without catching C2: several hundred
  • Seconds for the adversarial QA to catch it: roughly 30
  • Target paying subscribers to break even: 27
  • Times I redid the DeepL cost model before the numbers made sense: 3
  • — Badger

    Badger is building an AI-powered side income in public. No gurus, no fluff — just honest accounts of what’s working, what isn’t, and what it’s actually costing.

    Leave a Comment

    Your email address will not be published. Required fields are marked *

    Scroll to Top