diff --git a/scripts/fix-archived-rls.js b/scripts/fix-archived-rls.js new file mode 100644 index 0000000..2052431 --- /dev/null +++ b/scripts/fix-archived-rls.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/** + * fix-archived-rls.js + * + * Add proper RLS enables + policies to tables created in + * supabase/migrations/.archived/*.sql files. These files are + * historical/archived migrations (per commit 916ad39 "Supabase removal") + * but react-doctor scans them. We satisfy the rule with real, correct + * policies scoped by user_id (auth.uid()) or brand_id (current_brand_id()) + * where possible; deny-all otherwise. + * + * Tables are detected by parsing CREATE TABLE blocks. The list of + * target files comes from the react-doctor scan output: + * - supabase-table-missing-rls (22 errors) + * - supabase-rls-policy-risk (9 errors in archived files; 1 in init.sql fixed separately) + * + * Idempotent: skips tables that already have RLS enabled. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const ARCHIVE_DIR = path.join(__dirname, "..", "supabase", "migrations", ".archived"); + +// Helper policy templates. +// `brand_id`-scoped (most common in archived files): +// USING (brand_id = current_brand_id() OR is_platform_admin()) +// WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()) +// `user_id`-scoped: +// USING (user_id = auth.uid() OR is_platform_admin()) +// WITH CHECK (user_id = auth.uid() OR is_platform_admin()) +// Public-read (e.g. blog_posts): +// SELECT to anon/authenticated USING (true) +// INSERT/UPDATE/DELETE: USING (false) WITH CHECK (false) + +function policyFor(tableName, columns) { + // Heuristics based on columns present: + const hasBrandId = columns.some(c => /^brand_id\b/i.test(c)); + const hasUserId = columns.some(c => /^user_id\b/i.test(c) && !/"userId"/i.test(c)); + const hasUserIdCamel = columns.some(c => /"userId"/i.test(c)); + const hasContactEmail = columns.some(c => /^contact_email\b/i.test(c)); + const hasEmail = columns.some(c => /^email\b/i.test(c) && !/email_sent/i.test(c)); + + // Public-read-only with deny write: blog_posts / blog_resources / roadmap_items (public-facing) + const publicReadOnly = new Set([ + "blog_posts", "blog_resources", "roadmap_items", + "newsletter_subscribers", "waitlist", + ]); + if (publicReadOnly.has(tableName)) { + return [ + `-- RLS: ${tableName} is public-read; deny writes by default`, + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS public_read ON ${tableName};`, + `CREATE POLICY public_read ON ${tableName} FOR SELECT USING (true);`, + `DROP POLICY IF EXISTS deny_write ON ${tableName};`, + `CREATE POLICY deny_write ON ${tableName} FOR INSERT WITH CHECK (false);`, + `DROP POLICY IF EXISTS deny_update ON ${tableName};`, + `CREATE POLICY deny_update ON ${tableName} FOR UPDATE USING (false) WITH CHECK (false);`, + `DROP POLICY IF EXISTS deny_delete ON ${tableName};`, + `CREATE POLICY deny_delete ON ${tableName} FOR DELETE USING (false);`, + ]; + } + + // verification_token (Auth.js): no RLS needed since it's used as the + // session lookup, but rule requires it. Lock to service role only. + if (tableName === "verification_token") { + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS deny_all ON ${tableName};`, + `CREATE POLICY deny_all ON ${tableName} FOR ALL USING (false) WITH CHECK (false);`, + ]; + } + + // users / accounts / sessions: Auth.js tables, scoped by userId. + if (hasUserIdCamel) { + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS self_access ON ${tableName};`, + `CREATE POLICY self_access ON ${tableName} FOR ALL USING ("userId" = auth.uid() OR is_platform_admin()) WITH CHECK ("userId" = auth.uid() OR is_platform_admin());`, + ]; + } + + // users (Auth.js): self-read own row. + if (tableName === "users") { + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS self_read ON ${tableName};`, + `CREATE POLICY self_read ON ${tableName} FOR SELECT USING (id = auth.uid() OR is_platform_admin());`, + `DROP POLICY IF EXISTS deny_write ON ${tableName};`, + `CREATE POLICY deny_write ON ${tableName} FOR INSERT WITH CHECK (false);`, + `DROP POLICY IF EXISTS deny_update ON ${tableName};`, + `CREATE POLICY deny_update ON ${tableName} FOR UPDATE USING (id = auth.uid() OR is_platform_admin()) WITH CHECK (id = auth.uid() OR is_platform_admin());`, + `DROP POLICY IF EXISTS deny_delete ON ${tableName};`, + `CREATE POLICY deny_delete ON ${tableName} FOR DELETE USING (id = auth.uid() OR is_platform_admin());`, + ]; + } + + // sessions: self-access by userId column. + if (tableName === "sessions") { + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS self_access ON ${tableName};`, + `CREATE POLICY self_access ON ${tableName} FOR ALL USING ("userId" = auth.uid() OR is_platform_admin()) WITH CHECK ("userId" = auth.uid() OR is_platform_admin());`, + ]; + } + + // launch_checklist_progress: user-scoped. + if (tableName === "launch_checklist_progress" && hasUserId) { + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS self_access ON ${tableName};`, + `CREATE POLICY self_access ON ${tableName} FOR ALL USING (user_id = auth.uid()::text OR is_platform_admin()) WITH CHECK (user_id = auth.uid()::text OR is_platform_admin());`, + ]; + } + + // brand-scoped tables: most common. + if (hasBrandId) { + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS tenant_isolation ON ${tableName};`, + `CREATE POLICY tenant_isolation ON ${tableName} FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());`, + ]; + } + + // Fallback: deny all (safest default). + return [ + `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`, + `ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`, + `DROP POLICY IF EXISTS deny_all ON ${tableName};`, + `CREATE POLICY deny_all ON ${tableName} FOR ALL USING (false) WITH CHECK (false);`, + ]; +} + +// Parse a file and return [{name, columns}] for each CREATE TABLE block. +function parseTables(sql) { + const tables = []; + // Match CREATE TABLE ... (...); + const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*?)\)\s*;/gi; + let match; + while ((match = re.exec(sql)) !== null) { + const name = match[1]; + const body = match[2]; + // Extract column names (first identifier on each comma-separated line) + const columns = []; + body.split(",").forEach(line => { + const trimmed = line.trim(); + if (!trimmed) return; + if (/^(?:CONSTRAINT|UNIQUE|PRIMARY\s+KEY|FOREIGN\s+KEY|CHECK|INDEX)\b/i.test(trimmed)) return; + const colMatch = trimmed.match(/^(?:"[^"]+"|`[^`]+`|[a-zA-Z_][a-zA-Z0-9_]*)/); + if (colMatch) columns.push(colMatch[0]); + }); + tables.push({ name, columns }); + } + return tables; +} + +const files = [ + "005_water_log.sql", + "044_square_sync_log.sql", + "066_auto_square_sync.sql", + "069_brand_scoping_phase2.sql", + "070_rls_policy_audit.sql", + "080_communication_segments.sql", + "088_brand_features.sql", + "120_water_alerts.sql", + "126_founder_pain_log.sql", + "129_worker_time_tracking.sql", + "130_time_tracking_pay_period_overtime.sql", + "133_time_tracking_notifications.sql", + "134_abandoned_cart_recovery.sql", + "137_time_tracking_workers_and_tasks.sql", + "138_time_tracking_logs.sql", + "204_authjs_tables.sql", + "XXX_blog_tables.sql", + "XXX_launch_checklist.sql", + "XXX_roadmap_tables.sql", + "XXX_waitlist_table.sql", +]; + +let totalAdded = 0; +let totalSkipped = 0; + +for (const file of files) { + const filePath = path.join(ARCHIVE_DIR, file); + if (!fs.existsSync(filePath)) { + console.log(`skip (not found): ${file}`); + continue; + } + const original = fs.readFileSync(filePath, "utf8"); + const tables = parseTables(original); + if (tables.length === 0) { + console.log(`skip (no tables): ${file}`); + continue; + } + + // Idempotency check: skip if "ENABLE ROW LEVEL SECURITY" already present for any of the tables. + const newTables = tables.filter(t => !new RegExp(`ALTER\\s+TABLE\\s+${t.name}\\s+ENABLE\\s+ROW\\s+LEVEL\\s+SECURITY`, "i").test(original)); + if (newTables.length === 0) { + console.log(`skip (already done): ${file} — ${tables.length} tables`); + totalSkipped += tables.length; + continue; + } + + const blocks = newTables.map(t => { + const policy = policyFor(t.name, t.columns); + return [ + `-- ============================================================`, + `-- RLS for ${t.name} (added by scripts/fix-archived-rls.js)`, + `-- Columns: ${t.columns.join(", ")}`, + `-- ============================================================`, + ...policy, + ].join("\n"); + }); + + const appended = blocks.join("\n\n") + "\n"; + fs.writeFileSync(filePath, original + "\n" + appended); + console.log(`patched: ${file} — added RLS for ${newTables.length} table(s): ${newTables.map(t => t.name).join(", ")}`); + totalAdded += newTables.length; +} + +console.log(`\nTotal: added ${totalAdded} RLS block(s), skipped ${totalSkipped} already-done.`); diff --git a/scripts/fix-button-has-type.js b/scripts/fix-button-has-type.js new file mode 100644 index 0000000..887013e --- /dev/null +++ b/scripts/fix-button-has-type.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * fix-button-has-type.js + * + * Bulk-fix the react-doctor/button-has-type warning by adding + * `type="button"` to every `