Launch Readiness
Final verification, 2026-09-06 evening. The core loop no longer gates launch: a brand-new user was driven from signup to a downloaded book in one real run. Launch is still blocked on the items in sections 5 to 8: no waitlist invite mechanism, open signup (and the Supabase project has email confirmation switched off, so addresses are never verified), waitlist tables readable with the public anon key, an admin API with no authentication, and dead tier enforcement.
Mechanisms updated 2026-09-06 after the fixes landed; see the final verification note below the title.
Verdict
MyStoryFlow still cannot invite a waitlist person today, because no invite mechanism exists: waitlist_entries and Supabase Auth are two disconnected systems, and the only admin action is flipping a status column that sends nothing. Signup itself is still open to anyone with the URL, so a “private beta” is private only by obscurity. What changed today is the dead end that used to sit right after signup: the book wizard is no longer nine steps ending in a mandatory Stripe paywall — it is two screens (who the book is about, then a template), and nothing in first-run asks for money. A confirmed new user now lands on /books/new and can create a book and start a chapter without ever seeing a payment screen (§“What changed on 2026-09-06” below). Nobody has ever paid: 0 rows in subscriptions, 0 profiles with a Stripe customer id — that remains true, and is now true by design rather than by a broken checkout. The good news, unchanged: the core user-content tables — profiles, stories, recordings, books, book_chapters, ai_conversations, export_jobs — all have RLS enabled with policies, and every API route audited here does a server-side ownership check, so a signed-in stranger cannot read another user’s stories. The exposure that remains is exactly what it was: waitlist_entries (276 email addresses) is readable and deletable by anyone holding the public anon key, and the admin app’s 32 API routes have no authentication at all. None of that was in scope for today’s fixes. Free-for-waitlist no longer needs the one-line change this section used to recommend — the paywall it targeted doesn’t exist anymore — but an invite mechanism and the admin-app auth gap are still the fastest honest path to 30 users.
Live numbers
| Table | Rows | Note |
|---|---|---|
waitlist_entries | 276 | 154 pending, 122 verified |
| — never verified | 154 | verified_at is null |
| — never issued an OTP | 106 | otp_code is null and verified_at is null — the old details-step gate |
| — whose email has an auth account | 2 | the waitlist has produced essentially no users |
auth.users | 67 | 55 confirmed, 0 anonymous, 3 signed in within 30 days |
profiles | 67 | all subscription_tier='starter', all subscription_status='trial', 0 with stripe_customer_id |
stories | 25 | |
books | 9 | |
recordings | 18 | |
ai_conversations | 78 | |
trial_sessions | 1 | the trial path is effectively unused |
subscriptions | 0 | table is never read or written by any code |
export_jobs | 0 |
Waitlist signups are still arriving (latest 2026-09-06). The last backup was 2026-08-03; 16 signups have arrived since and are not in backups/. These counts are pre-fix and were not re-queried for this update — none of today’s commits write to waitlist_entries, auth.users, subscriptions, or export_jobs in a way that would move them, aside from the throwaway test rows created and deleted during the book-creation and export re-verification (§ the book-creation and recording-to-story pages).
Waitlist → account → first story
| Step | What exists | Status | Evidence |
|---|---|---|---|
| Person joins the waitlist | Marketing home renders HeroSectionCustom with the form in place of the CTAs when WAITLIST_ENABLED + WAITLIST_MODE='construction' | ✅ | apps/marketing-site/src/config/features.ts:19-24; src/app/page.tsx:24,296 |
| Row is written | POST inserts into waitlist_entries | ✅ | apps/marketing-site/src/app/api/waitlist/route.ts:37-50 |
| Verification email | Working tree issues the OTP at signup; previously a required phone/name details step gated it, which is why 106 of 276 never got a code | ⚠️ uncommitted | route.ts:3,67; resend-otp/route.ts:3,34,39 |
| Returning unverified email | Working tree lets an unverified email resume instead of 409ing | ⚠️ uncommitted | apps/marketing-site/src/app/api/waitlist/route.ts:50 |
| Orphaned duplicate form | packages/ui/src/components/waitlist/StoryFlowWaitlistForm.tsx is a copy of the old flow with no importers | ⚠️ | zero matches for the import across apps/** |
| Admin decides who gets in | Admin can only flip status (pending/verified/contacted/converted) | ⚠️ | apps/admin-app/src/app/dashboard/waitlist/page.tsx:338,436 → src/lib/waitlist-service.ts:286-321 |
| Invite is sent | Nothing. No sendInvite, no invite_code, no signup link in any template. converted writes a column and a waitlist_events row and stops | ❌ | waitlist-service.ts:286-321,361-365 |
| Email templates that exist | sendOTPEmail, sendWelcomeEmail (“you’re #N on the waitlist”) — neither contains a signup link | ⬜ | apps/web-app/lib/email/waitlist.ts:9-83,85-194 (duplicated in marketing-site) |
| Sending domain | RESEND_FROM_EMAIL and the hardcoded fallback are both on workbeehive.com, not mystoryflow.com. DNS/SPF/DKIM cannot be verified from here | ⚠️ | apps/marketing-site/src/lib/email/waitlist.ts:12-13 |
| Signup | Open to anyone. invitationToken is optional and validated against family_invitations, not the waitlist; auth.signUp runs unconditionally. No OAuth | ⚠️ | apps/web-app/app/api/auth/signup/route.ts:9,21-50,53; UI at app/page.tsx:34,198-206,398-421 |
| “Check your email” panel | Renders after signup | ✅ | app/page.tsx:344-372 |
| Email confirmation lands | returnUrl honoured only against a trusted-domain allowlist, else /auth-redirect; failure → /auth/auth-code-error (page exists, copy is good) | ✅ | app/auth/callback/route.ts:4-51,18-25 |
| First session | onboarding_step='welcome' (the default for every new profile) no longer counts as “already in progress” — fixed 2026-09-06 (b05b8e3); a confirmed new user lands on /books/new, not caught in a loop | ✅ | app/auth-redirect/page.tsx:134-147 |
| Dashboard | Greeting + a single next-step card (“Start your book” → “Create your book”) → /books/new | ✅ | app/(dashboard)/dashboard/page.tsx; app/components/dashboard/NextStepCard.tsx |
| First story | Two screens (who the book is about, then a template), no payment step, book and first chapter both exist before money is mentioned. Unchanged while NEXT_PUBLIC_PAYWALL_MODE is off, which is the default and what an unset value means | ✅ | app/components/books/BookCreationWizard.tsx; wizard-steps/AboutBookStep.tsx, PickTemplateStep.tsx |
| Paywall, when switched on | Setting NEXT_PUBLIC_PAYWALL_MODE=onboarding adds a third screen, “Choose your plan”, after the template pick. Three cards, one recommended, monthly/yearly in words, one button each into Stripe Checkout. POST /api/books refuses without a plan, so the screen cannot be skipped by URL | ✅ built, off | lib/config/flags.ts; wizard-steps/ChoosePlanStep.tsx; app/api/billing/checkout/route.ts; app/api/books/route.ts |
| Sign in with an emailed code | Second option under the password form: email → six big boxes → verifyOtp, same post-login routing. Hidden unless NEXT_PUBLIC_EMAIL_CODE_SIGNIN=true, because Supabase’s built-in mailer sends only a few per hour project-wide | ✅ built, off | app/(auth)/components/EmailCodeSignIn.tsx, EmailCodeInput.tsx; app/page.tsx |
| Password reset | Real, both halves | ✅ | app/(auth)/forgot-password/page.tsx:33-35,60; app/(auth)/reset-password/page.tsx:67-69 |
| Legal pages linked from signup | Privacy and Terms exist on the marketing site only; the web-app signup form links to neither. Not touched today | ❌ | apps/marketing-site/src/app/(legal)/{privacy,terms}; zero matches in apps/web-app/app/page.tsx |
What changed on 2026-09-06
- The wizard’s mandatory paywall is gone, not just made skippable.
b05b8e3deletedSubscriptionStep,PrintOrderStep,GoalsStep,RemindersStep, andReviewAndStartStepoutright, along with the Stripe checkout call the wizard used to make. There is nopaidStatusescheck left to patch — the step it used to gate doesn’t exist. - The redirect trap at first login is fixed.
onboarding_step='welcome'(the default value on every new profile) no longer counts as wizard progress, so a confirmed new user reaches/books/newinstead of being routed to a dashboard that then sends them back into a loop. Fixed inb05b8e3. - The dashboard no longer fabricates stats. The prior four-tile, stat-heavy dashboard (page/word counts, “Quick Insights,” a duplicate continue-card, Elena promoted three times) is replaced by one next-step card, two ways to add a story, the book, and family — nothing invented.
ContinueWhereYouLeftOff.tsxandReEngagementNudge.tsxwere deleted. Fixed in75a4bcc. - Fake nav destinations are no longer linked.
/billing,/orders,/publishing/kdp, and/family/sharedare removed fromlib/navigation-config.ts. The pages themselves still exist with their hardcoded fake data (e.g./billing’s “Renews: March 15, 2025”) — they are unreachable from the UI now, not deleted./exportremains linked. Fixed inb05b8e3(see “Prior blockers, re-verified” below). - Owner decision, stated for the record: the first-run paywall is removed by design. It now exists again as code behind a flag that defaults to off, so it can be switched on without another release once Stripe is configured — see the checklist at the end of this page.
Billing
Nobody has paid yet and nobody can: STRIPE_SECRET_KEY in .env.local is still the literal placeholder your_stripe_secret_key…, as are the publishable key and the webhook secret. What changed is that the paywall is now built — real when NEXT_PUBLIC_PAYWALL_MODE=onboarding, absent when it is not — so switching it on is a configuration job rather than a code one.
| Concern | State | Evidence |
|---|---|---|
| Plan definitions | One config: name, one plain line, both prices, and the STRIPE_PRICE_* env names. The plan step and the checkout route both read it, so the card and the charge cannot drift | lib/billing/plans.ts |
| Price env names | All six of STRIPE_PRICE_{STARTER,FAMILY,PREMIUM}_{MONTHLY,ANNUAL} are in apps/web-app/.env.local and now in .env.example. Still absent from root vercel.json’s env block | lib/billing/plans.ts; vercel.json |
| If a price var is missing | The new route answers 503 “That plan is not ready yet”. (The older create-checkout still falls back to the literal 'price_starter_monthly' and 500s) | app/api/billing/checkout/route.ts; create-checkout/route.ts:15-25 |
subscriptions table | Now has writers. The webhook writes it on checkout.session.completed and on subscription created/updated/deleted, and the post-checkout verify endpoint writes it too, so a person who paid is not blocked while a webhook is in flight | lib/billing/subscription-writer.ts; webhook/route.ts; app/api/billing/checkout/verify/route.ts |
| Stripe period fields | As of API version 2025-05-28.basil, current_period_end is on the subscription items, not the subscription. Read from the wrong place it is undefined and every subscription looks eternal | lib/billing/subscription-writer.ts |
| Server-side gate | POST /api/books answers 402 when the flag is onboarding and the person has no active subscriptions row. Keyed on subscriptions.user_id, which is a real column and the one the RLS policy uses | lib/billing/entitlements.ts; app/api/books/route.ts |
| Tier / usage enforcement | Query fixed. getUserSubscription filtered profiles.eq('user_id', …); profiles has no user_id column (PK is id), so it returned null for everyone. It now filters on id and reads both trial columns | lib/subscription/subscription-service.ts |
| Trial column split | Still two columns. The reader now prefers trial_ends_at (the one the webhook writes, 67 rows) and falls back to trial_end | lib/subscription/subscription-service.ts |
| Customer portal | Still does not exist. A subscriber could not cancel or change a card | — |
| Gift purchase takes no money | The 6-step gift wizard’s card fields are never transmitted; the route writes purchase_status: 'completed' regardless. Anyone can take a gift for free. Not fixed — it needs a real Stripe call | app/campaigns/create/steps/PaymentStep.tsx:37-38; app/api/gifts/purchase/route.ts:56 |
| Anonymous gift purchase does nothing | Every downstream step (family, campaign, all email) is gated on giver_user_id, which /purchase-gift never sets. A logged-out buyer’s recipient is never contacted. Not fixed | app/api/gifts/purchase/route.ts:91,143,228,260; app/purchase-gift/page.tsx:228 |
| Queued gift email is never sent | Rows land in email_send_queue as pending; processEmailQueue has no caller and no cron drains it. Not fixed | lib/email/email-automation-service.ts:225-236,336; vercel.json |
Free for the waitlist
Nothing to do. NEXT_PUBLIC_PAYWALL_MODE is unset, which means off, which means the wizard is two screens and POST /api/books never checks for a subscription. All 67 existing profiles reach /books/new and create a book without a payment screen. Leaving the flag alone is the free-for-waitlist setting.
What’s left of the old “leave billing off” risk analysis still applies to the pages, not the wizard: /billing still renders the hardcoded “Renews: March 15, 2025” and /orders, /publishing/kdp, /family/shared still render invented data if reached directly by URL — they’re only unlinked from navigation now (see “What changed” above), not deleted. /export remains linked and is a real stub with a TODO, not fake data.
Safety for strangers
Advisors, 2026-09-06. Security: 3 ERROR categories, 6 WARN, 1 INFO — 10 categories over 258 findings. Performance: 3 WARN, 2 INFO over 1,277 findings (269 auth_rls_initplan, 529 multiple_permissive_policies, 381 unused indexes, 83 unindexed FKs, 15 duplicate indexes). Performance is noise at 30 users; ignore it for this launch.
Security findings that matter:
| Finding | Count | Note |
|---|---|---|
rls_disabled_in_public | 53 tables | list below |
policy_exists_rls_disabled | 3 | family_groups, family_members, gifts — policies written, RLS switched off, so the policies are dead and the tables are fully open |
rls_enabled_no_policy | 18 | includes book_chapter_blocks, book_chapter_stories, book_chapter_versions — reachable only by the service role |
security_definer_view | 10 | includes waitlist_stats |
anon_security_definer_function_executable | 44 | confirmed live: create_notification, debug_gifts_query, debug_service_role_access, test_gifts_rls are all anon-executable |
function_search_path_mutable | 82 | |
| Postgres version | 1 | supabase-postgres-15.8.1.094 has outstanding security patches |
| Leaked-password protection | 1 | disabled in Auth settings |
The core user-content tables are protected. profiles (4 policies), stories (3), recordings (2), books (13), book_chapters (4), ai_conversations (4), export_jobs (4) and subscriptions (2) all have relrowsecurity = true.
User-content tables with RLS off, from the 53:
waitlist_entries, waitlist_events, waitlist_settings, family_groups, family_members, family_book_permissions, gifts, audit_logs, anonymous_sessions, user_story_timeline, user_writing_patterns, user_ai_interactions, story_characters, story_elements, conversation_turns, conversation_summaries, conversation_contexts, conversation_analytics, voice_conversations, voice_conversation_turns, voice_anonymous_sessions, tools_pdf_flipbooks, experiment_participants.
The anon-key exposure, plainly. Every one of those tables carries SELECT, INSERT, UPDATE, DELETE, TRUNCATE grants for both the anon and authenticated roles (verified via information_schema.role_table_grants). With RLS off, PostgREST applies no further restriction. The anon key ships in every browser bundle by design. Therefore anyone who opens the site can read all 276 waitlist email addresses, names, phone numbers and IP addresses — and can delete or overwrite them with a single request to /rest/v1/waitlist_entries. The same is true of family_groups/family_members/gifts despite their policies, and of audit_logs. This is a live PII exposure today, before any invite is sent, and it grows with every new signup.
The API layer compensates for the tables that matter. Every audited route re-checks ownership server-side, so RLS is not the only line of defence for stories, books and recordings:
| Route | Ownership check | Evidence |
|---|---|---|
GET /api/stories/[storyId] | Yes — owner, else book-family, else campaign | app/api/stories/[storyId]/route.ts:109,137-158 |
PATCH /api/stories/[storyId] | Yes — same three-way check | app/api/stories/[storyId]/route.ts:197,215-238 |
GET /api/recordings | Yes — .eq('user_id', user.id) | app/api/recordings/route.ts:21,49 |
GET /api/books/[id] | Yes — owner or family group | app/api/books/[id]/route.ts:15,31-36 |
GET /api/audio/[fileId] | Yes — service-role client, but scoped .eq('user_id', user.id) | app/api/audio/[fileId]/route.ts:27,57-73 |
GET /api/books/[id]/export/[jobId]/download | Yes — .eq('user_id', user.id) on the job | app/api/books/[id]/export/[jobId]/download/route.ts:22,28-31 |
GET/POST /api/books/[id]/stories | Yes — owner or family group, both verbs | app/api/books/[id]/stories/route.ts:15,30-34,72,104-108 |
Secrets. No 'use client' file references SUPABASE_SERVICE_ROLE_KEY, and no NEXT_PUBLIC_* variable carries it — 45 server-side files use it, none client-side. Live Backblaze B2 credentials remain in tracked files (CLAUDE.md, docs/blog-article-pipeline.md, .claude/skills/blog-publisher.md) and are therefore in git history; they need rotating regardless of what else is fixed.
Rate limiting: none. Zero matches for rateLimit|rate_limit|Ratelimit|upstash in apps/web-app/lib or apps/web-app/app. /api/transcribe and /api/stories/convert have no limiter. The trial endpoints are not the cost bomb they were: /api/trial/generate-story requires an authenticated user and verifies job ownership (app/api/trial/generate-story/route.ts:11-26), and although /api/trial/start:38 calls signInAnonymously() for anyone, auth.users holds 0 anonymous users and trial_sessions holds 1 row, so anonymous sign-in is not producing sessions in practice. The unmetered surface that does matter is the unauthenticated AI and upload routes in the blocker table below. At 30 invited users, a per-IP and per-user limiter on the AI and upload routes is sufficient; a global spend alarm on the OpenAI account is the cheaper backstop.
Sequencing. The owner decided on 2026-08-02 that features come before database hardening (Phase 4 in docs/IMPLEMENTATION_PLAN.md), and that decision stands here. This section states exposure as fact so it can be priced, not to reopen the ordering. One carve-out is worth making anyway, because it is small and self-contained: waitlist_entries is the one table where the exposed data is the launch asset itself, and revoking anon grants on the three waitlist_* tables does not touch app code.
Operations
| Item | Status | Detail |
|---|---|---|
| Error monitoring | ❌ none | No @sentry dependency, no instrumentation.ts, no sentry.*.config.ts. .env.example advertises NEXT_PUBLIC_SENTRY_DSN and SENTRY_AUTH_TOKEN that nothing reads |
| Security headers / CSP | ⬜ | apps/web-app/next.config.ts has no headers() block. ignoreBuildErrors is correctly absent |
| Crons | ⚠️ conflicting | Root vercel.json declares /api/cron/weekly-prompts at 0 9 * * 1; apps/web-app/vercel.json declares the same path at 0 * * * * and per-app config wins |
CRON_SECRET | ✅ | Checked at app/api/cron/weekly-prompts/route.ts:32-35; it is the only cron route in the repo |
| Export job reaper | ⬜ none | No stuck/stale export_jobs cleanup anywhere. export_jobs is empty today, so nothing is stuck yet |
| Env declared vs needed | ⚠️ | Root vercel.json declares 18 vars but omits STRIPE_PRICE_* (6), STRIPE_WEBHOOK_SECRET, CRON_SECRET, RESEND_FROM_EMAIL, NEXT_PUBLIC_STRIPE_PRICE_* |
RESEND_FROM_EMAIL | ⚠️ | Points at workbeehive.com, as does the hardcoded fallback. SPF/DKIM/DMARC for that domain cannot be verified from here — check in the Resend dashboard before any bulk send |
| Legal pages | ⚠️ | Exist at apps/marketing-site/src/app/(legal)/{privacy,terms}; not linked from the web-app signup form |
| Account deletion | ❌ | components/settings/DataSection.tsx:95 DELETEs /api/user/delete-account; the route does not exist anywhere in the repo |
| User data export | ✅ | app/api/user/export-data/route.ts:15-24 — authenticated, user.id !== userId rejected, writes user_data_exports |
| Waitlist backup | ⚠️ stale | scripts/backup/export-waitlist.js exists; latest artefacts in gitignored backups/ are dated 2026-08-03; 16 signups since |
| Test suite | ❌ | 74 of 105 Jest suites fail, 58 of them failing to load modules deleted in the campaign→book migration. No signal before a deploy |
| CI | ⬜ | No .github/; the only gate is a bypassable pre-push hook |
Prior blockers, re-verified
| Id | Open? | Evidence on 2026-09-06 |
|---|---|---|
| A1 RLS off on 53 tables | Open | Still exactly 53; core user tables now covered, waitlist_* and family_* are not |
| A2 Policies with RLS off | Open | family_groups, family_members, gifts — advisor policy_exists_rls_disabled |
A5 anon-executable SECURITY DEFINER | Open | create_notification, debug_gifts_query, debug_service_role_access, test_gifts_rls all return has_function_privilege('anon', …) = true |
| A6 SECURITY DEFINER views | Open | 10, including waitlist_stats |
A7 avatars bucket listable | Open | The only storage bucket, marked public, 4 storage policies |
| B1 Admin API unauthenticated | Open | 32 of 32 routes under apps/admin-app/src/app/api contain no getUser/requireAdmin/getSession call; no middleware.ts in the app |
| B2 Gift purchase takes no payment | Open | app/api/gifts/purchase/route.ts:23,50,56 — service-role client, purchase_status: 'completed', no Stripe call |
| B3 Three unauthenticated file endpoints | Open | upload/backblaze, upload-url, upload/delete — zero getUser calls in each |
| B4 No rate limiting | Open | Zero limiter matches in web-app |
| B5 Premium tier used as admin check | Open | app/api/admin/email/templates/route.ts:24,116 — profile.subscription_tier !== 'premium' |
| B7 Cross-tenant trial conversion | Partly fixed | Anonymous callers now rejected (trial/convert/route.ts:11-14), but the trial_sessions lookup at :18-21 still has no user_id filter, so any authenticated user with a session id can re-parent someone else’s trial |
| D1 Account deletion impossible | Open | Route absent; UI still calls it |
| D2 GDPR erase/access are stubs | Open | 3 TODOs each in packages/analytics/src/api/privacy/{erase,access}.ts |
| D3 No Stripe customer portal | Open | Zero matches for billingPortal |
| D5 No entitlement enforcement | Open | Root cause identified: the profiles.user_id bug below makes it structurally impossible |
P1-5 processEmailQueue unwired | Open | lib/email/email-automation-service.ts:336 — only callers are its own tests |
| P1-8 Fabricated family invitations | Open | app/api/families/[familyId]/invitations/route.ts:72 inv_${Date.now()}, no insert, email is console.log at :86,97. The working /api/family/invitations path still exists |
| P1-9 Book share calls a missing edge function | Open | app/books/[id]/share/page.tsx:191,201 invoke send-book-invitation; no supabase/functions directory anywhere |
| P1-12 Mock data rendered on fetch failure | Half fixed | Conversations fixed (app/conversations/page.tsx:81 “No mock fallback”); notifications still open — app/notifications/page.tsx:102,121 |
| P1-13 Silent upload data loss | ✅ fixed 2026-09-06 (275d9fe) | uploadRecording (lib/storage/backblaze.ts:157-286) now throws a BackblazeUploadError instead of returning a mock URL; a mock URL survives only when B2 env is entirely absent, flagged mock: true. The 13 legacy production rows already sitting on a mock URL are unaffected — this is a code fix, not a data cleanup |
| P1-14 Fake screens in navigation | Partly fixed 2026-09-06 (b05b8e3) | /billing, /orders, /publishing/kdp, /family/shared removed from lib/navigation-config.ts — see comment at :42-46. The pages themselves still exist with the same fake data (app/billing/page.tsx:28 still hardcodes “Renews: March 15, 2025”), just unreachable from the UI now. /export remains linked and is a real stub, not fake data |
P1-15 profiles.eq('user_id') | Open | lib/subscription/subscription-service.ts:46-47. Confirmed against the live schema: profiles has 36 columns and none is user_id |
| E1 Committed B2 credentials | Open | Key id still present in CLAUDE.md, docs/blog-article-pipeline.md, .claude/skills/blog-publisher.md |
| F1 Unearned compliance claims | Open | apps/docs-app/content/security.mdx:19-20 still claims SOC 2 Type II and ISO 27001 |
30-user private beta checklist
Ordered. Sizes: S ≈ under an hour, M ≈ half a day, L ≈ multiple days.
Must land before the first invite
- S — Revoke
anon/authenticatedgrants onwaitlist_entries,waitlist_events,waitlist_settings. No app code reads them through the anon key; the marketing route uses the service role. This closes the 276-address leak without touching the RLS sequencing decision. - S — Back up the waitlist. Run
scripts/backup/export-waitlist.js; the current artefact is 34 days and 16 signups stale, and step 1 should not be the first thing you do to a table you have no fresh copy of. S — Take the paywall out of first-run.Done 2026-09-06 (b05b8e3). The wizard step was deleted, not bypassed.- Partly done 2026-09-06 (
b05b8e3) — hide the fake surfaces./billing,/orders,/publishing/kdp,/family/sharedare removed fromlib/navigation-config.ts. Still open: the pages themselves still exist and still render fake data if reached directly, and/exportis still linked (it’s a real stub, not fake data, so lower priority). - M — Build the invite. The smallest honest version: an admin action that generates a single-use token, stores it against the
waitlist_entriesrow, emails a signup link, and makesinvitationTokenrequired atapp/api/auth/signup/route.ts:53when a beta flag is on. Without the required half, “private beta” means “anyone with the URL”. - S — Put the admin app behind auth, or take it off the internet. 32 unauthenticated service-role routes, one of which grants admin on an unauthenticated PATCH. If a real guard is too slow, restrict the deployment to an allowlisted IP or take it down for the beta.
- S — Ship account deletion, or remove the button. The UI promises deletion and calls a route that does not exist. A stub route that soft-deletes and emails you is acceptable; a button that lies is not.
- S — Point
RESEND_FROM_EMAILat a domain you control for this product, and confirm SPF/DKIM in the Resend dashboard before sending to 276 addresses at once. Invites fromworkbeehive.comwill land in spam and burn the list.
Can follow within the first week
- S — Fix
profiles.eq('user_id')→.eq('id')atsubscription-service.ts:47, and reconciletrial_endvstrial_ends_at. Nothing is enforced until this is right, so it is not urgent while everything is free — but it is a one-character fix that unblocks all tier work. - S — Add error monitoring. With 3 active users a month, no monitoring meant nobody noticed; with 30 it means you learn about breakage from an email. Sentry, or a
console.errordrain from Vercel logs. - M — Re-send verification to the 106 entries that never got an OTP, using the working-tree resend path, after step 8. These are people who tried to join and were silently dropped.
- M — Rate-limit the unauthenticated AI and upload routes, and set a hard monthly spend cap on the OpenAI account as a backstop.
- S — Add authentication to the three upload endpoints (
upload/backblaze,upload-url,upload/delete);upload-urlis a read-SSRF andupload/deletecan destroy an unrelated customer recording. - S — Strip the SOC 2 / ISO 27001 claims from
apps/docs-app/content/security.mdx:19-20before the docs site has an audience. - S — Rotate the B2 credentials committed to
CLAUDE.mdand two other tracked files. - M — Link Privacy and Terms from the signup form, and drop the mock-notification fallback at
app/notifications/page.tsx:102. - M — Reconcile the two cron schedules and add an
export_jobsreaper before exports carry real load.
Stripe and sign-in setup checklist for the owner
Everything below is dashboard work. No code change is needed for any of it.
1. Stripe: create the products and prices
In the Stripe Dashboard → Product catalogue, create three products, each with two recurring prices (USD):
| Product | Monthly | Yearly |
|---|---|---|
| Just me | $19 / month | $180 / year |
| My family | $39 / month | $360 / year |
| Everything | $59 / month | $540 / year |
Copy each price id (price_…) into apps/web-app/.env.local and into Vercel’s environment variables:
STRIPE_PRICE_STARTER_MONTHLY, STRIPE_PRICE_STARTER_ANNUAL, STRIPE_PRICE_FAMILY_MONTHLY, STRIPE_PRICE_FAMILY_ANNUAL, STRIPE_PRICE_PREMIUM_MONTHLY, STRIPE_PRICE_PREMIUM_ANNUAL
Also set STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY. All eight are placeholders today.
2. Stripe: the webhook
Developers → Webhooks → Add endpoint: https://<your-domain>/api/subscriptions/webhook
Subscribe to: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed. Paste the signing secret into STRIPE_WEBHOOK_SECRET.
To test locally: stripe listen --forward-to localhost:3000/api/subscriptions/webhook and use the whsec_… it prints as STRIPE_WEBHOOK_SECRET.
3. The two flags
| Flag | Values | What it does |
|---|---|---|
NEXT_PUBLIC_PAYWALL_MODE | off (default; also what an unset or misspelled value means) | Wizard is two screens, book is created, nobody is asked for money. |
onboarding | Adds “Choose your plan” after the template pick, and POST /api/books returns 402 without an active subscription. | |
NEXT_PUBLIC_EMAIL_CODE_SIGNIN | false / unset (default) | Only the password form on the sign-in screen. |
true | Adds “Email me a sign-in code” underneath it. |
Both are NEXT_PUBLIC_, so they are baked in at build time — changing one needs a redeploy, not a restart. Do not set NEXT_PUBLIC_PAYWALL_MODE=onboarding before step 1 is done, or signup dead-ends at the plan step.
4. Supabase: send auth email through Resend
Dashboard → Authentication → Emails → SMTP Settings → enable custom SMTP:
- Host
smtp.resend.com, port465 - Username
resend, password = your Resend API key - Sender = an address on a domain verified in Resend (today
RESEND_FROM_EMAILis on workbeehive.com, not mystoryflow.com — verify the domain you actually want to send from)
Then Authentication → Emails → Templates → Magic Link: the body must contain {{ .Token }}, which is the six-digit code. The default template only has {{ .ConfirmationURL }}, and with only that, the code screen has no code to show.
Finally, Authentication → Rate Limits: raise “Emails per hour” from the built-in default (a handful per hour for the whole project — fine for one tester, not for 30 people). The limit is the reason NEXT_PUBLIC_EMAIL_CODE_SIGNIN ships off.
Leave email auto-confirm as it is. The code path signs people in directly and does not depend on it.