shippingszn 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +2320 -230
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as path12 from "node:path";
4
+ import * as path19 from "node:path";
5
5
  import * as process2 from "node:process";
6
6
  import { createRequire } from "node:module";
7
7
 
@@ -256,7 +256,8 @@ var PATTERN_DEFINITION_FILES = /* @__PURE__ */ new Set([
256
256
  ]);
257
257
  var PATTERN_DEFINITION_PREFIXES = [
258
258
  "tools/cli/test/fixtures/",
259
- "artifacts/checklist/src/data/checklist/"
259
+ "artifacts/checklist/src/data/checklist/",
260
+ "lib/checklist-data/src/"
260
261
  ];
261
262
  function isScanExempt(relPath) {
262
263
  const p = relPosix(relPath);
@@ -266,6 +267,23 @@ function isScanExempt(relPath) {
266
267
  }
267
268
  return false;
268
269
  }
270
+ function isLikelyNonRuntimePath(relPath) {
271
+ const p = relPosix(relPath).toLowerCase();
272
+ const base = path2.basename(p);
273
+ if (base.endsWith(".d.ts")) return true;
274
+ if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/.test(base) || p.includes("/__tests__/") || p.includes("/test/") || p.includes("/tests/")) {
275
+ return true;
276
+ }
277
+ if (p === "claude.md" || p === "agents.md" || p === "devops.md") return true;
278
+ if (p.startsWith("docs/") || p.includes("/docs/") || p.startsWith("scripts/") || p.includes("/scripts/") || p.startsWith("examples/") || p.includes("/examples/") || p.startsWith("artifacts/mockup-sandbox/") || p.includes("/artifacts/mockup-sandbox/")) {
279
+ return true;
280
+ }
281
+ return false;
282
+ }
283
+ function isUiLibraryPrimitive(relPath) {
284
+ const p = relPosix(relPath).toLowerCase();
285
+ return p.startsWith("components/ui/") || p.includes("/components/ui/") || p.startsWith("src/components/ui/") || p.includes("/src/components/ui/") || p.startsWith("app/components/ui/") || p.includes("/app/components/ui/") || p.startsWith("ui/") || p.includes("/shadcn/ui/");
286
+ }
269
287
  var SERVE_INDICATORS = [
270
288
  "emitFile",
271
289
  "setHeader",
@@ -301,6 +319,1333 @@ async function isAssetEmittedDynamically(ctx, assetName) {
301
319
  return false;
302
320
  }
303
321
 
322
+ // ../../lib/checklist-data/src/items-critical-a.ts
323
+ var ITEMS_CRITICAL_A = [
324
+ {
325
+ id: "value-prop",
326
+ number: 1,
327
+ title: "Be able to explain what this is in one sentence",
328
+ category: "Product & Launch",
329
+ priority: "critical",
330
+ timeEstimate: "1 hr",
331
+ prompt: `Act as a sharp positioning consultant in the style of April Dunford. I'll describe my product to you. Push back if my answers are vague \u2014 keep asking until you have specifics. Then craft a one-sentence value proposition using this template: "[App] helps [specific person] [do specific thing] without [common pain]." Give me 3 alternative versions varying tone (confident, plain, slightly playful). Then write 3 supporting bullets that are outcomes, not features. Finally, draft a 60-word elevator pitch I could say out loud at a party. Before writing anything, ask me: who exactly is the target user, what do they currently use instead, and why does that current solution suck for them. Don't generate a single line of marketing copy until you have those three answers.`,
332
+ what: "If a stranger lands on your homepage and can't figure out what you do in five seconds, they're gone. Same if you can't answer \u201Cso what does it do?\u201D at a party without rambling. The exercise of squeezing your app into one clear sentence forces you to find the actual point.",
333
+ why: "Most launches don't fail from bad code \u2014 they fail because nobody understands why they should care. A clear one-line pitch is what makes someone read the second sentence.",
334
+ steps: [
335
+ 'Fill in the template: "[App] helps [specific person] [do specific thing] without [common pain]." Specific beats clever every time.',
336
+ "Put that sentence at the very top of your homepage in big, confident text.",
337
+ "Add three short supporting bullets that prove it (not adjectives \u2014 actual outcomes).",
338
+ `Identify exactly who it's for. "Everyone" is not a target.`,
339
+ "Read your sentence to 5 people who don't know your app. If they can't roughly repeat it back, it's not done."
340
+ ],
341
+ redFlags: [
342
+ 'Generic descriptions like "the best [thing] for [vague]"',
343
+ "You can't name a specific person it's for",
344
+ "Friends still don't get it after you explain twice",
345
+ "Your homepage talks about features instead of what changes for the user",
346
+ "You can't say what makes you different from the obvious alternative"
347
+ ],
348
+ cliCoverage: "manual_only",
349
+ whyManual: "A clear one-sentence value prop is a positioning judgment, not a code property. The scanner can detect a homepage exists; only a human can decide whether the sentence on it actually lands with target users."
350
+ },
351
+ {
352
+ id: "ai-audit",
353
+ number: 2,
354
+ title: "Audit what your AI builder actually shipped",
355
+ category: "Operations",
356
+ priority: "critical",
357
+ timeEstimate: "2 hr+",
358
+ prompt: "Act as a senior engineer doing a code audit on a project I built mostly with AI. Walk through every file and flag anything that looks like leftover scaffolding, mock data, or unfinished work that snuck into a real user flow. Specifically search for and list, with file and line: TODO/FIXME/XXX/HACK comments; the strings 'mock', 'dummy', 'placeholder', 'lorem', 'fake', 'sample', 'stub'; hardcoded test emails (test@example.com, john.doe, jane.doe); hardcoded fake names, fake numbers, fake credit cards; functions that return the same constant regardless of input; console.log/print statements anywhere on a real request path; debug routes, /test pages, or admin shortcuts. For each finding tell me: is this real work that needs to be done, or leftover scaffolding to delete. Then pick 3 of my most important functions and walk me through line by line what they actually do, in plain English, so I can tell if they match what I think they do. Don't fix anything yet \u2014 give me the report first.",
359
+ what: "Your AI builder is a fast, productive contractor who occasionally lies. It will tell you 'done!' when something is actually mocked, stubbed, hardcoded, or half-built. Before you launch you need to do a literal walkthrough of your codebase looking for the patterns AI builders ship by accident.",
360
+ why: "Vibe-coded apps go live with placeholder users called 'John Doe', mock API responses returning the same fake data every time, TODO comments inside real flows, debug logs leaking sensitive info, and functions that return constants instead of doing the thing. None of that explodes loudly \u2014 it just quietly makes your app a lie until a real user notices.",
361
+ steps: [
362
+ "In your editor, do a project-wide search (Cmd+Shift+F or your platform's search) one string at a time: TODO, FIXME, XXX, HACK, mock, dummy, placeholder, lorem, fake, sample, stub.",
363
+ "For each hit, decide: real work to finish, or leftover scaffolding to delete.",
364
+ "Search for hardcoded test data: test@example.com, john.doe, jane.doe, 555-1234, password123. Anything that smells synthetic shouldn't be in production code.",
365
+ "Open an incognito window and use your app as a brand new user. Sign up. Do the main thing. Does data actually save somewhere real, or are you looking at the same fake response every time?",
366
+ "Pick 3 random functions and ask your AI builder: 'walk me through what this does line by line in plain English.' If the explanation is fuzzy or doesn't match what you thought it does, that's a bug waiting."
367
+ ],
368
+ redFlags: [
369
+ "console.log or print statements on production code paths (especially logging tokens, full user records, or request bodies)",
370
+ "Functions that return the same value every time regardless of input",
371
+ "Forms that show a success message but you can't find the saved data anywhere",
372
+ "Comments like 'TODO: implement this' or 'replace with real API'",
373
+ "Variable names like fakeUsers, mockData, or testItems being used in production paths"
374
+ ],
375
+ cliCoverage: "automated",
376
+ cliPrompt: {
377
+ whatFailed: "The scanner found content that looks like leftover scaffolding from your AI builder \u2014 TODO/FIXME/HACK comments, placeholder strings ('lorem ipsum', 'John Doe', 'test@example.com'), or console-style debug calls sitting on real code paths. These are the patterns AI builders ship by accident: a contractor that says 'done' when the work is actually mocked or unfinished.",
378
+ whyItBlocksLaunch: "Vibe-coded apps go live with placeholder users called 'John Doe', mock API responses returning the same fake data every time, and debug log calls leaking tokens to the server log. None of that explodes loudly \u2014 it just quietly makes your app a lie until a real user notices and screenshots it.",
379
+ fixInstructions: "Treat each flagged file as a decision point: is this real work that needs to be finished, or leftover scaffolding to delete? Replace mock data with real database calls. Remove debug log statements from production paths. Either implement the TODO or remove the comment. Re-run the scanner \u2014 every finding should either be gone or downgraded with a documented reason.",
380
+ aiBuilderPrompt: "Walk through every finding in this scan flagged under 'ai-audit'. For each one, tell me: is this real work that needs to be done, or leftover scaffolding to delete? For TODO/FIXME comments, propose what the code should actually do (don't just remove the comment if there's missing logic). For placeholder strings like 'lorem ipsum' or 'test@example.com', replace with real content or remove the surface. For debug log statements on request paths, remove them but propose proper structured logging if the trace was actually useful (use the project's logger, never raw console output on a request path).",
381
+ verificationStep: "Re-run `npx shippingszn` and confirm zero findings tagged `ai-audit`. Then open the app in an incognito window and use it as a brand-new user \u2014 confirm data persists somewhere real and you don't see any of the placeholder text in the live UI."
382
+ }
383
+ },
384
+ {
385
+ id: "security-scanner",
386
+ number: 3,
387
+ title: "Run the Security & Privacy Scanner",
388
+ category: "Security",
389
+ priority: "critical",
390
+ timeEstimate: "1 hr",
391
+ prompt: "Act as a senior application security engineer. Audit this entire project end-to-end for the most common ways an early-stage app gets hacked or embarrassed in public. Check for: (1) leaked secrets in code, env files, or git history; (2) outdated dependencies with known CVEs; (3) unsafe code patterns (eval, raw shell exec, SQL string concatenation, unsanitized HTML rendering); (4) any file or asset that should never be deployed (test fixtures with real user data, debug endpoints, .DS_Store, .env, sample API keys); (5) overly permissive defaults (CORS *, public S3 buckets, open admin routes). For each finding, output: severity (Critical/High/Medium/Low), one-sentence plain-English description, exact file and line, and recommended fix. Do NOT fix anything yet \u2014 give me the full report sorted by severity, then wait for me to approve fixes one batch at a time.",
392
+ what: "Most modern AI builders (Replit, Lovable, Bolt, Cursor, etc.) ship with a security scanner \u2014 a one-click check that goes through your whole project and looks for the common ways apps get hacked, leak data, or accidentally ship dangerous files. Think of it as a smoke detector for your code: it can't fix problems for you, but it tells you exactly where they are before your users find them.",
393
+ why: "You don't know what you don't know. A scanner catches the obvious dangerous stuff \u2014 leaked passwords, outdated libraries with known holes, code patterns that hackers automate against \u2014 so you stop those before opening the doors. If your builder doesn't ship one, you can run an open-source equivalent (Snyk, Semgrep) for free.",
394
+ steps: [
395
+ `Open your builder and look for a "Security Scanner," "Audit," or "Vulnerabilities" panel \u2014 it usually has its own tab. If yours doesn't have one, install Snyk or Semgrep (both free for small projects).`,
396
+ "Click Run and let it finish. It usually takes a few minutes; longer for big projects.",
397
+ "Sort by severity and look at everything marked Critical or High first. Ignore Low for now.",
398
+ `For each finding, click into it. The scanner explains what's wrong in plain language and often suggests a fix. If you don't understand a finding, paste it into your AI builder and ask "what does this mean and how do I fix it?"`,
399
+ "Re-run the scanner after each batch of fixes. You want zero Critical and as few High as possible before launch."
400
+ ],
401
+ redFlags: [
402
+ "Any Critical finding you can't explain in your own words",
403
+ `High-severity issues you're planning to "deal with later"`,
404
+ 'A long list of "hidden" or "ignored" issues with no notes on why',
405
+ "Findings that show your app sending user data to companies you didn't intend",
406
+ "Files flagged as malicious or unsafe that you don't recognize"
407
+ ],
408
+ cliCoverage: "manual_only",
409
+ whyManual: "shippingszn IS the security scanner referenced by this item. Running `npx shippingszn` and reading the report is the act of completing this checklist item. Mark it complete after a clean scan + reviewing findings."
410
+ },
411
+ {
412
+ id: "secrets",
413
+ number: 4,
414
+ title: "Lock up your API keys and passwords",
415
+ category: "Security",
416
+ priority: "critical",
417
+ timeEstimate: "1 hr",
418
+ prompt: "Act as a security engineer. Scan my entire codebase (including config, scripts, tests, frontend, build output, and any committed .env files) for hardcoded secrets \u2014 API keys, tokens, database passwords, OAuth client secrets, webhook signing keys, JWT signing keys, anything that should be private. For each finding, tell me: file, line, what kind of secret it appears to be, and which service it's for. Then refactor every finding to read from environment variables with clear names (OPENAI_API_KEY, STRIPE_SECRET_KEY, DATABASE_URL, etc.), and update any .env.example or docs to list the required variables. Also audit runtime leakage paths: any console.log / logger call that prints req.body, req.headers, full user objects, OAuth callback params, or error objects containing tokens; any Sentry (or equivalent) breadcrumbs that capture request bodies or auth headers without scrubbing; any TODO/debug comments that quote a real key. List every secret that needs to be ROTATED at the source service because it was previously committed, shared, or logged. Never print actual secret values back to me \u2014 only references and filenames.",
419
+ what: "Every app you build talks to other services \u2014 Stripe for payments, OpenAI for AI, your database for data. Each of those gives you a long secret string (an API key) that proves it's you. If those strings are sitting inside your code, anyone who looks at your code can use them. That includes anyone you accidentally share a screenshot with, anyone you push to GitHub, and anyone who gets into your builder account.",
420
+ why: `This is the #1 way vibe-coded apps get destroyed. One leaked OpenAI key can rack up thousands of dollars over a weekend. One leaked Stripe key can let someone refund every charge you've ever made. Every modern builder gives you a place to store these safely \u2014 usually called "Secrets," "Environment Variables," or ".env" \u2014 you just have to use it.`,
421
+ steps: [
422
+ "Find the Secrets / Environment Variables panel in your builder (usually a lock or key icon in the sidebar). Replit calls it Secrets, Vercel calls them Environment Variables, Bolt calls it .env, etc.",
423
+ 'Search your code for anything that looks like a long random string \u2014 things starting with "sk-", "AIza", or any variable named "API_KEY", "SECRET", "TOKEN", or "PASSWORD".',
424
+ "Run a git-history scan, not just a search through current files. Tools like gitleaks (free, one command: `gitleaks detect`) check every commit you've ever made \u2014 including ones where you 'removed' the key by editing the file. A removed key still lives in history forever, and bots scrape public GitHub for these patterns within minutes of a push.",
425
+ 'For each one, add it to Secrets / env vars with a clear name (like OPENAI_API_KEY), then ask your AI builder: "replace the hardcoded OPENAI_API_KEY in my code with the environment variable."',
426
+ "Test that the app still works after the swap.",
427
+ "If a key was ever committed to GitHub (even a private repo), screenshot, or pasted into a chat \u2014 treat it as already-leaked. Go to that service's dashboard, regenerate the key, and update the new value in your env vars. Just deleting the old code does NOTHING \u2014 bots scan public GitHub within minutes, and 'private' repos go public by accident more than you'd guess. Rotate first, scrub history second."
428
+ ],
429
+ redFlags: [
430
+ "You can search your project files and find an actual API key sitting in plain text",
431
+ "A database connection URL with the password right in it, in code",
432
+ "You've pasted real keys into chats, screenshots, or AI prompts and never rotated them",
433
+ "Your .env or secrets file is sitting in your GitHub history",
434
+ "You've never run gitleaks (or any history scanner) \u2014 only searched current files"
435
+ ],
436
+ cliCoverage: "automated",
437
+ cliPrompt: {
438
+ whatFailed: "Your codebase contains what looks like a hardcoded API key or password. The scanner found a string that matches a common provider's secret format (OpenAI, Anthropic, Stripe, AWS, GitHub, etc.) sitting directly in source code or a committed config file.",
439
+ whyItBlocksLaunch: "A leaked production key is the single fastest way to lose money or expose user data. Public-repo secrets get scraped within minutes of being committed, and the bots that find them spin up paid API calls or exfiltrate database content before you notice. Even private repos leak \u2014 screenshots, accidental publishes, and shared chat threads all count.",
440
+ fixInstructions: "Move every detected secret out of source into environment variables (OPENAI_API_KEY, STRIPE_SECRET_KEY, DATABASE_URL, etc.) and read them at runtime via process.env. Add the variable names to .env.example so future runs know what to set. Then ROTATE the original secret at the provider \u2014 the leaked value must be considered compromised. Update .gitignore to exclude .env, and run gitleaks across history to catch earlier commits.",
441
+ aiBuilderPrompt: "Find every hardcoded secret in this project (API keys, tokens, OAuth client secrets, JWT signing keys, webhook secrets, database passwords). For each: tell me the file, the line, and what kind of secret it is. Then refactor each to read from process.env with a clear name (OPENAI_API_KEY, STRIPE_SECRET_KEY, etc.). Add every required variable to .env.example. Make sure .env is in .gitignore. List every secret value that needs to be ROTATED at the source service. Never print actual secret values back to me \u2014 only references.",
442
+ verificationStep: "Run `npx shippingszn` again and confirm zero findings under `secrets`. Run `gitleaks detect --source . --verbose` to scan git history. Rotate any value that ever appeared in a commit, screenshot, or chat \u2014 even removing it from current code doesn't unrotate it."
443
+ }
444
+ },
445
+ {
446
+ id: "api-spend-cap",
447
+ number: 5,
448
+ title: "Cap every AI / API spend before someone bankrupts you",
449
+ category: "Operations",
450
+ priority: "critical",
451
+ timeEstimate: "2 hr+",
452
+ prompt: "Act as a senior backend engineer. Audit my app for every paid third-party API I call (OpenAI, Anthropic, Replicate, ElevenLabs, Twilio, image generation, transcription, anything per-request). For each one: (1) tell me where in the provider's dashboard to set a HARD spending cap (not just an alert) and what number to start with \u2014 recommend a sane default for an early-stage app; (2) add an in-app per-user daily and per-IP daily quota for that endpoint, returning a polite 429 when exceeded; (3) add a global kill-switch I can toggle via env var to instantly stop calls if costs spike; (4) log every paid call with user ID, route, token count or unit cost so I can see who is consuming what; (5) add a simple cost dashboard or weekly email summary. After you're done, tell me the absolute worst-case daily spend if my app got pounded by a bot.",
453
+ what: "Every AI API and paid third-party service charges per request. If your app calls OpenAI, Anthropic, Replicate, ElevenLabs, Twilio, or anything similar, an attacker (or a bug) can run those calls in a loop and turn your free trial into a four-figure bill overnight. Spend caps and per-user quotas are the seatbelts.",
454
+ why: "This is the most underrated risk for vibe-coded apps in 2026. AI builders happily wire up an OpenAI key for you with no quotas. One infinite loop, one abusive script, or one curious user can rack up $1K\u2013$10K in a weekend. The provider will not refund it. Caps cost nothing and take 10 minutes to set.",
455
+ steps: [
456
+ "Log into every paid API dashboard (OpenAI, Anthropic, Replicate, etc.) and set a HARD monthly spending cap, not just a warning email. Start low \u2014 you can raise it.",
457
+ "In your app, add per-user quotas: 'this user can make at most N AI requests per day.' Even logged-in users need this.",
458
+ "Add per-IP rate limits on AI endpoints, separate from your normal API rate limits \u2014 much stricter.",
459
+ "Add a global kill-switch (an env variable like AI_ENABLED=false) you can flip in 10 seconds if costs spike.",
460
+ "Log every paid call with user ID and rough cost so you can see who is burning money. Set up a weekly summary email."
461
+ ],
462
+ redFlags: [
463
+ "No spending cap set in your AI provider's dashboard",
464
+ "No per-user limit on your AI features \u2014 anyone signed up can call them unlimited times",
465
+ "AI endpoints exposed to logged-out users without strict per-IP limits",
466
+ "You can't quickly answer 'what's the worst case my AI bill could be tomorrow?'",
467
+ "No way to instantly stop AI calls if you see costs spike"
468
+ ],
469
+ cliCoverage: "automated"
470
+ },
471
+ {
472
+ id: "secure-auth",
473
+ number: 6,
474
+ title: "Use a real login system, not one you wrote yourself",
475
+ category: "Security",
476
+ priority: "critical",
477
+ timeEstimate: "2 hr+",
478
+ prompt: "Act as a senior application security engineer. I want to replace any custom or partial authentication in this app with a battle-tested provider. First, recommend the best fit for my stack from: Clerk, Auth0, Supabase Auth, Stytch, or my platform's built-in auth \u2014 and explain why in one paragraph. Then implement it end-to-end: signup, login, logout, password reset, email verification, session management, and protected routes (both pages and API). Use the provider's recommended secure defaults. Add server-side checks on every protected route \u2014 never trust the frontend. Add login and OTP rate limiting (max 5 attempts per 15 minutes per IP+identifier). If email or SMS one-time codes are used, normalize phone numbers to E.164, make resend/cooldown behavior explicit, use anti-enumeration responses, write clear mobile copy, verify real code delivery, and add a recovery path if paid access depends on OTP. Set session expiry to 24h of inactivity. Migrate any existing user data safely. After you're done, give me a checklist of what I need to verify in the provider dashboard before launch.",
479
+ what: "How users sign in, receive verification codes, recover paid access, and stay signed in. Done well, only the real user can get into their account. Done badly, an attacker can guess codes, enumerate customers, abuse SMS/email delivery, steal sessions, or read passwords straight out of your database.",
480
+ why: "If your login or OTP flow is weak, every other security thing you did doesn't matter \u2014 the attacker just walks in the front door or your paid users get locked out. The good news: you almost never need to build login from scratch. Use a proven provider and let shippingszn scan for the launch risks AI builders usually miss.",
481
+ steps: [
482
+ "Use a real auth provider (Clerk, Auth0, Supabase Auth, Stytch, or your platform's built-in auth like Replit Auth) instead of writing it yourself. Your AI builder can wire one up in a single prompt.",
483
+ "Never store passwords directly. Real auth providers store a one-way scrambled version (a hash) so even they can't read it.",
484
+ "Turn on rate limiting on login (max 5 wrong attempts in 15 minutes) so attackers can't sit there guessing forever.",
485
+ "If you use email or SMS OTP, normalize phone numbers, add resend cooldown copy, use generic success-shaped start responses, and smoke a real delivered code before launch.",
486
+ "If paid report or purchase access depends on OTP, add a recovery path: alternate contact, receipt/support handoff, or purchase history.",
487
+ "Set sessions to expire (12\u201324 hours is normal) so a stolen laptop doesn't mean a permanent account takeover.",
488
+ "Offer two-factor authentication (2FA) if your auth provider supports it \u2014 most do, with one toggle."
489
+ ],
490
+ redFlags: [
491
+ "You can see actual passwords in your database (real ones look like long random gibberish)",
492
+ "You can try the wrong password 100 times in a row and nothing stops you",
493
+ "Sessions never expire \u2014 once you log in, you're logged in forever",
494
+ "Your login page works over plain http://, not https://",
495
+ "You wrote login from scratch instead of using a provider",
496
+ "SMS OTP compares raw phone strings instead of normalized E.164 numbers",
497
+ "The OTP start screen reveals whether an email, phone, purchase, or report exists",
498
+ "Paid users have no fallback when the email or SMS code does not arrive",
499
+ "Session cookies missing HttpOnly, Secure, or SameSite (check Application \u2192 Cookies in dev tools)"
500
+ ],
501
+ cliCoverage: "automated",
502
+ cliPrompt: {
503
+ whatFailed: "The scanner found gaps in your authentication or one-time-code (OTP) flow. Common detections: phone numbers compared as raw strings instead of normalized E.164, missing resend/cooldown copy on OTP screens, anti-enumeration responses that reveal whether an account exists, no recovery path when paid access depends on OTP, no mobile one-time-code input attribute, or no evidence of a real delivered-code smoke test.",
504
+ whyItBlocksLaunch: "Auth and OTP bugs are the #1 way paid users get locked out of products they paid for. A user who can't receive their login code on launch day is a refund and a lifetime negative review. An OTP flow that reveals existing accounts is a free customer list for attackers. These bugs are invisible until production traffic hits.",
505
+ fixInstructions: "Use a real auth provider (Clerk, Auth0, Stytch, Supabase Auth) instead of a hand-rolled flow. Normalize phone numbers to E.164 before storing or comparing. Add explicit resend-cooldown copy on the OTP screen. Use generic 'we sent a code if that account exists' responses (anti-enumeration). Add inputmode=numeric and autocomplete=one-time-code to the mobile input. Test a real delivered code from production-similar settings before launch. Add a recovery path (alternate contact, support handoff) if paid access ever depends on OTP.",
506
+ aiBuilderPrompt: 'Audit this app\'s authentication and OTP flow against the shippingszn `secure-auth` checks. (1) Replace any hand-rolled login with Clerk/Auth0/Stytch/Supabase Auth. (2) Normalize all phone numbers to E.164 at the boundary. (3) Add resend-with-cooldown copy on the OTP screen. (4) Make the OTP-start response identical whether the account exists or not. (5) Add `inputmode="numeric"` and `autocomplete="one-time-code"` to the OTP input. (6) Add a recovery path for users who can\'t receive OTP and have already paid. (7) Verify session cookies are HttpOnly + Secure + SameSite=Lax. Show me the diff before applying.',
507
+ verificationStep: "Send yourself a real OTP from production-similar settings \u2014 confirm it lands in inbox/SMS within 60 seconds. Try the wrong code 6 times \u2014 confirm rate limiting kicks in. Try an account that doesn't exist \u2014 confirm the start screen looks identical to the success path. Re-run the scanner and confirm `secure-auth` is clean."
508
+ }
509
+ },
510
+ {
511
+ id: "common-attacks",
512
+ number: 7,
513
+ title: "Block the most common automated attacks",
514
+ category: "Security",
515
+ priority: "critical",
516
+ timeEstimate: "2 hr+",
517
+ prompt: "Act as a security auditor. Audit my full app against the OWASP Top 10, focusing on: (1) SQL injection \u2014 find any place I'm building queries with string concatenation or user input that bypasses my ORM's parameterization; (2) NoSQL injection (Mongo, Firestore) \u2014 any place user input becomes a filter object without validation; (3) command injection \u2014 any place user input reaches a shell-invocation API in my language (Node shelling out, Python shell wrappers, Ruby backticks, etc.); (4) LDAP injection if I talk to a directory service; (5) XSS \u2014 find any place user input is rendered as HTML without escaping (innerHTML, raw HTML props, v-html, raw template interpolation, legacy doc-write APIs); (6) CSRF \u2014 find any state-changing endpoint without CSRF protection or SameSite cookies; (7) SSRF \u2014 find any place user input becomes a URL my server fetches; (8) insecure deserialization; (9) open redirects; (10) file upload handling \u2014 verify every upload path enforces a MIME allowlist, max size, path-traversal-safe filenames, and stores outside the web root (or on object storage with no public list); (11) verbose error responses \u2014 verify NODE_ENV (or equivalent) is 'production' in production and no stack trace, ORM dump, or absolute file path ever leaks to an end-user response body. For each finding: file, line, severity, plain-English explanation, exact fix. Apply the fixes after listing them. Then add a Content Security Policy header tuned to my actual asset sources \u2014 no wildcards, minimal unsafe-inline, justify any exception.",
518
+ what: "There are a handful of attacks that bots constantly run against every site on the internet. They have ugly names \u2014 XSS, SQL injection, CSRF \u2014 but the idea is simple: they trick your app into running code or queries it shouldn't. Modern frameworks have built-in defenses; you just have to use them correctly.",
519
+ why: "These bots don't care who you are. They scan the entire internet looking for sites that forgot to defend. If yours is one of those, your data ends up dumped on a forum, your users get hijacked, and you find out by reading about yourself online.",
520
+ steps: [
521
+ "Never paste user input directly into a database query. Use the safe parameterized version your framework provides (your AI builder knows how \u2014 ask it).",
522
+ "Never paste user input directly into HTML you display to other users. Frameworks like React handle this safely by default \u2014 don't disable that behavior.",
523
+ "Add a Content Security Policy header (your AI builder can set this up in one prompt) that tells browsers what code they're allowed to run.",
524
+ "Use anti-CSRF tokens on forms that change data, if your auth provider doesn't already handle them.",
525
+ 'Ask your AI builder: "audit my app for XSS, SQL injection, and CSRF vulnerabilities and fix any you find."'
526
+ ],
527
+ redFlags: [
528
+ "Anywhere you're building SQL queries by gluing strings together",
529
+ "Anywhere user input gets shown back to other users without going through the framework's safe rendering",
530
+ "Your framework is showing security warnings in the console you've been ignoring",
531
+ "Forms that change data (delete, update, transfer money) work fine when called from random other websites"
532
+ ],
533
+ cliCoverage: "automated",
534
+ cliPrompt: {
535
+ whatFailed: "The scanner caught a code pattern that maps to a well-known automated attack: unsafe HTML rendering (XSS), wildcard CORS (any origin can call your API), runtime code-execution calls, or a SQL/NoSQL query built from string concatenation instead of parameterization.",
536
+ whyItBlocksLaunch: "These are not theoretical. Bots scan the entire internet looking for sites that forgot to use the framework's safe defaults. Your app is one bot away from a stolen session, a hijacked admin route, or a customer-data dump on a forum. The fix is almost always a one-line change to use the framework's safe primitive instead of the raw one.",
537
+ fixInstructions: "Replace unsafe HTML rendering APIs (innerHTML, raw HTML props, v-html, legacy doc-write APIs) with the framework's safe interpolation (React's default {}, Vue's {{ }}, etc.). Replace wildcard CORS (`*`) with an explicit allowlist of trusted origins. Eliminate runtime code-execution calls \u2014 there is almost always a parser or DSL that does the job safely. Use parameterized queries everywhere \u2014 never glue strings into SQL/NoSQL filters. Add a Content Security Policy header tuned to your actual asset origins.",
538
+ aiBuilderPrompt: "Audit every finding in this scan tagged `common-attacks`. For each: explain in plain English what the attack is, show me the exact line, and apply the smallest safe fix \u2014 escape HTML rather than disabling sanitization, parameterize queries rather than string-glue, narrow CORS to the actual production origin(s), replace runtime code-execution calls with a typed parser. Also add a Content Security Policy header that allowlists ONLY my actual asset origins (no wildcards, minimal unsafe-inline, justify every exception). Show me the diff before applying.",
539
+ verificationStep: "Re-run the scanner and confirm zero findings tagged `common-attacks`. In the deployed app, open dev-tools Network and confirm responses include a tight CSP header. Try posting a `<script>alert(1)</script>` payload anywhere user content is rendered \u2014 confirm it shows as text, not as an executed alert."
540
+ }
541
+ }
542
+ ];
543
+
544
+ // ../../lib/checklist-data/src/items-critical-b.ts
545
+ var ITEMS_CRITICAL_B = [
546
+ {
547
+ id: "https-headers",
548
+ number: 8,
549
+ title: "Force HTTPS and add browser-level defenses",
550
+ category: "Security",
551
+ priority: "critical",
552
+ timeEstimate: "1 hr",
553
+ prompt: "Act as a security engineer. Set my app up with production-grade security headers. Add: Strict-Transport-Security (HSTS) with includeSubDomains and a 1-year max-age once I'm confident; Content-Security-Policy tuned to the actual scripts/styles/images/fonts/connections my app uses (no wildcards, no unsafe-inline unless I explicitly approve it \u2014 list each exception with a justification); X-Content-Type-Options: nosniff; X-Frame-Options: DENY (or SAMEORIGIN if I embed my own pages \u2014 ask me); Referrer-Policy: strict-origin-when-cross-origin; Permissions-Policy disabling features I don't use (camera, microphone, geolocation, etc.). Add an application-level http\u2192https redirect as a backup to platform HTTPS. After you're done, tell me what to test at securityheaders.com and what grade I should expect.",
554
+ what: "HTTPS is the little padlock in the browser bar. It encrypts everything between your user and your server, so people sharing the same WiFi can't read passwords as they're being typed. Security headers are extra instructions you send to the browser saying \u201Cnever trust content claiming to be from me unless it really is.\u201D",
555
+ why: "Without HTTPS, anyone on the same coffee-shop WiFi can read your users' passwords as plaintext. Without security headers, attackers can wrap your site inside theirs (clickjacking) or trick browsers into running malicious scripts. The fixes are basically free.",
556
+ steps: [
557
+ "Most modern hosting (Replit, Vercel, Netlify, Cloudflare Pages, Railway) gives you HTTPS automatically when you publish \u2014 confirm the lock icon shows up in the browser bar.",
558
+ "Set up an automatic redirect from http:// to https:// so no one accidentally lands on the unencrypted version.",
559
+ "Add a Content-Security-Policy header that locks down where your scripts, images, and fonts are allowed to come from.",
560
+ "Add X-Content-Type-Options: nosniff and X-Frame-Options: DENY (or SAMEORIGIN if you embed your own pages).",
561
+ "Test your site at https://securityheaders.com \u2014 aim for at least an A grade."
562
+ ],
563
+ redFlags: [
564
+ "Any page on your site that loads over plain http:// in production",
565
+ "A grade of D or F on securityheaders.com",
566
+ "Other websites can put your app in an iframe (potential clickjacking)",
567
+ "Browser console shows mixed-content warnings"
568
+ ],
569
+ cliCoverage: "automated"
570
+ },
571
+ {
572
+ id: "dev-prod-data",
573
+ number: 9,
574
+ title: "Keep your test data away from real users",
575
+ category: "Infrastructure",
576
+ priority: "critical",
577
+ timeEstimate: "30 min",
578
+ prompt: 'Act as a senior infrastructure engineer. Audit my project to confirm development and production are properly separated. Verify: (1) my dev workspace connects to a dev database, NOT production; (2) my production deployment uses production-only environment variables; (3) seed scripts, fixtures, and mock data are excluded from production; (4) destructive scripts (drop tables, wipe data, factory reset) cannot run in production by accident \u2014 add an explicit guard that fails if NODE_ENV is "production" unless I pass a confirmation flag; (5) any analytics/error monitoring is properly tagged by environment so I can tell dev noise from real production events. Tell me exactly which database my code reads from in each context. If anything is misconfigured, fix it and explain in plain English what was wrong.',
579
+ what: "Two databases: one to mess around with while you build (development), one that holds your actual users' actual data (production). They should never touch each other. Most modern builders and hosted database services (Replit, Supabase, Neon, PlanetScale) set you up with both automatically when you publish.",
580
+ why: "If you point a half-broken in-progress feature at the real database, you can corrupt or wipe real user data with one bad query. If you copy real user data into your dev environment, you've taken a privacy obligation and made it more likely to leak. Keep them apart.",
581
+ steps: [
582
+ "Know which is which: the database in your workspace is dev. The one your published app uses is prod.",
583
+ "Never paste production database credentials into your dev workspace.",
584
+ "When testing destructive features (delete account, bulk update, mass email), only test against dev data.",
585
+ "If you ever need a slice of prod data for debugging, scrub names, emails, and any personal info first.",
586
+ "Read your platform's docs on production databases so you know how to push schema changes (new tables, renamed columns) safely. Most have a one-page guide \u2014 the link below is Replit's, as one example."
587
+ ],
588
+ redFlags: [
589
+ `You can't answer "which database is my live app reading from right now?"`,
590
+ "Your in-progress code points at the production database",
591
+ 'You manually edit production data ("just one quick fix") with no rollback plan',
592
+ "You've copied real production data to your dev environment without scrubbing",
593
+ "Schema changes (new tables, renamed columns) go live without ever being tested first"
594
+ ],
595
+ references: [
596
+ {
597
+ label: "Example: Replit production databases",
598
+ url: "https://docs.replit.com/cloud-services/storage-and-databases/sql-database"
599
+ }
600
+ ],
601
+ cliCoverage: "automated"
602
+ },
603
+ {
604
+ id: "backups",
605
+ number: 10,
606
+ title: "Back up your database \u2014 and actually test the restore",
607
+ category: "Infrastructure",
608
+ priority: "critical",
609
+ timeEstimate: "1 hr",
610
+ prompt: "Act as a senior infrastructure engineer. Tell me, for my specific database setup, exactly: (1) is automatic point-in-time backup turned on, and how far back can I restore? (2) where the backups physically live and who can access them; (3) the EXACT click-by-click procedure to restore my database to a point 24 hours ago in a non-destructive way (clone first, swap if good, never restore in place blind). Then walk me through ACTUALLY DOING a test restore right now to a clone \u2014 not in theory, in practice \u2014 so I know it works before I need it. Also recommend an additional manual backup strategy (weekly export to object storage I control) so I'm not 100% dependent on my host. Output a one-page DISASTER_RECOVERY.md I can keep with my project.",
611
+ what: "An automatic, recent copy of your entire database stored somewhere safe \u2014 and the ability to restore from it without panicking. Most managed databases (Neon, Supabase, Replit DB, RDS, PlanetScale) include some form of backup, but defaults vary, and 'a backup exists' is not the same thing as 'a backup that works.'",
612
+ why: "Sooner or later you, your AI builder, or a script will run the wrong query against the production database. Without a tested backup, your only options are 'rebuild from memory' and 'apologize to users in public.' With one, it's a 10-minute fix. The whole point of doing this BEFORE launch is that nobody is depending on the data yet \u2014 so testing the restore is free.",
613
+ steps: [
614
+ "Open your database's dashboard (Neon, Supabase, Replit, etc.) and find the Backups or Point-in-time Recovery section. Confirm automatic backups are on, and note how many days you can restore back.",
615
+ "Actually do a test restore \u2014 to a clone, not your real database. Most providers let you spin up a copy from a backup in one click. Do it once before launch so you know how.",
616
+ "Before any production schema migration \u2014 adding/dropping columns, renaming tables, changing constraints \u2014 take a fresh point-in-time snapshot first. 30 seconds of clicking now beats 6 hours of manually reconstructing data at midnight when the migration eats a column it shouldn't have. Make this a habit, not a launch-day-only thing.",
617
+ "Set yourself a calendar reminder for a weekly manual export (most ORMs and DBs have a one-line dump command), saved somewhere you control (object storage, your laptop). Defense in depth.",
618
+ "Write a one-page note (DISASTER_RECOVERY.md) for future-you: where backups live, exact steps to restore, who to call. Put it next to your code.",
619
+ "NEVER restore directly over your live database without first restoring to a clone and confirming it has what you expected."
620
+ ],
621
+ redFlags: [
622
+ "You don't actually know whether automatic backups are on for your database",
623
+ "You've never performed a restore \u2014 only assumed it would work",
624
+ "You run schema migrations against production without taking a fresh snapshot first",
625
+ "Backups stored in the same account as the database (one compromised login = both gone)",
626
+ "No documented procedure \u2014 when disaster strikes, you'll improvise badly",
627
+ "Retention is 24 hours or less (one missed day and you're cooked)"
628
+ ],
629
+ cliCoverage: "manual_only",
630
+ whyManual: "The scanner can detect a docs/RECOVER.md (covered by extended internal-audit checks) but cannot confirm the documented procedure has been tested against a real restore. Owner must run the drill once before launch."
631
+ },
632
+ {
633
+ id: "secure-api",
634
+ number: 11,
635
+ title: "Lock down your app's behind-the-scenes URLs",
636
+ category: "Security",
637
+ priority: "critical",
638
+ timeEstimate: "2 hr+",
639
+ prompt: "Act as a backend security engineer. Go through every API endpoint in my app and check two things for each: (1) authentication \u2014 is the user logged in? (2) authorization \u2014 is THIS user allowed to access THIS specific resource? (the IDOR check). Pay especially close attention to endpoints that use IDs from the URL like /users/:id or /orders/:id \u2014 these are the most commonly broken. Output a table per endpoint: route, method, requires login? (Y/N), checks resource ownership? (Y/N), risk level. Then fix every endpoint that's missing checks. Add input validation (Zod, Yup, or my framework's equivalent) to every endpoint \u2014 validate types, ranges, lengths, formats. Lock down CORS to my own domain \u2014 no wildcards in production. After fixes, give me 3 curl commands I can run to verify a logged-out user, a regular logged-in user, and another user's account all get blocked appropriately.",
640
+ what: "Your app has a frontend (what users see) and a backend (the URLs the frontend calls to load and save data \u2014 these are called API endpoints). If those backend URLs aren't checking who's asking and what they're allowed to do, anyone with browser developer tools can call them directly and do whatever they want.",
641
+ why: 'This is one of the most common silent disasters in vibe-coded apps: the frontend hides the "delete account" button from non-admins, but the backend lets anyone call /api/delete-account if they know the URL. The button is decoration; the backend check is the actual lock.',
642
+ steps: [
643
+ 'Every endpoint that touches user data should check: "is this person logged in?"',
644
+ `Every endpoint that touches a specific user's data should also check: "is this person allowed to access THIS user's data?" This is the most commonly missed step.`,
645
+ "Validate every input on the backend \u2014 don't trust the frontend to send you clean data.",
646
+ `Ask your AI builder: "go through every API endpoint in my project and tell me which ones don't check authentication or authorization, and fix them."`,
647
+ "Set up CORS so only your own frontend can call your backend."
648
+ ],
649
+ redFlags: [
650
+ "Admin functions you can call from a logged-out browser",
651
+ "You can change the user ID in a URL (/api/users/123 \u2192 /api/users/124) and read someone else's data",
652
+ "Your backend trusts whatever the frontend sends without re-checking it",
653
+ "CORS is wide open (Access-Control-Allow-Origin: *) on a non-public API"
654
+ ],
655
+ cliCoverage: "automated"
656
+ },
657
+ {
658
+ id: "access-control",
659
+ number: 12,
660
+ title: "Decide who's allowed to do what",
661
+ category: "Security",
662
+ priority: "critical",
663
+ timeEstimate: "2 hr+",
664
+ prompt: "Act as a senior backend engineer. Help me design and implement a clean role/permission model. First, ask me what user types my app has (e.g., owner, admin, member, free, paid). Then create a single source of truth for permissions \u2014 either a permissions matrix or a function like can(user, action, resource) \u2014 and wire EVERY sensitive route, mutation, and UI element through it. Default to deny \u2014 only allow what's explicitly granted. Hide UI elements based on permissions, but ALSO enforce them server-side (frontend hiding is never a security control). Document the model in a short PERMISSIONS.md so future-me can read it. After you're done, write 5 manual test cases I can run to confirm a regular user can't access admin functionality by URL guessing or modifying request payloads.",
665
+ what: "Most apps have at least two kinds of users \u2014 regular users and admins (you). Some have more (free vs. paid, owners vs. members, etc.). Access control is the rules that say \u201Cthis person can do this, but not that.\u201D Without it, a curious user can stumble into pages or actions they shouldn't have.",
666
+ why: "The classic disaster: a regular user discovers /admin still works for them, deletes a few records to see what happens, and now you have angry users and no backups. Or, more quietly: paid features accidentally available to free users, costing you revenue.",
667
+ steps: [
668
+ "Make a list: what types of users does your app have? (Owner, admin, member, guest, free, paid.)",
669
+ "For each sensitive action (edit, delete, view billing, invite), write down who is allowed.",
670
+ "Enforce those rules on the backend, not just by hiding buttons on the frontend.",
671
+ "Default to no access \u2014 only grant what someone explicitly needs.",
672
+ "Try to break it: log in as a regular user and try to access admin URLs directly. Try to access another user's data by changing IDs. If anything works that shouldn't, fix it."
673
+ ],
674
+ redFlags: [
675
+ "You only enforce permissions by hiding buttons on the frontend",
676
+ "Changing an ID in a URL gives you access to data you shouldn't see",
677
+ "Free users can hit paid features by guessing URLs",
678
+ `There's no clear list anywhere of "admin can do X, regular user can do Y"`
679
+ ],
680
+ cliCoverage: "manual_only",
681
+ whyManual: "Role-based access boundaries (admin can do X but user cannot) need a runtime test with two real accounts. Code review alone misses bugs in auth-middleware ordering."
682
+ },
683
+ {
684
+ id: "legal-pages",
685
+ number: 13,
686
+ title: "Add real Terms and Privacy pages (don't fake these)",
687
+ category: "Product & Launch",
688
+ priority: "critical",
689
+ timeEstimate: "1 hr",
690
+ prompt: 'Act as a product engineer. Add /terms and /privacy pages to my app. IMPORTANT: do not invent legal language. Instead, generate a structured outline of every section that needs to exist in each page, customized to what my app actually does. Detect what to include by inspecting my dependencies and code \u2014 list every third-party service I integrate (Stripe, OpenAI, Google Analytics, Resend, Sentry, etc.) and note which ones need to be disclosed in the privacy policy. Output the outline as headings with a 1-sentence description of what each section should cover. Add a clear banner at the top of each page: "This is a starting outline. Get the actual legal language from a lawyer or a service like Termly, iubenda, or Termageddon." Then add the page routes, link them from the footer / signup / cookie banner, and include an "Effective date" field and a real contact email.',
691
+ what: "Two pages most apps need: Terms of Service (the rules of using your app) and a Privacy Policy (what data you collect and what you do with it). They are legal documents \u2014 the words matter, and they have to actually describe what your app does.",
692
+ why: "These protect you from getting sued and protect your users from being misled. Most platforms (App Store, Google, Stripe, even Google sign-in) require them. Generated or copy-pasted policies that don't match your actual product are worse than nothing \u2014 they're evidence in a lawsuit.",
693
+ steps: [
694
+ "Do not have an AI write your final legal pages. Use a reputable template service (Termly, iubenda, Termageddon) or pay an actual attorney for a few hours.",
695
+ "Tailor whatever template you use to match what your app actually does \u2014 every third-party service you use (analytics, AI, payments, email) probably needs to be mentioned.",
696
+ "Create /terms and /privacy pages and link them from the footer, signup, and any place you collect data.",
697
+ "Include an effective date and a real way to contact you.",
698
+ "If you collect cookies or run analytics, add a cookie banner where required (especially in the EU and UK)."
699
+ ],
700
+ redFlags: [
701
+ "No terms or privacy page at all",
702
+ "AI-generated policies that talk about features your app doesn't have",
703
+ "Pages copied from another company (with their company name still in there)",
704
+ "No effective date, no contact info",
705
+ "Your privacy policy doesn't mention services you actually use (Stripe, OpenAI, Google Analytics, etc.)"
706
+ ],
707
+ cliCoverage: "automated"
708
+ },
709
+ {
710
+ id: "account-deletion",
711
+ number: 14,
712
+ title: "Give users a way to delete their account and export their data",
713
+ category: "Product & Launch",
714
+ priority: "critical",
715
+ timeEstimate: "2 hr+",
716
+ prompt: "Act as a privacy-aware product engineer. Add two flows to my app: (1) Account deletion \u2014 a button in account settings that, when confirmed (with a second-step modal), permanently deletes the user, all their personal data, and all their content, with a 7-day grace period during which the account can be recovered. After 7 days, the deletion is hard. Email confirmation when initiated and when finalized. (2) Data export \u2014 a button that emails the user a downloadable JSON or CSV of all their personal data and content within 24 hours. Audit my schema and tell me which tables/columns count as 'personal data' and need to be included. Make sure the deletion respects foreign-key constraints and removes data from any third party I forward to (Stripe customer, email provider, analytics). Don't break referential integrity for OTHER users (e.g., comments by deleted user become 'deleted user' instead of cascading). Output a short DATA_RIGHTS.md describing what's deleted and what's retained for legal reasons.",
717
+ what: "Two buttons in account settings: 'Download my data' and 'Delete my account.' One emails the user a copy of everything you have on them; the other actually removes them. Both are required almost everywhere personal data is regulated, and both are missing from almost every vibe-coded app.",
718
+ why: "GDPR (EU), CCPA (California), and an expanding list of US state laws make these legally required if you have any users in those places \u2014 which you will, because the internet is global. Beyond legal: it's the right thing, it builds trust, and it costs basically nothing to add now versus a panicked weekend later when someone files a complaint.",
719
+ steps: [
720
+ "Add a 'Delete my account' button in account settings. Require a second confirmation step ('type DELETE to confirm') so it's not accidental.",
721
+ "Implement a 7-day grace period: the account is disabled immediately, deleted permanently after 7 days. Email the user when each happens.",
722
+ "Make sure deletion removes their data from EVERY system: your database, your email provider's contact list, Stripe customer record, analytics, error monitoring. List these in code so future-you remembers.",
723
+ "Add a 'Download my data' button that emails them a JSON or CSV export of everything you have on them within 24 hours.",
724
+ "Write a short page (linked from privacy policy and account settings) explaining what gets deleted, what's kept and why (e.g., financial records you must legally retain), and how long it all takes."
725
+ ],
726
+ redFlags: [
727
+ "No way for a user to delete their account from inside the app",
728
+ "Deleting an account leaves their data in your database 'just in case'",
729
+ "Deleting an account removes them from your DB but not from Stripe / your email tool / your analytics",
730
+ "No way for a user to get a copy of their data",
731
+ "You'd genuinely struggle to comply if a user emailed you tomorrow asking to be deleted"
732
+ ],
733
+ cliCoverage: "manual_only",
734
+ whyManual: "A delete-account endpoint can exist in code without working end-to-end (cascade handling, third-party deletions, grace period, confirmation flow). Run the flow with a test account and confirm every related row is gone."
735
+ },
736
+ {
737
+ id: "consent-banner",
738
+ number: 15,
739
+ title: "Add a cookie / consent banner if you have any non-US traffic",
740
+ category: "Product & Launch",
741
+ priority: "critical",
742
+ timeEstimate: "1 hr",
743
+ prompt: "Act as a privacy-compliance engineer. Add a GDPR/CCPA-compliant consent banner to my app. Requirements: (1) shows on first visit; (2) clearly distinguishes 'strictly necessary' cookies (always on, no consent needed) from 'analytics/marketing/advertising' (off by default \u2014 explicit opt-in); (3) BEFORE consent, no analytics, no third-party trackers, no advertising pixels load \u2014 only essential auth/session cookies; (4) AFTER consent, the chosen categories load. Use a reputable open-source library (CookieConsent, Klaro) or a service (Cookiebot, Termly's banner). Inventory every cookie and tracker my app currently sets \u2014 list them in the banner UI by category. Give users a persistent way to change their choice later (footer link). Don't fake the 'reject all' button \u2014 it has to actually do nothing. Document each cookie in the privacy policy.",
744
+ what: "A small banner that asks visitors before you load analytics, ad pixels, or any non-essential tracking. Required by law in the EU and UK (GDPR/ePrivacy), and increasingly in California, Brazil, Canada, and a growing list of US states. Even if your business is US-only, the moment one person from London visits your site, you're inside their rules.",
745
+ why: "Fines for missing consent banners are real \u2014 up to 4% of global revenue under GDPR \u2014 and enforcement against small sites has ramped up. Even before fines, app stores, ad networks, and analytics tools (Google in particular) increasingly require valid consent signals before they'll work properly.",
746
+ steps: [
747
+ "Use a battle-tested library: CookieConsent (open-source, free), Klaro, or a service like Cookiebot or Termly. Don't build this from scratch.",
748
+ "Inventory every cookie and tracker your app sets. Group into 'Strictly necessary' (auth, session \u2014 always on) and 'Analytics & marketing' (off by default).",
749
+ "Block analytics scripts, ad pixels, and any third-party tracker from loading until the user opts in. Most consent libraries handle this if wired correctly.",
750
+ "Make 'Reject all' a single click and equally prominent as 'Accept all.' Dark patterns ('Accept all' is a big button, 'Reject' is hidden) are themselves illegal.",
751
+ "Add a persistent way to change consent later \u2014 usually a footer link like 'Cookie preferences.'"
752
+ ],
753
+ redFlags: [
754
+ "No consent banner at all and you have non-US users",
755
+ "Analytics or ad pixels load before the user has accepted",
756
+ "'Reject all' button is hidden, missing, or styled to look unclickable",
757
+ "Banner has only an 'OK' button \u2014 that's not consent, that's notice",
758
+ "You can't list every cookie your site sets and what each one does"
759
+ ],
760
+ cliCoverage: "manual_only",
761
+ whyManual: "Whether a consent banner is required depends on what tracking cookies the live app sets and which jurisdictions you serve. Runtime + legal call, not a code property."
762
+ },
763
+ {
764
+ id: "payments",
765
+ number: 16,
766
+ title: "Make sure payments actually work before you charge people",
767
+ category: "Product & Launch",
768
+ priority: "critical",
769
+ timeEstimate: "2 hr+",
770
+ prompt: "Act as a payments engineer. Audit my app's entire paid flow before launch. First, identify whether I use Stripe, Lemon Squeezy, Paddle, RevenueCat, app-store payments, or something else. Then verify and fix: (1) checkout can be created only by the server, never with a client-supplied price or product; (2) the success page does NOT unlock paid access just because the URL says success; it checks the backend for a paid order; (3) webhooks verify the provider signature and persist paid status, receipt ID, amount, currency, and customer email; (4) every paid product, subscription, upgrade, cancellation, refund, and failed-payment state has a real user-facing path; (5) test-mode checkout, webhook delivery, receipt email, refund, and cancellation all work end-to-end. Output exact test-card steps, webhook setup steps, env vars, and the one thing I should click in the provider dashboard to prove money would actually move.",
771
+ what: "If your app charges money, the payment flow is not 'done' when the checkout button opens. It is done when payment succeeds, your backend hears the signed webhook, paid access unlocks, the receipt lands, and cancellation/refund paths do not strand the user.",
772
+ why: "This is where vibe-coded apps embarrass themselves fast. A founder launches, someone pays, the success page lies, the webhook never arrives, and now the first customer is both confused and charged. Or worse: a user edits a client-side price and buys the expensive thing for $0. Payments need a real dry run before launch day.",
773
+ steps: [
774
+ "Run a full test-mode checkout from a logged-out or brand-new account. Pay with the provider's test card and confirm the success page shows the right purchased thing.",
775
+ "Verify paid access comes from your backend's recorded payment state, not from a success URL, localStorage flag, or client-side boolean.",
776
+ "Open the provider dashboard and confirm the webhook endpoint is live, signing is verified in code, and the event was delivered successfully.",
777
+ "Test the unhappy paths: failed card, duplicate click, refresh after checkout, refund, cancellation, expired subscription, and trying to access paid content before the webhook arrives.",
778
+ "Send yourself the receipt or confirmation email and confirm it lands in the inbox with the right amount, product, support contact, and refund/cancel instructions."
779
+ ],
780
+ redFlags: [
781
+ "The frontend sends the price, plan, or product ID and the backend trusts it",
782
+ "Paid access unlocks just because the browser landed on /success",
783
+ "No webhook handler, or a webhook handler that does not verify provider signatures",
784
+ "You have never tested refund, cancellation, failed card, or duplicate checkout",
785
+ "A customer could pay and have no obvious way to get help if access does not unlock"
786
+ ],
787
+ cliCoverage: "automated"
788
+ },
789
+ {
790
+ id: "file-uploads",
791
+ number: 17,
792
+ title: "Lock down uploads and private files",
793
+ category: "Security",
794
+ priority: "critical",
795
+ timeEstimate: "2 hr+",
796
+ prompt: "Act as an application security engineer. Audit every place my app accepts, stores, processes, previews, or downloads user files. For each upload path, verify and fix: (1) max file size and per-user storage quota; (2) MIME/type allowlist checked on the server, not only by file extension; (3) dangerous files rejected or sandboxed (HTML, SVG with scripts, executables, archives if not needed); (4) filenames normalized so path traversal like ../ cannot work; (5) private files stored outside the public web root or in object storage with private buckets and signed, expiring URLs; (6) image/document processing strips metadata where appropriate and cannot execute embedded code; (7) antivirus or malware scanning is added if users can share files with others. Output every upload route, who can access each file afterward, and exact manual tests I should run before launch.",
797
+ what: "Uploads are any file a user can give your app: avatars, PDFs, CSVs, images, documents, audio, exports, attachments. They look harmless until one private upload becomes public, one huge file knocks over your server, or one malicious file gets served back to another user.",
798
+ why: "AI builders love to wire 'upload a file' in one prompt and skip the boring safety rails. That means public buckets, unlimited file sizes, trusting .jpg extensions, and download URLs anyone can guess. If users trust you with files, you need to prove those files stay private and bounded.",
799
+ steps: [
800
+ "List every upload entry point: avatar, import, support attachment, chat file, document, CSV, admin upload, anything.",
801
+ "Set hard limits: allowed file types, max file size, per-user storage quota, and max number of files per object or account.",
802
+ "Validate type on the server using MIME sniffing or file magic, not just the browser's accept attribute or the filename extension.",
803
+ "Store private files in a private bucket and serve them through signed, short-lived URLs after checking the current user is allowed to read that exact file.",
804
+ "Try to break it: upload a huge file, a renamed .exe, an HTML file, an SVG with script, a filename with ../, and another user's file URL. All should fail safely."
805
+ ],
806
+ redFlags: [
807
+ "Uploads go directly into a public /uploads folder or public object-storage bucket",
808
+ "Only the frontend checks allowed file types",
809
+ "No max file size or storage quota",
810
+ "Anyone with the URL can view another user's supposedly private file",
811
+ "Uploaded SVG/HTML files are served back as executable browser content"
812
+ ],
813
+ cliCoverage: "automated"
814
+ },
815
+ {
816
+ id: "ai-guardrails",
817
+ number: 18,
818
+ title: "Put guardrails around AI outputs and actions",
819
+ category: "Security",
820
+ priority: "critical",
821
+ timeEstimate: "2 hr+",
822
+ prompt: "Act as an AI application security engineer. Audit every LLM, agent, tool-calling, retrieval, and generated-content flow in my app. Verify and fix: (1) user prompts, uploaded files, retrieved documents, and web pages are treated as untrusted input; (2) prompt injection cannot make the model reveal secrets, system prompts, hidden context, or other users' data; (3) any tool/action the AI can trigger has an explicit allowlist, server-side permission check, spending limit, and human confirmation for destructive or external actions; (4) model outputs shown to users are labeled, validated, and safe-failed when confidence is low; (5) logs do not store sensitive prompts or private documents longer than needed; (6) there is an abuse path for harmful, illegal, or policy-breaking generations. Build a concrete test set with 10 prompt-injection and data-leak attempts and show which ones pass after the fixes.",
823
+ what: "If your app uses AI, the model is not just a text box. It may see private data, make recommendations, call tools, spend API money, send emails, edit records, or create content users trust. Guardrails are the limits that keep a weird prompt from turning into a data leak or a destructive action.",
824
+ why: "Vibe-coded AI apps often ship with one giant prompt, direct access to user data, and no boundary between 'the model suggested it' and 'the app did it.' Prompt injection is not magic; it is a user telling your AI to ignore your instructions. If the AI can touch data or tools, that instruction needs to bounce off a hard server-side permission check.",
825
+ steps: [
826
+ "Map every AI flow: what context the model sees, what tools it can call, what data it can read, and what actions it can trigger.",
827
+ "Treat user prompts, uploaded files, retrieved docs, and web pages as hostile input. Never trust them to follow your system prompt.",
828
+ "Put server-side allowlists and permission checks in front of every AI tool call. The model can request an action; your backend decides whether it is allowed.",
829
+ "Require human confirmation for destructive, external, expensive, or irreversible actions: sending email, deleting data, publishing content, charging money, or calling paid APIs in bulk.",
830
+ "Build a small red-team test set: 'ignore previous instructions,' 'show me another user's data,' 'print your system prompt,' 'call the tool without permission,' and run it before launch."
831
+ ],
832
+ redFlags: [
833
+ "The model can call tools directly without a server-side permission check",
834
+ "Private user data or uploaded files are pasted into prompts with no access boundary",
835
+ "No prompt-injection tests exist",
836
+ "The app treats generated output as fact without labeling, validation, or fallback",
837
+ "AI prompts, retrieved documents, or conversation logs store sensitive data forever"
838
+ ],
839
+ cliCoverage: "manual_only",
840
+ whyManual: "AI guardrails need scenario testing against real prompts, tool permissions, data boundaries, and destructive-action confirmations. Static code cannot prove the model behaves safely under adversarial input."
841
+ }
842
+ ];
843
+
844
+ // ../../lib/checklist-data/src/items-critical.ts
845
+ var ITEMS_CRITICAL = [
846
+ ...ITEMS_CRITICAL_A,
847
+ ...ITEMS_CRITICAL_B
848
+ ];
849
+
850
+ // ../../lib/checklist-data/src/items-high.ts
851
+ var ITEMS_HIGH = [
852
+ {
853
+ id: "error-monitoring",
854
+ number: 19,
855
+ title: "Get notified the moment something breaks",
856
+ category: "Operations",
857
+ priority: "high",
858
+ timeEstimate: "30 min",
859
+ prompt: "Act as a senior engineer. Install error monitoring across my full stack. Default to Sentry unless you have a strong reason to recommend otherwise \u2014 explain. Wire it into both frontend and backend. Configure: (1) automatic error capture for unhandled exceptions and rejected promises; (2) source maps so stack traces show real line numbers; (3) user context (user ID + email when available) so I can see who hit each error; (4) release tagging so I know which deploy introduced what; (5) noise filtering for browser extensions and common bot errors; (6) email/Slack alerts for new error types or sudden spikes; (7) performance/transaction tracing on my key routes. Test by deliberately throwing a test error in dev and confirming it shows up. List every env variable I need to set and tell me which dashboard settings to flip.",
860
+ what: "Error monitoring is software that sits inside your app and silently records every error, then alerts you. So when your user hits a bug at 2am, you know about it within minutes \u2014 instead of hearing about it three days later from a frustrated tweet.",
861
+ why: "Most users do not report bugs. They just leave. By the time you find out something is broken from feedback, dozens of people have already bounced. With error monitoring, you see problems immediately and can fix them before they spread.",
862
+ steps: [
863
+ "Sign up for Sentry (most popular, generous free tier), Rollbar, or PostHog.",
864
+ "Install their SDK in both your frontend and backend (your AI builder can do this in one prompt).",
865
+ "Connect it to email or Slack so new errors notify you.",
866
+ "For the first week after launch, check the dashboard daily \u2014 fix recurring errors before they pile up.",
867
+ "Configure it to ignore known noise (browser extensions, bots) so real signal stands out."
868
+ ],
869
+ redFlags: [
870
+ "The same error happening to dozens of users without anyone telling you",
871
+ "Errors trending upward week over week",
872
+ "Database connection failures showing up regularly",
873
+ "No one has looked at the error dashboard in over a week"
874
+ ],
875
+ cliCoverage: "automated"
876
+ },
877
+ {
878
+ id: "uptime-monitoring",
879
+ number: 20,
880
+ title: "Get pinged the moment your app goes completely down",
881
+ category: "Operations",
882
+ priority: "high",
883
+ timeEstimate: "30 min",
884
+ prompt: "Act as an SRE. Set me up with external uptime monitoring on my production URL (and any other critical surfaces \u2014 API health endpoint, login page, marketing site). Use Better Stack, UptimeRobot, or Pingdom \u2014 recommend one in 2 sentences. Configure: (1) check every 1-3 minutes from at least 3 geographic regions; (2) alerts to BOTH email and SMS / Slack \u2014 pick whatever I'll actually see at 2am; (3) require 2 consecutive failed checks before alerting (avoid false alarms from a single blip); (4) a public status page so users can self-serve when something is broken (most providers include this free); (5) add a simple /health endpoint to my backend that returns 200 + a timestamp + database connectivity check, so the monitor can verify more than just 'web server returns HTML'. Walk me through the dashboard step by step.",
885
+ what: "An external service that pings your site every minute or so. If it ever fails to respond, you get a text or email within minutes \u2014 even at 3am. Different from error monitoring, which only works when your app is up enough to phone home.",
886
+ why: "When your app is fully dead \u2014 server crashed, database unreachable, deployment broke \u2014 your error tracker can't tell you because it's also down or never sees the requests. The only thing that catches a total outage is something completely outside your stack pinging you from the outside. Most early-stage apps find out they're down from a customer complaint hours later.",
887
+ steps: [
888
+ "Sign up for UptimeRobot (free tier is fine for one site), Better Stack, or Pingdom.",
889
+ "Add monitors for: your homepage, your login page, and a /health endpoint on your backend.",
890
+ "Configure alerts to BOTH email and your phone (SMS or push) \u2014 pick whatever wakes you up.",
891
+ "Add a /health endpoint to your backend that does a quick database query and returns 200 only if everything works. Otherwise the monitor will say 'up' even when the database is dead.",
892
+ "Turn on the public status page (most providers include this free) and link to it from your footer or feedback widget \u2014 saves you from being flooded with 'is it down?' messages."
893
+ ],
894
+ redFlags: [
895
+ "You only know you're down when a user emails you",
896
+ "Monitoring is set to 'check every hour' (way too slow for a real launch)",
897
+ "Alerts go only to an email you check twice a day",
898
+ "/health endpoint just returns 200 without actually checking the database",
899
+ "No public status page \u2014 every outage = 100 'is it down?' messages"
900
+ ],
901
+ cliCoverage: "manual_only",
902
+ whyManual: "External uptime monitors live in a provider dashboard, not in the codebase. The scanner cannot prove the production URL is being checked from multiple regions or that alerts reach your phone."
903
+ },
904
+ {
905
+ id: "rate-limiting",
906
+ number: 21,
907
+ title: "Cap how often someone can hit your app",
908
+ category: "Security",
909
+ priority: "high",
910
+ timeEstimate: "1 hr",
911
+ prompt: "Act as a backend security engineer. Add rate limiting to every public-facing endpoint. Use a sensible default (60 req/min/IP) for read endpoints, and tighter limits for sensitive ones: login (5 per 15 min per IP+email), signup (3 per hour per IP), password reset (3 per hour per email), AI/expensive endpoints (whatever fits a daily budget \u2014 ask me what I'm willing to spend per day). Return a clean 429 response with a Retry-After header and a friendly JSON message. Log every rate-limit hit with IP and route so I can see attacks. Use my framework's recommended middleware (express-rate-limit, hono-rate-limit, etc.). Recommend in-memory vs Redis backing based on whether I'm running multiple instances. After you're done, give me a curl one-liner I can run to verify it actually blocks me after N attempts.",
912
+ what: "Rate limiting puts a maximum on how many requests one person (or one IP address) can make in a given window \u2014 say, 60 requests a minute. Without it, a single bot can hammer your backend until it falls over, run up your AI bill, or brute-force passwords until something works.",
913
+ why: "Without rate limits you are one bad actor away from a $10,000 OpenAI bill, a crashed server, or a leaked password. Rate limits are cheap to add and save you from a long list of nightmares.",
914
+ steps: [
915
+ "Add rate limiting to every public-facing endpoint.",
916
+ "Use stricter limits on the sensitive ones: login (5 per 15 minutes), signup (3 per hour), password reset (3 per hour), AI calls (whatever fits your budget).",
917
+ `Return a clear "you're going too fast, try again in X seconds" message \u2014 don't just silently fail.`,
918
+ "Watch for repeated rate-limit hits \u2014 they're usually attacks. Have alerts set up for spikes.",
919
+ 'Ask your AI builder: "add per-IP rate limiting to all my endpoints with stricter limits on login, signup, and password reset."'
920
+ ],
921
+ redFlags: [
922
+ "No rate limits at all",
923
+ "Same limits everywhere (login should be way stricter than browsing)",
924
+ "Auth endpoints (login, signup, password reset) not strictly capped \u2014 brute-force and credential-stuffing bots will find you",
925
+ "No alerting when someone repeatedly hits the limit",
926
+ "You can't see how often this is happening"
927
+ ],
928
+ cliCoverage: "automated"
929
+ },
930
+ {
931
+ id: "dependency-audit",
932
+ number: 22,
933
+ title: "Patch your dependencies for known vulnerabilities",
934
+ category: "Security",
935
+ priority: "high",
936
+ timeEstimate: "30 min",
937
+ prompt: "Act as a senior security engineer. Audit my project's installed dependencies for known vulnerabilities AND for supply-chain hygiene, and apply fixes. Tasks: (1) run the right auditor for my package manager \u2014 `npm audit` or `pnpm audit` for Node, `pip-audit` for Python, `bundle audit` for Ruby, `cargo audit` for Rust \u2014 and report the severity counts (critical / high / moderate / low). (2) for every Critical or High finding, try the auto-fix first (`npm audit fix`); if that doesn't clear it, upgrade the offending package manually and test the app after each change. Don't blindly `--force`. (3) if clearing a CVE needs a major-version bump that would break my app, document the CVE + the blocked upgrade + my exposure, then add a temporary mitigation if one's possible. (4) supply-chain hygiene \u2014 for every direct dependency, look up weekly download count and most-recent release date on the registry. Flag any dep with <1k weekly downloads (could be a typosquat \u2014 check the name against the obvious legitimate package) and any dep with no release in 12+ months (unmaintained \u2014 plan a replacement). (5) turn on automated dependency updates so this doesn't rot: Dependabot (free on GitHub) or Renovate \u2014 PR weekly for minor/patch, prompt on major. (6) add an audit step to my CI pipeline that fails the build on any new Critical CVE. When you're done, tell me plainly: any CVEs that made it to prod, any I deferred (with reasoning), any suspicious or abandoned deps I should swap, and the auto-update cadence you configured.",
938
+ what: "Your app pulls in hundreds of third-party packages via `npm install` (or pip, bundle, cargo). Some of those packages have known security bugs with public write-ups and working exploits. Auditing means running one command to list every known bug in your dependencies, then upgrading to the fixed versions.",
939
+ why: "Most successful attacks on small apps aren't clever \u2014 they're automated scanners finding sites that ship an old version of a popular library with a published CVE. The fix is usually a one-command upgrade. Skipping this is handing attackers the easiest version of your app.",
940
+ steps: [
941
+ "Run your package manager's audit command (`npm audit`, `pnpm audit`, `pip-audit`, `bundle audit`, `cargo audit`) and read the output.",
942
+ "For every Critical or High finding, try the auto-fix first (e.g. `npm audit fix`). Test that the app still works after each fix.",
943
+ "For fixes that require a major-version bump, read the package's upgrade notes before updating \u2014 breaking changes are real.",
944
+ "If a CVE has no fix yet, at least know you have it. Document it and subscribe to the package's security advisories.",
945
+ "Turn on Dependabot or Renovate in your GitHub repo so it opens PRs as new versions ship \u2014 you're not manually checking anymore.",
946
+ "Add the audit command to your CI pipeline so shipping a new Critical CVE breaks the build."
947
+ ],
948
+ redFlags: [
949
+ "You have never run `npm audit` (or your language's equivalent) on this project",
950
+ "The audit shows Critical severity \u2014 and you shipped anyway",
951
+ "Dependabot / Renovate is not turned on",
952
+ "Your lockfile hasn't been touched in more than 6 months",
953
+ "CI never fails on a new CVE \u2014 you'll only find out from a bug report or a breach"
954
+ ],
955
+ cliCoverage: "manual_only",
956
+ whyManual: "Dependency risk changes daily and needs the package manager plus registry advisories at scan time. Static checklist data cannot prove every critical CVE has been patched or consciously deferred."
957
+ },
958
+ {
959
+ id: "logging",
960
+ number: 23,
961
+ title: "Keep a paper trail of what your app is doing",
962
+ category: "Operations",
963
+ priority: "high",
964
+ timeEstimate: "1 hr",
965
+ prompt: `Act as a senior backend engineer. Set up structured JSON logging across my app using my framework's recommended logger (pino, winston, etc.). Log: every login, signup, logout, password change, payment event, admin action, and every error with full context (user ID, request ID, route, sanitized params). NEVER log passwords, tokens, full credit card numbers, full session IDs, or any PII beyond what's strictly necessary \u2014 implement a redaction list. Add request ID middleware so every log line in a single request can be correlated. Set log levels: debug only in dev, info+ in production. Send logs somewhere I can search (Logtail, Axiom, Datadog, or my platform's log viewer). After you're done, write me a short "logging do/don't" reference card to paste into my README.`,
966
+ what: "Logging is your app writing down what it did, when, and for whom \u2014 like a security camera for code. When something goes wrong (a charge failed, an account got locked, data is missing), good logs let you reconstruct exactly what happened. Bad logs leave you guessing.",
967
+ why: "When you eventually have a weird bug or a user complaint that doesn't match what you see, logs are the difference between a 5-minute fix and a multi-day investigation. They're also your evidence if you ever have to prove what happened (security incident, billing dispute, abuse report).",
968
+ steps: [
969
+ "Use a structured logger (one that writes JSON, not raw text) \u2014 most frameworks have one built in.",
970
+ "Log every login, signup, password change, payment, and important user action.",
971
+ "Log every error with full context (which user, which endpoint, what they were doing).",
972
+ "NEVER log passwords, API keys, full credit card numbers, or any other secret. This is itself a security incident waiting to happen.",
973
+ "Keep logs for at least 30 days so you can investigate slow-burning issues."
974
+ ],
975
+ redFlags: [
976
+ "You're logging passwords or full personal info",
977
+ "Errors in production with no log trail at all",
978
+ "Logs are unstructured walls of text you can't search",
979
+ "Logs disappear within a day"
980
+ ],
981
+ cliCoverage: "manual_only",
982
+ whyManual: "Logging is only launch-ready if the right events arrive in a searchable production sink without secrets. The scanner cannot prove retention, redaction, or dashboard access from code alone."
983
+ },
984
+ {
985
+ id: "session-management",
986
+ number: 24,
987
+ title: "Make sessions feel safe AND convenient",
988
+ category: "Security",
989
+ priority: "high",
990
+ timeEstimate: "1 hr",
991
+ prompt: `Act as a security engineer. Audit and improve my session management. Verify and fix: (1) sessions expire after 24h of inactivity; (2) password change invalidates ALL existing sessions for that user, not just the current one; (3) there's a "log out everywhere" button in account settings that actually works; (4) sessions are stored server-side (or as signed/encrypted JWTs with a short TTL plus refresh tokens) \u2014 never plaintext cookies; (5) session IDs rotate on login to prevent session fixation; (6) if I use JWTs, verify the verifier explicitly allowlists the signing algorithm (HS256 or RS256) and REJECTS tokens where alg is "none" or differs from what I signed with \u2014 algorithm-confusion attacks are a common JWT foot-gun. Verify the signing key is at least 256 bits of real randomness (not "secret" or "changeme") and comes from an env var, not a literal; (7) optional but recommended: show users a list of active sessions with device, IP, and last activity, with the ability to revoke any. Implement the changes, then explain each one in a plain-English sentence so I understand what changed and why.`,
992
+ what: "How you handle \u201Cis this person still logged in?\u201D over time. Get this right and users stay logged in long enough to be useful, but not so long that a forgotten laptop becomes a permanent risk.",
993
+ why: "Bad session handling is either annoying (kicked out every 20 minutes) or dangerous (still logged in three months later on a shared computer). The right defaults make both rare.",
994
+ steps: [
995
+ "Set sessions to expire after a reasonable window of inactivity (12\u201324 hours is normal).",
996
+ 'Add a "Remember me" checkbox if you want longer sessions \u2014 but only when explicitly chosen by the user.',
997
+ "When a user changes their password, log out all their other sessions automatically.",
998
+ 'Give users a "log out everywhere" button in their account settings.',
999
+ "If your auth provider supports it, show users where they're currently logged in (device, IP, last activity)."
1000
+ ],
1001
+ redFlags: [
1002
+ "Sessions that never expire",
1003
+ "Changing your password doesn't kick out other sessions",
1004
+ "No way for a user to see or end their other sessions",
1005
+ 'Insecure "Remember me" (a long-lived plaintext token in a cookie)'
1006
+ ],
1007
+ cliCoverage: "automated"
1008
+ },
1009
+ {
1010
+ id: "github",
1011
+ number: 25,
1012
+ title: "Connect to GitHub for backups and history",
1013
+ category: "Infrastructure",
1014
+ priority: "high",
1015
+ timeEstimate: "30 min",
1016
+ prompt: "Act as a developer-tooling expert. Walk me through connecting this project to a private GitHub repo step by step, in the simplest possible way for someone who has never used git from the command line. Then: (1) audit existing git history for any committed secrets and tell me which ones need to be rotated; (2) generate a complete .gitignore tuned to my exact stack (no .env, no build artifacts, no local DBs, no .DS_Store, no IDE folders); (3) verify nothing sensitive is currently being tracked; (4) set up a CODEOWNERS file with my GitHub username as default owner; (5) add a basic GitHub Actions workflow that runs my linter, typecheck, and tests on every push and PR. Explain each command in plain English BEFORE I run it.",
1017
+ what: "GitHub is an external service that stores every version of your code, forever. Even if your project on your builder gets deleted, broken, or accidentally rolled back too far, GitHub has every version you ever pushed. It's also how anyone else (a co-founder, a contractor) collaborates with you.",
1018
+ why: `Most builders have their own undo/checkpoint history, but having your code in a second place is one more disaster you'll never have. Plus: when you eventually want to hire a real developer, the first thing they'll ask is "can I have GitHub access?"`,
1019
+ steps: [
1020
+ "Create a free GitHub account if you don't have one.",
1021
+ "In your builder, connect your project to a new GitHub repository \u2014 most have a one-click GitHub button in their Git or Version Control pane.",
1022
+ "Make the repo private if your code includes business logic you don't want copied.",
1023
+ "Confirm your secrets are NOT being pushed. Most builders exclude their secrets store by default, but double-check there's no .env file or hardcoded key leaking through. If anything sensitive made it in, rotate the keys at the source service immediately.",
1024
+ "Push regularly \u2014 at least daily, ideally after every meaningful change."
1025
+ ],
1026
+ redFlags: [
1027
+ "No version control at all (your only copy lives inside one builder)",
1028
+ "Secrets accidentally pushed to the repo",
1029
+ "You haven't pushed in days \u2014 you could lose work to one bad rollback",
1030
+ "Public repo containing private business logic or customer data"
1031
+ ],
1032
+ cliCoverage: "automated"
1033
+ },
1034
+ {
1035
+ id: "rollback",
1036
+ number: 26,
1037
+ title: "Know how to roll back a bad deploy in under a minute",
1038
+ category: "Operations",
1039
+ priority: "high",
1040
+ timeEstimate: "15 min",
1041
+ prompt: "Act as a deployment engineer. For my specific hosting platform, walk me through the EXACT click-by-click procedure to roll back to the previous deploy. Then have me actually do it once \u2014 to a previous commit, then forward again \u2014 so I know the muscle memory before I need it. Also: (1) tell me what's preserved during a rollback (env variables, secrets, data) and what isn't (any DB migration that ran on the bad deploy is NOT undone \u2014 call this out); (2) recommend whether I should enable preview deployments on every PR/branch so I can test changes before they hit production; (3) give me a 5-line emergency runbook (ROLLBACK.md) I can paste into my repo: 'If production is broken, do these 3 things in this order.'",
1042
+ what: "The ability to undo a deployment in 30 seconds and get back to the last known good version. Almost every modern host (Replit, Vercel, Netlify, Railway, Render, Fly) supports one-click rollback to any prior version \u2014 but you need to know where the button is BEFORE the bad deploy.",
1043
+ why: "You will ship something broken to production. Your AI builder will help you 'fix' something at midnight and the fix will be worse. The difference between a 30-second outage and a 3-hour panic is whether you've practiced rolling back once when nothing was wrong.",
1044
+ steps: [
1045
+ "Find the Deployments or Releases panel in your hosting platform (most have 'Promote to production' or 'Rollback' next to each version).",
1046
+ "Do a practice rollback NOW, while everything is fine. Roll back one version, confirm the site still works, then roll forward again. You want this in muscle memory.",
1047
+ "Understand what rollback does NOT undo: any database migration that ran on the bad version is still applied \u2014 your code is rolled back but your schema isn't. Plan accordingly (migrations should be backwards compatible).",
1048
+ "Turn on preview deployments (Vercel, Netlify, Railway all do this) so every change gets a temporary URL you can test before it touches production.",
1049
+ "Write a 5-line ROLLBACK.md in your repo: '1. Open hosting dashboard. 2. Find latest known-good version. 3. Click Rollback. 4. Verify site works. 5. Tell users in status page / Twitter what happened.'"
1050
+ ],
1051
+ redFlags: [
1052
+ "You don't know where the rollback button is in your hosting dashboard",
1053
+ "You've never actually performed a rollback even once",
1054
+ "All your changes go straight to production without a preview deployment",
1055
+ "Database migrations run automatically and are not reversible",
1056
+ "No written runbook \u2014 at 2am you'll be improvising in panic"
1057
+ ],
1058
+ cliCoverage: "manual_only",
1059
+ whyManual: "Rollback readiness is muscle memory in the hosting dashboard plus a real recovery path for data migrations. The scanner cannot prove you practiced rollback and forward again."
1060
+ },
1061
+ {
1062
+ id: "soft-launch",
1063
+ number: 27,
1064
+ title: "Soft-launch to 5 friends before you launch publicly",
1065
+ category: "Product & Launch",
1066
+ priority: "high",
1067
+ timeEstimate: "1 hr",
1068
+ prompt: "Act as a product launch coach. Help me set up a soft launch to 5-10 friends/early users 48 hours before my public launch. Output: (1) a short personal outreach message I can DM each one \u2014 friendly, asks a specific favor, sets expectations; (2) a one-page 'try this' brief: signup flow \u2192 core feature \u2192 one specific thing to try; (3) a feedback capture template (Google Form or Typeform) with 5 sharp questions: what was confusing, what was broken, what would you tell a friend it does, would you actually use this and why, on a scale of 1-10 how likely to recommend; (4) a 'watch them use it' protocol if I can get 1-2 of them on a screen-share \u2014 what to watch for, what to NOT do (don't help, don't explain, don't apologize); (5) a triage rubric for sorting their feedback into 'must fix before public launch' vs 'next week' vs 'never'. Be ruthless about scope \u2014 this is 48 hours, not 4 weeks.",
1069
+ what: "Send your app to 5-10 friends or early users 1-3 days before the public launch and watch what happens. Not for moral support \u2014 for finding the obvious things you've gone blind to from staring at it for two weeks.",
1070
+ why: "This is the highest-leverage and cheapest item on the entire list. Five strangers will find five things you missed: the signup form that breaks on Safari, the button labeled 'Submit' that should be 'Save', the empty state that looks like a broken page. Catching these now costs an hour. Catching them on launch day in front of a thousand strangers costs your reputation.",
1071
+ steps: [
1072
+ "Make a list of 5-10 people who match your target user (not just supportive friends \u2014 actual matches). Include at least 2 who will be a little brutal.",
1073
+ "Send each one a personal message (NOT a group blast) 48 hours before launch. Tell them what to try, what kind of feedback you want, how long it'll take, and that 'this is broken' is the most useful thing they can say.",
1074
+ "If you can, get 1-2 on a screen-share. Watch silently. Don't help, don't explain, don't apologize. Where they hesitate is your bug list.",
1075
+ "Collect feedback in one place (a Google Form or shared doc), not scattered DMs. Easier to spot patterns.",
1076
+ "Sort the feedback into three buckets: must-fix-before-launch, do-in-week-1, never. Be ruthless \u2014 most public launches fail because the founder tried to fix everything in 48 hours and burned out."
1077
+ ],
1078
+ redFlags: [
1079
+ "Going straight to public launch without anyone outside your head ever using it",
1080
+ "Only sending to people you know will love it (selection bias = useless feedback)",
1081
+ "Helping users when they get stuck instead of letting them struggle (you can't be there on launch day)",
1082
+ "Trying to 'fix everything' from soft launch \u2014 you'll miss public launch and burn out",
1083
+ "Feedback scattered across 10 DMs and a notebook \u2014 patterns are invisible"
1084
+ ],
1085
+ cliCoverage: "manual_only",
1086
+ whyManual: "A soft launch is evidence from real humans, not a deploy artifact. The scanner cannot prove five target users tried the product and found the launch-day friction."
1087
+ },
1088
+ {
1089
+ id: "interviews",
1090
+ number: 28,
1091
+ title: "Talk to your first 10 users on a real call",
1092
+ category: "Growth",
1093
+ priority: "high",
1094
+ timeEstimate: "2 hr+",
1095
+ prompt: `Act as a customer research expert in the style of Rob Fitzpatrick (The Mom Test). Help me prep for my first 10 user interview calls. Output: (1) a 15-minute interview script with 8-10 open-ended questions designed to surface real behavior and real pain \u2014 NOT to validate my product. Explicitly avoid leading questions like "don't you love\u2026?"; (2) an outreach email template I can personalize and send to early signups, offering a $20 gift card; (3) a calendar-friendly question set; (4) a one-page note-taking template that captures the user's actual words verbatim, not my interpretation; (5) a synthesis template I can use after 5+ interviews to spot patterns across calls. Before generating anything, ask me what my product does, who the users are, and what I'm trying to learn \u2014 so the script is sharp, not generic.`,
1096
+ what: "A 15-minute video or phone call with each of your first early users. Not a survey. Not a Slack DM. An actual conversation where you mostly listen.",
1097
+ why: "Data tells you what people do. Conversations tell you why. You'll find out which features confused them, which ones they actually use, and which problem they'd pay to solve. You can't guess your way to that.",
1098
+ steps: [
1099
+ "Reach out personally to your first 10\u201320 signups. Offer a $20 gift card if it helps (it does).",
1100
+ "Schedule 15 minutes \u2014 keep it short so people actually show up.",
1101
+ 'Ask open questions: "Walk me through how you discovered us." "What were you trying to do when you signed up?" "What almost made you leave?"',
1102
+ "Listen way more than you talk. Don't defend the product, don't pitch. Take notes.",
1103
+ "After 5\u201310 calls, look for patterns. Those are your roadmap."
1104
+ ],
1105
+ redFlags: [
1106
+ "You've never actually talked to a user out loud",
1107
+ "You only talk to people who already love it (selection bias)",
1108
+ `You ask leading questions ("don't you love that we did X?")`,
1109
+ "You spend the call defending your decisions instead of listening",
1110
+ "No notes \u2014 insights evaporate the moment the call ends"
1111
+ ],
1112
+ cliCoverage: "manual_only",
1113
+ whyManual: "Customer interviews are a research practice with real conversations and notes. The scanner can never tell whether ten users said the pain out loud in their own words."
1114
+ },
1115
+ {
1116
+ id: "onboarding",
1117
+ number: 29,
1118
+ title: "Make the first 5 minutes obvious",
1119
+ category: "Product & Launch",
1120
+ priority: "high",
1121
+ timeEstimate: "2 hr+",
1122
+ prompt: "Act as a senior product designer focused on first-run onboarding. Audit my app from the perspective of a brand-new user who has never seen it before. Create a fresh account and walk the first 5 minutes. Verify and fix: (1) the landing page tells me exactly what to do next; (2) signup does not ask for anything unnecessary before value; (3) the first logged-in screen has one obvious next action, not a dead empty state; (4) sample data, templates, or guided setup exist if the product is blank by default; (5) every permission, integration, or setup step explains why it is needed; (6) the user reaches a real 'aha' moment or useful artifact within 5 minutes. Output a friction log with timestamp, screen, what confused me, and the smallest fix. Then implement the top 3 fixes without redesigning the whole app.",
1123
+ what: "The first 5 minutes are the difference between 'I get it' and 'I'll come back later' (they won't). A new user needs one clear path from landing page to signup to first value, even if their account starts with no data.",
1124
+ why: "Vibe-coded apps often work only for the founder because the founder already knows what every blank page means. New users see an empty dashboard, a generic 'Get started' button, or a setup maze and assume the app is broken. You do not need fancy onboarding. You need the next action to be obvious.",
1125
+ steps: [
1126
+ "Create a brand-new account with an email that has never touched the app. Do not use your founder/admin account.",
1127
+ "Start a timer and try to get to first value in 5 minutes: a saved result, a generated output, a created project, a shared link, whatever your app promises.",
1128
+ "Make the first logged-in screen intentional. If there is no data yet, show an empty state with one clear action, not a blank dashboard.",
1129
+ "Add sample data, templates, import prompts, or a 3-step setup checklist if the product needs context before it can be useful.",
1130
+ "Watch one person do this without explanation. Where they pause, click randomly, or ask 'what now?' is the bug list."
1131
+ ],
1132
+ redFlags: [
1133
+ "The first logged-in page is empty with no obvious next action",
1134
+ "Users have to configure integrations before understanding the product",
1135
+ "Signup asks for a long profile before the user sees value",
1136
+ "Buttons say vague things like 'Continue' or 'Submit' when the action should be specific",
1137
+ "You cannot explain what first value looks like in one sentence"
1138
+ ],
1139
+ cliCoverage: "manual_only",
1140
+ whyManual: "First-run onboarding is a human walkthrough from fresh account to first value. The scanner cannot prove a brand-new user understands what to do in the first five minutes."
1141
+ }
1142
+ ];
1143
+
1144
+ // ../../lib/checklist-data/src/items-medium.ts
1145
+ var ITEMS_MEDIUM = [
1146
+ {
1147
+ id: "analytics",
1148
+ number: 30,
1149
+ title: "Set up basic usage tracking",
1150
+ category: "Operations",
1151
+ priority: "medium",
1152
+ timeEstimate: "1 hr",
1153
+ prompt: "Act as a product analytics engineer. Install PostHog (or recommend Plausible / Fathom / GA4 if better for my case \u2014 explain why in 2 sentences). Wire automatic pageview tracking across my app. Then: (1) ask me to list 3-5 key user actions worth tracking (signup completed, feature X used, paid conversion, etc.); (2) instrument event tracking for each one with consistent naming (snake_case verbs); (3) set up identify() calls so events tie to specific users; (4) build me a starter dashboard showing weekly signups, activations, and a funnel from landing \u2192 signup \u2192 first key action; (5) confirm tracking respects Do-Not-Track and works with my cookie banner if I have one. List every env var I need to set.",
1154
+ what: "Analytics is software that records what users actually do in your app \u2014 which pages they visit, which buttons they click, where they drop off. Without it you're flying blind about what's working.",
1155
+ why: "Your gut about what users do is almost always wrong. Analytics replaces guessing with evidence \u2014 so when you decide what to fix or build next, you're using data instead of your favorite theory.",
1156
+ steps: [
1157
+ "Pick one tool. PostHog is free and great for product analytics. Plausible or Fathom for simple, privacy-friendly traffic stats. Google Analytics if you want the giant ecosystem.",
1158
+ "Install it on every page (one snippet \u2014 your AI builder can do this).",
1159
+ "Decide your 3\u20135 metrics that actually matter (signups, activations, paid conversions, daily actives \u2014 pick yours).",
1160
+ "Set up event tracking for each one and verify the events show up in the dashboard.",
1161
+ "Make a habit of looking at the dashboard daily for the first month."
1162
+ ],
1163
+ redFlags: [
1164
+ "No analytics installed at all",
1165
+ "Analytics installed but you've never looked at it",
1166
+ "You're tracking everything except the things that matter",
1167
+ 'No clear definition of "a successful week" in numbers'
1168
+ ],
1169
+ cliCoverage: "manual_only",
1170
+ whyManual: "Analytics has to be verified inside the live analytics dashboard with real events. The scanner cannot prove pageviews, key actions, consent behavior, or reporting are actually arriving."
1171
+ },
1172
+ {
1173
+ id: "db-performance",
1174
+ number: 31,
1175
+ title: "Make your database queries fast",
1176
+ category: "Infrastructure",
1177
+ priority: "medium",
1178
+ timeEstimate: "2 hr+",
1179
+ prompt: `Act as a senior database engineer. Audit my database schema and ORM queries for performance. Specifically: (1) list every column I filter, sort, join, or group by, and tell me which ones are missing indexes \u2014 recommend the right index type (B-tree, partial, composite); (2) find every N+1 query pattern (loading a list and then making one query per item) and refactor to joins, eager loading, or batched queries; (3) find any query that fetches all rows when it should paginate; (4) find unnecessary SELECT * \u2014 replace with explicit columns; (5) verify connection pooling is configured correctly for my deployment topology. Output a before/after summary per fix with an estimated impact (e.g., "200 queries \u2192 1 query per page load"). Generate the migration files for new indexes. Don't run them \u2014 let me review first.`,
1180
+ what: "Your database stores all your data, and your app constantly asks it questions (\u201Cgive me this user's posts\u201D). Without basic tuning, those questions get slower as data grows \u2014 until pages take 5 seconds to load, you exhaust your database connection limit, or your hosting bill explodes.",
1181
+ why: "Slow apps lose users. Each one-second delay roughly halves the chance someone will wait. And it's almost never a hardware problem \u2014 it's usually a missing index on a table, or the same query running 200 times when it could run once.",
1182
+ steps: [
1183
+ 'Add an index on any column you search or filter by often (user_id, email, created_at). your AI builder can identify candidates: "audit my schema and suggest indexes based on how I query each table."',
1184
+ "Make sure you're using connection pooling \u2014 most ORMs have it on by default, but verify.",
1185
+ 'Watch for the "N+1 query" pattern (loading a list, then making one extra query per item). Refactor those into a single join.',
1186
+ "Cache expensive read queries that don't change often (e.g., a leaderboard that updates hourly).",
1187
+ "Turn on slow query logging and check it weekly. Anything over 500ms deserves a look."
1188
+ ],
1189
+ redFlags: [
1190
+ "Pages that take 2+ seconds to load even when there's only a little data",
1191
+ "A single page view triggers hundreds of database queries",
1192
+ "Database CPU pegged at 100% under light load",
1193
+ "You have no idea which queries are your slowest"
1194
+ ],
1195
+ cliCoverage: "manual_only",
1196
+ whyManual: "Database performance depends on real query plans, row counts, indexes, and connection pooling under production-like load. The scanner cannot prove the app stays fast as data grows."
1197
+ },
1198
+ {
1199
+ id: "custom-domain",
1200
+ number: 32,
1201
+ title: "Get your own domain (yourname.com)",
1202
+ category: "Infrastructure",
1203
+ priority: "medium",
1204
+ timeEstimate: "30 min",
1205
+ prompt: "Act as an infrastructure engineer. Walk me through pointing a custom domain at my deployed app, end to end. First, ask me: my domain registrar (Namecheap, Cloudflare, etc.), my hosting/builder, and my domain (e.g. example.com). Then output: (1) the exact DNS records I need to add (type, name, value, TTL); (2) where to add them in my registrar's UI, click by click; (3) how long propagation typically takes; (4) how to set up www \u2192 root redirect (or root \u2192 www \u2014 ask my preference); (5) how to verify the lock icon and HTTPS are working; (6) how to add HSTS once I'm confident the site is stable. Use plain English \u2014 assume I have no DNS knowledge.",
1206
+ what: "Right now your app probably lives at some auto-generated URL on your builder's domain (e.g. something.replit.app, my-app.lovable.app, project.vercel.app). A custom domain (yourname.com) makes you look like a real product, not a weekend project, and gives you the freedom to switch hosting providers later without changing your URL.",
1207
+ why: "First impressions matter more than they should. People trust their-startup.com way more than their-startup-prod-v2.some-builder.app. It also makes your URL memorable and tweetable.",
1208
+ steps: [
1209
+ "Buy a domain from Namecheap, Porkbun, or Cloudflare Registrar \u2014 they all sell at near-cost. Avoid GoDaddy.",
1210
+ "In your builder's deployment or hosting settings, add your custom domain. It will show you exactly which DNS records to add at your registrar.",
1211
+ "Add those DNS records at your registrar. Wait a few minutes (sometimes hours) for the change to spread across the internet.",
1212
+ "By default the site lives at example.com. If you want www.example.com to also work, the cleanest way is to put your domain behind Cloudflare and set up a www \u2192 root redirect rule (link below).",
1213
+ "Once it's live, confirm the lock icon appears (HTTPS is automatic on most modern hosting) and that http:// redirects to https://."
1214
+ ],
1215
+ redFlags: [
1216
+ "Still using the default builder-supplied subdomain (.replit.app, .lovable.app, .vercel.app, etc.) in production",
1217
+ "Lock icon missing (SSL not provisioned yet)",
1218
+ "Both www and non-www serve duplicate content with no clear primary",
1219
+ "DNS pointing somewhere stale (an old hosting provider, a dead service)"
1220
+ ],
1221
+ references: [
1222
+ {
1223
+ label: "Redirect www to root (Cloudflare)",
1224
+ url: "https://developers.cloudflare.com/rules/url-forwarding/single-redirects/examples/redirect-www-to-root/"
1225
+ }
1226
+ ],
1227
+ cliCoverage: "manual_only",
1228
+ whyManual: "Custom-domain readiness depends on live DNS, hosting settings, redirects, and issued certificates. The scanner cannot prove the exact production domain is pointed correctly without owner DNS verification."
1229
+ },
1230
+ {
1231
+ id: "email",
1232
+ number: 33,
1233
+ title: "Make app emails actually arrive",
1234
+ category: "Infrastructure",
1235
+ priority: "medium",
1236
+ timeEstimate: "1 hr",
1237
+ prompt: "Act as a backend engineer. Set up production-grade transactional email. Default to Resend unless you have a strong reason to recommend Postmark/SendGrid \u2014 explain. Tasks: (1) install the SDK and replace any nodemailer / raw SMTP / personal-Gmail code; (2) generate the SPF, DKIM, and DMARC DNS records I need to add for my sending domain (ask me what domain to use); (3) build clean, on-brand templates for welcome, password reset, email verification, and any other transactional events my app needs; (4) add a /unsubscribe handler for any non-transactional emails; (5) add proper error handling \u2014 log delivery failures and retry transient ones; (6) test by sending each flow to my own inbox. Show me how to verify deliverability with mail-tester.com and what score to aim for.",
1238
+ what: "When your app sends emails (welcome, password reset, receipts, invites), they need to land in real inboxes from a real sending domain. A dedicated email service plus SPF, DKIM, and DMARC is what keeps your app from looking broken when the code technically sent the message.",
1239
+ why: "Email bugs feel like product bugs to users. If password reset, login code, receipt, or invite emails land in spam, people do not think 'deliverability issue' \u2014 they think your app does not work. This is also where paid users get locked out and refund requests start.",
1240
+ steps: [
1241
+ "Pick a provider: Resend (modern, dev-friendly, generous free tier) or Postmark (excellent deliverability) for transactional. SendGrid if you need bulk.",
1242
+ "Create an account and verify your sending domain. Add SPF, DKIM, and DMARC DNS records, then wait until the provider marks them verified.",
1243
+ `Replace anywhere in your code that sends email to use the provider's SDK. Ask your AI builder: "switch all my outgoing email to use Resend."`,
1244
+ "Build clean templates for: welcome, password reset, key transactional events. Keep them simple and on-brand.",
1245
+ "Test every critical flow by sending to your own Gmail, iCloud, and Outlook/Hotmail addresses if you can: signup, password reset, login code, invite, receipt, and support reply.",
1246
+ "Run one message through mail-tester.com or a similar deliverability checker and fix anything obvious before launch."
1247
+ ],
1248
+ redFlags: [
1249
+ "App emails coming from your personal Gmail",
1250
+ "Test emails landing in spam",
1251
+ "No SPF / DKIM / DMARC configured for your domain",
1252
+ "Password reset, OTP, invite, or receipt emails have never been tested in a real inbox",
1253
+ "No way to see when emails fail to send"
1254
+ ],
1255
+ cliCoverage: "manual_only",
1256
+ whyManual: "Email deliverability lives in provider dashboards and real inboxes. The scanner cannot prove SPF, DKIM, DMARC, templates, retries, or inbox placement without a delivered test email."
1257
+ },
1258
+ {
1259
+ id: "mobile",
1260
+ number: 34,
1261
+ title: "Make it not suck on phones",
1262
+ category: "Product & Launch",
1263
+ priority: "medium",
1264
+ timeEstimate: "2 hr+",
1265
+ prompt: "Act as a senior mobile-first frontend engineer. Audit my app at 375px wide (iPhone SE) and 414px wide (iPhone Pro). For every page, find and fix: (1) any horizontal scroll; (2) any tap target smaller than 44x44px; (3) any text smaller than 16px; (4) any input where the on-screen keyboard would cover the field being typed into; (5) any layout that breaks (overlapping, clipped, unreadable). Also walk through my most important flows (signup, the core feature, checkout) end-to-end on mobile and report any friction. List every issue with file/component, then fix them. Don't make desktop worse \u2014 use responsive breakpoints, not mobile-only overrides. After fixes, list every URL I should manually re-test on a real phone before launch.",
1266
+ what: "Most of your users will open your site on their phone first. If buttons are too small to tap, text needs zooming, or the layout breaks sideways, they leave and never come back.",
1267
+ why: "Over half of all web traffic is mobile. If your app is desktop-only by accident, you're losing more than half your potential users before they even see your value.",
1268
+ steps: [
1269
+ "Open your app on your actual phone, not just the desktop browser shrunk down. The two are different.",
1270
+ "Make sure body text is at least 16px so people don't have to zoom.",
1271
+ "Buttons and tappable links should be at least 44 by 44 pixels \u2014 finger-sized, not cursor-sized.",
1272
+ "Test the most-used flows (signup, the one feature people came for, checkout) all the way through on a phone.",
1273
+ "No horizontal scrolling. Ever. Fix any element that overflows the screen."
1274
+ ],
1275
+ redFlags: [
1276
+ "Buttons you can't reliably tap with a thumb",
1277
+ "Tiny text that makes you pinch to zoom",
1278
+ "Pages that scroll sideways",
1279
+ "Forms where the keyboard covers the input you're typing into",
1280
+ "You've only tested in the desktop browser at full width"
1281
+ ],
1282
+ cliCoverage: "manual_only",
1283
+ whyManual: "Mobile readiness is a real-device walkthrough problem. Static signals cannot prove tap targets, keyboard behavior, checkout friction, or awkward responsive states on actual phones."
1284
+ },
1285
+ {
1286
+ id: "performance",
1287
+ number: 35,
1288
+ title: "Make it fast",
1289
+ category: "Product & Launch",
1290
+ priority: "medium",
1291
+ timeEstimate: "2 hr+",
1292
+ prompt: "Act as a web performance engineer. Audit my app for Core Web Vitals (LCP, INP, CLS) and overall load time. Find and fix: (1) oversized images (anything over 200KB unless absolutely necessary) \u2014 convert to webp/avif and add proper width/height attributes; (2) any blocking JavaScript or CSS in <head>; (3) any third-party script that isn't async/defer; (4) any heavy component above the fold that could be lazy-loaded; (5) missing cache-control headers on static assets; (6) any client-side fetch waterfall that could be a single batched request. After fixes, tell me the realistic mobile pagespeed.web.dev score I should expect. If my framework supports it, set up automatic image optimization and route-level code splitting.",
1293
+ what: "How quickly your pages load and respond. Fast feels professional and confident; slow feels broken and amateur \u2014 even if everything technically works.",
1294
+ why: "People bail on slow pages, and they bail fast \u2014 most users give up on a site that takes longer than three seconds. Fast sites also rank better in search.",
1295
+ steps: [
1296
+ "Compress and resize images before uploading. Use modern formats (webp, avif). Don't serve a 5MB photo when 200KB looks identical.",
1297
+ "Set up caching headers on static stuff (images, fonts, CSS) so returning visitors don't re-download everything.",
1298
+ "Run your homepage through https://pagespeed.web.dev \u2014 aim for 90+ on mobile.",
1299
+ "Avoid loading huge JavaScript bundles. If you're using a framework, lazy-load anything that isn't needed for the first paint.",
1300
+ "Use a CDN (Cloudflare's free tier works great) so people far from your server still get fast loads."
1301
+ ],
1302
+ redFlags: [
1303
+ "Initial page load over 3 seconds on a fast connection",
1304
+ "Huge unoptimized images on the homepage",
1305
+ "PageSpeed score under 70 on mobile",
1306
+ "No caching headers \u2014 every visit re-downloads everything"
1307
+ ],
1308
+ cliCoverage: "manual_only",
1309
+ whyManual: "Performance depends on live assets, hosting cache headers, network waterfalls, and Core Web Vitals. The scanner cannot prove real mobile load speed from checklist content alone."
1310
+ },
1311
+ {
1312
+ id: "accessibility",
1313
+ number: 36,
1314
+ title: "Make it usable for everyone, including people with disabilities",
1315
+ category: "Product & Launch",
1316
+ priority: "medium",
1317
+ timeEstimate: "2 hr+",
1318
+ prompt: 'Act as a senior accessibility engineer. Audit my app against WCAG 2.1 AA. For every page: (1) check color contrast (4.5:1 for normal text, 3:1 for large text and UI components); (2) verify all interactive elements are reachable and operable by keyboard alone (Tab, Shift+Tab, Enter, Space, arrow keys where appropriate); (3) verify proper semantic HTML \u2014 actual <button>, <nav>, <main>, <h1>-<h6>, not <div onClick>; (4) verify every meaningful image has alt text and decorative ones use alt=""; (5) verify form inputs have associated <label> elements and aria-describedby for help/errors; (6) verify focus states are visible and not removed by CSS; (7) verify color is never the only indicator of meaning. Output a prioritized fix list, then apply the fixes. Run axe-core (or your equivalent) at the end and show me the clean report.',
1319
+ what: "Accessibility means making sure your app works for people who use screen readers, navigate with a keyboard instead of a mouse, have low vision, or can't see colors well. It also accidentally makes the app better for everyone \u2014 clearer, more readable, more navigable.",
1320
+ why: "It's the right thing to do. It expands who can use your product. And in many places (US, EU, UK) it's legally required \u2014 there are real lawsuits.",
1321
+ steps: [
1322
+ "Use real semantic HTML \u2014 actual <button>, <nav>, <a> tags, not divs with click handlers.",
1323
+ "Make sure text has enough contrast against its background. The WebAIM contrast checker is free.",
1324
+ "Test that you can navigate the entire app with only the Tab key \u2014 every interactive thing should be reachable.",
1325
+ 'Add alt text to every meaningful image. Decorative images get alt="".',
1326
+ "Run an accessibility checker (axe DevTools, Lighthouse) on each page and fix what it finds."
1327
+ ],
1328
+ redFlags: [
1329
+ "Light gray text on white backgrounds (or any low-contrast combo)",
1330
+ "You can't Tab through the app",
1331
+ "Form inputs with no labels",
1332
+ "Images that carry meaning but have no alt text",
1333
+ "Color is the ONLY way you signal something (e.g., red = error with no other indicator)"
1334
+ ],
1335
+ cliCoverage: "manual_only",
1336
+ whyManual: "Accessibility needs keyboard and assistive-tech verification across real pages. Static code checks miss focus order, contrast in rendered states, and whether the flow is actually usable."
1337
+ },
1338
+ {
1339
+ id: "seo",
1340
+ number: 37,
1341
+ title: "Set up the basics so search and social shares work",
1342
+ category: "Growth",
1343
+ priority: "medium",
1344
+ timeEstimate: "1 hr",
1345
+ prompt: "Act as a technical SEO engineer. Add complete meta tags to every page. For each route, generate a unique <title> (\u226460 chars, includes brand) and meta description (\u2264160 chars, action-oriented and not stuffed with keywords). Add full Open Graph tags (og:title, og:description, og:image with absolute URL, og:url, og:type, og:site_name) and Twitter Card tags (summary_large_image variant with the same image). Add a canonical link per page. If my framework supports it (Next, Remix, Nuxt, SvelteKit, etc.), use the framework's metadata API; otherwise inject into <head>. Generate /sitemap.xml and /robots.txt using the real production domain (ask me what it is). When done, give me 3 URLs from my site I can paste into opengraph.xyz to verify previews look right.",
1346
+ what: "Tiny bits of metadata in your HTML that tell Google what each page is about, and tell Twitter, LinkedIn, Slack, and iMessage how to display a preview when someone shares your link.",
1347
+ why: "These are your free billboards. A page with no title gets no clicks in Google. A link in iMessage with no preview image looks broken or sketchy. You only have to do this once per page.",
1348
+ steps: [
1349
+ "Give every page a unique <title> (under 60 characters) that describes the page and includes your brand.",
1350
+ "Add a unique meta description (under 160 characters) \u2014 this is what shows under your link in Google.",
1351
+ "Add Open Graph tags (og:title, og:description, og:image, og:url) \u2014 these power link previews in iMessage, Slack, LinkedIn.",
1352
+ "Add Twitter Card tags (twitter:card = summary_large_image plus title/description/image) for Twitter/X previews.",
1353
+ "Test how your link looks BEFORE you announce it: opengraph.xyz, metatags.io, or just paste it into a Slack channel and see."
1354
+ ],
1355
+ redFlags: [
1356
+ "Every page has the same generic title",
1357
+ "No meta descriptions (Google generates random snippets for you)",
1358
+ "Sharing your link shows no preview image",
1359
+ "Preview image is broken or shows a default builder-supplied placeholder",
1360
+ "No canonical URL set (causes duplicate-content issues in search)"
1361
+ ],
1362
+ cliCoverage: "automated"
1363
+ },
1364
+ {
1365
+ id: "feedback",
1366
+ number: 38,
1367
+ title: "Give users an easy way to tell you what's broken",
1368
+ category: "Product & Launch",
1369
+ priority: "medium",
1370
+ timeEstimate: "1 hr",
1371
+ prompt: 'Act as a product engineer. Add an in-app feedback widget. Requirements: (1) a small persistent button \u2014 floating bottom-right, or a "Feedback" link in the footer/settings menu; (2) the form has 3 fields: "What were you trying to do?", "What happened?", "What did you expect?" plus an optional email and an optional screenshot upload; (3) submissions go to my email via Resend AND get stored somewhere searchable (a database table or a service like Linear/Plain/Crisp); (4) automatically attach context: current URL, browser, viewport size, user ID if logged in, recent error from console if any; (5) thank-you state after submission with a friendly message and a "we read every one" line; (6) fully accessible \u2014 keyboard navigable, escape to close, focus trapped while open. Test the full flow before declaring it done.',
1372
+ what: "A button somewhere obvious in your app that says \u201CFeedback\u201D or \u201CReport a bug\u201D and opens a simple form. Without it, people who hit problems just leave silently.",
1373
+ why: "Your first 100 users see things you'll never see. They use the app on devices you don't own, with workflows you didn't imagine, and they hit bugs you can't reproduce. You need a low-friction way for them to tell you \u2014 and you need to actually read every one.",
1374
+ steps: [
1375
+ 'Add a "Feedback" or "Report issue" button somewhere persistent (footer, settings menu, floating button).',
1376
+ "Open a simple form: a free-text box, an optional email, an optional screenshot. That's it.",
1377
+ "Ask three things: what were you trying to do, what happened, what did you expect?",
1378
+ "Pipe submissions somewhere you actually check (your inbox, a Slack channel) \u2014 not a database table no one looks at.",
1379
+ "Promise yourself a 24-hour response window during launch week. Reply to everyone, even the angry ones."
1380
+ ],
1381
+ redFlags: [
1382
+ "No way to contact you from inside the app",
1383
+ "Feedback form goes to an inbox no one reads",
1384
+ "You only respond to praise, not complaints",
1385
+ "Bug reports vanish without acknowledgment",
1386
+ "You have no system for turning reports into a real fix list"
1387
+ ],
1388
+ cliCoverage: "manual_only",
1389
+ whyManual: "A feedback widget only counts if a real report reaches the place you actually watch. The scanner cannot prove submissions, screenshots, context capture, and response workflow end-to-end."
1390
+ },
1391
+ {
1392
+ id: "responsive-actions",
1393
+ number: 39,
1394
+ title: "Make every action tell the user what's happening",
1395
+ category: "Product & Launch",
1396
+ priority: "medium",
1397
+ timeEstimate: "2 hr+",
1398
+ prompt: "Act as a senior frontend engineer auditing user-facing async UX. Walk every place a user kicks off something that talks to my server \u2014 clicking a button, submitting a form, uploading a file, paying. For each one verify: (1) a visible loading state appears within ~100ms \u2014 spinner, progress bar, inline 'Saving\u2026', not silence; (2) the trigger control disables (or visibly changes) while the request is in flight, and re-enables on success OR error so a failed click is retryable; (3) success and error states are explicit \u2014 no 'click and pray'; (4) for any write that creates data the user paid for or cares about (orders, payments, reservations, sends, AI generations they paid tokens for), the server enforces idempotency \u2014 the same request twice never creates two records (idempotency keys, server-side dedup by client-generated UUID, or a database unique constraint, whichever fits the route). Also audit empty states: a brand-new user with no data should see an intentional empty page, not a blank one that looks broken. For each finding output: file/component, what's missing, exact fix. Apply the fixes. Don't make spinners janky \u2014 keep them subtle, on-brand, and never jumpy.",
1399
+ what: "Every time a user clicks a button that talks to the server, three things have to be true: they SEE that something is happening, they CAN'T accidentally fire it twice, and the request is safe to receive twice on the backend anyway. Vibe-coded apps default to silence-then-success \u2014 and that's how you ship duplicate orders, double charges, and refresh-mid-submit chaos.",
1400
+ why: "This one isn't dramatic until it is. The user clicks Submit, sees nothing for a beat, decides the page is broken, hits refresh \u2014 and now your app has either lost their input, charged them twice, or sent the same email twenty times. Loading state + disabled-while-submitting + server-side idempotency are the three legs of the stool. Two out of three is not safe. Do this on day one, not day thirty.",
1401
+ steps: [
1402
+ "On every button or form that fires a network request, show a loading state within ~100ms \u2014 a spinner, an inline 'Saving\u2026', or a skeleton. Silence is a bug.",
1403
+ "Disable the trigger while the request is in flight, and re-enable on success OR error. Buttons that stay dead after an error force the user to refresh \u2014 and that's where you lose data.",
1404
+ "For any write the user would mind being duplicated (order, payment, reservation, message sent, AI generation), enforce idempotency on the server \u2014 a client-generated idempotency key in the request, a unique database constraint, or a server-side dedup window. Disabling the button alone is not enough; a slow phone, a flaky network, or an aggressive double-tap will get past it.",
1405
+ "Test the failure mode on purpose. In your browser dev tools, throttle the network to 'Slow 3G' and double-click Submit before it finishes. If you end up with two records, your idempotency is missing.",
1406
+ "Empty states get the same energy. A brand-new user with no data should see a designed empty page with a clear next action \u2014 not a blank screen that looks like the load failed."
1407
+ ],
1408
+ redFlags: [
1409
+ "You can double-click any submit button and create two records",
1410
+ "Spinners that show up after a 1-second delay (or never)",
1411
+ "Buttons that stay disabled forever after an error \u2014 user has to refresh to retry",
1412
+ "Payments, orders, or other 'really would hate to do this twice' actions with no idempotency on the server",
1413
+ "Empty pages that look identical to a broken page \u2014 a brand-new user can't tell if it loaded"
1414
+ ],
1415
+ cliCoverage: "manual_only",
1416
+ whyManual: "Responsive actions need hands-on flow testing with slow networks and double-clicks. The scanner cannot prove every important mutation shows loading, recovers from errors, and is idempotent."
1417
+ },
1418
+ {
1419
+ id: "launch-polish",
1420
+ number: 40,
1421
+ title: "Walk through the whole app one last time",
1422
+ category: "Product & Launch",
1423
+ priority: "medium",
1424
+ timeEstimate: "2 hr+",
1425
+ prompt: `Act as a senior product designer doing a launch readiness review. Do a thorough end-to-end pass through my app and find every rough edge. Specifically: (1) every loading state \u2014 does it look intentional or like the page is broken? (2) every empty state \u2014 is it designed, with helpful copy and a clear next action? (3) every error state \u2014 is the message human and actionable, not "Error: undefined"? (4) every form \u2014 does it validate inline and tell users exactly what went wrong? (5) every link \u2014 does it actually go somewhere live? (6) visit a URL that doesn't exist (yourdomain.com/this-page-isnt-real) \u2014 is there a real 404 page that gets people back home, or do they land on a white screen / the platform's default error page? (7) every piece of copy \u2014 does it sound like a person wrote it? Cut AI tells like "unleash", "leverage", "in today's fast-paced world", "seamlessly", "harness the power of". Output a prioritized fix list by severity, then apply the fixes. Don't rewrite working code \u2014 only touch what's actually wrong.`,
1426
+ what: "A nitpicky end-to-end pass through your own product, looking for the small ugly stuff: typos, broken links, awkward loading states, white-screen 404s on URLs that don't exist, blank pages where data hasn't loaded yet, copy that sounds like an AI wrote it.",
1427
+ why: "This is where good products separate from great ones. Users don't consciously notice polish, but they feel it as \u201Cthis feels solid\u201D or \u201Cthis feels janky.\u201D",
1428
+ steps: [
1429
+ "Click every link, every button, every menu item. Note anything that goes nowhere or breaks.",
1430
+ "Visit a URL that doesn't exist on purpose (yourdomain.com/not-a-real-page). If you get a white screen or your hosting provider's default error page, build a real 404 \u2014 a page that says 'we couldn't find that' and links back home in one tap. Broken links, mistyped URLs, and old social shares all land here, and white screens send away visitors who took months to acquire.",
1431
+ "Refresh on a slow connection (browser dev tools can simulate one) \u2014 make sure loading states look intentional, not broken.",
1432
+ "Look at empty states (a brand-new account with no data) \u2014 design these on purpose, don't leave them blank.",
1433
+ `Read every piece of copy out loud. Cut every sentence you can. Replace AI tells ("Unleash the power of...", "In today's fast-paced world...") with how a real person actually talks.`,
1434
+ "Have one friend complete a key flow (signup \u2192 first action) without your help. Watch where they hesitate. That's your homework."
1435
+ ],
1436
+ redFlags: [
1437
+ "Broken links anywhere",
1438
+ "Visiting a non-existent URL shows a white screen or the platform's default error page (no real 404)",
1439
+ "Typos in user-facing text",
1440
+ "Loading states that look like errors",
1441
+ "Empty states that look broken or unfinished",
1442
+ "Copy that sounds like a chatbot wrote it"
1443
+ ],
1444
+ cliCoverage: "automated"
1445
+ },
1446
+ {
1447
+ id: "launch-list",
1448
+ number: 41,
1449
+ title: "Map exactly where your first 100 users will come from",
1450
+ category: "Growth",
1451
+ priority: "medium",
1452
+ timeEstimate: "1 hr",
1453
+ prompt: "Act as a no-BS growth strategist for early-stage products. Help me build a concrete, named launch distribution plan for getting my first 100 users \u2014 not 'do social media' generic advice. First, ask me what my product is and who it's for. Then output a one-page plan: (1) the 5-10 specific subreddits where my target user actually hangs out, with each subreddit's posting rules (no spam, founder-friendly, requires X karma, etc.) and the EXACT post angle that fits each one; (2) 3-5 niche communities (Discord servers, Slack groups, Indie Hackers, specific newsletters' comment sections); (3) 10-20 named individuals (Twitter/X, LinkedIn) who would plausibly share this if I sent a thoughtful DM, with a script for what to send each; (4) the right launch surface for me \u2014 Product Hunt yes/no with reasoning, Hacker News yes/no with reasoning, BetaList, Indie Hackers; (5) what to NOT do (cold mass-DM blast, generic LinkedIn post, paid ads on day one). Be specific to MY product \u2014 refuse to give generic advice.",
1454
+ what: "A written list, BEFORE launch day, of the exact 30-50 places, communities, and people you'll tell about your app \u2014 and what you'll say in each one. Not 'post on social media.' Specific subreddits with their rules, specific Discord servers, specific people whose DMs you'll personally write.",
1455
+ why: "Every failed indie launch went the same way: 'I posted it on Twitter and Product Hunt and got 14 visitors.' Distribution doesn't happen because you launched \u2014 it happens because you spent the week before launch deciding exactly who to tell and what to say to each. Doing this work after launch is too late; you'll be busy answering bug reports.",
1456
+ steps: [
1457
+ "Make a literal list. Spreadsheet, doc, anything. Columns: where, who runs it, their rules, my angle for THIS audience, when I'll post.",
1458
+ "For each spot, lurk first. Read the last week of posts. Match their tone. Communities can smell drive-by spam in one sentence.",
1459
+ "Tailor the message \u2014 same product, different pitch for each audience. Indie Hackers wants the founder story; r/your-niche wants 'here's a tool I made for our problem.'",
1460
+ "Identify 10-20 named humans whose audience overlaps yours. Write each one a real, personal DM the day of launch \u2014 not a copy-paste blast.",
1461
+ "Decide if you're doing Product Hunt / Hacker News and prep accordingly. Both reward founders who showed up early in their communities; they punish drive-bys."
1462
+ ],
1463
+ redFlags: [
1464
+ "Your launch plan is 'I'll post on Twitter and Product Hunt'",
1465
+ "You haven't lurked in the communities you plan to post in",
1466
+ "Same generic copy posted everywhere",
1467
+ "Mass cold DM / mass cold email blast (kills your reputation faster than any bug)",
1468
+ "No named list \u2014 only 'social media' and 'communities' as vague buckets"
1469
+ ],
1470
+ cliCoverage: "manual_only",
1471
+ whyManual: "Distribution planning is a founder artifact: named communities, real rules, and outreach angles. The scanner cannot prove your first-100-user list exists or is credible."
1472
+ },
1473
+ {
1474
+ id: "support",
1475
+ number: 42,
1476
+ title: "Give users a real way to get help",
1477
+ category: "Product & Launch",
1478
+ priority: "medium",
1479
+ timeEstimate: "1 hr",
1480
+ prompt: "Act as a customer support systems designer for a tiny early-stage app. Add a simple, real support path that a paying or stuck user can find in under 10 seconds. Requirements: (1) a support email or help link visible from footer, account/settings, checkout/success, receipts, and error states; (2) route support messages somewhere I actually monitor daily; (3) include enough context automatically when possible: user ID, email, current URL, order/report ID, browser, and last error; (4) create canned first replies for payment issue, login/code not arriving, data deletion/export, bug report, and refund request; (5) add a simple SUPPORT.md with where messages land, expected response time during launch week, and refund/escalation rules. Do not add a giant help center. Make the smallest support loop that prevents paying users from feeling abandoned.",
1481
+ what: "Feedback is how users tell you what is broken. Support is how a real person gets help when they are stuck, locked out, charged, confused, or angry. For a vibe-coded launch, this can be one monitored email address \u2014 but it has to be visible and it has to work.",
1482
+ why: "The worst early customer experience is not a bug. It is paying, getting stuck, and finding no human way out. A simple support path turns panic into a conversation and keeps small launch issues from becoming public complaints.",
1483
+ steps: [
1484
+ "Create one support destination you will actually watch during launch week: support@yourdomain.com, a shared inbox, Plain, Crisp, Intercom, HelpScout, or even a monitored Gmail alias.",
1485
+ "Put the support link where pain happens: footer, account/settings, checkout success, receipt email, error pages, login/OTP screens, and paid-report pages.",
1486
+ "When support is opened from inside the app, include context automatically if you can: current URL, user ID, email, order/report ID, browser, and the last visible error.",
1487
+ "Write canned first replies for the obvious launch-week cases: payment did not unlock, login code missing, refund request, bug report, account deletion/export, and 'what is this charge?'",
1488
+ "Commit to a launch-week response window you can actually meet, then write it down where future-you will see it."
1489
+ ],
1490
+ redFlags: [
1491
+ "No support link anywhere inside the app",
1492
+ "Support goes to an inbox no one checks",
1493
+ "Receipt, checkout success, and error screens give users no way to contact you",
1494
+ "No plan for payment/access issues after someone pays",
1495
+ "Support messages arrive with no context, forcing users to re-explain everything"
1496
+ ],
1497
+ cliCoverage: "manual_only",
1498
+ whyManual: "Support readiness is only real if a message reaches the inbox or tool the owner watches and includes enough context to resolve it. The scanner cannot prove someone will see and answer the request."
1499
+ }
1500
+ ];
1501
+
1502
+ // ../../lib/checklist-data/src/items-lower.ts
1503
+ var ITEMS_LOWER = [
1504
+ {
1505
+ id: "aeo",
1506
+ number: 43,
1507
+ title: "Make AI assistants able to recommend you",
1508
+ category: "Growth",
1509
+ priority: "lower",
1510
+ timeEstimate: "2 hr+",
1511
+ prompt: "Act as an AEO (answer engine optimization) specialist. Make my site quotable by AI engines (ChatGPT, Perplexity, Google AI Overviews, Claude). Tasks: (1) generate JSON-LD structured data for my homepage (Organization, WebSite) and key pages (Product, FAQPage, Article where applicable) and embed it in <head>; (2) create a real /faq page covering the 10-20 questions a real prospect asks before buying \u2014 ask me what my product is so the questions are accurate. Each answer 40-60 words, direct answer first, then context; (3) restructure my main marketing pages so each section starts with a 1-2 sentence summary BEFORE the long explanation (AI engines grab the top); (4) make sure no SEO-important content is gated behind a login or only rendered client-side; (5) add an /llms.txt at the root summarizing my site for AI crawlers. Test the result with Google's Rich Results Test and report what each page is eligible for.",
1512
+ what: "More and more people ask ChatGPT, Perplexity, or Google's AI Overviews instead of clicking through search results. Those AIs are pulling answers from websites \u2014 but only the ones structured in a way they can confidently quote. AEO is the work of making your site quotable.",
1513
+ why: `Search is shifting from "here are 10 links" to "here's the answer." If your content isn't structured for AI engines to extract, you're invisible to a fast-growing channel \u2014 even if your normal SEO is fine.`,
1514
+ steps: [
1515
+ "Add a real FAQ page covering the top 10\u201320 questions people actually ask about your product. Keep answers tight (40\u201360 words each).",
1516
+ 'Write headings as full questions ("How do I cancel my subscription?" instead of "Cancellation").',
1517
+ "Add structured data (JSON-LD) to your pages: at minimum FAQPage schema on the FAQ page and Organization schema on your homepage. your AI builder can generate these.",
1518
+ "Lead each content section with a 1\u20132 sentence direct answer at the top, before the long-form explanation. AIs grab the top.",
1519
+ "Make sure important content isn't locked behind login or only rendered by JavaScript \u2014 AI crawlers often miss those.",
1520
+ "Add an /llms.txt file at your site root. It's the AI-crawler equivalent of robots.txt: a short plain-text summary of what your site is, who it's for, and links to your sitemap and FAQ. ChatGPT, Perplexity, and Claude all read it.",
1521
+ "Test your structured data with Google's Rich Results Test \u2014 it tells you what AIs can actually see."
1522
+ ],
1523
+ redFlags: [
1524
+ "No structured data anywhere on your site",
1525
+ "Long-winded content with no short summary at the top",
1526
+ "Important content gated behind a login wall",
1527
+ "No FAQ section answering the questions people actually ask",
1528
+ "Headings that are vague labels instead of clear questions",
1529
+ "No /llms.txt at your root \u2014 AI crawlers have nothing to anchor on",
1530
+ "Content that targets keyword strings instead of full questions people actually ask"
1531
+ ],
1532
+ cliCoverage: "automated"
1533
+ },
1534
+ {
1535
+ id: "community",
1536
+ number: 44,
1537
+ title: "Start a small community space",
1538
+ category: "Growth",
1539
+ priority: "lower",
1540
+ timeEstimate: "1 hr",
1541
+ prompt: `Act as a community-building strategist who has launched multiple successful Discord/Slack communities from zero. Help me set up a small community space for my early users. First, ask me: who are my users (consumers? developers? professionals?), what platform have they used before, and how big do I realistically expect this to get in 90 days. Then output: (1) a recommendation for which platform fits \u2014 Discord, Slack, Circle, etc. \u2014 with one-paragraph reasoning; (2) a starter channel/category structure (max 5-7 channels \u2014 don't over-build); (3) welcome message + rules + onboarding DM I can copy-paste and tweak; (4) a 30-day content plan: what I post each day for the first month so it doesn't go silent; (5) the exact list of 10-20 first people to hand-invite and a script for asking them; (6) red flags to watch for. Be opinionated and concrete \u2014 no vague "engage your community" platitudes.`,
1542
+ what: "A central place (Discord, Slack, Circle, or even a private Telegram) where your users can hang out, ask questions, share what they're building with your tool, and run into you.",
1543
+ why: "Engaged users become unpaid evangelists, your fastest source of product ideas, and your best customer support. They also stick around longer because they have a relationship, not just a tool.",
1544
+ steps: [
1545
+ "Pick one platform \u2014 don't spread yourself across three. Discord is most common for early-stage; Slack if your users are professionals.",
1546
+ "Hand-invite your first 10\u201320 power users personally. Don't do a public push until there's something there.",
1547
+ "Show up daily for the first month. Reply to every message. Welcome every new arrival by name.",
1548
+ "Encourage members to help each other \u2014 celebrate it when they do.",
1549
+ "Ship visible improvements based on community feedback and shout out the person who suggested it."
1550
+ ],
1551
+ redFlags: [
1552
+ "Empty server because you started it before having users",
1553
+ "You're absent \u2014 community goes silent without you",
1554
+ "You're trying to be on Discord AND Slack AND Telegram (pick one)",
1555
+ "No moderation plan \u2014 the first troll ruins the vibe for everyone",
1556
+ "You ignore feedback even when it's consistent"
1557
+ ],
1558
+ cliCoverage: "manual_only",
1559
+ whyManual: "Community quality is about real people, active moderation, and daily founder presence. The scanner cannot prove a Discord or Slack space is alive or useful."
1560
+ },
1561
+ {
1562
+ id: "iteration",
1563
+ number: 45,
1564
+ title: "Plan what you'll improve in week 1",
1565
+ category: "Growth",
1566
+ priority: "lower",
1567
+ timeEstimate: "30 min",
1568
+ prompt: `Act as a product manager helping me build my first post-launch iteration plan. I'll give you: (1) my top 5 pieces of user feedback so far; (2) my top 3 bugs from error monitoring; (3) my top 1-2 drop-off points in analytics. If I haven't given you that data, ask me for it \u2014 don't guess. Then build me a one-page plan: (a) the 3 highest impact-per-effort fixes/improvements to do this week; (b) the 2-3 bigger items for next month; (c) the things I should explicitly NOT do right now and why (a "not now" list is as important as a to-do list); (d) a one-paragraph changelog template I can publish each Friday so users can see I'm shipping. Be ruthless about scope \u2014 I'd rather ship 3 things well than 10 things poorly.`,
1569
+ what: "A short list of what you'll work on right after launch, based on what you learn from early users and analytics. Not a six-month roadmap \u2014 just \u201Chere are the next three things.\u201D",
1570
+ why: "Without a plan you'll either freeze (paralyzed by all the feedback) or thrash (rebuilding the homepage every Monday). A simple iteration loop turns the chaos of launch into measurable forward motion.",
1571
+ steps: [
1572
+ "After your first week, sit down and review: top 5 pieces of feedback, top 3 bugs from error monitoring, top 1\u20132 drop-off points in analytics.",
1573
+ "Pick the 3 things with the highest impact-per-effort. Ignore everything else for now.",
1574
+ "Decide which are quick wins (do this week) vs. bigger lifts (next month).",
1575
+ "Set a public next-update date so you have a forcing function.",
1576
+ "Tell your users what changed when you ship \u2014 they want to know they were heard."
1577
+ ],
1578
+ redFlags: [
1579
+ 'No plan beyond "keep building"',
1580
+ "Ignoring obvious problems multiple users have reported",
1581
+ "Adding new features no one asked for instead of fixing what's broken",
1582
+ "Silent updates \u2014 users don't know you're iterating",
1583
+ "Pivoting your whole strategy based on one loud complainer"
1584
+ ],
1585
+ cliCoverage: "manual_only",
1586
+ whyManual: "A week-one iteration plan depends on fresh user feedback, analytics, and bug data after launch. The scanner cannot know whether the roadmap reflects what users actually did."
1587
+ },
1588
+ {
1589
+ id: "installable-app",
1590
+ number: 46,
1591
+ title: "Make your app installable on phones",
1592
+ category: "Product & Launch",
1593
+ priority: "lower",
1594
+ timeEstimate: "30 min",
1595
+ prompt: "Act as a frontend engineer. Make my web app a proper Progressive Web App (PWA) so phone users can install it to their home screen. Tasks: (1) create a /manifest.json with name, short_name (\u226412 chars), start_url: '/', display: 'standalone', background_color, theme_color matching my brand, and an icons array with at least 192\xD7192 and 512\xD7512 PNGs (generate them from my logo if I don't have them yet \u2014 lossless, transparent background). (2) add <link rel='manifest' href='/manifest.json'> and <meta name='theme-color' content='#yourbrand'> to <head>. (3) generate a proper favicon bundle: favicon.ico (multi-size), favicon.svg, apple-touch-icon.png (180\xD7180), and wire them into <head> with <link rel='icon'> and <link rel='apple-touch-icon'>. (4) verify in Chrome DevTools \u2192 Application \u2192 Manifest that every field validates and the install prompt appears. (5) test on a real phone: open in Safari or Chrome, use Share \u2192 'Add to Home Screen', confirm the app launches full-screen without the browser chrome. Report any warnings from Lighthouse's PWA audit.",
1596
+ what: "A tiny /manifest.json plus proper-sized icons lets people add your site to their phone's home screen with one tap. When they launch it, it opens full-screen \u2014 no browser URL bar in the way \u2014 like a real app. Zero app-store submission needed.",
1597
+ why: "Most vibe-coded sites stop at the default favicon and never get installed. Users who add your app to their home screen return two to three times more often than bookmark-only users \u2014 and it's a 30-minute setup to enable it forever. Also closes the subtle credibility gap of a generic browser favicon on your production URL.",
1598
+ steps: [
1599
+ "Generate your icon set: at minimum a 512\xD7512 PNG, a 192\xD7192 PNG, a 180\xD7180 apple-touch-icon.png, and a favicon.ico or favicon.svg. Transparent backgrounds.",
1600
+ "Create a /manifest.json with your app name, short name (\u226412 chars), start URL, standalone display mode, theme color, and the icons.",
1601
+ 'Link it from <head>: <link rel="manifest" href="/manifest.json"> plus <meta name="theme-color" content="#yourbrand">.',
1602
+ 'Add <link rel="apple-touch-icon" href="/apple-touch-icon.png"> and <link rel="icon" href="/favicon.svg"> pointing at your icons.',
1603
+ "Open Chrome DevTools \u2192 Application \u2192 Manifest and confirm every field validates with no warnings.",
1604
+ "Open your site on a real phone, tap Share \u2192 Add to Home Screen, confirm it installs and launches full-screen."
1605
+ ],
1606
+ redFlags: [
1607
+ "Default browser favicon still showing in the tab",
1608
+ "No /manifest.json \u2014 iOS and Android can't install your site",
1609
+ "Icons are the framework's starter logo (Next.js, Vite placeholder, etc.)",
1610
+ "No apple-touch-icon \u2014 iPhone home-screen icon looks terrible",
1611
+ "Manifest exists but references missing or wrong-size icons"
1612
+ ],
1613
+ cliCoverage: "automated"
1614
+ }
1615
+ ];
1616
+
1617
+ // ../../lib/checklist-data/src/index.ts
1618
+ var CHECKLIST = [
1619
+ ...ITEMS_CRITICAL,
1620
+ ...ITEMS_HIGH,
1621
+ ...ITEMS_MEDIUM,
1622
+ ...ITEMS_LOWER
1623
+ ];
1624
+ var CHECKLIST_BY_ID = Object.fromEntries(CHECKLIST.map((item) => [item.id, item]));
1625
+
1626
+ // src/checks/make-finding.ts
1627
+ function makeFinding(input) {
1628
+ const item = CHECKLIST_BY_ID[input.itemId];
1629
+ const cliPrompt = item?.cliPrompt;
1630
+ const out = {
1631
+ checkId: input.checkId,
1632
+ itemId: input.itemId,
1633
+ severity: input.severity,
1634
+ message: input.message
1635
+ };
1636
+ if (input.file) out.file = input.file;
1637
+ if (input.line) out.line = input.line;
1638
+ if (input.evidence) out.evidence = input.evidence;
1639
+ if (cliPrompt) {
1640
+ out.whatFailed = cliPrompt.whatFailed;
1641
+ out.whyItBlocksLaunch = cliPrompt.whyItBlocksLaunch;
1642
+ out.fixInstructions = cliPrompt.fixInstructions;
1643
+ out.aiBuilderPrompt = cliPrompt.aiBuilderPrompt;
1644
+ out.verificationStep = cliPrompt.verificationStep;
1645
+ }
1646
+ return out;
1647
+ }
1648
+
304
1649
  // src/checks/secrets.ts
305
1650
  var SECRET_PATTERNS = [
306
1651
  {
@@ -420,7 +1765,7 @@ async function checkHardcodedSecrets(ctx) {
420
1765
  if (matchedRanges.some(([s, e]) => start < e && end > s)) continue;
421
1766
  matchedRanges.push([start, end]);
422
1767
  const line = findLine(content, start);
423
- findings.push({
1768
+ findings.push(makeFinding({
424
1769
  checkId: `secret-${pat.id}`,
425
1770
  itemId: "secrets",
426
1771
  severity: pat.severity,
@@ -428,7 +1773,7 @@ async function checkHardcodedSecrets(ctx) {
428
1773
  file: relPosix(file.relPath),
429
1774
  line,
430
1775
  evidence: `${m[0].slice(0, 6)}\u2026${m[0].slice(-4)} (${m[0].length} chars)`
431
- });
1776
+ }));
432
1777
  }
433
1778
  JWT_REGEX.lastIndex = 0;
434
1779
  let jm;
@@ -441,7 +1786,7 @@ async function checkHardcodedSecrets(ctx) {
441
1786
  const isServiceRole = !!payload && /"role"\s*:\s*"service_role"/.test(payload);
442
1787
  const line = findLine(content, start);
443
1788
  if (isServiceRole) {
444
- findings.push({
1789
+ findings.push(makeFinding({
445
1790
  checkId: "secret-supabase-service-role-jwt",
446
1791
  itemId: "secrets",
447
1792
  severity: "critical",
@@ -449,9 +1794,9 @@ async function checkHardcodedSecrets(ctx) {
449
1794
  file: relPosix(file.relPath),
450
1795
  line,
451
1796
  evidence: `${jm[0].slice(0, 6)}\u2026${jm[0].slice(-4)} (${jm[0].length} chars)`
452
- });
1797
+ }));
453
1798
  } else {
454
- findings.push({
1799
+ findings.push(makeFinding({
455
1800
  checkId: "secret-jwt",
456
1801
  itemId: "secrets",
457
1802
  severity: "high",
@@ -459,7 +1804,7 @@ async function checkHardcodedSecrets(ctx) {
459
1804
  file: relPosix(file.relPath),
460
1805
  line,
461
1806
  evidence: `${jm[0].slice(0, 6)}\u2026${jm[0].slice(-4)} (${jm[0].length} chars)`
462
- });
1807
+ }));
463
1808
  }
464
1809
  break;
465
1810
  }
@@ -485,8 +1830,13 @@ var HEX_SECRET_REGEX = /^[A-Fa-f0-9]{32,}$/;
485
1830
  var BASE64_SECRET_REGEX = /^[A-Za-z0-9+/_-]{40,}={0,2}$/;
486
1831
  var TEMPLATED_VALUE_REGEX = /\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*/;
487
1832
  var CONFIG_KEY_ALLOWLIST = /* @__PURE__ */ new Set([
488
- "DATABASE_URL"
1833
+ "DATABASE_URL",
489
1834
  // contains URL/host fragments, handled by other rules
1835
+ "account_id",
1836
+ // Cloudflare account id in wrangler.toml is public metadata
1837
+ "database_id",
1838
+ // Cloudflare D1 database ids are public resource ids
1839
+ "CLOUDFLARE_ACCOUNT_ID"
490
1840
  ]);
491
1841
  async function checkConfigSecretLeaks(ctx) {
492
1842
  const findings = [];
@@ -506,7 +1856,7 @@ async function checkConfigSecretLeaks(ctx) {
506
1856
  if (TEMPLATED_VALUE_REGEX.test(value)) continue;
507
1857
  const line = findLine(content, m.index);
508
1858
  if (VITE_SECRET_KEY_REGEX.test(key)) {
509
- findings.push({
1859
+ findings.push(makeFinding({
510
1860
  checkId: "config-vite-prefixed-secret",
511
1861
  itemId: "secrets",
512
1862
  severity: "high",
@@ -514,14 +1864,14 @@ async function checkConfigSecretLeaks(ctx) {
514
1864
  file: relPosix(file.relPath),
515
1865
  line,
516
1866
  evidence: `${key}=${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`
517
- });
1867
+ }));
518
1868
  continue;
519
1869
  }
520
1870
  if (CONFIG_KEY_ALLOWLIST.has(key)) continue;
521
1871
  const isHex = HEX_SECRET_REGEX.test(value);
522
1872
  const isBase64 = BASE64_SECRET_REGEX.test(value);
523
1873
  if (!isHex && !isBase64) continue;
524
- findings.push({
1874
+ findings.push(makeFinding({
525
1875
  checkId: "config-hardcoded-credential",
526
1876
  itemId: "secrets",
527
1877
  severity: "critical",
@@ -529,7 +1879,7 @@ async function checkConfigSecretLeaks(ctx) {
529
1879
  file: relPosix(file.relPath),
530
1880
  line,
531
1881
  evidence: `${key}=${value.slice(0, 4)}\u2026${value.slice(-2)} (${value.length} chars)`
532
- });
1882
+ }));
533
1883
  }
534
1884
  }
535
1885
  return findings;
@@ -549,13 +1899,13 @@ async function checkEnvCommitted(ctx) {
549
1899
  const base = path4.basename(file.relPath);
550
1900
  if (base !== ".env" && base !== ".env.local" && base !== ".env.production") continue;
551
1901
  if (!ignoresEnv) {
552
- findings.push({
1902
+ findings.push(makeFinding({
553
1903
  checkId: "env-not-ignored",
554
1904
  itemId: "secrets",
555
1905
  severity: "high",
556
1906
  message: `${base} found and your .gitignore does not appear to ignore .env files.`,
557
1907
  file: relPosix(file.relPath)
558
- });
1908
+ }));
559
1909
  }
560
1910
  }
561
1911
  return findings;
@@ -568,12 +1918,12 @@ async function checkEnvExample(ctx) {
568
1918
  });
569
1919
  if (hasEnv && !hasExample) {
570
1920
  return [
571
- {
1921
+ makeFinding({
572
1922
  checkId: "missing-env-example",
573
1923
  itemId: "secrets",
574
1924
  severity: "medium",
575
1925
  message: "Found a .env file but no .env.example. Add a sanitized .env.example so collaborators know which variables are required."
576
- }
1926
+ })
577
1927
  ];
578
1928
  }
579
1929
  return [];
@@ -582,12 +1932,12 @@ async function checkGitignore(ctx) {
582
1932
  const gitignorePath = path4.join(ctx.rootDir, ".gitignore");
583
1933
  if (!await fileExists(gitignorePath)) {
584
1934
  return [
585
- {
1935
+ makeFinding({
586
1936
  checkId: "missing-gitignore",
587
1937
  itemId: "github",
588
1938
  severity: "high",
589
1939
  message: "No .gitignore at the project root. Add one tuned to your stack so you don't accidentally commit secrets, local DBs, or build artifacts."
590
- }
1940
+ })
591
1941
  ];
592
1942
  }
593
1943
  return [];
@@ -638,12 +1988,12 @@ async function checkRobotsTxt(ctx) {
638
1988
  const dirs = await findPublicDirs(ctx);
639
1989
  if (dirs.length === 0) return [];
640
1990
  return [
641
- {
1991
+ makeFinding({
642
1992
  checkId: "missing-robots-txt",
643
1993
  itemId: "seo",
644
1994
  severity: "medium",
645
1995
  message: `No robots.txt found in any public directory (looked in: ${dirs.join(", ")}). Add one so search engines know what to crawl.`
646
- }
1996
+ })
647
1997
  ];
648
1998
  }
649
1999
  async function checkSitemapXml(ctx) {
@@ -654,12 +2004,12 @@ async function checkSitemapXml(ctx) {
654
2004
  if (dirs.length === 0) return [];
655
2005
  if (await hasDisallowAllRobots(ctx)) return [];
656
2006
  return [
657
- {
2007
+ makeFinding({
658
2008
  checkId: "missing-sitemap-xml",
659
2009
  itemId: "seo",
660
2010
  severity: "medium",
661
2011
  message: `No sitemap.xml found in any public directory (looked in: ${dirs.join(", ")}). Add one to help search engines index your pages.`
662
- }
2012
+ })
663
2013
  ];
664
2014
  }
665
2015
  async function checkFavicon(ctx) {
@@ -669,12 +2019,12 @@ async function checkFavicon(ctx) {
669
2019
  const has = ctx.files.some((f) => faviconRegex.test(f.relPath));
670
2020
  if (has) return [];
671
2021
  return [
672
- {
2022
+ makeFinding({
673
2023
  checkId: "missing-favicon",
674
2024
  itemId: "launch-polish",
675
2025
  severity: "lower",
676
2026
  message: "No custom favicon found in your public directory. The default browser favicon (or the framework starter one) tells visitors this is an unfinished AI-built project."
677
- }
2027
+ })
678
2028
  ];
679
2029
  }
680
2030
  async function checkLlmsTxt(ctx) {
@@ -684,12 +2034,12 @@ async function checkLlmsTxt(ctx) {
684
2034
  const dirs = await findPublicDirs(ctx);
685
2035
  if (dirs.length === 0) return [];
686
2036
  return [
687
- {
2037
+ makeFinding({
688
2038
  checkId: "missing-llms-txt",
689
2039
  itemId: "aeo",
690
2040
  severity: "lower",
691
2041
  message: `No llms.txt found in any public directory (looked in: ${dirs.join(", ")}). Add one so AI crawlers (ChatGPT, Perplexity, Claude) can understand your site.`
692
- }
2042
+ })
693
2043
  ];
694
2044
  }
695
2045
  async function checkPwaManifest(ctx) {
@@ -699,12 +2049,12 @@ async function checkPwaManifest(ctx) {
699
2049
  const has = ctx.files.some((f) => manifestRegex.test(f.relPath));
700
2050
  if (has) return [];
701
2051
  return [
702
- {
2052
+ makeFinding({
703
2053
  checkId: "missing-pwa-manifest",
704
2054
  itemId: "installable-app",
705
2055
  severity: "lower",
706
2056
  message: `No PWA manifest found in any public directory (looked in: ${dirs.join(", ")}). Add manifest.json (or site.webmanifest) so users can install your app to their phone's home screen.`
707
- }
2057
+ })
708
2058
  ];
709
2059
  }
710
2060
 
@@ -796,12 +2146,12 @@ async function checkSecurityHeaders(ctx) {
796
2146
  }
797
2147
  }
798
2148
  return [
799
- {
2149
+ makeFinding({
800
2150
  checkId: "missing-security-headers",
801
2151
  itemId: "https-headers",
802
2152
  severity: "high",
803
2153
  message: "Couldn't find any common security headers (CSP, HSTS, X-Frame-Options, etc.) or helmet() middleware in your server/host configs. Add them so the browser enforces baseline defenses."
804
- }
2154
+ })
805
2155
  ];
806
2156
  }
807
2157
 
@@ -837,11 +2187,21 @@ var DANGEROUS_PATTERNS = [
837
2187
  message: "Shell execution via exec/execSync \u2014 if any argument includes user input, this is a command-injection path. Prefer execFile with an argument array, or validate input strictly against an allowlist."
838
2188
  }
839
2189
  ];
2190
+ function hasSafeDangerousHtmlContext(content, index) {
2191
+ const before = content.slice(Math.max(0, index - 1200), index);
2192
+ const after = content.slice(index, Math.min(content.length, index + 500));
2193
+ if (/\b(DOMPurify|sanitizeHtml|sanitize)\b/.test(before + after)) {
2194
+ return true;
2195
+ }
2196
+ return /<style[\s\S]{0,240}$/.test(before);
2197
+ }
840
2198
  async function checkDangerousPatterns(ctx) {
841
2199
  const findings = [];
842
2200
  for (const file of ctx.files) {
843
2201
  if (!isTextFile(file)) continue;
844
2202
  if (isScanExempt(file.relPath)) continue;
2203
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
2204
+ if (isUiLibraryPrimitive(file.relPath)) continue;
845
2205
  const ext = path6.extname(file.relPath).toLowerCase();
846
2206
  if (![
847
2207
  ".ts",
@@ -860,15 +2220,18 @@ async function checkDangerousPatterns(ctx) {
860
2220
  const m = pat.regex.exec(content);
861
2221
  if (!m) continue;
862
2222
  if (lineContainsIgnoreMarker(content, m.index)) continue;
2223
+ if (pat.id === "dangerously-set-inner-html" && hasSafeDangerousHtmlContext(content, m.index)) {
2224
+ continue;
2225
+ }
863
2226
  const line = findLine(content, m.index);
864
- findings.push({
2227
+ findings.push(makeFinding({
865
2228
  checkId: pat.id,
866
2229
  itemId: pat.itemId,
867
2230
  severity: pat.severity,
868
2231
  message: pat.message,
869
2232
  file: relPosix(file.relPath),
870
2233
  line
871
- });
2234
+ }));
872
2235
  }
873
2236
  }
874
2237
  return findings;
@@ -974,14 +2337,14 @@ async function checkLanguagePatterns(ctx) {
974
2337
  const m = pat.regex.exec(content);
975
2338
  if (!m) continue;
976
2339
  const line = findLine(content, m.index);
977
- findings.push({
2340
+ findings.push(makeFinding({
978
2341
  checkId: pat.id,
979
2342
  itemId: pat.itemId,
980
2343
  severity: pat.severity,
981
2344
  message: pat.message,
982
2345
  file: relPosix(file.relPath),
983
2346
  line
984
- });
2347
+ }));
985
2348
  }
986
2349
  }
987
2350
  return findings;
@@ -1039,24 +2402,24 @@ async function checkPythonSecretKeyEnv(ctx) {
1039
2402
  if (envBacked) return [];
1040
2403
  if (!mentionsSecretKey) {
1041
2404
  return [
1042
- {
2405
+ makeFinding({
1043
2406
  checkId: "py-missing-secret-key-env",
1044
2407
  itemId: "secrets",
1045
2408
  severity: "high",
1046
2409
  message: "Detected a Django/Flask project but couldn't find SECRET_KEY anywhere in your settings. Configure it from an env var (e.g. os.environ['SECRET_KEY']) before deploying.",
1047
2410
  file: relPosix(candidates[0].file.relPath)
1048
- }
2411
+ })
1049
2412
  ];
1050
2413
  }
1051
2414
  return [
1052
- {
2415
+ makeFinding({
1053
2416
  checkId: "py-secret-key-not-from-env",
1054
2417
  itemId: "secrets",
1055
2418
  severity: "high",
1056
2419
  message: "Django/Flask SECRET_KEY is set in source but not read from an environment variable. Pull it from os.environ / os.getenv (or python-decouple / django-environ) so the real value stays out of the repo.",
1057
2420
  file: firstMention ? relPosix(firstMention.file.relPath) : void 0,
1058
2421
  line: firstMention?.line
1059
- }
2422
+ })
1060
2423
  ];
1061
2424
  }
1062
2425
  function isRailsProject(_ctx, files) {
@@ -1105,23 +2468,23 @@ async function checkRubySecretKeyBaseEnv(ctx) {
1105
2468
  if (envBacked) return [];
1106
2469
  if (!mentionsSecret) {
1107
2470
  return [
1108
- {
2471
+ makeFinding({
1109
2472
  checkId: "rb-missing-secret-key-base-env",
1110
2473
  itemId: "secrets",
1111
2474
  severity: "high",
1112
2475
  message: "Detected a Rails project but couldn't find secret_key_base wired up to ENV['SECRET_KEY_BASE'] or Rails.application.credentials anywhere in config/. Configure it before deploying."
1113
- }
2476
+ })
1114
2477
  ];
1115
2478
  }
1116
2479
  return [
1117
- {
2480
+ makeFinding({
1118
2481
  checkId: "rb-secret-key-base-not-from-env",
1119
2482
  itemId: "secrets",
1120
2483
  severity: "high",
1121
2484
  message: "Rails secret_key_base is referenced in config/ but not pulled from ENV['SECRET_KEY_BASE'] or Rails.application.credentials. Move the real value out of source.",
1122
2485
  file: firstMention ? relPosix(firstMention.file.relPath) : void 0,
1123
2486
  line: firstMention?.line
1124
- }
2487
+ })
1125
2488
  ];
1126
2489
  }
1127
2490
 
@@ -1136,31 +2499,32 @@ async function checkPlaceholderContent(ctx) {
1136
2499
  for (const file of ctx.files) {
1137
2500
  if (!isTextFile(file)) continue;
1138
2501
  if (isScanExempt(file.relPath)) continue;
2502
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
1139
2503
  const ext = path8.extname(file.relPath).toLowerCase();
1140
- if (![".ts", ".tsx", ".js", ".jsx", ".html", ".md", ".mdx"].includes(ext)) continue;
2504
+ if (![".ts", ".tsx", ".js", ".jsx", ".html"].includes(ext)) continue;
1141
2505
  const content = await readFileSafe(file);
1142
2506
  if (!content) continue;
1143
2507
  const todoMatch = TODO_REGEX.exec(content);
1144
2508
  if (todoMatch && !lineContainsIgnoreMarker(content, todoMatch.index)) todoCount++;
1145
2509
  const phMatch = PLACEHOLDER_REGEX.exec(content);
1146
2510
  if (phMatch && !lineContainsIgnoreMarker(content, phMatch.index)) {
1147
- placeholderHits.push({
2511
+ placeholderHits.push(makeFinding({
1148
2512
  checkId: "placeholder-content",
1149
2513
  itemId: "ai-audit",
1150
2514
  severity: "medium",
1151
2515
  message: `Placeholder content "${phMatch[0]}" found \u2014 make sure it isn't shown to real users.`,
1152
2516
  file: relPosix(file.relPath),
1153
2517
  line: findLine(content, phMatch.index)
1154
- });
2518
+ }));
1155
2519
  }
1156
2520
  }
1157
2521
  if (todoCount > 0) {
1158
- findings.push({
2522
+ findings.push(makeFinding({
1159
2523
  checkId: "todo-comments",
1160
2524
  itemId: "ai-audit",
1161
2525
  severity: "lower",
1162
2526
  message: `Found ${todoCount} file(s) with TODO/FIXME/XXX/HACK comments. Walk through them before launch and decide which are real work.`
1163
- });
2527
+ }));
1164
2528
  }
1165
2529
  return findings.concat(placeholderHits.slice(0, 25));
1166
2530
  }
@@ -1174,8 +2538,6 @@ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1174
2538
  ".jsx",
1175
2539
  ".mjs",
1176
2540
  ".cjs",
1177
- ".md",
1178
- ".mdx",
1179
2541
  ".html",
1180
2542
  ".htm",
1181
2543
  ".vue",
@@ -1193,13 +2555,13 @@ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1193
2555
  var OTP_CONTEXT = /\b(otp|one[-\s]?time(?: password| code)?|verification code|verify code|login code|magic code|passcode|2fa|mfa|two[-\s]?factor)\b/i;
1194
2556
  var AUTH_CONTEXT = /\b(auth|sign[-\s]?in|signin|login|session|protected route|private route)\b/i;
1195
2557
  var SMS_OR_PHONE_CONTEXT = /\b(sms|text message|twilio|phone|mobile number|mobile phone|PhoneInput)\b/i;
1196
- var PAID_REPORT_CONTEXT = /\b(paid report|report access|checkout handoff|checkoutHandoff|Stripe checkout|checkout email|checkout phone|checkout mobile|retrieve your report|account\/purchases|report session)\b/i;
2558
+ var PAID_REPORT_CONTEXT = /\b(paid report|report access|checkout handoff|checkoutHandoff|retrieve your report|account\/purchases|report session)\b/i;
1197
2559
  var FRONTEND_PATH_CONTEXT = /(^|\/)(login|signin|sign-in|auth|account|report|checkout)[^/]*\.(tsx|jsx|html|vue|svelte|astro)$/i;
1198
2560
  var PHONE_NORMALIZATION = /\b(normalizePhone|libphonenumber|parsePhoneNumber|isValidPhoneNumber|PhoneInput|E\.164|e164|react-phone-number-input|AsYouType)\b/i;
1199
2561
  var RESEND_BEHAVIOR = /\b(resend|send another|send a new code|request another|retry-after|try again in|wait a minute|cooldown|backoff)\b/i;
1200
2562
  var RATE_LIMIT = /\b(rateLimit|rate limit|Too many requests|retry-after|429|throttl|attempt limit|cooldown)\b/i;
1201
2563
  var ANTI_ENUMERATION = /\b(anti[-\s]?enumeration|success[-\s]?shaped|generic response|do not reveal|without revealing|hasPaidPurchase|no paid purchase|paid purchases|return\s+\{?\s*ok:\s*true|res\.json\(\s*\{\s*ok:\s*true)\b/i;
1202
- var ENUMERATION_LEAK = /\b(?:user|account|email|phone|checkout|purchase|report)\s+(?:not\s+found|does\s+not\s+exist|not\s+recognized|not\s+registered|has no paid|has no purchase|not paid)|\bno\s+(?:account|user|purchase|paid checkout)\b/i;
2564
+ var ENUMERATION_LEAK = /\b(?:user|account|email|phone|checkout|purchase|report)\s+(?:not\s+found|does\s+not\s+exist|not\s+recognized|not\s+registered|has no paid|has no purchase|not paid)|\bno\s+(?:account|user(?!-)|purchase|paid checkout)\b/i;
1203
2565
  var MOBILE_OTP_INPUT = /\b(inputMode|inputmode|one-time-code|autocomplete=["']one-time-code|maxLength\s*=\s*\{?\s*6|type=["']tel|pattern=["'][^"']*\\d|InputOTP|numeric)\b/i;
1204
2566
  var CLEAR_COPY = /\b(6[-\s]?digit|six[-\s]?digit|verification code|one[-\s]?time code|code expires|expires? in|latest code|checkout email|checkout mobile|same email|same mobile|SMS code|Email code|Stripe receipt)\b/i;
1205
2567
  var RECOVERY_PATH = /\b(support|receipt|fallback|email fallback|alternate channel|try email|try sms|contact us|restart checkout|fulfillment|purchase history|account\/purchases|returnTo|different contact)\b/i;
@@ -1207,6 +2569,8 @@ var DELIVERY_VERIFICATION = /\b(deliverability|delivery|delivered|arrives?|smoke
1207
2569
  function shouldScan(file) {
1208
2570
  if (!isTextFile(file)) return false;
1209
2571
  if (isScanExempt(file.relPath)) return false;
2572
+ if (isLikelyNonRuntimePath(file.relPath)) return false;
2573
+ if (isUiLibraryPrimitive(file.relPath)) return false;
1210
2574
  const ext = path9.extname(file.relPath).toLowerCase();
1211
2575
  return SCANNABLE_EXTENSIONS.has(ext);
1212
2576
  }
@@ -1228,14 +2592,14 @@ function evidence(hit, fallback) {
1228
2592
  return `${hit.file}:${hit.line} matched "${hit.text}".`;
1229
2593
  }
1230
2594
  function finding(input) {
1231
- return {
2595
+ return makeFinding({
1232
2596
  checkId: input.checkId,
1233
2597
  itemId: "secure-auth",
1234
2598
  severity: input.severity,
1235
2599
  message: input.message,
1236
2600
  evidence: input.evidence,
1237
2601
  ...input.hit ? { file: input.hit.file, line: input.hit.line } : {}
1238
- };
2602
+ });
1239
2603
  }
1240
2604
  async function collectOtpAuthSignals(ctx) {
1241
2605
  const signals = {
@@ -1254,13 +2618,16 @@ async function collectOtpAuthSignals(ctx) {
1254
2618
  const smsHit = firstHit(file, content, SMS_OR_PHONE_CONTEXT);
1255
2619
  const paidHit = firstHit(file, content, PAID_REPORT_CONTEXT);
1256
2620
  const frontendContext = FRONTEND_PATH_CONTEXT.test(rel);
1257
- signals.hasOtp = signals.hasOtp || Boolean(otpHit);
2621
+ const otpFlowHit = Boolean(
2622
+ otpHit && (authHit || smsHit || paidHit || frontendContext)
2623
+ );
2624
+ signals.hasOtp = signals.hasOtp || otpFlowHit;
1258
2625
  signals.hasSmsOrPhone = signals.hasSmsOrPhone || Boolean(smsHit);
1259
2626
  signals.hasPaidReportOtp = signals.hasPaidReportOtp || Boolean(paidHit && (otpHit || authHit));
1260
2627
  signals.hasFrontendOtp = signals.hasFrontendOtp || Boolean(frontendContext && (otpHit || authHit));
1261
2628
  signals.contextHit = pick(
1262
2629
  signals.contextHit,
1263
- otpHit ?? paidHit ?? authHit ?? smsHit
2630
+ (otpFlowHit ? otpHit : void 0) ?? paidHit ?? authHit ?? smsHit
1264
2631
  );
1265
2632
  signals.phoneNormalization = pick(
1266
2633
  signals.phoneNormalization,
@@ -1417,47 +2784,727 @@ async function checkOtpAuthReadiness(ctx) {
1417
2784
  return findings;
1418
2785
  }
1419
2786
 
1420
- // src/checks/index.ts
1421
- var ALL_CHECKS = [
1422
- { id: "hardcoded-secrets", run: checkHardcodedSecrets },
1423
- { id: "config-secret-leaks", run: checkConfigSecretLeaks },
1424
- { id: "env-committed", run: checkEnvCommitted },
1425
- { id: "env-example", run: checkEnvExample },
1426
- { id: "gitignore", run: checkGitignore },
1427
- { id: "robots-txt", run: checkRobotsTxt },
1428
- { id: "sitemap-xml", run: checkSitemapXml },
1429
- { id: "favicon", run: checkFavicon },
1430
- { id: "llms-txt", run: checkLlmsTxt },
1431
- { id: "pwa-manifest", run: checkPwaManifest },
1432
- { id: "security-headers", run: checkSecurityHeaders },
1433
- { id: "dangerous-patterns", run: checkDangerousPatterns },
1434
- { id: "language-patterns", run: checkLanguagePatterns },
1435
- { id: "python-secret-key-env", run: checkPythonSecretKeyEnv },
1436
- { id: "ruby-secret-key-base-env", run: checkRubySecretKeyBaseEnv },
1437
- { id: "placeholder-content", run: checkPlaceholderContent },
1438
- { id: "otp-auth-readiness", run: checkOtpAuthReadiness }
2787
+ // src/checks/api-spend-cap.ts
2788
+ import * as path10 from "node:path";
2789
+ var PAID_AI_HOSTS = [
2790
+ { host: "api.openai.com", provider: "OpenAI" },
2791
+ { host: "api.anthropic.com", provider: "Anthropic" },
2792
+ { host: "api.replicate.com", provider: "Replicate" },
2793
+ { host: "api.stability.ai", provider: "Stability AI" },
2794
+ { host: "api.mistral.ai", provider: "Mistral" },
2795
+ { host: "generativelanguage.googleapis.com", provider: "Google Gemini" },
2796
+ { host: "api.cohere.ai", provider: "Cohere" },
2797
+ { host: "api.cohere.com", provider: "Cohere" },
2798
+ { host: "api.together.xyz", provider: "Together AI" },
2799
+ { host: "openrouter.ai", provider: "OpenRouter" },
2800
+ { host: "api.groq.com", provider: "Groq" },
2801
+ { host: "api.fireworks.ai", provider: "Fireworks" }
2802
+ ];
2803
+ var PAID_AI_SDK_PATTERNS = [
2804
+ { regex: /\bnew\s+OpenAI\s*\(/, provider: "OpenAI" },
2805
+ { regex: /\bnew\s+Anthropic\s*\(/, provider: "Anthropic" },
2806
+ { regex: /\b@anthropic-ai\/sdk\b/, provider: "Anthropic" },
2807
+ { regex: /\bfrom\s+["']openai["']/, provider: "OpenAI" },
2808
+ { regex: /\bfrom\s+["']@anthropic-ai\/sdk["']/, provider: "Anthropic" },
2809
+ { regex: /\bnew\s+Replicate\s*\(/, provider: "Replicate" },
2810
+ { regex: /\b@google\/generative-ai\b/, provider: "Google Gemini" },
2811
+ { regex: /\bnew\s+CohereClient\s*\(/, provider: "Cohere" }
1439
2812
  ];
2813
+ var MITIGATION_REGEX = /\b(rateLimit|spendCap|rate-limit|spend-cap|rate_limit|spend_cap|express-rate-limit|@upstash\/ratelimit|@vercel\/edge|fastify-rate-limit|hono\/ratelimit|throttle|throttler|maxRequests|maxRequestsPerMinute|tokensPerMinute|tokensPerSecond|requestsPerMinute|requestsPerSecond|RateLimiter|Bottleneck|p-limit|p-throttle|limiter\(|ratelimit\(|withRateLimit|guard\b|budget\b|usageCap)\b/i;
2814
+ var RUNTIME_EXTS = /* @__PURE__ */ new Set([
2815
+ ".ts",
2816
+ ".tsx",
2817
+ ".js",
2818
+ ".jsx",
2819
+ ".mjs",
2820
+ ".cjs",
2821
+ ".py",
2822
+ ".rb",
2823
+ ".go",
2824
+ ".php"
2825
+ ]);
2826
+ function hasMitigationNearby(content, hitIndex) {
2827
+ const start = Math.max(0, hitIndex - 600);
2828
+ const end = Math.min(content.length, hitIndex + 600);
2829
+ return MITIGATION_REGEX.test(content.slice(start, end));
2830
+ }
2831
+ async function checkApiSpendCap(ctx) {
2832
+ const findings = [];
2833
+ const seenFiles = /* @__PURE__ */ new Set();
2834
+ for (const file of ctx.files) {
2835
+ if (!isTextFile(file)) continue;
2836
+ if (isScanExempt(file.relPath)) continue;
2837
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
2838
+ if (isUiLibraryPrimitive(file.relPath)) continue;
2839
+ const ext = path10.extname(file.relPath).toLowerCase();
2840
+ if (!RUNTIME_EXTS.has(ext)) continue;
2841
+ const content = await readFileSafe(file);
2842
+ if (!content) continue;
2843
+ let hitIndex = -1;
2844
+ let providerLabel = "";
2845
+ let detectionShape = "host";
2846
+ for (const { host, provider } of PAID_AI_HOSTS) {
2847
+ const idx = content.indexOf(host);
2848
+ if (idx === -1) continue;
2849
+ hitIndex = idx;
2850
+ providerLabel = provider;
2851
+ break;
2852
+ }
2853
+ if (hitIndex === -1) {
2854
+ for (const { regex, provider } of PAID_AI_SDK_PATTERNS) {
2855
+ const m = regex.exec(content);
2856
+ if (!m) continue;
2857
+ hitIndex = m.index;
2858
+ providerLabel = provider;
2859
+ detectionShape = "sdk";
2860
+ break;
2861
+ }
2862
+ }
2863
+ if (hitIndex === -1) continue;
2864
+ if (hasMitigationNearby(content, hitIndex)) continue;
2865
+ const rel = relPosix(file.relPath);
2866
+ if (seenFiles.has(rel)) continue;
2867
+ seenFiles.add(rel);
2868
+ const line = findLine(content, hitIndex);
2869
+ const detectionDescriptor = detectionShape === "host" ? `Direct call to ${providerLabel} (paid API host)` : `${providerLabel} SDK in use`;
2870
+ findings.push(
2871
+ makeFinding({
2872
+ checkId: "api-spend-cap-missing",
2873
+ itemId: "api-spend-cap",
2874
+ severity: "high",
2875
+ message: `${detectionDescriptor} without a visible rate-limit, spend-cap, or throttle signal nearby. An attacker (or a runaway loop) can burn paid credits faster than your billing alerts fire.`,
2876
+ file: rel,
2877
+ line,
2878
+ evidence: `${detectionDescriptor} at ${rel}:${line}. No rate-limit / spend-cap signal within +/- 600 chars.`
2879
+ })
2880
+ );
2881
+ }
2882
+ return findings;
2883
+ }
1440
2884
 
1441
- // src/items.ts
1442
- var CHECKLIST_ITEMS = {
1443
- secrets: {
1444
- id: "secrets",
1445
- title: "Lock up your API keys and passwords",
1446
- priority: "critical"
1447
- },
1448
- "common-attacks": {
1449
- id: "common-attacks",
1450
- title: "Block the most common automated attacks",
1451
- priority: "critical"
1452
- },
1453
- "https-headers": {
1454
- id: "https-headers",
1455
- title: "Force HTTPS and add browser-level defenses",
1456
- priority: "critical"
1457
- },
1458
- "dev-prod-data": {
1459
- id: "dev-prod-data",
1460
- title: "Keep your test data away from real users",
2885
+ // src/checks/rate-limiting.ts
2886
+ import * as path11 from "node:path";
2887
+ var SERVER_FRAMEWORK_DEPS = /* @__PURE__ */ new Set([
2888
+ "express",
2889
+ "fastify",
2890
+ "hono",
2891
+ "koa",
2892
+ "@nestjs/core",
2893
+ "next",
2894
+ "@sveltejs/kit",
2895
+ "astro",
2896
+ "@hono/node-server",
2897
+ "@trpc/server",
2898
+ "elysia",
2899
+ "h3"
2900
+ ]);
2901
+ var RATE_LIMIT_DEPS = /* @__PURE__ */ new Set([
2902
+ "express-rate-limit",
2903
+ "@upstash/ratelimit",
2904
+ "fastify-rate-limit",
2905
+ "@fastify/rate-limit",
2906
+ "hono-rate-limiter",
2907
+ "rate-limiter-flexible",
2908
+ "express-slow-down",
2909
+ "@vercel/firewall",
2910
+ "@arcjet/next",
2911
+ "@arcjet/node",
2912
+ "@arcjet/sveltekit",
2913
+ "@arcjet/bun",
2914
+ "p-limit",
2915
+ "p-throttle",
2916
+ "bottleneck",
2917
+ "limiter",
2918
+ "@upstash/redis"
2919
+ ]);
2920
+ var RATE_LIMIT_SOURCE_REGEX = /\b(rateLimit\(|rateLimiter\(|@upstash\/ratelimit|express-rate-limit|fastify-rate-limit|@arcjet|RateLimiter\(|new\s+Bottleneck\s*\(|throttle\(|withRateLimit)\b/i;
2921
+ function depsFromPackage(pkg) {
2922
+ const all = /* @__PURE__ */ new Set();
2923
+ for (const dep of [
2924
+ pkg.dependencies,
2925
+ pkg.devDependencies,
2926
+ pkg.peerDependencies
2927
+ ]) {
2928
+ if (!dep) continue;
2929
+ for (const name of Object.keys(dep)) all.add(name);
2930
+ }
2931
+ return all;
2932
+ }
2933
+ async function checkRateLimiting(ctx) {
2934
+ const pkgFile = ctx.files.find(
2935
+ (f) => relPosix(f.relPath) === "package.json"
2936
+ );
2937
+ if (!pkgFile) return [];
2938
+ const pkgRaw = await readFileSafe(pkgFile);
2939
+ if (!pkgRaw) return [];
2940
+ let pkg;
2941
+ try {
2942
+ pkg = JSON.parse(pkgRaw);
2943
+ } catch {
2944
+ return [];
2945
+ }
2946
+ const deps = depsFromPackage(pkg);
2947
+ const hasServerFramework = [...deps].some(
2948
+ (d) => SERVER_FRAMEWORK_DEPS.has(d)
2949
+ );
2950
+ if (!hasServerFramework) return [];
2951
+ const hasRateLimitDep = [...deps].some((d) => RATE_LIMIT_DEPS.has(d));
2952
+ if (hasRateLimitDep) return [];
2953
+ for (const file of ctx.files) {
2954
+ if (!isTextFile(file)) continue;
2955
+ const ext = path11.extname(file.relPath).toLowerCase();
2956
+ if (![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) continue;
2957
+ const content = await readFileSafe(file);
2958
+ if (!content) continue;
2959
+ if (RATE_LIMIT_SOURCE_REGEX.test(content)) return [];
2960
+ }
2961
+ return [
2962
+ makeFinding({
2963
+ checkId: "rate-limit-missing",
2964
+ itemId: "rate-limiting",
2965
+ severity: "high",
2966
+ message: "No rate-limit dependency or in-source throttle was detected. A public API without rate limits will be brute-forced or scraped within days of launch.",
2967
+ file: "package.json",
2968
+ evidence: "package.json declares a server framework (express / fastify / hono / next / etc.) but no rate-limit package and no rate-limit source signal was found."
2969
+ })
2970
+ ];
2971
+ }
2972
+
2973
+ // src/checks/error-monitoring.ts
2974
+ var ERROR_MONITORING_DEPS = /* @__PURE__ */ new Set([
2975
+ "@sentry/node",
2976
+ "@sentry/nextjs",
2977
+ "@sentry/react",
2978
+ "@sentry/browser",
2979
+ "@sentry/sveltekit",
2980
+ "@sentry/astro",
2981
+ "@sentry/remix",
2982
+ "@sentry/bun",
2983
+ "@sentry/vite-plugin",
2984
+ "@bugsnag/js",
2985
+ "@bugsnag/node",
2986
+ "@bugsnag/react",
2987
+ "rollbar",
2988
+ "@datadog/browser-rum",
2989
+ "dd-trace",
2990
+ "@honeybadger-io/js",
2991
+ "@honeybadger-io/react",
2992
+ "@highlight-run/node",
2993
+ "@highlight-run/react",
2994
+ "@logsnag/node",
2995
+ "@axiomhq/js",
2996
+ "@axiomhq/react",
2997
+ "newrelic",
2998
+ "elastic-apm-node",
2999
+ "@opentelemetry/api"
3000
+ ]);
3001
+ var ERROR_MONITORING_SOURCE_REGEX = /\b(Sentry\.init|Sentry\.captureException|Bugsnag\.start|bugsnag\(|Rollbar\.init|Honeybadger\.configure|datadogRum\.init|newrelic\.recordCustomEvent|tracer\.init|@sentry\/|@bugsnag\/|@datadog\/|@highlight-run\/|@honeybadger-io)\b/i;
3002
+ async function checkErrorMonitoring(ctx) {
3003
+ const pkgFile = ctx.files.find(
3004
+ (f) => relPosix(f.relPath) === "package.json"
3005
+ );
3006
+ if (!pkgFile) return [];
3007
+ const pkgRaw = await readFileSafe(pkgFile);
3008
+ if (!pkgRaw) return [];
3009
+ let pkg;
3010
+ try {
3011
+ pkg = JSON.parse(pkgRaw);
3012
+ } catch {
3013
+ return [];
3014
+ }
3015
+ const deps = /* @__PURE__ */ new Set([
3016
+ ...Object.keys(pkg.dependencies ?? {}),
3017
+ ...Object.keys(pkg.devDependencies ?? {})
3018
+ ]);
3019
+ if ([...deps].some((d) => ERROR_MONITORING_DEPS.has(d))) return [];
3020
+ for (const file of ctx.files) {
3021
+ if (!isTextFile(file)) continue;
3022
+ const content = await readFileSafe(file);
3023
+ if (!content) continue;
3024
+ if (ERROR_MONITORING_SOURCE_REGEX.test(content)) return [];
3025
+ }
3026
+ return [
3027
+ makeFinding({
3028
+ checkId: "error-monitoring-missing",
3029
+ itemId: "error-monitoring",
3030
+ severity: "medium",
3031
+ message: "No error-monitoring SDK detected (Sentry / Bugsnag / Rollbar / Datadog / Honeybadger / Highlight / etc.). Production errors that don't get captured become silent failures and shipping bugs.",
3032
+ file: "package.json",
3033
+ evidence: "package.json has no error-monitoring dependency and no error-tracking source signal was found."
3034
+ })
3035
+ ];
3036
+ }
3037
+
3038
+ // src/checks/legal-pages.ts
3039
+ import * as path12 from "node:path";
3040
+ var LEGAL_FILE_PATTERNS = [
3041
+ { regex: /\/(terms|tos|terms-of-service)(\.[a-z]+)?(\/|$)/i, itemKind: "terms" },
3042
+ { regex: /\/privacy(-policy)?(\.[a-z]+)?(\/|$)/i, itemKind: "privacy" }
3043
+ ];
3044
+ var LEGAL_LINK_PATTERNS = [
3045
+ { regex: /href=["']\/(terms|tos|terms-of-service)\b/i, itemKind: "terms" },
3046
+ { regex: /href=["']\/privacy(-policy)?\b/i, itemKind: "privacy" },
3047
+ { regex: /to=["']\/(terms|tos)\b/i, itemKind: "terms" },
3048
+ { regex: /to=["']\/privacy\b/i, itemKind: "privacy" }
3049
+ ];
3050
+ var RUNTIME_EXTS2 = /* @__PURE__ */ new Set([
3051
+ ".ts",
3052
+ ".tsx",
3053
+ ".js",
3054
+ ".jsx",
3055
+ ".mjs",
3056
+ ".cjs",
3057
+ ".html",
3058
+ ".astro",
3059
+ ".svelte",
3060
+ ".vue"
3061
+ ]);
3062
+ async function checkLegalPages(ctx) {
3063
+ const hasPkg = ctx.files.some(
3064
+ (f) => relPosix(f.relPath) === "package.json"
3065
+ );
3066
+ if (!hasPkg) return [];
3067
+ const found = /* @__PURE__ */ new Set();
3068
+ for (const file of ctx.files) {
3069
+ const rel = relPosix(file.relPath).toLowerCase();
3070
+ for (const { regex, itemKind } of LEGAL_FILE_PATTERNS) {
3071
+ if (regex.test(rel)) found.add(itemKind);
3072
+ }
3073
+ if (found.size === 2) break;
3074
+ }
3075
+ if (found.size < 2) {
3076
+ for (const file of ctx.files) {
3077
+ if (found.size === 2) break;
3078
+ if (!isTextFile(file)) continue;
3079
+ const ext = path12.extname(file.relPath).toLowerCase();
3080
+ if (!RUNTIME_EXTS2.has(ext)) continue;
3081
+ const content = await readFileSafe(file);
3082
+ if (!content) continue;
3083
+ for (const { regex, itemKind } of LEGAL_LINK_PATTERNS) {
3084
+ if (regex.test(content)) found.add(itemKind);
3085
+ }
3086
+ }
3087
+ }
3088
+ const findings = [];
3089
+ if (!found.has("terms")) {
3090
+ findings.push(
3091
+ makeFinding({
3092
+ checkId: "legal-page-terms-missing",
3093
+ itemId: "legal-pages",
3094
+ severity: "medium",
3095
+ message: "No Terms of Service route or link detected. If you take payment, run a marketplace, or collect user data, terms are a baseline expectation.",
3096
+ evidence: "No /terms, /tos, or /terms-of-service route file was found, and no source file linked to one."
3097
+ })
3098
+ );
3099
+ }
3100
+ if (!found.has("privacy")) {
3101
+ findings.push(
3102
+ makeFinding({
3103
+ checkId: "legal-page-privacy-missing",
3104
+ itemId: "legal-pages",
3105
+ severity: "medium",
3106
+ message: "No Privacy Policy route or link detected. EU GDPR, US state laws, and most app store / payment processor policies require a published privacy notice.",
3107
+ evidence: "No /privacy or /privacy-policy route file was found, and no source file linked to one."
3108
+ })
3109
+ );
3110
+ }
3111
+ return findings;
3112
+ }
3113
+
3114
+ // src/checks/payments-webhook.ts
3115
+ import * as path13 from "node:path";
3116
+ var WEBHOOK_HANDLER_REGEX = /(app|router)\.(post|use)\s*\(\s*["'`][^"'`]*\/(webhook|stripe[\w-]*hook[\w-]*|hooks\/stripe)/i;
3117
+ var SIGNATURE_VERIFY_REGEX = /\b(stripe\.webhooks\.constructEvent|Webhook\.constructEvent|webhooks\.constructEvent|verifyHeader\s*\(|constructEventAsync)\b/;
3118
+ var RUNTIME_EXTS3 = /* @__PURE__ */ new Set([
3119
+ ".ts",
3120
+ ".tsx",
3121
+ ".js",
3122
+ ".jsx",
3123
+ ".mjs",
3124
+ ".cjs"
3125
+ ]);
3126
+ async function checkPaymentsWebhook(ctx) {
3127
+ const pkgFile = ctx.files.find(
3128
+ (f) => relPosix(f.relPath) === "package.json"
3129
+ );
3130
+ if (!pkgFile) return [];
3131
+ const pkgRaw = await readFileSafe(pkgFile);
3132
+ if (!pkgRaw) return [];
3133
+ let pkg;
3134
+ try {
3135
+ pkg = JSON.parse(pkgRaw);
3136
+ } catch {
3137
+ return [];
3138
+ }
3139
+ const deps = /* @__PURE__ */ new Set([
3140
+ ...Object.keys(pkg.dependencies ?? {}),
3141
+ ...Object.keys(pkg.devDependencies ?? {})
3142
+ ]);
3143
+ const usesStripe = deps.has("stripe") || deps.has("@stripe/stripe-js");
3144
+ if (!usesStripe) return [];
3145
+ const findings = [];
3146
+ for (const file of ctx.files) {
3147
+ if (!isTextFile(file)) continue;
3148
+ if (isScanExempt(file.relPath)) continue;
3149
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
3150
+ if (isUiLibraryPrimitive(file.relPath)) continue;
3151
+ const ext = path13.extname(file.relPath).toLowerCase();
3152
+ if (!RUNTIME_EXTS3.has(ext)) continue;
3153
+ const content = await readFileSafe(file);
3154
+ if (!content) continue;
3155
+ const handlerMatch = WEBHOOK_HANDLER_REGEX.exec(content);
3156
+ if (!handlerMatch) continue;
3157
+ const start = Math.max(0, handlerMatch.index - 200);
3158
+ const end = Math.min(content.length, handlerMatch.index + 2e3);
3159
+ const window = content.slice(start, end);
3160
+ if (SIGNATURE_VERIFY_REGEX.test(window)) continue;
3161
+ const rel = relPosix(file.relPath);
3162
+ const line = findLine(content, handlerMatch.index);
3163
+ findings.push(
3164
+ makeFinding({
3165
+ checkId: "stripe-webhook-signature-missing",
3166
+ itemId: "payments",
3167
+ severity: "high",
3168
+ message: "Stripe is installed and a webhook-shaped route is bound here, but no `stripe.webhooks.constructEvent(...)` / signature-verify call was found within ~2000 characters of the handler. Without signature verification, anyone can POST a forged webhook to grant entitlements or trigger fulfillment. Owner must verify the verify call exists in the actual flow this route uses.",
3169
+ file: rel,
3170
+ line,
3171
+ evidence: `Webhook-shaped route bound at ${rel}:${line}. No 'stripe.webhooks.constructEvent' / 'Webhook.constructEvent' / 'verifyHeader(' call in the surrounding window.`
3172
+ })
3173
+ );
3174
+ return findings;
3175
+ }
3176
+ return findings;
3177
+ }
3178
+
3179
+ // src/checks/file-uploads.ts
3180
+ import * as path14 from "node:path";
3181
+ var UPLOAD_LIBRARY_DEPS = /* @__PURE__ */ new Set([
3182
+ "multer",
3183
+ "busboy",
3184
+ "formidable",
3185
+ "@fastify/multipart",
3186
+ "express-fileupload",
3187
+ "@hono/node-server",
3188
+ "uploadthing",
3189
+ "@uploadthing/react",
3190
+ "react-dropzone"
3191
+ ]);
3192
+ var UPLOAD_USAGE_PATTERNS = [
3193
+ { regex: /\bmulter\s*\(/, provider: "multer" },
3194
+ { regex: /\bnew\s+Busboy\s*\(/, provider: "busboy" },
3195
+ { regex: /\bnew\s+IncomingForm\s*\(/, provider: "formidable" },
3196
+ { regex: /\bcreateUploadthing\s*\(/, provider: "uploadthing" },
3197
+ { regex: /\bregisterMultipartFormParser\s*\(/, provider: "@fastify/multipart" },
3198
+ {
3199
+ regex: /\bapp\.use\s*\(\s*fileUpload\s*\(/,
3200
+ provider: "express-fileupload"
3201
+ }
3202
+ ];
3203
+ var SIZE_LIMIT_REGEX = /\b(limits\s*:\s*\{|fileSize\s*[:=]\s*[0-9]|maxFileSize|maxSize|maxBytes|fieldSize|maxFiles\s*[:=])\b/;
3204
+ var TYPE_FILTER_REGEX = /\b(mimetype|allowedMimeTypes|fileFilter|allowedExtensions|acceptedFileTypes|accept\s*[:=]\s*\[)\b/i;
3205
+ var RUNTIME_EXTS4 = /* @__PURE__ */ new Set([
3206
+ ".ts",
3207
+ ".tsx",
3208
+ ".js",
3209
+ ".jsx",
3210
+ ".mjs",
3211
+ ".cjs"
3212
+ ]);
3213
+ async function checkFileUploads(ctx) {
3214
+ const pkgFile = ctx.files.find(
3215
+ (f) => relPosix(f.relPath) === "package.json"
3216
+ );
3217
+ if (!pkgFile) return [];
3218
+ const pkgRaw = await readFileSafe(pkgFile);
3219
+ if (!pkgRaw) return [];
3220
+ let pkg;
3221
+ try {
3222
+ pkg = JSON.parse(pkgRaw);
3223
+ } catch {
3224
+ return [];
3225
+ }
3226
+ const deps = /* @__PURE__ */ new Set([
3227
+ ...Object.keys(pkg.dependencies ?? {}),
3228
+ ...Object.keys(pkg.devDependencies ?? {})
3229
+ ]);
3230
+ const hasUploadDep = [...deps].some((d) => UPLOAD_LIBRARY_DEPS.has(d));
3231
+ if (!hasUploadDep) return [];
3232
+ const findings = [];
3233
+ for (const file of ctx.files) {
3234
+ if (!isTextFile(file)) continue;
3235
+ if (isScanExempt(file.relPath)) continue;
3236
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
3237
+ if (isUiLibraryPrimitive(file.relPath)) continue;
3238
+ const ext = path14.extname(file.relPath).toLowerCase();
3239
+ if (!RUNTIME_EXTS4.has(ext)) continue;
3240
+ const content = await readFileSafe(file);
3241
+ if (!content) continue;
3242
+ let usageHit = null;
3243
+ for (const { regex, provider } of UPLOAD_USAGE_PATTERNS) {
3244
+ const m = regex.exec(content);
3245
+ if (m) {
3246
+ usageHit = { index: m.index, provider };
3247
+ break;
3248
+ }
3249
+ }
3250
+ if (!usageHit) continue;
3251
+ const start = Math.max(0, usageHit.index - 400);
3252
+ const end = Math.min(content.length, usageHit.index + 1200);
3253
+ const window = content.slice(start, end);
3254
+ const hasSizeLimit = SIZE_LIMIT_REGEX.test(window);
3255
+ const hasTypeFilter = TYPE_FILTER_REGEX.test(window);
3256
+ if (hasSizeLimit && hasTypeFilter) continue;
3257
+ const rel = relPosix(file.relPath);
3258
+ const line = findLine(content, usageHit.index);
3259
+ const missing = [];
3260
+ if (!hasSizeLimit) missing.push("size limit");
3261
+ if (!hasTypeFilter) missing.push("MIME / extension filter");
3262
+ findings.push(
3263
+ makeFinding({
3264
+ checkId: "file-upload-guards-missing",
3265
+ itemId: "file-uploads",
3266
+ severity: "medium",
3267
+ message: `${usageHit.provider} upload usage detected with no obvious ${missing.join(" and ")} nearby. Untyped or unbounded uploads can fill disk, smuggle malware, or trigger expensive processing pipelines. Owner must verify limits exist somewhere in the chain (CDN, edge config, or different file).`,
3268
+ file: rel,
3269
+ line,
3270
+ evidence: `${usageHit.provider} upload call at ${rel}:${line}. No ${missing.join(" / ")} signal in the surrounding window.`
3271
+ })
3272
+ );
3273
+ return findings;
3274
+ }
3275
+ return findings;
3276
+ }
3277
+
3278
+ // src/checks/session-cookie.ts
3279
+ import * as path15 from "node:path";
3280
+ var COOKIE_SET_PATTERNS = [
3281
+ {
3282
+ regex: /\bres\.cookie\s*\(\s*[^)]+\)/m,
3283
+ shape: "Express res.cookie(...)"
3284
+ },
3285
+ {
3286
+ regex: /\bsetCookie\s*\(\s*[^)]+\)/m,
3287
+ shape: "setCookie(...) call"
3288
+ },
3289
+ {
3290
+ regex: /\bcookies\s*\(\s*\)\.set\s*\(/m,
3291
+ shape: "Next.js cookies().set(...)"
3292
+ }
3293
+ ];
3294
+ var SESSION_CONFIG_REGEX = /\bcookie\s*:\s*\{[\s\S]{0,400}\}/g;
3295
+ var RUNTIME_EXTS5 = /* @__PURE__ */ new Set([
3296
+ ".ts",
3297
+ ".tsx",
3298
+ ".js",
3299
+ ".jsx",
3300
+ ".mjs",
3301
+ ".cjs"
3302
+ ]);
3303
+ function evaluateCookieBlock(block) {
3304
+ const missing = [];
3305
+ const insecure = [];
3306
+ if (!/httpOnly\s*[:=]/i.test(block)) {
3307
+ missing.push("httpOnly");
3308
+ } else if (/httpOnly\s*[:=]\s*(false|0)/i.test(block)) {
3309
+ insecure.push("httpOnly: false (JS-readable, exposed to XSS)");
3310
+ }
3311
+ if (!/secure\s*[:=]/i.test(block)) {
3312
+ missing.push("secure");
3313
+ } else if (/secure\s*[:=]\s*(false|0)/i.test(block)) {
3314
+ insecure.push("secure: false (will travel over plain HTTP)");
3315
+ }
3316
+ if (!/sameSite\s*[:=]/i.test(block)) {
3317
+ missing.push("sameSite");
3318
+ }
3319
+ return { missing, insecure };
3320
+ }
3321
+ async function checkSessionCookie(ctx) {
3322
+ const findings = [];
3323
+ const seenFiles = /* @__PURE__ */ new Set();
3324
+ for (const file of ctx.files) {
3325
+ if (!isTextFile(file)) continue;
3326
+ if (isScanExempt(file.relPath)) continue;
3327
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
3328
+ if (isUiLibraryPrimitive(file.relPath)) continue;
3329
+ const ext = path15.extname(file.relPath).toLowerCase();
3330
+ if (!RUNTIME_EXTS5.has(ext)) continue;
3331
+ const content = await readFileSafe(file);
3332
+ if (!content) continue;
3333
+ const rel = relPosix(file.relPath);
3334
+ if (seenFiles.has(rel)) continue;
3335
+ let match;
3336
+ SESSION_CONFIG_REGEX.lastIndex = 0;
3337
+ while ((match = SESSION_CONFIG_REGEX.exec(content)) !== null) {
3338
+ const { missing, insecure } = evaluateCookieBlock(match[0]);
3339
+ if (missing.length === 0 && insecure.length === 0) continue;
3340
+ const line = findLine(content, match.index);
3341
+ const reasons = [];
3342
+ if (missing.length > 0) {
3343
+ reasons.push(`missing flag(s): ${missing.join(", ")}`);
3344
+ }
3345
+ if (insecure.length > 0) {
3346
+ reasons.push(`insecure setting(s): ${insecure.join("; ")}`);
3347
+ }
3348
+ findings.push(
3349
+ makeFinding({
3350
+ checkId: "session-cookie-flags-missing",
3351
+ itemId: "session-management",
3352
+ severity: insecure.length > 0 ? "high" : "medium",
3353
+ message: `Session cookie config at ${rel}:${line} has ${reasons.join(" and ")}. Without httpOnly + secure + sameSite, session state is exposed to XSS, plain-HTTP transit, or cross-site request abuse. Owner must verify other config blocks aren't compensating.`,
3354
+ file: rel,
3355
+ line,
3356
+ evidence: `Cookie config block at ${rel}:${line}. ${reasons.join(". ")}.`
3357
+ })
3358
+ );
3359
+ seenFiles.add(rel);
3360
+ break;
3361
+ }
3362
+ if (seenFiles.has(rel)) continue;
3363
+ for (const { regex, shape } of COOKIE_SET_PATTERNS) {
3364
+ const m = regex.exec(content);
3365
+ if (!m) continue;
3366
+ const block = m[0];
3367
+ const { missing, insecure } = evaluateCookieBlock(block);
3368
+ if (missing.length === 0 && insecure.length === 0) continue;
3369
+ const line = findLine(content, m.index);
3370
+ const reasons = [];
3371
+ if (missing.length > 0) {
3372
+ reasons.push(`missing flag(s): ${missing.join(", ")}`);
3373
+ }
3374
+ if (insecure.length > 0) {
3375
+ reasons.push(`insecure setting(s): ${insecure.join("; ")}`);
3376
+ }
3377
+ findings.push(
3378
+ makeFinding({
3379
+ checkId: "session-cookie-flags-missing",
3380
+ itemId: "session-management",
3381
+ severity: insecure.length > 0 ? "high" : "medium",
3382
+ message: `${shape} at ${rel}:${line} has ${reasons.join(" and ")}. Owner must verify the cookie isn't session-critical or that flags are set in a wrapping framework default.`,
3383
+ file: rel,
3384
+ line,
3385
+ evidence: `${shape} at ${rel}:${line}. ${reasons.join(". ")}.`
3386
+ })
3387
+ );
3388
+ seenFiles.add(rel);
3389
+ break;
3390
+ }
3391
+ }
3392
+ return findings;
3393
+ }
3394
+
3395
+ // src/checks/unguarded-routes.ts
3396
+ import * as path16 from "node:path";
3397
+ var RISKY_ROUTE_PATTERNS = [
3398
+ {
3399
+ regex: /\b(app|router)\.(get|post|put|patch|delete)\s*\(\s*["'`]([^"'`]*\/(admin|internal|admin-api|management|console|backoffice)\b[^"'`]*)["'`]/g,
3400
+ shape: "admin/internal route"
3401
+ },
3402
+ {
3403
+ regex: /\b(app|router)\.(delete)\s*\(\s*["'`]([^"'`]+)["'`]/g,
3404
+ shape: "DELETE route"
3405
+ },
3406
+ {
3407
+ regex: /\b(app|router)\.(post|put|patch)\s*\(\s*["'`]([^"'`]*\/(reset|impersonate|seed|wipe|drop|nuke|migrate|admin)[^"'`]*)["'`]/g,
3408
+ shape: "destructive write route"
3409
+ }
3410
+ ];
3411
+ var AUTH_GUARD_REGEX = /\b(requireAuth|requireUser|requireAdmin|requireSession|isAuthenticated|isAdmin|withAuth|protect\s*\(|authenticate\s*\(|authMiddleware|verifySession|getServerSession|currentUser\s*\(|auth\.guard|sessionGuard|verifyToken)\b/;
3412
+ var RUNTIME_EXTS6 = /* @__PURE__ */ new Set([
3413
+ ".ts",
3414
+ ".tsx",
3415
+ ".js",
3416
+ ".jsx",
3417
+ ".mjs",
3418
+ ".cjs"
3419
+ ]);
3420
+ async function checkUnguardedRoutes(ctx) {
3421
+ const findings = [];
3422
+ const seenFiles = /* @__PURE__ */ new Set();
3423
+ for (const file of ctx.files) {
3424
+ if (!isTextFile(file)) continue;
3425
+ if (isScanExempt(file.relPath)) continue;
3426
+ if (isLikelyNonRuntimePath(file.relPath)) continue;
3427
+ if (isUiLibraryPrimitive(file.relPath)) continue;
3428
+ const ext = path16.extname(file.relPath).toLowerCase();
3429
+ if (!RUNTIME_EXTS6.has(ext)) continue;
3430
+ const content = await readFileSafe(file);
3431
+ if (!content) continue;
3432
+ const rel = relPosix(file.relPath);
3433
+ if (seenFiles.has(rel)) continue;
3434
+ if (AUTH_GUARD_REGEX.test(content)) continue;
3435
+ for (const { regex, shape } of RISKY_ROUTE_PATTERNS) {
3436
+ regex.lastIndex = 0;
3437
+ const m = regex.exec(content);
3438
+ if (!m) continue;
3439
+ const line = findLine(content, m.index);
3440
+ const routePath = m[3] ?? m[2] ?? "(unknown)";
3441
+ findings.push(
3442
+ makeFinding({
3443
+ checkId: "unguarded-route",
3444
+ itemId: "secure-api",
3445
+ severity: "high",
3446
+ message: `${shape} bound at ${rel}:${line} (path: ${routePath}). No obvious auth-guard middleware (requireAuth / isAdmin / withAuth / getServerSession / similar) was found anywhere in this file. Owner must verify the route is protected by a wrapping middleware, framework convention, or different file \u2014 this scanner cannot trace the middleware chain across files.`,
3447
+ file: rel,
3448
+ line,
3449
+ evidence: `${shape} declared at ${rel}:${line}. No recognized auth-guard symbol present in the file.`
3450
+ })
3451
+ );
3452
+ seenFiles.add(rel);
3453
+ break;
3454
+ }
3455
+ }
3456
+ return findings;
3457
+ }
3458
+
3459
+ // src/checks/index.ts
3460
+ var ALL_CHECKS = [
3461
+ { id: "hardcoded-secrets", run: checkHardcodedSecrets },
3462
+ { id: "config-secret-leaks", run: checkConfigSecretLeaks },
3463
+ { id: "env-committed", run: checkEnvCommitted },
3464
+ { id: "env-example", run: checkEnvExample },
3465
+ { id: "gitignore", run: checkGitignore },
3466
+ { id: "robots-txt", run: checkRobotsTxt },
3467
+ { id: "sitemap-xml", run: checkSitemapXml },
3468
+ { id: "favicon", run: checkFavicon },
3469
+ { id: "llms-txt", run: checkLlmsTxt },
3470
+ { id: "pwa-manifest", run: checkPwaManifest },
3471
+ { id: "security-headers", run: checkSecurityHeaders },
3472
+ { id: "dangerous-patterns", run: checkDangerousPatterns },
3473
+ { id: "language-patterns", run: checkLanguagePatterns },
3474
+ { id: "python-secret-key-env", run: checkPythonSecretKeyEnv },
3475
+ { id: "ruby-secret-key-base-env", run: checkRubySecretKeyBaseEnv },
3476
+ { id: "placeholder-content", run: checkPlaceholderContent },
3477
+ { id: "otp-auth-readiness", run: checkOtpAuthReadiness },
3478
+ { id: "api-spend-cap", run: checkApiSpendCap },
3479
+ { id: "rate-limiting", run: checkRateLimiting },
3480
+ { id: "error-monitoring", run: checkErrorMonitoring },
3481
+ { id: "legal-pages", run: checkLegalPages },
3482
+ { id: "payments-webhook", run: checkPaymentsWebhook },
3483
+ { id: "file-uploads", run: checkFileUploads },
3484
+ { id: "session-cookie", run: checkSessionCookie },
3485
+ { id: "unguarded-routes", run: checkUnguardedRoutes }
3486
+ ];
3487
+
3488
+ // src/items.ts
3489
+ var CHECKLIST_ITEMS = {
3490
+ secrets: {
3491
+ id: "secrets",
3492
+ title: "Lock up your API keys and passwords",
3493
+ priority: "critical"
3494
+ },
3495
+ "common-attacks": {
3496
+ id: "common-attacks",
3497
+ title: "Block the most common automated attacks",
3498
+ priority: "critical"
3499
+ },
3500
+ "https-headers": {
3501
+ id: "https-headers",
3502
+ title: "Force HTTPS and add browser-level defenses",
3503
+ priority: "critical"
3504
+ },
3505
+ "dev-prod-data": {
3506
+ id: "dev-prod-data",
3507
+ title: "Keep your test data away from real users",
1461
3508
  priority: "critical"
1462
3509
  },
1463
3510
  "secure-auth": {
@@ -1465,6 +3512,46 @@ var CHECKLIST_ITEMS = {
1465
3512
  title: "Prove auth, OTP, and report access before launch",
1466
3513
  priority: "critical"
1467
3514
  },
3515
+ "api-spend-cap": {
3516
+ id: "api-spend-cap",
3517
+ title: "Cap every AI / API spend before someone bankrupts you",
3518
+ priority: "critical"
3519
+ },
3520
+ "rate-limiting": {
3521
+ id: "rate-limiting",
3522
+ title: "Cap how often someone can hit your app",
3523
+ priority: "high"
3524
+ },
3525
+ "error-monitoring": {
3526
+ id: "error-monitoring",
3527
+ title: "See errors before users tell you about them",
3528
+ priority: "high"
3529
+ },
3530
+ "legal-pages": {
3531
+ id: "legal-pages",
3532
+ title: "Publish Terms of Service and a Privacy Policy",
3533
+ priority: "critical"
3534
+ },
3535
+ payments: {
3536
+ id: "payments",
3537
+ title: "Make sure payments actually work before you charge people",
3538
+ priority: "critical"
3539
+ },
3540
+ "file-uploads": {
3541
+ id: "file-uploads",
3542
+ title: "Lock down uploads and private files",
3543
+ priority: "critical"
3544
+ },
3545
+ "session-management": {
3546
+ id: "session-management",
3547
+ title: "Make sessions feel safe AND convenient",
3548
+ priority: "high"
3549
+ },
3550
+ "secure-api": {
3551
+ id: "secure-api",
3552
+ title: "Lock down your app's behind-the-scenes URLs",
3553
+ priority: "critical"
3554
+ },
1468
3555
  github: {
1469
3556
  id: "github",
1470
3557
  title: "Get your code into GitHub safely",
@@ -1503,13 +3590,13 @@ function permalinkFor(itemId, baseUrl) {
1503
3590
 
1504
3591
  // src/publish.ts
1505
3592
  import { promises as fs2 } from "node:fs";
1506
- import * as path10 from "node:path";
3593
+ import * as path17 from "node:path";
1507
3594
  var DEFAULT_BASE_URL = "https://shippingszn.com";
1508
3595
  var PUBLISH_TIMEOUT_MS = 3e3;
1509
3596
  async function detectStack(cwd2) {
1510
3597
  const tags = /* @__PURE__ */ new Set();
1511
3598
  try {
1512
- const raw = await fs2.readFile(path10.join(cwd2, "package.json"), "utf8");
3599
+ const raw = await fs2.readFile(path17.join(cwd2, "package.json"), "utf8");
1513
3600
  const pkg = JSON.parse(raw);
1514
3601
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1515
3602
  const has = (n) => n in deps;
@@ -1544,7 +3631,7 @@ async function detectStack(cwd2) {
1544
3631
  ];
1545
3632
  for (const [file, tag] of checks) {
1546
3633
  try {
1547
- await fs2.access(path10.join(cwd2, file));
3634
+ await fs2.access(path17.join(cwd2, file));
1548
3635
  tags.add(tag);
1549
3636
  } catch {
1550
3637
  }
@@ -1595,7 +3682,7 @@ async function publishScan(totals, filesScanned, opts) {
1595
3682
  }
1596
3683
 
1597
3684
  // src/proof.ts
1598
- import * as path11 from "node:path";
3685
+ import * as path18 from "node:path";
1599
3686
  var PROOF_TIMEOUT_MS = 5e3;
1600
3687
  var MAX_FINDINGS = 100;
1601
3688
  function shouldUploadProof() {
@@ -1629,7 +3716,7 @@ function buildProofPayload(report, scannerVersion) {
1629
3716
  version: 1,
1630
3717
  source: report.source ?? "cli",
1631
3718
  scanner: "shippingszn",
1632
- targetName: path11.basename(report.cwd) || "CLI scan",
3719
+ targetName: path18.basename(report.cwd) || "CLI scan",
1633
3720
  score: report.launchReadiness.score,
1634
3721
  label: report.launchReadiness.label,
1635
3722
  decision: report.launchReadiness.decision,
@@ -2129,6 +4216,7 @@ function normalizeLaunchFinding(finding2, source = "manual") {
2129
4216
  message: body,
2130
4217
  fixPrompt: aiBuilderPrompt,
2131
4218
  verify: verificationStep,
4219
+ ...finding2.itemId ? { itemId: finding2.itemId } : {},
2132
4220
  ...location ? { location } : {},
2133
4221
  ...finding2.file ? { file: finding2.file } : {},
2134
4222
  ...finding2.line ? { line: finding2.line } : {},
@@ -2289,8 +4377,8 @@ function parseArgs(argv2) {
2289
4377
  else if (a === "--proof") opts.proof = true;
2290
4378
  else if (a === "--no-color") opts.noColor = true;
2291
4379
  else if (a === "--base-url") opts.baseUrl = argv2[++i] ?? opts.baseUrl;
2292
- else if (a === "--cwd") opts.cwd = path12.resolve(argv2[++i] ?? opts.cwd);
2293
- else if (!a.startsWith("-")) opts.cwd = path12.resolve(a);
4380
+ else if (a === "--cwd") opts.cwd = path19.resolve(argv2[++i] ?? opts.cwd);
4381
+ else if (!a.startsWith("-")) opts.cwd = path19.resolve(a);
2294
4382
  }
2295
4383
  return opts;
2296
4384
  }
@@ -2324,12 +4412,6 @@ function color(enabled) {
2324
4412
  };
2325
4413
  }
2326
4414
  var SEVERITY_ORDER = ["critical", "high", "medium", "lower"];
2327
- var SEVERITY_LABEL = {
2328
- critical: "CRITICAL",
2329
- high: "HIGH",
2330
- medium: "MEDIUM",
2331
- lower: "LOWER"
2332
- };
2333
4415
  function printHelp() {
2334
4416
  process2.stdout.write(
2335
4417
  `shippingszn v${PKG_VERSION}
@@ -2341,20 +4423,21 @@ Usage:
2341
4423
  npx shippingszn [path] [options]
2342
4424
 
2343
4425
  Options:
2344
- --json Output a machine-readable JSON report.
4426
+ --json Output a machine-readable JSON summary.
2345
4427
  --publish Legacy alias. Anonymous Wall stats publish by default.
2346
4428
  --no-publish Disable the anonymous Wall post for this run.
2347
- --base-url <url> Base URL used to build links back to checklist items.
4429
+ --base-url <url> Base URL used to build links back to the Launch Fix Kit.
2348
4430
  (default: ${DEFAULT_BASE_URL2})
2349
4431
  --cwd <path> Directory to scan. Default: current working directory.
2350
- --no-color Disable ANSI colors in the human-readable report.
4432
+ --no-color Disable ANSI colors in the human-readable summary.
2351
4433
  -h, --help Show this help.
2352
4434
  -v, --version Print version.
2353
4435
 
2354
- The scanner only reads files. It never writes, modifies, or deletes. It posts
2355
- anonymous aggregate Wall stats by default: no source code, paths, filenames,
2356
- project names, secrets, handles, emails, or repo URLs.
2357
- Exit code is non-zero if any Critical findings are detected.
4436
+ This is the FREE preflight. It tells you launch debt exists. It does NOT tell
4437
+ you what's broken or how to fix it \u2014 that's gated behind the $49 Launch Fix Kit
4438
+ (unlocks full findings + 46-item workbook + paste-ready AI-builder prompts +
4439
+ verification steps + red-flag checks). Read-only on disk. Anonymous aggregate
4440
+ counts post to the Wall by default. Exit code non-zero if any critical findings.
2358
4441
  `
2359
4442
  );
2360
4443
  }
@@ -2392,9 +4475,10 @@ async function run() {
2392
4475
  }
2393
4476
  }
2394
4477
  const trimmedBaseUrl = opts.baseUrl.replace(/\/$/, "");
2395
- const reportUrl = `${trimmedBaseUrl}/report`;
4478
+ const fixKitUrl = `${trimmedBaseUrl}/fix-kit`;
4479
+ const reportUrl = fixKitUrl;
2396
4480
  const proofCreatePath = `${trimmedBaseUrl}/scan`;
2397
- const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON on shippingszn.com/scan if you want to attach the local scan to a paid report handoff.";
4481
+ const proofUploadHint = "Run `npx shippingszn --proof` if you want to publish a proof URL with this scan.";
2398
4482
  const tracked_aware = applyTrackingAwareSeverity(all, tracked);
2399
4483
  const source = scanSource();
2400
4484
  const enriched = tracked_aware.map((f) => {
@@ -2408,6 +4492,11 @@ async function run() {
2408
4492
  body: f.message,
2409
4493
  message: f.message,
2410
4494
  evidence: f.evidence,
4495
+ whatFailed: f.whatFailed,
4496
+ whyItBlocksLaunch: f.whyItBlocksLaunch,
4497
+ fixInstructions: f.fixInstructions,
4498
+ aiBuilderPrompt: f.aiBuilderPrompt,
4499
+ verificationStep: f.verificationStep,
2411
4500
  file: f.file,
2412
4501
  line: f.line,
2413
4502
  permalink,
@@ -2423,6 +4512,9 @@ async function run() {
2423
4512
  message: normalized.message,
2424
4513
  whatFailed: normalized.whatFailed,
2425
4514
  whyItBlocksLaunch: normalized.whyItBlocksLaunch,
4515
+ fixInstructions: normalized.fixInstructions,
4516
+ aiBuilderPrompt: normalized.aiBuilderPrompt,
4517
+ verificationStep: normalized.verificationStep,
2426
4518
  evidence: normalized.evidence,
2427
4519
  confidence: normalized.confidence,
2428
4520
  ...normalized.location ? { location: normalized.location } : {},
@@ -2439,6 +4531,18 @@ async function run() {
2439
4531
  if (a.itemId !== b.itemId) return a.itemId.localeCompare(b.itemId);
2440
4532
  return a.checkId.localeCompare(b.checkId);
2441
4533
  });
4534
+ const publicFindings = enriched.map((f) => {
4535
+ const {
4536
+ whatFailed: _whatFailed,
4537
+ whyItBlocksLaunch: _whyItBlocksLaunch,
4538
+ fixInstructions: _fixInstructions,
4539
+ aiBuilderPrompt: _aiBuilderPrompt,
4540
+ verificationStep: _verificationStep,
4541
+ body: _body,
4542
+ ...publicFinding
4543
+ } = f;
4544
+ return publicFinding;
4545
+ });
2442
4546
  const totals = {
2443
4547
  critical: 0,
2444
4548
  high: 0,
@@ -2475,14 +4579,20 @@ async function run() {
2475
4579
  filesScanned: files.length,
2476
4580
  totals,
2477
4581
  launchReadiness,
2478
- findings: enriched
4582
+ findings: publicFindings
2479
4583
  };
2480
4584
  let proofResult = { status: "skipped" };
2481
4585
  if (opts.proof) {
2482
- proofResult = await uploadProof(report, {
2483
- baseUrl: opts.baseUrl,
2484
- scannerVersion: PKG_VERSION
2485
- });
4586
+ proofResult = await uploadProof(
4587
+ {
4588
+ ...report,
4589
+ findings: enriched
4590
+ },
4591
+ {
4592
+ baseUrl: opts.baseUrl,
4593
+ scannerVersion: PKG_VERSION
4594
+ }
4595
+ );
2486
4596
  if (proofResult.status === "uploaded") {
2487
4597
  report.launchReadiness.proofUrl = proofResult.proofUrl;
2488
4598
  report.launchReadiness.proofResultId = proofResult.id;
@@ -2511,149 +4621,129 @@ async function run() {
2511
4621
  }
2512
4622
  } catch {
2513
4623
  }
4624
+ const automatedAreas = CHECKLIST.filter(
4625
+ (item) => item.cliCoverage === "automated"
4626
+ ).length;
4627
+ const ownerVerifyAreas = CHECKLIST.filter(
4628
+ (item) => item.cliCoverage === "manual_only"
4629
+ ).length;
4630
+ const band = totals.critical > 0 ? "no_go" : totals.high > 0 ? "fix_first" : launchReadiness.score >= 90 ? "launchable" : launchReadiness.score >= 70 ? "verify_before_launch" : "fix_first";
4631
+ const bandLabel = {
4632
+ no_go: "NO-GO",
4633
+ fix_first: "FIX FIRST",
4634
+ verify_before_launch: "VERIFY BEFORE LAUNCH",
4635
+ launchable: "LAUNCHABLE"
4636
+ }[band];
4637
+ const wallPublishedViaProof = proofResult.status === "uploaded" && !!proofResult.wallUrl;
4638
+ const wallStatus = wallPublishedViaProof ? "published" : proofResult.status === "uploaded" && proofResult.wallPublishError ? "failed" : publishResult;
4639
+ const wallUrl = wallPublishedViaProof ? proofResult.wallUrl : publishResult === "published" ? `${trimmedBaseUrl}/wall` : void 0;
4640
+ const wallError = proofResult.wallPublishError;
2514
4641
  if (opts.json) {
2515
- process2.stdout.write(JSON.stringify(report, null, 2) + "\n");
4642
+ const panicSummary = {
4643
+ score: launchReadiness.score,
4644
+ band,
4645
+ counts: { ...totals },
4646
+ filesScanned: files.length,
4647
+ coverage: {
4648
+ automatedAreas,
4649
+ ownerVerifyAreas,
4650
+ totalAreas: CHECKLIST.length
4651
+ },
4652
+ scannerVersion: PKG_VERSION,
4653
+ detailsLocked: true,
4654
+ unlockUrl: fixKitUrl,
4655
+ wall: {
4656
+ status: wallStatus,
4657
+ ...wallUrl ? { url: wallUrl } : {},
4658
+ ...wallError ? { error: wallError } : {}
4659
+ },
4660
+ proof: {
4661
+ status: proofResult.status,
4662
+ ...proofResult.status === "uploaded" ? {
4663
+ url: proofResult.proofUrl,
4664
+ resultId: proofResult.id
4665
+ } : {},
4666
+ ...proofResult.status === "failed" && proofResult.error ? { error: proofResult.error } : {}
4667
+ }
4668
+ };
4669
+ process2.stdout.write(JSON.stringify(panicSummary, null, 2) + "\n");
2516
4670
  return totals.critical > 0 ? 1 : 0;
2517
4671
  }
2518
- const sevColor = (s) => {
2519
- if (s === "critical") return c.red;
2520
- if (s === "high") return c.yellow;
2521
- if (s === "medium") return c.blue;
2522
- return c.gray;
2523
- };
2524
- const safe = (s) => s.replace(/[\x00-\x1f\x7f]/g, "?");
4672
+ const bandColor = band === "no_go" ? c.red : band === "fix_first" ? c.yellow : band === "verify_before_launch" ? c.blue : c.green;
2525
4673
  process2.stdout.write(
2526
4674
  `
2527
4675
  ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
2528
4676
  `
2529
4677
  );
2530
- process2.stdout.write(
2531
- c.dim(`Scanned ${files.length} files in ${opts.cwd}
4678
+ process2.stdout.write(c.dim(`Scanned ${files.length} files.
2532
4679
 
2533
- `)
2534
- );
4680
+ `));
2535
4681
  process2.stdout.write(
2536
- `${c.bold("Launch Readiness Score:")} ${launchReadiness.score}/100 ${c.dim(`(${launchReadiness.label})`)}
2537
- `
2538
- );
2539
- process2.stdout.write(
2540
- `${c.bold("Scope:")} ${safe(launchReadiness.coverageSummary)}
4682
+ `${c.bold("Launch Debt Score:")} ${launchReadiness.score}/100 \u2014 ${bandColor(c.bold(bandLabel))}
4683
+
2541
4684
  `
2542
4685
  );
4686
+ process2.stdout.write(`${c.bold("Findings detected:")}
4687
+ `);
4688
+ process2.stdout.write(` ${c.red(`${totals.critical} critical`)}
4689
+ `);
4690
+ process2.stdout.write(` ${c.yellow(`${totals.high} high`)}
4691
+ `);
4692
+ process2.stdout.write(` ${c.blue(`${totals.medium} medium`)}
4693
+ `);
4694
+ process2.stdout.write(` ${c.gray(`${totals.lower} lower`)}
4695
+
4696
+ `);
4697
+ process2.stdout.write(`${c.bold("Coverage:")}
4698
+ `);
2543
4699
  process2.stdout.write(
2544
- `${c.bold("Top next step:")} ${safe(launchReadiness.topNextStep)}
4700
+ c.dim(
4701
+ ` ${automatedAreas} of ${CHECKLIST.length} launch-readiness areas checked by the scanner
2545
4702
  `
4703
+ )
2546
4704
  );
2547
4705
  process2.stdout.write(
2548
- `${c.bold("Action plan:")} ${c.dim("Exact AI-builder prompts and verification steps are in the paid report.")}
2549
- `
4706
+ c.dim(` ${ownerVerifyAreas} require owner verification
4707
+
4708
+ `)
2550
4709
  );
2551
- if (launchReadiness.reportUrl) {
2552
- process2.stdout.write(
2553
- `${c.bold("Need a written launch decision?")} ${c.dim(`Get the $49 report: ${launchReadiness.reportUrl}`)}
2554
- `
2555
- );
2556
- }
2557
- if (proofResult.status === "uploaded") {
2558
- process2.stdout.write(
2559
- `${c.bold("Proof URL:")} ${c.dim(proofResult.proofUrl ?? "")}
2560
- `
2561
- );
2562
- process2.stdout.write(
2563
- `${c.bold("Report URL:")} ${c.dim(proofResult.reportUrl ?? "")}
2564
- `
2565
- );
2566
- if (proofResult.wallUrl) {
2567
- process2.stdout.write(
2568
- `${c.bold("Wall URL:")} ${c.dim(proofResult.wallUrl)}
2569
- `
2570
- );
2571
- }
4710
+ if (totals.critical + totals.high + totals.medium + totals.lower > 0) {
2572
4711
  process2.stdout.write(
2573
- `${c.bold("Badge Markdown:")} ${c.dim(proofResult.badgeMarkdown ?? "")}
2574
- `
2575
- );
2576
- if (proofResult.wallPublishError) {
2577
- process2.stdout.write(
2578
- `${c.bold("Wall publish failed:")} ${c.dim(proofResult.wallPublishError)}
2579
- `
2580
- );
2581
- }
2582
- } else if (proofResult.status === "failed") {
2583
- process2.stdout.write(
2584
- `${c.bold("Proof upload failed:")} ${c.dim(proofResult.error ?? "Unknown upload error.")}
2585
- `
4712
+ c.bold(
4713
+ "Unlock the Launch Fix Kit to see exactly what's broken and how to fix it:\n"
4714
+ )
2586
4715
  );
4716
+ process2.stdout.write(` ${c.cyan(fixKitUrl)}
4717
+
4718
+ `);
2587
4719
  } else {
2588
- const wallLine = publishResult === "published" ? `Posted anonymous scan stats to ${opts.baseUrl.replace(/\/$/, "")}/wall.` : opts.publish ? "Anonymous Wall stats post by default. If the network is blocked, the local scan still finishes." : "Private local run: Wall publish disabled.";
2589
- process2.stdout.write(
2590
- `${c.bold("Wall signal:")} ${c.dim(wallLine)}
2591
- `
2592
- );
2593
- }
2594
- process2.stdout.write("\n");
2595
- if (enriched.length === 0) {
2596
4720
  process2.stdout.write(
2597
4721
  c.green(
2598
- "No findings. The scanner did not catch obvious launch blockers in this pass.\n\n"
4722
+ "No findings detected. The Launch Fix Kit covers the 27 owner-verify areas the scanner can't reach:\n"
2599
4723
  )
2600
4724
  );
2601
- if (publishResult === "published") {
2602
- process2.stdout.write(
2603
- c.dim(
2604
- `Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall
2605
- `
2606
- )
2607
- );
2608
- }
2609
- return 0;
4725
+ process2.stdout.write(` ${c.cyan(fixKitUrl)}
4726
+
4727
+ `);
2610
4728
  }
2611
- for (const sev of SEVERITY_ORDER) {
2612
- const group = enriched.filter((f) => f.severity === sev);
2613
- if (group.length === 0) continue;
4729
+ if (proofResult.status === "uploaded" && proofResult.proofUrl) {
4730
+ process2.stdout.write(c.dim(`Proof URL: ${proofResult.proofUrl}
4731
+ `));
4732
+ } else if (proofResult.status === "failed") {
2614
4733
  process2.stdout.write(
2615
- `${sevColor(sev)(c.bold(`${SEVERITY_LABEL[sev]} (${group.length})`))}
2616
- `
4734
+ c.dim(`(Proof upload failed: ${proofResult.error ?? "unknown"})
4735
+ `)
2617
4736
  );
2618
- for (const f of group) {
2619
- const loc = f.file ? ` ${c.dim(`\u2014 ${safe(f.file)}${f.line ? `:${f.line}` : ""}`)}` : "";
2620
- process2.stdout.write(` ${c.bold("\u2022")} ${safe(f.message)}${loc}
2621
- `);
2622
- if (f.evidence) {
2623
- process2.stdout.write(` ${c.dim(`evidence: ${safe(f.evidence)}`)}
2624
- `);
2625
- }
2626
- process2.stdout.write(
2627
- ` ${c.cyan(`\u2192 ${safe(f.itemTitle)}`)} ${c.dim(f.permalink)}
2628
- `
2629
- );
2630
- }
2631
- process2.stdout.write("\n");
2632
4737
  }
2633
- process2.stdout.write(
2634
- `${c.bold("Summary:")} ${c.red(`${totals.critical} critical`)}, ${c.yellow(`${totals.high} high`)}, ${c.blue(`${totals.medium} medium`)}, ${c.gray(`${totals.lower} lower`)}
2635
- `
2636
- );
2637
4738
  if (publishResult === "published") {
2638
4739
  process2.stdout.write(
2639
- c.dim(
2640
- `
2641
- Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall
2642
- `
2643
- )
4740
+ c.dim(`(Anonymous score posted to ${trimmedBaseUrl}/wall)
4741
+ `)
2644
4742
  );
2645
4743
  }
2646
4744
  if (totals.critical > 0) {
2647
- process2.stdout.write(
2648
- c.red("\nCritical findings detected. Exiting with code 1.\n")
2649
- );
2650
4745
  return 1;
2651
4746
  }
2652
- process2.stdout.write(
2653
- c.dim(
2654
- "\nNo critical findings. Open the linked checklist items to dig deeper.\n"
2655
- )
2656
- );
2657
4747
  return 0;
2658
4748
  }
2659
4749
  run().then(