@soulbatical/tetra-dev-toolkit 1.9.3 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -171,5 +171,5 @@ function getFixSuggestion(file, content) {
171
171
  if (content.includes('AuthenticatedRequest') || content.includes('req.userToken')) {
172
172
  return `User context available — use adminDB(req) or userDB(req) instead of createClient()`
173
173
  }
174
- return `Use one of: adminDB(req), userDB(req), publicDB(), superadminDB(req), or systemDB(context)`
174
+ return `Use one of: adminDB(req), userDB(req), publicDB(), superadminDB(req), or systemDB(context). See: stella_howto_get slug="tetra-architecture-guide"`
175
175
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Frontend Supabase Queries Detection — HARD BLOCK
3
+ *
4
+ * Frontend code MUST NEVER query the database directly.
5
+ * All data access must go through backend API endpoints.
6
+ *
7
+ * This check finds:
8
+ * - supabase.from('table') calls in frontend code
9
+ * - supabase.rpc('function') calls in frontend code
10
+ * - supabase.storage calls in frontend code
11
+ *
12
+ * ALLOWED:
13
+ * - supabase.auth.* (login, signup, session management — that's what the anon key is for)
14
+ * - Type-only imports (import type { ... })
15
+ * - Test files
16
+ * - Supabase client initialization files (creating the client is OK, using .from() is NOT)
17
+ *
18
+ * WHY THIS MATTERS:
19
+ * - Frontend queries bypass backend validation, audit logging, and business logic
20
+ * - RLS is the ONLY protection — if policies are wrong, data leaks
21
+ * - Backend APIs can enforce rate limiting, input validation, and access control
22
+ * - Frontend queries expose your table schema to anyone with DevTools
23
+ */
24
+
25
+ import { glob } from 'glob'
26
+ import { readFileSync } from 'fs'
27
+
28
+ export const meta = {
29
+ id: 'frontend-supabase-queries',
30
+ name: 'Frontend Direct DB Queries',
31
+ category: 'security',
32
+ severity: 'critical',
33
+ description: 'Blocks direct Supabase .from()/.rpc()/.storage calls in frontend code — all data access must go through backend API'
34
+ }
35
+
36
+ /**
37
+ * Patterns that indicate a direct database query (not auth)
38
+ */
39
+ const QUERY_PATTERNS = [
40
+ // .from('table_name') or .from("table_name") or .from(variable)
41
+ { regex: /\.from\s*\(\s*['"`]/, type: 'direct-db-query', desc: '.from() query' },
42
+ { regex: /\.from\s*\(\s*[a-zA-Z]/, type: 'direct-db-query', desc: '.from() query with variable' },
43
+ // .rpc('function_name')
44
+ { regex: /\.rpc\s*\(\s*['"`]/, type: 'direct-rpc-call', desc: '.rpc() call' },
45
+ // .storage.from('bucket')
46
+ { regex: /\.storage\s*\./, type: 'direct-storage-access', desc: '.storage access' },
47
+ ]
48
+
49
+ /**
50
+ * Lines to skip (not violations)
51
+ */
52
+ const SKIP_PATTERNS = [
53
+ // Comments
54
+ /^\s*\/\//,
55
+ /^\s*\*/,
56
+ /^\s*\/\*/,
57
+ // Type definitions
58
+ /import\s+type/,
59
+ // Array.from() — not Supabase
60
+ /Array\.from/,
61
+ // Date.from, Object.from, etc
62
+ /\b(?:Array|Object|Date|Map|Set|FormData|URLSearchParams|Buffer)\.from/,
63
+ // new FormData().from — not Supabase
64
+ /FormData/,
65
+ ]
66
+
67
+ /**
68
+ * Files that are allowed to have Supabase queries in the frontend.
69
+ * These should be VERY rare — ideally zero.
70
+ */
71
+ const ALLOWED_FILES = [
72
+ // Supabase client setup files (creating client is OK)
73
+ /supabase\.ts$/,
74
+ /supabaseClient\.ts$/,
75
+ /supabase-client\.ts$/,
76
+ /createBrowserClient/,
77
+ ]
78
+
79
+ export async function run(config, projectRoot) {
80
+ const results = {
81
+ passed: true,
82
+ findings: [],
83
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
84
+ }
85
+
86
+ // Detect frontend directories
87
+ const frontendDirs = []
88
+ const candidates = ['frontend', 'web', 'app', 'client', 'src/app', 'src/pages', 'src/components']
89
+ for (const dir of candidates) {
90
+ try {
91
+ const files = await glob(`${dir}/**/*.{ts,tsx,js,jsx}`, {
92
+ cwd: projectRoot,
93
+ ignore: config.ignore || []
94
+ })
95
+ if (files.length > 0) {
96
+ frontendDirs.push(dir)
97
+ }
98
+ } catch {
99
+ // ignore
100
+ }
101
+ }
102
+
103
+ // If no frontend directory found, also check for Next.js app router pattern
104
+ if (frontendDirs.length === 0) {
105
+ results.skipped = true
106
+ results.skipReason = 'No frontend directory detected'
107
+ return results
108
+ }
109
+
110
+ // Scan all frontend files
111
+ for (const dir of frontendDirs) {
112
+ const files = await glob(`${dir}/**/*.{ts,tsx,js,jsx}`, {
113
+ cwd: projectRoot,
114
+ ignore: [
115
+ ...config.ignore,
116
+ '**/*.test.*',
117
+ '**/*.spec.*',
118
+ '**/*.d.ts',
119
+ '**/node_modules/**',
120
+ '**/.next/**',
121
+ '**/dist/**',
122
+ '**/build/**',
123
+ ]
124
+ })
125
+
126
+ for (const file of files) {
127
+ // Check if file is in allowed list
128
+ if (ALLOWED_FILES.some(pattern => pattern.test(file))) continue
129
+
130
+ try {
131
+ const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
132
+ const lines = content.split('\n')
133
+
134
+ for (let i = 0; i < lines.length; i++) {
135
+ const line = lines[i]
136
+
137
+ // Skip comment lines and type imports
138
+ if (SKIP_PATTERNS.some(p => p.test(line))) continue
139
+
140
+ for (const pattern of QUERY_PATTERNS) {
141
+ if (pattern.regex.test(line)) {
142
+ // Double check it's not Array.from or similar
143
+ if (/(?:Array|Object|Date|Map|Set|FormData|URLSearchParams|Buffer|crypto)\.from/.test(line)) continue
144
+ // Skip if it's clearly in a comment at end of line
145
+ const commentIdx = line.indexOf('//')
146
+ const matchIdx = line.search(pattern.regex)
147
+ if (commentIdx >= 0 && commentIdx < matchIdx) continue
148
+
149
+ results.passed = false
150
+ results.findings.push({
151
+ file,
152
+ line: i + 1,
153
+ type: pattern.type,
154
+ severity: 'critical',
155
+ message: `BLOCKED: Frontend code has direct Supabase ${pattern.desc}. Move to backend API endpoint.`,
156
+ snippet: line.trim().substring(0, 120),
157
+ fix: `Create a backend route+controller+service, use adminDB(req), and call via apiClient.get() from frontend. See: stella_howto_get slug="tetra-architecture-guide" for the complete migration pattern.`
158
+ })
159
+ results.summary.critical++
160
+ results.summary.total++
161
+ break // One finding per line is enough
162
+ }
163
+ }
164
+ }
165
+ } catch {
166
+ // Skip unreadable files
167
+ }
168
+ }
169
+ }
170
+
171
+ return results
172
+ }
@@ -63,6 +63,7 @@ export async function run(config, projectRoot) {
63
63
  const tables = new Map() // tableName -> { created: true, rlsEnabled: false, policies: [], droppedPolicies: [], file: string }
64
64
  const securityDefinerFns = [] // { name, file }
65
65
  const publicTables = config.supabase?.publicTables || []
66
+ const backendOnlyTables = config.supabase?.backendOnlyTables || []
66
67
 
67
68
  for (const filePath of sqlFiles) {
68
69
  let content
@@ -171,6 +172,9 @@ export async function run(config, projectRoot) {
171
172
  // Skip intentionally public tables
172
173
  if (publicTables.includes(tableName)) continue
173
174
 
175
+ // Skip backend-only tables (RLS ON + 0 policies is intentional — accessed via systemDB only)
176
+ if (backendOnlyTables.includes(tableName)) continue
177
+
174
178
  // Skip internal Supabase tables
175
179
  if (tableName.startsWith('_') || tableName.startsWith('pg_') || tableName.startsWith('auth_')) continue
176
180
 
@@ -191,17 +195,17 @@ export async function run(config, projectRoot) {
191
195
 
192
196
  results.details.tablesWithRls++
193
197
 
194
- // 2. Check policies exist
198
+ // 2. Check policies exist — RLS ON + 0 policies = DEAD TABLE (nobody can access, not even legit code)
195
199
  if (info.policies.length === 0) {
196
200
  results.passed = false
197
201
  results.findings.push({
198
202
  file: info.file,
199
- type: 'No RLS policies',
200
- severity: 'high',
201
- message: `Table "${tableName}" has RLS enabled but NO policies defined (all access blocked)`,
203
+ type: 'RLS without policies',
204
+ severity: 'critical',
205
+ message: `Table "${tableName}" has RLS enabled but ZERO policies all access blocked, table is dead. Either add policies or document as backend-only in .tetra-quality.json supabase.backendOnlyTables`,
202
206
  table: tableName
203
207
  })
204
- results.summary.high++
208
+ results.summary.critical++
205
209
  results.summary.total++
206
210
  } else {
207
211
  results.details.tablesWithPolicies++
package/lib/runner.js CHANGED
@@ -11,6 +11,7 @@ import * as hardcodedSecrets from './checks/security/hardcoded-secrets.js'
11
11
  import * as serviceKeyExposure from './checks/security/service-key-exposure.js'
12
12
  import * as deprecatedSupabaseAdmin from './checks/security/deprecated-supabase-admin.js'
13
13
  import * as directSupabaseClient from './checks/security/direct-supabase-client.js'
14
+ import * as frontendSupabaseQueries from './checks/security/frontend-supabase-queries.js'
14
15
  import * as systemdbWhitelist from './checks/security/systemdb-whitelist.js'
15
16
  import * as huskyHooks from './checks/stability/husky-hooks.js'
16
17
  import * as ciPipeline from './checks/stability/ci-pipeline.js'
@@ -33,6 +34,7 @@ const ALL_CHECKS = {
33
34
  serviceKeyExposure,
34
35
  deprecatedSupabaseAdmin,
35
36
  directSupabaseClient,
37
+ frontendSupabaseQueries,
36
38
  systemdbWhitelist,
37
39
  gitignoreValidation
38
40
  ],
@@ -169,7 +171,9 @@ export async function runQuickCheck(options = {}) {
169
171
  // Run only critical security checks
170
172
  const criticalChecks = [
171
173
  hardcodedSecrets,
172
- serviceKeyExposure
174
+ serviceKeyExposure,
175
+ directSupabaseClient,
176
+ frontendSupabaseQueries
173
177
  ]
174
178
 
175
179
  const results = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.9.3",
3
+ "version": "1.10.1",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },