op-q

Software Engineer

Case study

Ponchii — every story deserves a world

A creator-first storytelling and worldbuilding platform. Creators build a project, fill it with stories and a worldbook, publish entries, and sell monthly access. Readers subscribe, read, and talk about it in chapter chat. A Next.js web app, a read-only Expo mobile app, and a standalone Rust chat service all sit on top of one Postgres database that stays the source of truth for access and money.

project → story → entry core loop block-based entry editor + asset library worldbook + story hub EUR subscriptions via Stripe Connect layered signup abuse protection SSR + SEO with a protection boundary five-surface CI pipeline Rust chat service (standalone) chat wired into the web app text-to-speech service
01

The product


The idea behind Ponchii is that creators aren't publishing isolated chapters — they're building worlds. Stories, characters, lore, timelines, reader community, and eventually products all orbit one creative world, and readers should feel like they're entering a living space rather than opening another content page.

creator loop

Create a world, write stories and entries, build lore in the worldbook, launch paid access, and choose which lore appears on the reader-facing story hub.

reader loop

Discover a world, read in a cinematic reader, subscribe when access is paid, explore the connected lore, and react in chapter chat.

launch wedge

Independent manga, webcomic, and illustrated serial creators — stories where chapters, lore, community, and membership genuinely benefit from living in one place.

The long-term platform is multi-format — text, comics, audio, interactive stories — but v1 is deliberately about making this one loop feel real and trustworthy end to end.

02

System boundary


apps/web is the product: the entry editor and viewer, the worldbook, the story hub, every Stripe route, and Supabase Auth. apps/mobile is a reader — it can show a library and already-purchased access, but by product rule it can never start a payment, subscription, or payout flow; that stays on ponchii.com under App Store and Play policy. services/chat is a separate Rust process for chapter chat, reachable only with a token the web app issues after checking access. Everything durable — projects, stories, entries, worldbook content, access state, chat history — lives in one Supabase Postgres database.

flowchart LR
    R["Reader / creator browser"] --> WEB["Next.js web app\napps/web · Vercel"]
    M["Mobile app\napps/mobile · Expo\nread-only, no payments"] --> WEB
    WEB --> PG[("Supabase Postgres\nprojects, stories, entries,\nworldbook, access, billing")]
    WEB --> SBA["Supabase Auth"]
    WEB --> ST["Stripe\nConnect + subscriptions"]
    WEB --> AB["CAPTCHA +\nlayered rate limits"]
    WEB -->|signed access token| CHAT["Chat service\nservices/chat · Fly.io"]
    CHAT --> PG
    CHAT -.-> RD[("Redis\npub/sub fanout")]
    EDGE["Scheduled functions\ncleanups + ops digest"] --> PG
    EDGE --> MAIL["Transactional email"]
    TTS["services/tts\nplanned"] -.-> WEB
    classDef store fill:#1f6f8b,stroke:#14526a,color:#fff
    classDef planned stroke-dasharray: 4 3
    class PG store
    class RD,TTS planned
    

Dashed nodes are implemented but not yet load-bearing, or not built yet.

03

The core domain records


The product's core loop is four concepts deep, and almost everything else hangs off them.

project

A creator's world. Owns members, privacy defaults, and the design/layout config for its public page.

story

Lives inside a project. Carries its own access, subscription price tier, and launch/monetization state.

entry

What a reader actually reads — chapters built from text, image, and scene blocks in the entry editor.

worldbook entry

The creator's private lore and timeline notes. Linked to specific stories only when marked to appear on the story hub.

04

The creative surface: editor, viewer, assets


The entry editor is the emotional center of the product — it has to feel like a creative tool, not a form. Entries are block-based: rich text (built on a vendored ProseMirror/tiptap core), images, and visual scene blocks, with a creator mode and a reader mode rendering from the same content so the preview a creator trusts is the page a reader gets. The editor carries the details a real writing tool needs — keyboard shortcuts, calm autosave, and block actions that stay out of the way until hovered — because the product pillar is that creators should never feel blocked by a spinner or an admin flow.

Uploads feed a scoped asset library. Every asset belongs to a project, story, or entry scope, can be copied or moved between scopes, and supports drag-and-drop into the editor. The UI inserts an optimistic placeholder immediately — with the real upload settling in the background — so creators are never stuck watching a spinner.

flowchart LR
    ED["Entry editor\ntext · image · scene blocks"] -->|upload| API["Server route\nownership + limit checks"]
    API --> R2[("Object storage\nprivate bucket")]
    API --> DB[("asset + usage rows")]
    ED -->|stores stable object key| DB
    RV["Reader view"] -->|asset key| SIGN["short-lived signed URL\ncached just under its expiry"]
    SIGN --> R2
    classDef store fill:#1f6f8b,stroke:#14526a,color:#fff
    class R2,DB store
    

Entries store stable object keys, never URLs. Readers get short-lived signed URLs minted on demand and cached client-side just under their expiry, so protected story images stay protected without re-signing on every render.

Usage rows tie every stored object to what references it, so lifecycle cleanup jobs can tell which objects are safe to remove from storage — deleting a story can't silently strand or orphan a paid story's assets.

