Saas-Starter-Kit/Saas-Kit-prisma
1 critical finding to fix first.
4 errors and 7 warnings in the paths between 14 routes and 6 tables.
Every file was read, but 1 place in them could not be parsed — listed at the end of this report.
- ROUTES
- 14
- TABLES
- 6
- FILES READ
- 134/134
- RULES RUN
- 12/12
11 findings
1 critical3 high7 medium
Server Action UpdateUserSubscription (src/lib/API/Database/user/mutations.ts) can reach users without authenticating.
Anyone, signed in or not, can write to users (email, emailVerified) — personal or secret fields.
- Data reached
users(write) · sensitive:email, emailVerified- Missing
- Authentication before the handler reaches data, and a query scoped to the caller.
- Confidence
- No middleware, guard or wrapper is applied to this route anywhere in src/lib/API/Database/user/mutations.ts.
- Path
- Server Action UpdateUserSubscription (src/lib/API/Database/user/mutations.ts) → users
Prove it
# A Server Action is a public POST endpoint. Its id ships to the browser: # find it in your built client chunks next to "UpdateUserSubscription". curl -i -X POST 'https://YOUR_APP/' \ -H 'Next-Action: ACTION_ID' \ -H 'content-type: text/plain;charset=UTF-8' -d '[]' # No cookie is sent. A 200 that runs the action means anyone can call UpdateUserSubscription.Fix
"use server"; export async function UpdateUserSubscription(formData: FormData) { // The page's own session check does not run for an action: check here. const user = await getCurrentUser(); // e.g. (await supabase.auth.getUser()).data.user if (!user) throw new Error("Unauthorized"); // ...scope every query to user.id }Regression test
import { UpdateUserSubscription } from "@/lib/API/Database/user/mutations"; // Make your session lookup return no user for this test. vi.mock("@/lib/auth", () => ({ getCurrentUser: async () => null })); test("UpdateUserSubscription refuses a signed-out caller", async () => { await expect(UpdateUserSubscription(new FormData())).rejects.toThrow(); });Intended? This cannot be declared intentional: a public write is not a design choice the check will bless. Accept it as known debt: run
wyro-check --update-baselineand commit the ledger. It stays visible and CI fails only on new findings.auth-before-dataServer Action UpdateSubscription (src/lib/API/Database/subscription/mutations.ts) can reach subscriptions without authenticating.
An unauthenticated caller can write to subscriptions. Writes are how data gets corrupted or planted.
- Data reached
subscriptions(write)- Missing
- Authentication before the handler reaches data, and a query scoped to the caller.
- Confidence
- No middleware, guard or wrapper is applied to this route anywhere in src/lib/API/Database/subscription/mutations.ts.
- Path
- Server Action UpdateSubscription (src/lib/API/Database/subscription/mutations.ts) → subscriptions
Prove it
# A Server Action is a public POST endpoint. Its id ships to the browser: # find it in your built client chunks next to "UpdateSubscription". curl -i -X POST 'https://YOUR_APP/' \ -H 'Next-Action: ACTION_ID' \ -H 'content-type: text/plain;charset=UTF-8' -d '[]' # No cookie is sent. A 200 that runs the action means anyone can call UpdateSubscription.Fix
"use server"; export async function UpdateSubscription(formData: FormData) { // The page's own session check does not run for an action: check here. const user = await getCurrentUser(); // e.g. (await supabase.auth.getUser()).data.user if (!user) throw new Error("Unauthorized"); // ...scope every query to user.id }Regression test
import { UpdateSubscription } from "@/lib/API/Database/subscription/mutations"; // Make your session lookup return no user for this test. vi.mock("@/lib/auth", () => ({ getCurrentUser: async () => null })); test("UpdateSubscription refuses a signed-out caller", async () => { await expect(UpdateSubscription(new FormData())).rejects.toThrow(); });Intended? This cannot be declared intentional: a public write is not a design choice the check will bless. Accept it as known debt: run
wyro-check --update-baselineand commit the ledger. It stays visible and CI fails only on new findings.auth-before-dataServer Action UpdateTodo (src/lib/API/Database/todos/mutations.ts) can reach todos without authenticating.
An unauthenticated caller can write to todos. Writes are how data gets corrupted or planted.
- Data reached
todos(write)- Missing
- Authentication before the handler reaches data, and a query scoped to the caller.
- Confidence
- No middleware, guard or wrapper is applied to this route anywhere in src/lib/API/Database/todos/mutations.ts.
- Path
- Server Action UpdateTodo (src/lib/API/Database/todos/mutations.ts) → todos
Prove it
# A Server Action is a public POST endpoint. Its id ships to the browser: # find it in your built client chunks next to "UpdateTodo". curl -i -X POST 'https://YOUR_APP/' \ -H 'Next-Action: ACTION_ID' \ -H 'content-type: text/plain;charset=UTF-8' -d '[]' # No cookie is sent. A 200 that runs the action means anyone can call UpdateTodo.Fix
"use server"; export async function UpdateTodo(formData: FormData) { // The page's own session check does not run for an action: check here. const user = await getCurrentUser(); // e.g. (await supabase.auth.getUser()).data.user if (!user) throw new Error("Unauthorized"); // ...scope every query to user.id }Regression test
import { UpdateTodo } from "@/lib/API/Database/todos/mutations"; // Make your session lookup return no user for this test. vi.mock("@/lib/auth", () => ({ getCurrentUser: async () => null })); test("UpdateTodo refuses a signed-out caller", async () => { await expect(UpdateTodo(new FormData())).rejects.toThrow(); });Intended? This cannot be declared intentional: a public write is not a design choice the check will bless. Accept it as known debt: run
wyro-check --update-baselineand commit the ledger. It stays visible and CI fails only on new findings.auth-before-dataServer Action DeleteTodo (src/lib/API/Database/todos/mutations.ts) can reach todos without authenticating.
An unauthenticated caller can write to todos. Writes are how data gets corrupted or planted.
- Data reached
todos(write)- Missing
- Authentication before the handler reaches data, and a query scoped to the caller.
- Confidence
- No middleware, guard or wrapper is applied to this route anywhere in src/lib/API/Database/todos/mutations.ts.
- Path
- Server Action DeleteTodo (src/lib/API/Database/todos/mutations.ts) → todos
Prove it
# A Server Action is a public POST endpoint. Its id ships to the browser: # find it in your built client chunks next to "DeleteTodo". curl -i -X POST 'https://YOUR_APP/' \ -H 'Next-Action: ACTION_ID' \ -H 'content-type: text/plain;charset=UTF-8' -d '[]' # No cookie is sent. A 200 that runs the action means anyone can call DeleteTodo.Fix
"use server"; export async function DeleteTodo(formData: FormData) { // The page's own session check does not run for an action: check here. const user = await getCurrentUser(); // e.g. (await supabase.auth.getUser()).data.user if (!user) throw new Error("Unauthorized"); // ...scope every query to user.id }Regression test
import { DeleteTodo } from "@/lib/API/Database/todos/mutations"; // Make your session lookup return no user for this test. vi.mock("@/lib/auth", () => ({ getCurrentUser: async () => null })); test("DeleteTodo refuses a signed-out caller", async () => { await expect(DeleteTodo(new FormData())).rejects.toThrow(); });Intended? This cannot be declared intentional: a public write is not a design choice the check will bless. Accept it as known debt: run
wyro-check --update-baselineand commit the ledger. It stays visible and CI fails only on new findings.auth-before-dataPOST /api/chat has no validator or rate limiter attached.
POST /api/chat accepts any body at any rate. Malformed input reaches your handler, and nothing stops a script calling it in a loop.
- Missing
- Schema validation of the request body, and a rate limit.
- Confidence
- Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.
Prove it
curl -i -X POST 'https://YOUR_API/api/chat' \ -H 'content-type: application/json' -d '{"unexpected": {"nested": [1,2,3]}}' # Expect 400. A 2xx or 500 means the body is not validated.Fix
const Body = z.object({ name: z.string().max(200) }).strict(); const parsed = Body.safeParse(req.body); if (!parsed.success) return res.status(400).json(parsed.error.flatten());Regression test
test("POST /api/chat rejects an unexpected body", async () => { const res = await request(app).post("/api/chat").send({ unexpected: true }); expect(res.status).toBe(400); });Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guardedServer Action UpdateSubscription (src/lib/API/Database/subscription/mutations.ts) has no validator or rate limiter attached.
Server Action UpdateSubscription (src/lib/API/Database/subscription/mutations.ts) is a public POST endpoint: it accepts whatever arguments a caller sends, as often as they send them.
- Missing
- Schema validation of the action's arguments, and a rate limit.
- Confidence
- Inline validation (zod.parse on the form data) and platform rate limits are not always visible to the parser. Check before acting.
Fix
const Input = z.object({ title: z.string().min(1).max(200) }); export async function UpdateSubscription(formData: FormData) { const parsed = Input.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: parsed.error.flatten() }; // ...use parsed.data, never formData directly }Intended? If validation and rate limiting happen upstream (Vercel Firewall, a middleware), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guardedServer Action UpdateTodo (src/lib/API/Database/todos/mutations.ts) has no validator or rate limiter attached.
Server Action UpdateTodo (src/lib/API/Database/todos/mutations.ts) is a public POST endpoint: it accepts whatever arguments a caller sends, as often as they send them.
- Missing
- Schema validation of the action's arguments, and a rate limit.
- Confidence
- Inline validation (zod.parse on the form data) and platform rate limits are not always visible to the parser. Check before acting.
Fix
const Input = z.object({ title: z.string().min(1).max(200) }); export async function UpdateTodo(formData: FormData) { const parsed = Input.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: parsed.error.flatten() }; // ...use parsed.data, never formData directly }Intended? If validation and rate limiting happen upstream (Vercel Firewall, a middleware), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guardedServer Action DeleteTodo (src/lib/API/Database/todos/mutations.ts) has no validator or rate limiter attached.
Server Action DeleteTodo (src/lib/API/Database/todos/mutations.ts) is a public POST endpoint: it accepts whatever arguments a caller sends, as often as they send them.
- Missing
- Schema validation of the action's arguments, and a rate limit.
- Confidence
- Inline validation (zod.parse on the form data) and platform rate limits are not always visible to the parser. Check before acting.
Fix
const Input = z.object({ title: z.string().min(1).max(200) }); export async function DeleteTodo(formData: FormData) { const parsed = Input.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: parsed.error.flatten() }; // ...use parsed.data, never formData directly }Intended? If validation and rate limiting happen upstream (Vercel Firewall, a middleware), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guardedServer Action UpdateUserSubscription (src/lib/API/Database/user/mutations.ts) has no validator or rate limiter attached.
Server Action UpdateUserSubscription (src/lib/API/Database/user/mutations.ts) is a public POST endpoint: it accepts whatever arguments a caller sends, as often as they send them.
- Missing
- Schema validation of the action's arguments, and a rate limit.
- Confidence
- Inline validation (zod.parse on the form data) and platform rate limits are not always visible to the parser. Check before acting.
Fix
const Input = z.object({ title: z.string().min(1).max(200) }); export async function UpdateUserSubscription(formData: FormData) { const parsed = Input.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: parsed.error.flatten() }; // ...use parsed.data, never formData directly }Intended? If validation and rate limiting happen upstream (Vercel Firewall, a middleware), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guardedServer Action RetrieveSubscription (src/lib/API/Services/stripe/customer.ts) has no validator or rate limiter attached.
Server Action RetrieveSubscription (src/lib/API/Services/stripe/customer.ts) is a public POST endpoint: it accepts whatever arguments a caller sends, as often as they send them.
- Missing
- Schema validation of the action's arguments, and a rate limit.
- Confidence
- Inline validation (zod.parse on the form data) and platform rate limits are not always visible to the parser. Check before acting.
Fix
const Input = z.object({ title: z.string().min(1).max(200) }); export async function RetrieveSubscription(formData: FormData) { const parsed = Input.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: parsed.error.flatten() }; // ...use parsed.data, never formData directly }Intended? If validation and rate limiting happen upstream (Vercel Firewall, a middleware), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guardedServer Action UpdateStripeCustomerEmail (src/lib/API/Services/stripe/customer.ts) has no validator or rate limiter attached.
Server Action UpdateStripeCustomerEmail (src/lib/API/Services/stripe/customer.ts) is a public POST endpoint: it accepts whatever arguments a caller sends, as often as they send them.
- Missing
- Schema validation of the action's arguments, and a rate limit.
- Confidence
- Inline validation (zod.parse on the form data) and platform rate limits are not always visible to the parser. Check before acting.
Fix
const Input = z.object({ title: z.string().min(1).max(200) }); export async function UpdateStripeCustomerEmail(formData: FormData) { const parsed = Input.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: parsed.error.flatten() }; // ...use parsed.data, never formData directly }Intended? If validation and rate limiting happen upstream (Vercel Firewall, a middleware), turn the rule off for the repo:
{ "policy": { "rules": { "public-entry-guarded": "off" } } }in wyro.json.public-entry-guarded
1 place could not be parsed, so any route or table declared there is missing from this report:
src/app/api/auth/[...nextauth]/route.ts— Next route file exports no recognised method handler
Free account, no card. The repository opens as an editable graph.
Add this check to the README
[](https://wyro.in/scan/Saas-Starter-Kit/Saas-Kit-prisma)It updates itself whenever the repository changes and links back to this report.
What this is
Wyro reads the repository’s routes and data models and checks the paths between them: whether a route can reach a table without passing a guard, whether a datastore holding personal data is exposed to a public read, whether an endpoint that issues credentials requires the credentials it issues.
It is rule-based, not a model. The same commit produces the same result every time, and it does not guess at business rules it cannot see. A rule with nothing to look at is reported as not having run — never as a pass.
This check runs on public source through GitHub’s own API and needs no account. A free account adds the editable architecture canvas, private repositories on paid plans, and a CI gate. No card required.
Maintain this repository? You can have this report removed, and a real vulnerability is disclosed to you privately before it is published. How public reports are handled.