Illustrative example. Perch is a fictional sample application, audited in full to show the method on a payments-and-concurrency product.
All reports →Audit report · v0.2.0
Perch
Release recommendation
Ready to ship, risks noted
Static review · runtime not exercised
A well-built app: strong authorization, validated inputs, clean sweeps. The open risks are data-integrity and capacity backstops, not exposure, and all are fixable without a redesign.
Why this verdict
Assessed statically; runtime not exercised. Nothing critical or high survived verification, and two findings that entered as risks were reclassified to improvements once the skeptic confirmed no harm path is reachable today. Four verified risks remain, all in the data-integrity and capacity class: a booking race, webhook redelivery, an unbounded serverless connection pool, and migrations applied inline at deploy with no tested restore. Acceptable to ship for a small invite audience; close them before a broader launch or a traffic push.
What drives this verdict
- CON-01Booking a slot is a check-then-insert without a transaction or a unique constraint. The handler queries for an existing appointment in the slot, then creates one if none is found, so two concurrent requests for the same slot can both pass the check and both insert.
- REL-01The Stripe webhook handler is not idempotent. It verifies the signature correctly but does not record processed event IDs, so a redelivered event is applied a second time.
- INF-01The Prisma client connects with a DATABASE_URL that sets no connection_limit, while the app runs on a serverless platform where each concurrent invocation opens its own pool. Under load this can exhaust Postgres connections.
- DATA-01Prisma migrations are applied automatically as part of the deploy step, with no gate ordering the schema change relative to the code rollout, no tested backup or restore procedure, and no defined deletion or retention policy for customer and appointment records.
Instrumentation: PostHog is initialized and page views are captured, but the activation funnel is not instrumented on one identity across client and server: signup fires client-side while a completed first booking has no server-side event, so activation cannot be measured end to end. No event plan or documented waiver exists. Instrument the booking-completed event server-side before any growth push.
What's solid (5)
Authorization
Every authenticated route resolves the caller's organization server-side from the session and never trusts an orgId from the request body or query string. Verified across all 14 API route handlers and the (app) server components: no handler reads a tenant identifier from user input.
Webhook integrity
The Stripe webhook route verifies the signature with the raw request body before any parsing, using stripe.webhooks.constructEvent against STRIPE_WEBHOOK_SECRET. Unsigned or tampered payloads are rejected with a 400 before they reach business logic. (Idempotency is a separate gap, tracked as REL-01.)
Session handling
Auth cookies are httpOnly, Secure, and SameSite=Lax, and the middleware redirects unauthenticated users away from the (app) segment before any data loads. Session lookups are server-side; no token is exposed to client JavaScript.
Input validation
Mutation endpoints parse request bodies through Zod schemas at the boundary and return typed 422s on failure, so malformed input cannot reach Prisma. The schemas are colocated with their routes and consistently applied.
Secret hygiene
No secrets are committed. All credentials (DATABASE_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY) are read from the environment, and .env is gitignored with a checked-in .env.example carrying placeholders only.
Risks to weigh (4)
Findings with a path to harm in production. These are what the verdict weighs.
CON-01mediumBooking a slot is a check-then-insert without a transaction or a unique constraint. The handler queries for an existing appointment in the slot, then creates one if none is found, so two concurrent requests for the same slot can both pass the check and both insert.concurrency · app/api/appointments/route.ts:63 · createAppointment
REL-01mediumThe Stripe webhook handler is not idempotent. It verifies the signature correctly but does not record processed event IDs, so a redelivered event is applied a second time.reliability · app/api/webhooks/stripe/route.ts:48 · POST
INF-01mediumThe Prisma client connects with a DATABASE_URL that sets no connection_limit, while the app runs on a serverless platform where each concurrent invocation opens its own pool. Under load this can exhaust Postgres connections.infra · lib/db.ts:8 · prisma
DATA-01mediumPrisma migrations are applied automatically as part of the deploy step, with no gate ordering the schema change relative to the code rollout, no tested backup or restore procedure, and no defined deletion or retention policy for customer and appointment records.data-migration-safety · package.json:15 · scripts.deploy
The skeptic confirmed the deploy pipeline applies migrations inline with the code push and found no backup, restore, or retention configuration anywhere in the repo. Confirmed as a risk: the rollout hazard is reachable by routine use, since any deploy carrying a schema change exposes live tenants to the mixed-version window. Whether a backup can actually be restored is a runtime exercise and is listed under Not assessed.
Improvements (14)
Safe today. Ways to make the app more robust, observable, and consistent over time. These do not affect the verdict.
SEC-01mediumTenant isolation for appointment data is enforced entirely in the application layer through an orgId filter, with no row-level scoping at the database. Correctness depends on every current and future query remembering to call the withOrg() helper.security · app/api/appointments/route.ts:41 · GET
Entered as a high risk (suspected cross-tenant read). The skeptic traced all 14 route handlers and every Prisma call against the appointment, booking, and customer tables: each one passes through withOrg() or an equivalent orgId filter, so there is no live exposure. Downgraded to a medium improvement: with no harm path reachable today, the residual is defense-in-depth robustness the app lacks but does not bleed from, which classifies as an improvement under the kind rule.
OPS-01mediumFailures on the booking and Stripe webhook paths are not captured by any structured logging or error-tracking service. The only failure signal is console.error to ephemeral serverless stdout, with no error tracker, no alert, and no named on-call owner.operability · app/api/webhooks/stripe/route.ts:55 · POST
Entered as a risk on suspicion of a hidden active failure on the revenue paths. The skeptic grepped package.json and the lib/ tree for any error-tracking or structured-logging dependency and found none, confirming the observability gap, but also found no evidence of any currently failing path going unseen. With no active failure in evidence, the gap is observability thinness rather than a harm path reachable today, so it was reclassified from risk to improvement under the kind rule; severity stays medium because a future silent failure on a revenue path is serious. Whether an alert would reach an owner once wired is a runtime check, listed under Not assessed.
12 more · hygiene and polish›
SEC-02lowThe public booking endpoint has no rate limiting. It accepts unauthenticated POSTs to create pending appointments and trigger confirmation emails.security · app/api/public/book/route.ts:18 · POST
SEC-03lowThe shared error responder serializes the raw error message into the JSON response body in non-development environments, which can surface Prisma internals (constraint names, column names) to clients.security · lib/api-error.ts:27 · toErrorResponse
REL-02lowConfirmation emails are sent with a single awaited Resend call and no retry or failure surfacing. If the send throws, the error is caught and swallowed and the booking proceeds silently.reliability · lib/email/send.ts:15 · sendConfirmation
A11Y-01lowIcon-only action buttons (edit, cancel, reschedule) in the appointment list render an SVG with no accessible name, so assistive technology announces them as unlabeled buttons.accessibility · app/(app)/appointments/AppointmentRow.tsx:52 · AppointmentRow
UI-01lowTwo different loading spinners are used across the app: a shared <Spinner /> component in some places and an inline ad-hoc animate-spin div in others, so loading states look inconsistent.ui · app/(app)/appointments/page.tsx:71 · AppointmentsPage
TEST-01lowThe controls the other findings depend on are not pinned by any automated test. No test asserts that withOrg() scopes every query to the caller's org (SEC-01), that the booking write rejects a double-booking (CON-01), or that a redelivered Stripe event is processed once (REL-01), so a future refactor can silently remove any of these guards.testing-confidence · package.json:12 · scripts.test
SHIP-01lowDeployment is push-to-main straight to production with no documented rollback path, no staged or canary rollout, no feature-flag mechanism, and no post-deploy verification that the user-visible outcome still works after a release.release-safety · .github/workflows/deploy.yml:8 · deploy
PERF-01lowThe appointments list loads each appointment's provider and customer in a per-row query rather than a single joined read, an N+1 access pattern. Combined with the unbounded per-instance connection pool in INF-01, a traffic spike can turn list rendering into a burst of queries that pressures Postgres connections.performance-capacity · app/(app)/appointments/page.tsx:34 · AppointmentsPage
CON-02informationalThe billing seat count is updated with a read-modify-write (read current seats, add or subtract, write back) with no optimistic lock or atomic increment.concurrency · lib/billing/seats.ts:22 · syncSeatCount
REL-03informationalThe outbound calendar-sync fetch has no timeout, so a slow or hanging upstream can block the request until the platform's own limit fires.reliability · lib/calendar/sync.ts:30 · pushToProvider
A11Y-02informationalInline form validation errors are rendered next to their inputs but are not programmatically associated with them via aria-describedby, and the input is not marked aria-invalid.accessibility · app/(app)/settings/ProfileForm.tsx:44 · ProfileForm
UI-02informationalA few components hardcode hex color values instead of using the Tailwind theme tokens, so the palette can drift from the design system.ui · app/(app)/dashboard/StatCard.tsx:19 · StatCard
Prioritised remediation plan
- 01
Close the data-integrity races before load rises
Add a unique constraint on (slotId, providerId) so double-booking is rejected by the database, and add a webhook-event ledger with a unique event.id so Stripe redeliveries are effectively-once (the ledger dedupes duplicate effects; it is not general exactly-once delivery). These two are the risks most likely to produce real, customer-visible corruption under concurrency or retries.
CON-01, REL-01
- 02
Bound the database connection path before a traffic push
Put the serverless runtime behind a connection pooler with a bounded per-instance limit, and collapse the appointments list's N+1 to a single joined read, which compounds the same pool pressure. Together they turn a latent capacity cliff into a non-issue before broader traffic arrives.
INF-01, PERF-01
- 03
Separate migrations from deploy and prove a restore
Run schema migrations in an expand/contract order decoupled from the code rollout so old and new instances both tolerate the intermediate schema, prove a backup can actually be restored, and document a rollback plus a post-deploy check so a bad release is neither all-or-nothing nor invisible. None of this changes product behavior; together it is the difference between an invite audience and a broader launch.
DATA-01, SHIP-01
- 04
Stand up observability and the tenant-isolation backstop
Wire error tracking and an alert with a named owner on the booking and webhook paths so a silent failure in a revenue path is seen, add a database-level backstop for tenant isolation (RLS or a single enforced data-access layer) so isolation no longer depends on per-query discipline, and pin both with regression tests so the guards cannot silently vanish in a refactor.
OPS-01, SEC-01, TEST-01
- 05
Make outbound side effects resilient
Give confirmation emails a visible status and a retry path, add a timeout to the calendar-sync fetch, rate-limit the public booking endpoint, and make the seat-count write atomic. These raise the app's tolerance for slow upstreams, abuse, and races without changing core behavior.
REL-02, REL-03, SEC-02, CON-02
- 06
Accessibility and consistency polish
Label the icon-only action buttons, associate form errors with their inputs, standardize the spinner, move hardcoded colors onto tokens, and stop leaking Prisma error text to clients. Low-risk cleanup that closes the remaining minor gaps.
A11Y-01, A11Y-02, UI-01, UI-02, SEC-03
Quick wins: low regression risk
Add a unique index on (slotId, providerId) to eliminate double-booking
CON-01Blast radius: If a legitimate use case allows two appointments in the same slot for one provider (for example overlapping hold plus confirmed), the constraint would reject it; confirm the domain forbids that before adding the index.
Add aria-labels to the icon-only appointment actions
A11Y-01Blast radius: Pure additive attribute change; the only risk is a mislabeled action, so the label text should be reviewed against what each button actually does.
Stop returning raw err.message to clients
SEC-03Blast radius: Client code or tests that assert on specific error strings would need updating to the new stable error codes.
Requires deeper investigation
Whether tenant isolation should move to database-level enforcement
SEC-01 is safe today because every query is disciplined, but application-only isolation is a standing liability as the team and the query surface grow. Deciding between Postgres RLS (with a per-request org context) and a single enforced data-access layer is an architectural choice worth making deliberately rather than patching per-query.
SEC-01
End-to-end idempotency of all billing side effects
REL-01 fixes webhook-level dedupe, but the deeper question is whether each billing mutation (seat sync, receipt email, subscription state) is independently idempotent. CON-02's non-atomic seat count is a symptom of the same class. A pass over the billing module to make every side effect safe to replay would close the category, not just the one handler.
REL-01, CON-02
Pin the security-critical invariants with regression tests
SEC-01, CON-01, and REL-01 recommend guards (org-scoping, a unique booking constraint, a webhook-event ledger) that no test currently exercises (TEST-01). Once the fixes land, a small suite that fails when org-scoping, the booking constraint, or webhook idempotency regresses would keep this audit's conclusions true over time, rather than trusting future refactors to preserve them by hand.
TEST-01, SEC-01, CON-01, REL-01
Not assessed: runtime-only checks skipped
Color contrast of text and interactive elements against their backgrounds
To verify: Run axe-core or Lighthouse against the running app on the key screens (dashboard, appointments, booking, settings) and review any contrast violations.
Keyboard focus visibility and tab order through the booking flow
To verify: Tab through the public booking flow and the (app) appointment actions with the mouse unused, confirming a visible focus ring on every interactive element and a logical order.
Actual behavior of the double-booking race under real concurrency
To verify: Fire two concurrent POSTs for the same slot against a staging instance and confirm whether both succeed; after the fix, confirm the second returns 409.
Screen-reader announcement flow across the booking and settings screens
To verify: Walk the booking and settings flows with VoiceOver or NVDA and confirm labels, error associations (A11Y-02), and status updates are announced coherently.
Actual appointments-list latency and the Postgres connection ceiling under concurrent load
To verify: Load a staging org with a realistic appointment volume, drive concurrent traffic against the appointments list and booking endpoints, and watch query latency and active Postgres connections against max_connections.
That a database backup can actually be restored
To verify: Take a backup of a production-like database, restore it into a fresh instance, confirm the application runs correctly against the restored data, and record the recovery time.
That failures on the booking and webhook paths actually page a human
To verify: After adding error tracking, force a handled failure in the webhook and booking paths on staging and confirm an alert is delivered to the named owner within the expected time.
Real-device behavior of the booking and account flows on a phone
To verify: Open the booking and account flows on a real iPhone-class device at 393px, fill the forms with the on-screen keyboard up, and confirm no input is occluded, nothing overflows horizontally, and every control is tappable.
Mechanical sweeps
- dependency-auditcleanNo known vulnerabilities in the production dependency tree at audit time. Next.js, Prisma, Stripe, and Resend SDKs are on current minor releases.
- secret-scancleanNo committed secrets in the working tree or tracked history. All credentials are read from the environment, .env is gitignored, and .env.example carries placeholders only.
- lintcleanNo lint errors. A handful of warnings for unused variables in test fixtures, none in shipping paths.
- typecheckcleanType check passes with no errors under strict mode.