Phase 1: token schemas and anon RLS check script

tokens.ts validates link tokens (32 hex chars) before any DB query.
check:rls proves the anon key cannot read or insert on any table, and
fails loudly if the migration has not been applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:49:53 +02:00
co-authored by Claude Opus 5.5
parent c89a82295b
commit b134f87326
4 changed files with 90 additions and 1 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"check:rls": "node --env-file=.env.local scripts/check-rls.mjs"
},
"dependencies": {
"@supabase/ssr": "^0.12.7",
+50
View File
@@ -0,0 +1,50 @@
// Proves the anon key has no table access (CLAUDE.md rule 2).
// Run with: npm run check:rls
import { createClient } from "@supabase/supabase-js";
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments"];
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !anonKey || !serviceKey) {
console.error("Missing Supabase env vars. Fill in .env.local first.");
process.exit(1);
}
const options = { auth: { persistSession: false, autoRefreshToken: false } };
const anon = createClient(url, anonKey, options);
const service = createClient(url, serviceKey, options);
let failed = false;
const fail = (message) => {
failed = true;
console.error(` FAIL ${message}`);
};
for (const table of TABLES) {
console.log(table);
// Not a HEAD request: those report no error when the table is missing.
const real = await service.from(table).select("id", { count: "exact" }).limit(1);
if (real.error) {
fail(`table not reachable with the service key (${real.error.code}). Has the migration been applied?`);
continue;
}
const read = await anon.from(table).select("id").limit(1);
if (read.data?.length) fail("anon can read rows");
else if (real.count === 0) console.log(" ok anon reads nothing (table is empty, so this is a weak check)");
else console.log(` ok anon reads nothing (${real.count} rows exist)`);
const write = await anon.from(table).insert({}).select("id");
if (!write.error) {
fail("anon inserted a row");
await service.from(table).delete().in("id", write.data.map((row) => row.id));
} else {
console.log(` ok anon insert denied (${write.error.code})`);
}
}
console.log(failed ? "\nRLS check FAILED" : "\nRLS check passed: anon has no table access.");
process.exit(failed ? 1 : 0);
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { idSchema, tokenSchema } from "./tokens";
describe("tokenSchema", () => {
it("accepts 32 lowercase hex chars", () => {
expect(tokenSchema.safeParse("0123456789abcdef0123456789abcdef").success).toBe(true);
});
it.each([
["too short", "abc123"],
["too long", "0123456789abcdef0123456789abcdef0"],
["uppercase", "0123456789ABCDEF0123456789ABCDEF"],
["non-hex", "0123456789abcdef0123456789abcdeg"],
["injection attempt", "0123456789abcdef0123456789abcde'"],
["empty", ""],
])("rejects %s", (_label, value) => {
expect(tokenSchema.safeParse(value).success).toBe(false);
});
});
describe("idSchema", () => {
it("accepts a uuid and rejects anything else", () => {
expect(idSchema.safeParse("3f2b8c1e-9d4a-4e6f-8b7a-1c2d3e4f5a6b").success).toBe(true);
expect(idSchema.safeParse("not-a-uuid").success).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
// Matches new_token() in supabase/migrations/0001_init.sql: 16 random bytes as hex = 128 bits.
// Validate the shape before touching the DB so junk URLs never become queries.
export const tokenSchema = z.string().regex(/^[0-9a-f]{32}$/, "Invalid link");
export const scoreboardTokenSchema = tokenSchema;
export const judgeTokenSchema = tokenSchema;
export const openTokenSchema = tokenSchema;
export const idSchema = z.uuid();