05

The public web: SSR, SEO, and the protection boundary


Discovery is a product feature: a public creator, world, or story should be findable from a search engine, while private, draft, unlaunched, and paid-only content must never leak into public HTML. Every public surface is server-rendered by the Next.js App Router, and access is decided on the server before render — so protected content can't end up in a cache, a crawler, or view-source, no matter what the client does.

  • URLs are the information architecture: /handle/project/story/entry, built from a single canonical-path module so every surface — pages, metadata, sitemap — agrees on one URL per resource, with slugs normalized and encoded in one place instead of ad hoc per page.
  • The sitemap is generated from the database and includes only what is genuinely public: public profiles, public projects, launched stories, published entries. Robots rules keep account, settings, and API routes out of the index entirely.
  • Static route names are reserved at the database level, so no one can register a handle like settings and shadow a real route — the URL namespace is protected by a constraint, not a code review.
  • Canonical URLs, Open Graph metadata, and structured descriptions come from one shared metadata module, so a retitled story keeps one canonical address instead of forking into duplicates.
06

Signup: passwordless, and abuse-resistant by construction


Ponchii signup is a single email-OTP form that serves both login and registration. That convenience has a cost: the send-code endpoint is a spam cannon if left open, so a CAPTCHA challenge and several independent layers of rate limiting stand between an email address and an OTP send.

sequenceDiagram
    participant B as Browser
    participant CF as CAPTCHA provider
    participant API as send-code endpoint
    participant KV as Rate limiter
    participant SB as Supabase Auth
    B->>CF: render managed/invisible challenge
    CF-->>B: challenge token
    B->>API: send-code(email, token)
    API->>CF: verify token, action, hostname
    API->>KV: check layered windows
    KV-->>API: within limits
    API->>SB: send OTP
    SB-->>API: pending auth identity created
    API-->>B: server-side OTP session
    B->>API: verify-code(otp)
    API->>SB: verify code, bound to OTP session
    SB-->>API: email confirmed
    API->>SB: create application profile
    Note over SB: the application profile only exists
after verification — never before

Supabase's OTP flow creates a pending auth identity for a brand-new email by default. Ponchii treats that row as a pending challenge, not a product user — the application profile isn't created until the email is actually confirmed.

Rate-limit layers

LayerWhy this axis
Flood gateThe cheapest circuit breaker — runs before CAPTCHA verification even happens.
IPCoarsest signal; also the easiest for an attacker to rotate, so it never stands alone.
Network prefixCatches a botnet spreading requests across one shared range.
EmailThe identity actually being registered.
IP + email pairCan't be bypassed by rotating only one axis at a time.

Verification has its own tighter policy on top: repeated failures escalate to a fresh CAPTCHA and then to blocking, tracked by keyed HMAC identifiers rather than raw email or IP addresses. And the limiter fails closed — if the backing store is unavailable, the endpoint returns 503 rather than pretending a request is the first one it has seen.

07

Pending users don't linger


A scheduled cleanup worker claims unverified auth identities past their retention window using FOR UPDATE SKIP LOCKED, assigns each job a lock token and a short lease, and only deletes an identity if it's still unverified and the worker still owns the lock at the moment of deletion. An expired lease just returns the job to the pool for the next run.

stateDiagram-v2
    [*] --> pending_auth: OTP requested
    pending_auth --> verified: email confirmed
inside retention window pending_auth --> claimed: cleanup worker,
lock token + lease claimed --> deleted: still unverified,
worker still owns the lock claimed --> pending_auth: lease expired,
recovered next run verified --> [*] deleted --> [*] classDef terminal fill:#2c7a4b,stroke:#1e5a37,color:#fff classDef dead fill:#a5432c,stroke:#7c3220,color:#fff class verified terminal class deleted dead

Retention is a bounded, database-configured window. No verification-reminder email is ever sent to a pending identity — bot-created addresses never receive mail from Ponchii.

08

Money: Stripe and Supabase, never the frontend


Story access is EUR-only, tax-inclusive, priced at one of four fixed tiers, with a 15% platform fee taken through destination charges on the platform account. Creators need a connected Stripe Express account — one per user, country confirmed up front — before they can launch a paid story at all.

stateDiagram-v2
    [*] --> no_access
    no_access --> subscribing: reader opens pay modal
saved card or new SetupIntent subscribing --> active: subscription created,
automatic_tax + tax-inclusive EUR active --> canceling: auto-renew off canceling --> active: auto-renew back on
before period end canceling --> expired: current period ends active --> expired: payment fails permanently expired --> subscribing: resubscribe expired --> [*] classDef terminal fill:#2c7a4b,stroke:#1e5a37,color:#fff class active terminal

Access follows Stripe subscription status and period dates, never optimistic frontend state. Canceling keeps access until the current paid period actually ends.

  • Existing subscribers keep their original monthly price when a creator changes the public tier — repricing is never retroactive.
  • New subscriptions block before payment if Stripe Tax can't actually collect for the buyer's location, so Ponchii never silently charges an untaxed invoice.
  • Returning readers subscribe from a saved card in roughly two clicks; billing address is collected once, through Stripe's own Elements, and stored on the Stripe Customer rather than in Ponchii's tables.
