diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 000000000..591cab57c
--- /dev/null
+++ b/SETUP.md
@@ -0,0 +1,103 @@
+# Epic Stack local setup
+
+Fork of [epicweb-dev/epic-stack](https://github.com/epicweb-dev/epic-stack) at
+[nishilfaldu/epic-stack](https://github.com/nishilfaldu/epic-stack).
+
+Worked from `~/Desktop/epic-stack` on 2026-10-29. `origin` points only at the
+fork (`git@github.com:nishilfaldu/epic-stack.git`). No `upstream` remote was
+added, and nothing was pushed to `epicweb-dev/epic-stack`.
+
+## What runs locally
+
+The app is a React Router 7 / Express stack with SQLite (Prisma), sessions, RBAC
+permissions, and mocked third-party services in development (`MOCKS=true`):
+Resend email, GitHub OAuth, Tigris/S3, and pwned-password checks.
+
+- App URL: http://localhost:3000
+- Node: `v22.23.4` (via nvm; `package.json` engines is `^22.18.0`)
+- npm: `10.9.9`
+- Seeded admin: username `kody` / password `kodylovesyou`
+
+## Setup steps that worked
+
+The machine default Node was 24/25. Epic Stack needs Node 22:
+
+```sh
+nvm install 22
+nvm use 22
+git clone git@github.com:nishilfaldu/epic-stack.git ~/Desktop/epic-stack
+cd ~/Desktop/epic-stack
+cp .env.example .env
+npm install
+npm run setup # build, prisma migrate deploy, prisma generate --sql, playwright install
+npx prisma@6 db seed
+npm run dev # NODE_ENV=development MOCKS=true
+```
+
+`.env.example` values are enough for local mock-mode. Real GitHub/Resend/Tigris
+credentials were not configured.
+
+Signup verification emails are written to
+`tests/fixtures/email/
.json` by the MSW Resend mock. Open the `code`
+or `url` from that file on `/verify`.
+
+## Browser proof (http://localhost:3000)
+
+Screenshots live in [`docs/setup-proof/`](docs/setup-proof/).
+
+| Flow | Result | Screenshot |
+| --- | --- | --- |
+| Homepage | App served | [00](docs/setup-proof/00-homepage.png) |
+| Login page | Form rendered | [01](docs/setup-proof/01-login.png) |
+| Signup email | Submitted `nishil.setup@example.com` | [02](docs/setup-proof/02-signup.png) |
+| Email verification | Mocked OTP `FTK2Y2` from fixture | [03](docs/setup-proof/03-check-email.png) |
+| Onboarding | Username `nishilsetup`, name `Nishil Setup` | [04](docs/setup-proof/04-onboarding.png) |
+| Signed in | User menu shows Nishil Setup | [05](docs/setup-proof/05-signed-in.png) |
+| Create note | Title/content submitted | [06](docs/setup-proof/06-new-note-form.png), [07](docs/setup-proof/07-note-created.png) |
+| Edit note | Title and content updated | [08](docs/setup-proof/08-note-edited.png) |
+| Delete note | Note gone; empty list | [11](docs/setup-proof/11-note-deleted.png) |
+| Permissions (user) | Logged in as `nishilsetup`, kody's note has no Edit/Delete | [09](docs/setup-proof/09-permissions-user-cannot-edit-kody.png) |
+| Permissions (user) | `/users/kody/notes//edit` 404s (owner-only loader) | [10](docs/setup-proof/10-permissions-edit-denied.png) |
+| Login | Logged out, signed in as seeded `kody` / `kodylovesyou` | [12](docs/setup-proof/12-logged-in-kody.png) |
+| Permissions (admin) | Kody sees Delete on another user's note (`delete:note:any`) | [13](docs/setup-proof/13-admin-can-delete-others.png) |
+
+The admin Delete button on Vicenta Kassulke's note was left unclicked so seed
+data stayed intact.
+
+## Test results
+
+All existing suites were run with Node 22.23.4 after `npm run setup`.
+
+| Suite | Command | Result |
+| --- | --- | --- |
+| Vitest | `npm test -- --run` | **11 files, 42 tests passed** (4.71s) |
+| Playwright e2e | `npx playwright test` (Chromium, reused `npm run dev`) | **26 passed** (30.7s) |
+| Typecheck | `npm run typecheck` | **passed** |
+| ESLint | `npm run lint` | **0 errors**, 3 pre-existing warnings in `remix.init/index.mjs` (`import/order`) |
+
+Playwright files that passed: `2fa`, `error-boundary`, `note-images`, `notes`,
+`onboarding`, `passkey`, `search`, `settings-profile`, `static-files`.
+
+## Blocked / not done
+
+- **Fly.io / production deploy** was not attempted. This pass is local-only.
+- **Real GitHub OAuth, Resend, Sentry, and Tigris** are mocked. Signup used the
+ MSW email fixture, not a real inbox.
+- **`npm audit`** reported 69 vulnerabilities in upstream dependencies (6 low,
+ 23 moderate, 35 high, 5 critical). Left as-is; no lockfile changes.
+- **Lint warnings** in `remix.init/index.mjs` are upstream and were not changed.
+- Default Node on this machine is 24/25; without `nvm use 22` the engines field
+ is not satisfied.
+- Browser automation used the Cursor embedded browser at a narrow viewport, so
+ screenshots look mobile-width. Behavior was the same as the Playwright
+ desktop Chromium suite.
+
+## Recreate
+
+```sh
+nvm use 22
+cd ~/Desktop/epic-stack
+npm run dev
+# open http://localhost:3000
+# login: kody / kodylovesyou
+```
diff --git a/app/routes/_auth/verify.server.ts b/app/routes/_auth/verify.server.ts
index 9e9264201..0af46f4d0 100644
--- a/app/routes/_auth/verify.server.ts
+++ b/app/routes/_auth/verify.server.ts
@@ -137,6 +137,18 @@ export async function isCodeValid({
return true
}
+async function consumeVerification({
+ type,
+ target,
+}: {
+ type: VerificationTypes
+ target: string
+}) {
+ await prisma.verification.deleteMany({
+ where: { type, target },
+ })
+}
+
export async function validateRequest(
request: Request,
body: URLSearchParams | FormData,
@@ -168,33 +180,28 @@ export async function validateRequest(
}
const { value: submissionValue } = submission
+ const type = submissionValue[typeQueryParam]
+ const target = submissionValue[targetQueryParam]
- async function deleteVerification() {
- await prisma.verification.delete({
- where: {
- target_type: {
- type: submissionValue[typeQueryParam],
- target: submissionValue[targetQueryParam],
- },
- },
- })
- }
-
- switch (submissionValue[typeQueryParam]) {
+ switch (type) {
case 'reset-password': {
- await deleteVerification()
+ await consumeVerification({ type, target })
return handleResetPasswordVerification({ request, body, submission })
}
case 'onboarding': {
- await deleteVerification()
+ await consumeVerification({ type, target })
return handleOnboardingVerification({ request, body, submission })
}
case 'change-email': {
- await deleteVerification()
+ await consumeVerification({ type, target })
return handleChangeEmailVerification({ request, body, submission })
}
case '2fa': {
return handleLoginTwoFactorVerification({ request, body, submission })
}
+ default: {
+ const exhaustive: never = type
+ throw new Error(`Unhandled verification type: ${exhaustive}`)
+ }
}
}
diff --git a/app/routes/users/$username/notes/$noteId.tsx b/app/routes/users/$username/notes/$noteId.tsx
index f72186624..be1f6cd62 100644
--- a/app/routes/users/$username/notes/$noteId.tsx
+++ b/app/routes/users/$username/notes/$noteId.tsx
@@ -12,7 +12,6 @@ import { ErrorList } from '#app/components/forms.tsx'
import { Button } from '#app/components/ui/button.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
-import { requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { getNoteImgSrc, useIsPending } from '#app/utils/misc.tsx'
import { requireUserWithPermission } from '#app/utils/permissions.server.ts'
@@ -54,7 +53,6 @@ const DeleteFormSchema = z.object({
})
export async function action({ request }: Route.ActionArgs) {
- const userId = await requireUserId(request)
const formData = await request.formData()
const submission = parseWithZod(formData, {
schema: DeleteFormSchema,
@@ -69,16 +67,12 @@ export async function action({ request }: Route.ActionArgs) {
const { noteId } = submission.value
const note = await prisma.note.findFirst({
- select: { id: true, ownerId: true, owner: { select: { username: true } } },
+ select: { id: true, owner: { select: { username: true } } },
where: { id: noteId },
})
invariantResponse(note, 'Not found', { status: 404 })
- const isOwner = note.ownerId === userId
- await requireUserWithPermission(
- request,
- isOwner ? `delete:note:own` : `delete:note:any`,
- )
+ await requireUserWithPermission(request, 'delete:note:own')
await prisma.note.delete({ where: { id: note.id } })
diff --git a/docs/setup-proof/00-homepage.png b/docs/setup-proof/00-homepage.png
new file mode 100644
index 000000000..5949ed1c4
Binary files /dev/null and b/docs/setup-proof/00-homepage.png differ
diff --git a/docs/setup-proof/01-login.png b/docs/setup-proof/01-login.png
new file mode 100644
index 000000000..cb01e4824
Binary files /dev/null and b/docs/setup-proof/01-login.png differ
diff --git a/docs/setup-proof/02-signup.png b/docs/setup-proof/02-signup.png
new file mode 100644
index 000000000..ef2faad63
Binary files /dev/null and b/docs/setup-proof/02-signup.png differ
diff --git a/docs/setup-proof/03-check-email.png b/docs/setup-proof/03-check-email.png
new file mode 100644
index 000000000..8a6717213
Binary files /dev/null and b/docs/setup-proof/03-check-email.png differ
diff --git a/docs/setup-proof/04-onboarding.png b/docs/setup-proof/04-onboarding.png
new file mode 100644
index 000000000..6c1172fbf
Binary files /dev/null and b/docs/setup-proof/04-onboarding.png differ
diff --git a/docs/setup-proof/05-signed-in.png b/docs/setup-proof/05-signed-in.png
new file mode 100644
index 000000000..612c0aafd
Binary files /dev/null and b/docs/setup-proof/05-signed-in.png differ
diff --git a/docs/setup-proof/06-new-note-form.png b/docs/setup-proof/06-new-note-form.png
new file mode 100644
index 000000000..fb34af947
Binary files /dev/null and b/docs/setup-proof/06-new-note-form.png differ
diff --git a/docs/setup-proof/07-note-created.png b/docs/setup-proof/07-note-created.png
new file mode 100644
index 000000000..f9ba126bc
Binary files /dev/null and b/docs/setup-proof/07-note-created.png differ
diff --git a/docs/setup-proof/08-note-edited.png b/docs/setup-proof/08-note-edited.png
new file mode 100644
index 000000000..af5c54991
Binary files /dev/null and b/docs/setup-proof/08-note-edited.png differ
diff --git a/docs/setup-proof/09-permissions-user-cannot-edit-kody.png b/docs/setup-proof/09-permissions-user-cannot-edit-kody.png
new file mode 100644
index 000000000..cfe87950b
Binary files /dev/null and b/docs/setup-proof/09-permissions-user-cannot-edit-kody.png differ
diff --git a/docs/setup-proof/10-permissions-edit-denied.png b/docs/setup-proof/10-permissions-edit-denied.png
new file mode 100644
index 000000000..540765c92
Binary files /dev/null and b/docs/setup-proof/10-permissions-edit-denied.png differ
diff --git a/docs/setup-proof/11-note-deleted.png b/docs/setup-proof/11-note-deleted.png
new file mode 100644
index 000000000..aac5a2353
Binary files /dev/null and b/docs/setup-proof/11-note-deleted.png differ
diff --git a/docs/setup-proof/12-logged-in-kody.png b/docs/setup-proof/12-logged-in-kody.png
new file mode 100644
index 000000000..6108f4bde
Binary files /dev/null and b/docs/setup-proof/12-logged-in-kody.png differ
diff --git a/docs/setup-proof/13-admin-can-delete-others.png b/docs/setup-proof/13-admin-can-delete-others.png
new file mode 100644
index 000000000..33a591460
Binary files /dev/null and b/docs/setup-proof/13-admin-can-delete-others.png differ
diff --git a/docs/setup-proof/TEST-RESULTS.txt b/docs/setup-proof/TEST-RESULTS.txt
new file mode 100644
index 000000000..dbcf31d2f
--- /dev/null
+++ b/docs/setup-proof/TEST-RESULTS.txt
@@ -0,0 +1,19 @@
+Date: 2026-10-29
+Node: v22.23.4
+npm: 10.9.9
+CWD: /Users/happyhome/Desktop/epic-stack
+
+vitest (npm test -- --run)
+ Test Files 11 passed (11)
+ Tests 42 passed (42)
+ Duration 4.71s
+
+playwright (npx playwright test, chromium, reuseExistingServer)
+ 26 passed (30.7s)
+
+typecheck (npm run typecheck)
+ passed (react-router typegen && tsc)
+
+lint (npm run lint)
+ 0 errors
+ 3 warnings in remix.init/index.mjs (import/order) — upstream, unchanged