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 · 2026-07-12
Perch
Release recommendation
Ready to ship, risks noted
Static review · runtime not exercised
Strong security posture. The open risks are data-integrity, operational-readiness, and hardening, not exposure, and all are fixable without a rewrite.
Why this verdict
Assessed statically, no runtime pass. Nothing critical or high survived verification. The one high (a cross-tenant read on the appointments API) was downgraded to medium once the skeptic confirmed every live query is org-scoped through the withOrg() helper, a defense-in-depth gap rather than an active exposure. What remains is data-integrity, operational-readiness, and hardening risk: the booking and webhook races (CON-01, REL-01), the tenant-isolation and connection-pool backstops (SEC-01, INF-01), and two operational gaps this run surfaced, no error tracking or alerting on the revenue paths (OPS-01) and migrations applied inline at deploy with no tested restore (DATA-01). For a small-team invite audience it is acceptable to ship with, but close those six before a broader launch or a marketing-driven traffic spike.
What drives this verdict
- SEC-01Tenant 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.
- 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.
- OPS-01Failures 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, so a silent failure in a revenue path goes unnoticed.
- 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 end to end: signup fires client-side while a completed first booking has no server-side event, so activation cannot be measured on one identity across client and server. 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 (6)
Findings with a path to harm in production. These are what the verdict weighs.
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 high (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 medium because the residual risk is defense-in-depth (no database-level enforcement), not an exploitable path in the current code.
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
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, so a silent failure in a revenue path goes unnoticed.operability · app/api/webhooks/stripe/route.ts:55 · POST
The skeptic grepped package.json and the lib/ tree for any error-tracking or structured-logging dependency and found none; the only failure handling on the booking and webhook paths is console.error. Confirmed that a thrown error in production produces no durable signal and no alert. Whether an alert would actually reach an owner once wired is a runtime check, listed under Not assessed.
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 in the repo. Confirmed as a real data-safety gap; whether a backup can actually be restored is a runtime exercise and is listed under Not assessed.
Improvements (12)
Safe today. Ways to make the app more robust, observable, and consistent over time. These do not affect the verdict.
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, and no feature-flag mechanism to decouple a risky change from its 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 findings most likely to produce real, customer-visible corruption under concurrency or retries.
CON-01, REL-01
- 02
Harden tenant isolation and the connection pool
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 put the serverless runtime behind a connection pooler with a bounded per-instance limit. Collapse the appointments list's N+1 (PERF-01) to a single joined read, which compounds the same pool pressure. All three are hardening that turns a latent cliff into a non-issue before broader traffic.
SEC-01, INF-01, PERF-01
- 03
Stand up operational readiness before a broader launch
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 (OPS-01), separate migrations from the code deploy and prove a backup can actually be restored (DATA-01), and document a rollback plus a staged rollout so a bad release is not all-or-nothing across every tenant (SHIP-01). None changes product behavior; together they are the difference between an invite audience and a broader launch.
OPS-01, DATA-01, SHIP-01
- 04
Make outbound side effects resilient
Give confirmation emails a visible status and a retry path, add a timeout to the calendar-sync fetch, and rate-limit the public booking endpoint. These raise the app's tolerance for slow upstreams and abuse without changing core behavior.
REL-02, REL-03, SEC-02
- 05
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. 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.