09

Scheduled jobs


Lifecycle work runs as scheduled serverless functions over Postgres job queues. Each cron-invoked function validates its own long random bearer secret and fails closed if any required configuration is missing.

JobGuarantee
Unverified-user cleanupLock token + lease over FOR UPDATE SKIP LOCKED; structurally unable to touch a verified user.
Account cleanupDrains a due-time job queue for account deletions, with bounded retry.
Project cleanupMoves a project through its pending-deletion lifecycle.
Story cleanupMirrors story-level deactivation and deletion, including subscription wind-down.
Daily ops digestOne email per local day. An hourly gate checks the local hour instead of hardcoding UTC — so it survives DST — and a per-date run record blocks duplicate sends.
10

Chapter chat


Chat is a standalone Rust/Axum WebSocket service on Fly.io — deliberately decoupled from the Next.js request lifecycle. It never trusts the browser for identity: the web app checks Supabase Auth and paid access, then issues a short-lived signed token scoped to one user and one room before the browser ever opens a socket.

sequenceDiagram
    participant R as Reader browser
    participant WEB as Web app
    participant CHAT as Chat service
    participant PG as Postgres
    participant RD as Redis
    R->>WEB: open chapter (has paid access)
    WEB->>WEB: issue short-lived signed token
scoped to user + room WEB-->>R: token R->>CHAT: wss connect with token CHAT->>CHAT: verify token + origin allow-list CHAT->>PG: room exists and enabled? PG-->>CHAT: room is live CHAT-->>R: recent history replay R->>CHAT: send message CHAT->>PG: persist message CHAT->>RD: publish to room channel RD-->>CHAT: fan out to other instances CHAT-->>R: broadcast message

Messages are persisted before they're broadcast, so a client that reconnects mid-flight recovers from Postgres even if it misses a pub/sub event. Redis is fanout only — Postgres is still the durable store, capped per room and trimmed by retention.

11

Repository layout


PathOwns
apps/webNext.js product: entry editor/viewer, worldbook, story hub, every Stripe route, Supabase Auth integration, signup abuse protection.
apps/mobileExpo reader app. Shows purchased access only — never initiates a payment or payout flow.
services/chatStandalone Rust/Axum WebSocket chat service, deployed to Fly.io independently of the web app.
services/ttsPlanned Rust text-to-speech service behind a provider-neutral adapter — not implemented yet.
supabase/migrationsEvery schema change: core tables, RLS policies, cleanup job queues, chat tables, worldbook, billing history.
supabase/functionsScheduled functions — the cleanup workers and the daily ops digest.
12

How it ships


Four surfaces, four toolchains, one validation pipeline. Every push and pull request runs five parallel CI jobs, each checking its surface with that surface's native tools — and a repository safety job scans for common secret shapes before anything else matters.

flowchart LR
    PUSH["push / PR\nmain · dev"] --> SAFE["Repository safety\nsecret-shape scan"]
    PUSH --> W["Web\nlint · typecheck · unit tests\n· build · Playwright e2e"]
    PUSH --> MO["Mobile\nlint · typecheck\n· Expo config check"]
    PUSH --> CH["Chat\nrustfmt · clippy -D warnings\n· cargo test"]
    PUSH --> EF["Functions\ndeno check, frozen deps"]
    W --> V["Vercel"]
    CH --> F["Fly.io\nDocker image"]
    EF --> SBD["Supabase\nfunctions + reviewed migrations"]
    classDef target fill:#1f6f8b,stroke:#14526a,color:#fff
    class V,F,SBD target
    

Deploys are per-surface: the web app ships through Vercel, the chat service as a Docker image on Fly.io, and scheduled functions and migrations through the Supabase CLI — with migrations reviewed against the pending list before every push.

  • Toolchains are pinned everywhere — Node via .nvmrc, an exact pnpm version, a pinned Rust toolchain — and every install uses a frozen lockfile, so CI and a laptop build the same thing.
  • The end-to-end job drives the real signup flow in a real browser using the CAPTCHA provider's published test keys, so the auth path is exercised on every push without touching production services.
  • CI runs against placeholder environment values, and the app fails closed on missing configuration — a misconfigured environment breaks the build, not production.
  • Only main and dev are long-lived branches; feature branches are short-lived and deleted after merge, and in-flight CI runs are cancelled when a branch moves on.
13

What keeps it correct


  • Stripe and Supabase stay the source of truth for access — the frontend never grants access optimistically.
  • An application profile only exists after email verification, so an address that never verifies never becomes a product user, a billing target, or a metric.
  • Row Level Security is enabled on every public table reachable through a Supabase client; server routes use the cookie-backed anon client by default, so policies still apply unless a route deliberately drops to service-role.
  • The mobile app is structurally incapable of starting a Stripe flow — there's no route for it to call, by design, not just by convention.
  • The cleanup workers' lock-token-plus-lease claims mean a crashed or overlapping run can never double-delete or permanently strand a row.
  • Asset usage rows tie every stored object to what references it, so lifecycle cleanup can't strand or orphan a paid story's assets.