@soulbatical/tetra-dev-toolkit 1.13.0 → 1.14.0

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.
@@ -20,9 +20,9 @@
20
20
  * Reference: stella_howto_get slug="tetra-architecture-guide"
21
21
  */
22
22
 
23
- import { readFileSync, existsSync } from 'fs'
23
+ import { readFileSync, existsSync, readdirSync } from 'fs'
24
24
  import { join } from 'path'
25
- import { glob } from 'glob'
25
+ import { globSync } from 'glob'
26
26
 
27
27
  export const meta = {
28
28
  id: 'config-rls-alignment',
@@ -176,7 +176,6 @@ function parseMigrations(projectRoot) {
176
176
  }
177
177
 
178
178
  function findFiles(projectRoot, pattern) {
179
- const { globSync } = require('glob')
180
179
  try {
181
180
  return globSync(pattern, { cwd: projectRoot, absolute: true, ignore: ['**/node_modules/**'] })
182
181
  } catch {
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Route ↔ Config Alignment Check
3
+ *
4
+ * Verifies that route files match the feature config accessLevel:
5
+ *
6
+ * - accessLevel 'admin' → route file must be adminRoutes.ts AND must have authenticateToken + requireOrganizationAdmin
7
+ * - accessLevel 'user' → route file must be userRoutes.ts AND must have authenticateToken
8
+ * - accessLevel 'public' → route can be publicRoutes.ts, NO auth middleware required
9
+ * - accessLevel 'system' → should NOT have any route file (backend-only)
10
+ * - accessLevel 'creator' → route must have authenticateToken
11
+ *
12
+ * CRITICAL if an admin endpoint has no auth middleware.
13
+ * HIGH if route name doesn't match access level.
14
+ */
15
+
16
+ import { readFileSync, existsSync } from 'fs'
17
+ import { join, basename, dirname } from 'path'
18
+ import { globSync } from 'glob'
19
+
20
+ export const meta = {
21
+ id: 'route-config-alignment',
22
+ name: 'Route ↔ Config Alignment',
23
+ category: 'security',
24
+ severity: 'critical',
25
+ description: 'Verifies route middleware matches feature config accessLevel'
26
+ }
27
+
28
+ /**
29
+ * Find files matching a glob pattern
30
+ */
31
+ function findFiles(projectRoot, pattern) {
32
+ try {
33
+ return globSync(pattern, { cwd: projectRoot, absolute: true, ignore: ['**/node_modules/**'] })
34
+ } catch {
35
+ return []
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Parse all feature configs to extract tableName → accessLevel + feature directory
41
+ */
42
+ function parseFeatureConfigs(projectRoot) {
43
+ const configs = [] // { tableName, accessLevel, configFile, featureDir }
44
+
45
+ const configFiles = [
46
+ ...findFiles(projectRoot, 'backend/src/features/**/config/*.config.ts'),
47
+ ...findFiles(projectRoot, 'src/features/**/config/*.config.ts')
48
+ ]
49
+
50
+ for (const file of configFiles) {
51
+ let content
52
+ try { content = readFileSync(file, 'utf-8') } catch { continue }
53
+
54
+ const tableMatch = content.match(/tableName:\s*['"]([^'"]+)['"]/)
55
+ if (!tableMatch) continue
56
+
57
+ const tableName = tableMatch[1]
58
+
59
+ const accessMatch = content.match(/accessLevel:\s*['"]([^'"]+)['"]/)
60
+ const accessLevel = accessMatch ? accessMatch[1] : 'admin'
61
+
62
+ // Feature directory is two levels up from config file (features/X/config/file.ts → features/X)
63
+ const configDir = dirname(file)
64
+ const featureDir = dirname(configDir)
65
+
66
+ configs.push({
67
+ tableName,
68
+ accessLevel,
69
+ configFile: file.replace(projectRoot + '/', ''),
70
+ featureDir
71
+ })
72
+ }
73
+
74
+ return configs
75
+ }
76
+
77
+ /**
78
+ * Find route files in a feature directory
79
+ */
80
+ function findRouteFiles(featureDir) {
81
+ const routesDir = join(featureDir, 'routes')
82
+ if (!existsSync(routesDir)) return []
83
+
84
+ try {
85
+ return globSync('*.ts', { cwd: routesDir, absolute: true, ignore: ['*.d.ts'] })
86
+ } catch {
87
+ return []
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if a route file contains authenticateToken middleware
93
+ */
94
+ function hasAuthMiddleware(content) {
95
+ return /authenticateToken/.test(content)
96
+ }
97
+
98
+ /**
99
+ * Check if a route file contains requireOrganizationAdmin middleware
100
+ */
101
+ function hasOrgAdminMiddleware(content) {
102
+ return /requireOrganizationAdmin/.test(content)
103
+ }
104
+
105
+ /**
106
+ * Expected route filename for a given accessLevel
107
+ */
108
+ function expectedRouteFileName(accessLevel) {
109
+ switch (accessLevel) {
110
+ case 'admin': return 'adminRoutes.ts'
111
+ case 'user': return 'userRoutes.ts'
112
+ case 'public': return 'publicRoutes.ts'
113
+ default: return null
114
+ }
115
+ }
116
+
117
+ export async function run(config, projectRoot) {
118
+ const results = {
119
+ passed: true,
120
+ skipped: false,
121
+ findings: [],
122
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
123
+ details: { routesChecked: 0, violations: 0 }
124
+ }
125
+
126
+ const featureConfigs = parseFeatureConfigs(projectRoot)
127
+
128
+ if (featureConfigs.length === 0) {
129
+ results.skipped = true
130
+ results.skipReason = 'No feature config files found'
131
+ return results
132
+ }
133
+
134
+ for (const cfg of featureConfigs) {
135
+ const routeFiles = findRouteFiles(cfg.featureDir)
136
+
137
+ // --- system: should NOT have any route file ---
138
+ if (cfg.accessLevel === 'system') {
139
+ if (routeFiles.length > 0) {
140
+ const routeNames = routeFiles.map(f => basename(f)).join(', ')
141
+ results.findings.push({
142
+ file: cfg.configFile,
143
+ line: 1,
144
+ type: 'system-has-routes',
145
+ severity: 'high',
146
+ message: `Config declares accessLevel "system" for table "${cfg.tableName}" but feature has route files: ${routeNames}. System features should be backend-only with no HTTP routes.`,
147
+ fix: `Remove route files or change accessLevel in the config.`
148
+ })
149
+ results.summary.high++
150
+ results.summary.total++
151
+ results.passed = false
152
+ results.details.violations++
153
+ }
154
+ continue
155
+ }
156
+
157
+ // Skip features with no routes (may be intentional for some configs)
158
+ if (routeFiles.length === 0) continue
159
+
160
+ // Check each route file in this feature
161
+ for (const routeFile of routeFiles) {
162
+ results.details.routesChecked++
163
+
164
+ let content
165
+ try { content = readFileSync(routeFile, 'utf-8') } catch { continue }
166
+
167
+ const routeName = basename(routeFile)
168
+ const relRouteFile = routeFile.replace(projectRoot + '/', '')
169
+
170
+ // --- admin checks ---
171
+ if (cfg.accessLevel === 'admin') {
172
+ // Route name should be adminRoutes.ts
173
+ if (routeName === 'adminRoutes.ts') {
174
+ // CRITICAL: admin route MUST have authenticateToken
175
+ if (!hasAuthMiddleware(content)) {
176
+ results.findings.push({
177
+ file: relRouteFile,
178
+ line: 1,
179
+ type: 'admin-route-no-auth',
180
+ severity: 'critical',
181
+ message: `Admin route for table "${cfg.tableName}" is missing authenticateToken middleware. Endpoints are accessible without authentication.`,
182
+ fix: `Add authenticateToken middleware: router.use(authenticateToken, requireOrganizationAdmin)`
183
+ })
184
+ results.summary.critical++
185
+ results.summary.total++
186
+ results.passed = false
187
+ results.details.violations++
188
+ }
189
+
190
+ // CRITICAL: admin route MUST have requireOrganizationAdmin
191
+ if (!hasOrgAdminMiddleware(content)) {
192
+ results.findings.push({
193
+ file: relRouteFile,
194
+ line: 1,
195
+ type: 'admin-route-no-org-admin',
196
+ severity: 'critical',
197
+ message: `Admin route for table "${cfg.tableName}" is missing requireOrganizationAdmin middleware. Any authenticated user can access admin endpoints.`,
198
+ fix: `Add requireOrganizationAdmin middleware: router.use(authenticateToken, requireOrganizationAdmin)`
199
+ })
200
+ results.summary.critical++
201
+ results.summary.total++
202
+ results.passed = false
203
+ results.details.violations++
204
+ }
205
+ }
206
+ }
207
+
208
+ // --- user checks ---
209
+ if (cfg.accessLevel === 'user') {
210
+ if (routeName === 'userRoutes.ts') {
211
+ if (!hasAuthMiddleware(content)) {
212
+ results.findings.push({
213
+ file: relRouteFile,
214
+ line: 1,
215
+ type: 'user-route-no-auth',
216
+ severity: 'critical',
217
+ message: `User route for table "${cfg.tableName}" is missing authenticateToken middleware. Endpoints are accessible without authentication.`,
218
+ fix: `Add authenticateToken middleware: router.use(authenticateToken)`
219
+ })
220
+ results.summary.critical++
221
+ results.summary.total++
222
+ results.passed = false
223
+ results.details.violations++
224
+ }
225
+ }
226
+
227
+ // HIGH: user-level feature shouldn't primarily use adminRoutes
228
+ if (routeName === 'adminRoutes.ts') {
229
+ results.findings.push({
230
+ file: relRouteFile,
231
+ line: 1,
232
+ type: 'user-feature-admin-route',
233
+ severity: 'high',
234
+ message: `Config declares accessLevel "user" for table "${cfg.tableName}" but has adminRoutes.ts. Route file name does not match access level.`,
235
+ fix: `Rename to userRoutes.ts or update config accessLevel to "admin".`
236
+ })
237
+ results.summary.high++
238
+ results.summary.total++
239
+ results.passed = false
240
+ results.details.violations++
241
+ }
242
+ }
243
+
244
+ // --- creator checks ---
245
+ if (cfg.accessLevel === 'creator') {
246
+ // Creator routes must have authenticateToken
247
+ if (!hasAuthMiddleware(content) && routeName !== 'publicRoutes.ts') {
248
+ results.findings.push({
249
+ file: relRouteFile,
250
+ line: 1,
251
+ type: 'creator-route-no-auth',
252
+ severity: 'critical',
253
+ message: `Creator route "${routeName}" for table "${cfg.tableName}" is missing authenticateToken middleware. Endpoints are accessible without authentication.`,
254
+ fix: `Add authenticateToken middleware: router.use(authenticateToken)`
255
+ })
256
+ results.summary.critical++
257
+ results.summary.total++
258
+ results.passed = false
259
+ results.details.violations++
260
+ }
261
+ }
262
+
263
+ // --- public checks: no auth required, just verify naming ---
264
+ // public routes are fine without auth middleware, no action needed
265
+
266
+ // --- Cross-access-level route name mismatch ---
267
+ if (cfg.accessLevel === 'admin' && routeName === 'publicRoutes.ts' && !hasAuthMiddleware(content)) {
268
+ results.findings.push({
269
+ file: relRouteFile,
270
+ line: 1,
271
+ type: 'admin-feature-public-route-no-auth',
272
+ severity: 'critical',
273
+ message: `Config declares accessLevel "admin" for table "${cfg.tableName}" but has a publicRoutes.ts without auth. Admin data may be exposed publicly.`,
274
+ fix: `Either add auth middleware to publicRoutes.ts or ensure it only exposes non-sensitive endpoints.`
275
+ })
276
+ results.summary.critical++
277
+ results.summary.total++
278
+ results.passed = false
279
+ results.details.violations++
280
+ }
281
+ }
282
+
283
+ // HIGH: Check if expected route file name exists for the access level
284
+ const expected = expectedRouteFileName(cfg.accessLevel)
285
+ if (expected && routeFiles.length > 0) {
286
+ const hasExpected = routeFiles.some(f => basename(f) === expected)
287
+ if (!hasExpected) {
288
+ const routeNames = routeFiles.map(f => basename(f)).join(', ')
289
+ results.findings.push({
290
+ file: cfg.configFile,
291
+ line: 1,
292
+ type: 'route-name-mismatch',
293
+ severity: 'high',
294
+ message: `Config declares accessLevel "${cfg.accessLevel}" for table "${cfg.tableName}" expecting ${expected} but found: ${routeNames}.`,
295
+ fix: `Rename the primary route file to ${expected} or update the config accessLevel.`
296
+ })
297
+ results.summary.high++
298
+ results.summary.total++
299
+ results.passed = false
300
+ results.details.violations++
301
+ }
302
+ }
303
+ }
304
+
305
+ return results
306
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * RPC Security Mode Check — HARD BLOCK
3
+ *
4
+ * Scans ALL SQL migrations for RPC functions and verifies their security mode:
5
+ *
6
+ * SECURITY DEFINER = function runs as the DB owner, BYPASSES RLS completely.
7
+ * SECURITY INVOKER = function runs as the calling user, RLS is enforced.
8
+ *
9
+ * Rules:
10
+ * - Data query RPCs (get_*, list_*, search_*) → MUST be INVOKER
11
+ * - Auth helper functions (auth_org_id, auth_uid) → DEFINER is OK (they need to read auth.users)
12
+ * - Count/results RPCs linked to feature configs → MUST be INVOKER
13
+ * - Public RPCs (explicitly returning only public columns) → DEFINER is OK if whitelisted
14
+ *
15
+ * Whitelist: .tetra-quality.json → supabase.securityDefinerWhitelist: ['auth_org_id', ...]
16
+ *
17
+ * Reference: stella_howto_get slug="tetra-architecture-guide"
18
+ */
19
+
20
+ import { readFileSync, existsSync } from 'fs'
21
+ import { join } from 'path'
22
+ import { globSync } from 'glob'
23
+
24
+ export const meta = {
25
+ id: 'rpc-security-mode',
26
+ name: 'RPC Security Mode',
27
+ category: 'security',
28
+ severity: 'critical',
29
+ description: 'Verifies all RPC functions use SECURITY INVOKER (not DEFINER) unless explicitly whitelisted. DEFINER bypasses RLS completely.'
30
+ }
31
+
32
+ // Auth helper functions that legitimately need SECURITY DEFINER
33
+ const BUILTIN_DEFINER_WHITELIST = [
34
+ 'auth_org_id',
35
+ 'auth_admin_organizations',
36
+ 'auth_user_organizations',
37
+ 'get_user_org_role',
38
+ 'get_org_id',
39
+ 'handle_new_user',
40
+ 'moddatetime',
41
+ // Supabase internal
42
+ 'pgsodium_encrypt',
43
+ 'pgsodium_decrypt'
44
+ ]
45
+
46
+ export async function run(config, projectRoot) {
47
+ const results = {
48
+ passed: true,
49
+ skipped: false,
50
+ findings: [],
51
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
52
+ details: { rpcsFound: 0, definerCount: 0, invokerCount: 0, defaultCount: 0, whitelistedCount: 0 }
53
+ }
54
+
55
+ const migrationDirs = [
56
+ join(projectRoot, 'supabase/migrations'),
57
+ join(projectRoot, 'backend/supabase/migrations')
58
+ ]
59
+
60
+ const sqlFiles = []
61
+ for (const dir of migrationDirs) {
62
+ if (!existsSync(dir)) continue
63
+ try {
64
+ const files = globSync('*.sql', { cwd: dir, absolute: true })
65
+ sqlFiles.push(...files)
66
+ } catch { /* skip */ }
67
+ }
68
+
69
+ if (sqlFiles.length === 0) {
70
+ results.skipped = true
71
+ results.skipReason = 'No SQL migration files found'
72
+ return results
73
+ }
74
+
75
+ // Build whitelist from config + builtins
76
+ const userWhitelist = (config.supabase?.securityDefinerWhitelist || [])
77
+ const whitelist = new Set([...BUILTIN_DEFINER_WHITELIST, ...userWhitelist])
78
+
79
+ // Track latest definition per function (migrations can override)
80
+ const functions = new Map() // funcName → { securityMode, file, line, isDataQuery }
81
+
82
+ for (const file of sqlFiles) {
83
+ let content
84
+ try { content = readFileSync(file, 'utf-8') } catch { continue }
85
+
86
+ const relFile = file.replace(projectRoot + '/', '')
87
+
88
+ // Find all CREATE [OR REPLACE] FUNCTION statements
89
+ const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(/gi
90
+ let match
91
+
92
+ while ((match = funcRegex.exec(content)) !== null) {
93
+ const funcName = match[1]
94
+ const startPos = match.index
95
+
96
+ // Extract the function body (up to next CREATE FUNCTION or end of file, max 5000 chars)
97
+ const bodyEnd = Math.min(startPos + 5000, content.length)
98
+ const funcBody = content.substring(startPos, bodyEnd)
99
+
100
+ // Determine security mode
101
+ let securityMode = 'DEFAULT' // PostgreSQL default is INVOKER
102
+ if (/SECURITY\s+DEFINER/i.test(funcBody.substring(0, 2000))) {
103
+ securityMode = 'DEFINER'
104
+ } else if (/SECURITY\s+INVOKER/i.test(funcBody.substring(0, 2000))) {
105
+ securityMode = 'INVOKER'
106
+ }
107
+
108
+ // Determine if this is a data query function
109
+ const isDataQuery = /^(get_|list_|search_|find_|fetch_|count_)/i.test(funcName) ||
110
+ /_counts$|_results$|_detail$/i.test(funcName)
111
+
112
+ // Calculate line number
113
+ const beforeMatch = content.substring(0, startPos)
114
+ const line = (beforeMatch.match(/\n/g) || []).length + 1
115
+
116
+ // Store (later definitions override earlier ones)
117
+ functions.set(funcName, { securityMode, file: relFile, line, isDataQuery, funcBody: funcBody.substring(0, 500) })
118
+ }
119
+ }
120
+
121
+ results.details.rpcsFound = functions.size
122
+
123
+ for (const [funcName, info] of functions) {
124
+ if (info.securityMode === 'DEFINER') {
125
+ results.details.definerCount++
126
+
127
+ // Check whitelist
128
+ if (whitelist.has(funcName)) {
129
+ results.details.whitelistedCount++
130
+ continue
131
+ }
132
+
133
+ // Data query RPCs with DEFINER = CRITICAL
134
+ if (info.isDataQuery) {
135
+ results.passed = false
136
+ results.findings.push({
137
+ file: info.file,
138
+ line: info.line,
139
+ type: 'data-rpc-security-definer',
140
+ severity: 'critical',
141
+ message: `Data RPC "${funcName}" uses SECURITY DEFINER — bypasses ALL RLS policies. Any authenticated user can see ALL data from ALL organizations.`,
142
+ fix: `Change to SECURITY INVOKER or remove the SECURITY DEFINER clause. If this function legitimately needs DEFINER, add "${funcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json.`
143
+ })
144
+ results.summary.critical++
145
+ results.summary.total++
146
+ } else {
147
+ // Non-data RPCs with DEFINER = HIGH (should still be investigated)
148
+ results.findings.push({
149
+ file: info.file,
150
+ line: info.line,
151
+ type: 'non-data-rpc-security-definer',
152
+ severity: 'high',
153
+ message: `RPC "${funcName}" uses SECURITY DEFINER but is not whitelisted. DEFINER functions bypass RLS — ensure this is intentional.`,
154
+ fix: `Change to SECURITY INVOKER, or add "${funcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json if DEFINER is intentional.`
155
+ })
156
+ results.summary.high++
157
+ results.summary.total++
158
+ results.passed = false
159
+ }
160
+ } else if (info.securityMode === 'INVOKER') {
161
+ results.details.invokerCount++
162
+ } else {
163
+ results.details.defaultCount++
164
+ // DEFAULT = INVOKER in PostgreSQL, which is correct
165
+ }
166
+ }
167
+
168
+ return results
169
+ }
package/lib/runner.js CHANGED
@@ -15,6 +15,7 @@ import * as frontendSupabaseQueries from './checks/security/frontend-supabase-qu
15
15
  import * as tetraCoreCompliance from './checks/security/tetra-core-compliance.js'
16
16
  import * as mixedDbUsage from './checks/security/mixed-db-usage.js'
17
17
  import * as configRlsAlignment from './checks/security/config-rls-alignment.js'
18
+ import * as rpcSecurityMode from './checks/security/rpc-security-mode.js'
18
19
  import * as systemdbWhitelist from './checks/security/systemdb-whitelist.js'
19
20
  import * as huskyHooks from './checks/stability/husky-hooks.js'
20
21
  import * as ciPipeline from './checks/stability/ci-pipeline.js'
@@ -24,6 +25,7 @@ import * as fileSize from './checks/codeQuality/file-size.js'
24
25
  import * as namingConventions from './checks/codeQuality/naming-conventions.js'
25
26
  import * as routeSeparation from './checks/codeQuality/route-separation.js'
26
27
  import * as gitignoreValidation from './checks/security/gitignore-validation.js'
28
+ import * as routeConfigAlignment from './checks/security/route-config-alignment.js'
27
29
  import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
28
30
  import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
29
31
  import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
@@ -41,8 +43,10 @@ const ALL_CHECKS = {
41
43
  tetraCoreCompliance,
42
44
  mixedDbUsage,
43
45
  configRlsAlignment,
46
+ rpcSecurityMode,
44
47
  systemdbWhitelist,
45
- gitignoreValidation
48
+ gitignoreValidation,
49
+ routeConfigAlignment
46
50
  ],
47
51
  stability: [
48
52
  huskyHooks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },