@soulbatical/tetra-dev-toolkit 1.16.2 → 1.17.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.
package/README.md CHANGED
@@ -58,7 +58,7 @@ LAYER 8: DB HELPERS adminDB/userDB/publicDB/systemDB enforce correct
58
58
  | `tetra-audit` | Run quality/security/hygiene checks |
59
59
  | `tetra-audit quick` | Quick critical checks (pre-commit) |
60
60
  | `tetra-audit security` | Full security suite (12 checks) |
61
- | `tetra-audit stability` | Stability suite (3 checks) |
61
+ | `tetra-audit stability` | Stability suite (16 checks) |
62
62
  | `tetra-audit codeQuality` | Code quality suite (4 checks) |
63
63
  | `tetra-audit supabase` | Supabase suite (3 checks) |
64
64
  | `tetra-audit hygiene` | Repo hygiene suite (2 checks) |
@@ -119,13 +119,26 @@ Exit codes: `0` = passed, `1` = failed (CRITICAL/HIGH), `2` = error. No middle g
119
119
  | `rpc-param-mismatch` | critical | TypeScript `.rpc()` calls with wrong parameter names vs SQL |
120
120
  | `rpc-generator-origin` | high | RPC functions not generated by Tetra SQL Generator |
121
121
 
122
- ### Stability (3 checks)
122
+ ### Stability (16 checks)
123
123
 
124
124
  | Check | Severity | What it catches |
125
125
  |-------|----------|-----------------|
126
126
  | `husky-hooks` | medium | Missing pre-commit/pre-push hooks |
127
127
  | `ci-pipeline` | medium | Missing or incomplete CI config |
128
128
  | `npm-audit` | high | Known vulnerabilities in dependencies |
129
+ | `tests` | high | Missing test framework, test files, or test scripts |
130
+ | `eslint-security` | high | Missing ESLint config, security plugin, or SonarJS plugin |
131
+ | `typescript-strict` | medium | TypeScript strict mode not enabled |
132
+ | `coverage-thresholds` | medium | No coverage thresholds configured |
133
+ | `knip` | medium | Dead code: unused files, dependencies, exports |
134
+ | `dependency-cruiser` | medium | Circular dependencies, architecture violations |
135
+ | `dependency-automation` | medium | No Dependabot or Renovate configured |
136
+ | `prettier` | low | No code formatter configured |
137
+ | `conventional-commits` | low | No commit message convention enforced |
138
+ | `bundle-size` | low | No bundle size monitoring |
139
+ | `sast` | medium | No static application security testing |
140
+ | `license-audit` | low | No license compliance checking |
141
+ | `security-layers` | high | Missing security layers (auth, rate limiting, CORS, etc.) |
129
142
 
130
143
  ### Code Quality (4 checks)
131
144
 
@@ -220,6 +233,18 @@ If any step fails, fix it before writing code. No exceptions.
220
233
 
221
234
  ## Changelog
222
235
 
236
+ ### 1.16.0
237
+
238
+ **New: Full Stability Suite (16 checks)**
239
+ - Stability suite expanded from 3 → 16 checks via health check adapter
240
+ - New checks: tests, eslint-security, typescript-strict, coverage-thresholds, knip, dependency-cruiser, dependency-automation, prettier, conventional-commits, bundle-size, sast, license-audit, security-layers
241
+ - `tetra-audit stability` now catches missing test infrastructure, dead code, formatting, and security layer gaps
242
+ - Health checks (score-based) automatically adapted to runner format (pass/fail)
243
+ - RPC generator: SECURITY DEFINER → SECURITY INVOKER for all data RPCs
244
+ - Migration lint: expanded whitelist for legitimate DEFINER functions
245
+ - Mixed DB checker: fixed regex that matched `superadminDB` as `adminDB`
246
+ - Route config checker: support `@tetra-audit-ignore` directive
247
+
223
248
  ### 1.15.0
224
249
 
225
250
  **New: Migration Lint + DB Push Guard**
@@ -184,6 +184,43 @@ function parseMigrations(projectRoot) {
184
184
  })
185
185
  }
186
186
 
187
+ // Find PL/pgSQL loop-based CREATE POLICY patterns (DO blocks with EXECUTE format)
188
+ // Handles two patterns:
189
+ // Pattern A: FOR t IN SELECT unnest(ARRAY['table1','table2']) LOOP ... EXECUTE format('CREATE POLICY ...')
190
+ // Pattern B: tables TEXT[] := ARRAY['table1','table2']; FOREACH tbl IN ARRAY tables LOOP ... EXECUTE format('CREATE POLICY ...')
191
+ if (/DO\s+\$/.test(content) && /EXECUTE\s+format\s*\(\s*'CREATE\s+POLICY/i.test(content)) {
192
+ // Try unnest pattern first, then FOREACH/variable pattern
193
+ const arrayMatch = content.match(/unnest\s*\(\s*ARRAY\s*\[\s*'([^[\]]+)'\s*\]/i)
194
+ || content.match(/(?:TEXT\[\]|text\[\])\s*:=\s*ARRAY\s*\[\s*'([^[\]]+)'\s*\]/i)
195
+ if (arrayMatch) {
196
+ const loopTables = arrayMatch[1].split(/'\s*,\s*'/).map(t => t.trim())
197
+ const execLines = [...content.matchAll(/EXECUTE\s+format\s*\(\s*'(CREATE\s+POLICY\s+.*?)'\s*,/gi)]
198
+ for (const exec of execLines) {
199
+ const stmt = exec[1]
200
+ const forOp = stmt.match(/FOR\s+(SELECT|INSERT|UPDATE|DELETE|ALL)/i)
201
+ const operation = forOp ? forOp[1].toUpperCase() : 'ALL'
202
+ const hasUsing = /\bUSING\b/i.test(stmt)
203
+ const hasWithCheck = /WITH\s+CHECK/i.test(stmt)
204
+ const condMatch = stmt.match(/(?:USING|WITH\s+CHECK)\s*\(\s*(.*?)\s*\)/i)
205
+ const condition = condMatch ? condMatch[1] : ''
206
+
207
+ for (const table of loopTables) {
208
+ if (!tables.has(table)) tables.set(table, { rlsEnabled: false, policies: [], rpcFunctions: new Map() })
209
+ const policyName = `${table}_${operation.toLowerCase()}_org`
210
+ if (!tables.get(table).policies.find(p => p.name === policyName)) {
211
+ tables.get(table).policies.push({
212
+ name: policyName,
213
+ operation,
214
+ using: hasUsing ? condition : '',
215
+ withCheck: hasWithCheck ? condition : '',
216
+ file: relFile
217
+ })
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+
187
224
  // Find RPC functions and their security mode
188
225
  const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(([\s\S]*?)\)\s*RETURNS\s+([\s\S]*?)(?:LANGUAGE|AS)/gi
189
226
  for (const m of content.matchAll(funcRegex)) {
@@ -204,6 +241,7 @@ function parseMigrations(projectRoot) {
204
241
  }
205
242
  }
206
243
  }
244
+
207
245
  }
208
246
 
209
247
  return tables
@@ -221,8 +259,10 @@ function findFiles(projectRoot, pattern) {
221
259
  * Check if a USING clause enforces org isolation
222
260
  */
223
261
  function isOrgIsolation(using) {
224
- return /auth_org_id\(\)|auth_admin_organizations\(\)/i.test(using) &&
225
- /organization_id/i.test(using)
262
+ const hasOrgFunction = /auth_org_id\(\)|auth_admin_organizations\(\)/i.test(using)
263
+ // Legacy pattern: auth.jwt() -> 'app_metadata' ->> 'organization_id'
264
+ const hasLegacyJwtOrg = /auth\.jwt\(\)\s*->\s*'app_metadata'\s*->>\s*'organization_id'/i.test(using)
265
+ return (hasOrgFunction || hasLegacyJwtOrg) && /organization_id/i.test(using)
226
266
  }
227
267
 
228
268
  /**
@@ -287,6 +327,7 @@ export async function run(config, projectRoot) {
287
327
  const updatePolicies = policies.filter(p => p.operation === 'UPDATE' || p.operation === 'ALL')
288
328
  const deletePolicies = policies.filter(p => p.operation === 'DELETE' || p.operation === 'ALL')
289
329
 
330
+
290
331
  // CHECK 2: Must have policies for all operations
291
332
  const missingOps = []
292
333
  if (selectPolicies.length === 0) missingOps.push('SELECT')
@@ -383,8 +424,12 @@ export async function run(config, projectRoot) {
383
424
  }
384
425
  }
385
426
 
386
- // CHECK 4: RPC functions must be SECURITY INVOKER
427
+ // CHECK 4: RPC functions must be SECURITY INVOKER (unless whitelisted)
428
+ const definerWhitelist = config?.supabase?.securityDefinerWhitelist || []
387
429
  for (const rpcName of cfg.rpcFunctions) {
430
+ // Skip if explicitly whitelisted in config
431
+ if (definerWhitelist.includes(rpcName)) continue
432
+
388
433
  const rpcInfo = tableRls.rpcFunctions.get(rpcName)
389
434
  if (rpcInfo && rpcInfo.securityMode === 'DEFINER') {
390
435
  results.passed = false
@@ -394,7 +439,7 @@ export async function run(config, projectRoot) {
394
439
  type: 'rpc-security-definer',
395
440
  severity: 'critical',
396
441
  message: `RPC function "${rpcName}" for table "${tableName}" uses SECURITY DEFINER — this BYPASSES RLS completely. Any authenticated user can see ALL data.`,
397
- fix: `Change to SECURITY INVOKER or remove the SECURITY DEFINER clause.`
442
+ fix: `Change to SECURITY INVOKER or add "${rpcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json.`
398
443
  })
399
444
  results.summary.critical++
400
445
  results.summary.total++
@@ -74,8 +74,8 @@ export async function run(config, projectRoot) {
74
74
  summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
75
75
  }
76
76
 
77
- // Build whitelist from hardcoded + config
78
- const configWhitelist = config?.supabase?.directSupabaseClientWhitelist || config?.directSupabaseClientWhitelist || []
77
+ // Build whitelist from hardcoded + config (canonical: security.directSupabaseClientWhitelist)
78
+ const configWhitelist = config?.security?.directSupabaseClientWhitelist || config?.directSupabaseClientWhitelist || []
79
79
  const extraPatterns = configWhitelist.map(p => new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
80
80
  const allAllowed = [...ALLOWED_FILES, ...extraPatterns]
81
81
 
@@ -77,7 +77,8 @@ export async function run(config, projectRoot) {
77
77
  }
78
78
 
79
79
  // Config-based ignore for controllers that legitimately mix DB helpers
80
- const mixedDbIgnore = config?.supabase?.mixedDbUsageIgnore || []
80
+ // Canonical: security.mixedDbWhitelist
81
+ const mixedDbIgnore = config?.security?.mixedDbWhitelist || config?.mixedDbWhitelist || []
81
82
 
82
83
  const files = await glob('**/*[Cc]ontroller*.ts', {
83
84
  cwd: projectRoot,
@@ -157,11 +158,12 @@ export async function run(config, projectRoot) {
157
158
 
158
159
  // HIGH: Multiple DB helper types in one controller
159
160
  if (usedLevels.length > 1) {
160
- // Allow: Superadmin controllers may legitimately use SUPERADMIN + ADMIN
161
+ // Allow: Superadmin controllers use superadminDB which internally calls systemDB
162
+ // So tetra sees SUPERADMIN + SYSTEM (or SUPERADMIN + ADMIN) — both are legitimate
161
163
  const isSuperadminMixingAdmin = expectedLevel === 'SUPERADMIN' &&
162
164
  usedLevels.length === 2 &&
163
165
  usedLevels.includes('SUPERADMIN') &&
164
- usedLevels.includes('ADMIN')
166
+ (usedLevels.includes('ADMIN') || usedLevels.includes('SYSTEM'))
165
167
 
166
168
  if (!isSuperadminMixingAdmin) {
167
169
  results.passed = false
@@ -102,6 +102,32 @@ function hasOrgAdminMiddleware(content) {
102
102
  return /requireOrganizationAdmin/.test(content)
103
103
  }
104
104
 
105
+ /**
106
+ * Check if admin routes are protected by a RouteManager group-level middleware.
107
+ * Many projects apply auth middleware at the route group level (e.g., all /api/admin/* routes)
108
+ * rather than in individual route files.
109
+ */
110
+ function hasRouteManagerGroupAuth(projectRoot) {
111
+ const candidates = [
112
+ join(projectRoot, 'backend/src/core/RouteManager.ts'),
113
+ join(projectRoot, 'src/core/RouteManager.ts'),
114
+ join(projectRoot, 'backend/src/routes/index.ts'),
115
+ join(projectRoot, 'src/routes/index.ts')
116
+ ]
117
+
118
+ for (const file of candidates) {
119
+ if (!existsSync(file)) continue
120
+ try {
121
+ const content = readFileSync(file, 'utf-8')
122
+ // Check for group middleware pattern: prefix '/api/admin' with authenticateToken
123
+ if (/\/api\/admin/.test(content) && /authenticateToken/.test(content)) {
124
+ return true
125
+ }
126
+ } catch { /* skip */ }
127
+ }
128
+ return false
129
+ }
130
+
105
131
  /**
106
132
  * Expected route filename for a given accessLevel
107
133
  */
@@ -123,8 +149,11 @@ export async function run(config, projectRoot) {
123
149
  details: { routesChecked: 0, violations: 0 }
124
150
  }
125
151
 
126
- // Config-based ignore for specific features/routes
127
- const routeIgnore = config?.supabase?.routeConfigAlignmentIgnore || []
152
+ // Canonical: security.routeConfigIgnore
153
+ const routeIgnore = config?.security?.routeConfigIgnore || config?.routeConfigAlignmentIgnore || []
154
+
155
+ // Detect if RouteManager applies group-level auth middleware to /api/admin/*
156
+ const routeManagerHasGroupAuth = hasRouteManagerGroupAuth(projectRoot)
128
157
 
129
158
  const featureConfigs = parseFeatureConfigs(projectRoot)
130
159
 
@@ -176,8 +205,8 @@ export async function run(config, projectRoot) {
176
205
  if (cfg.accessLevel === 'admin') {
177
206
  // Route name should be adminRoutes.ts
178
207
  if (routeName === 'adminRoutes.ts') {
179
- // CRITICAL: admin route MUST have authenticateToken
180
- if (!hasAuthMiddleware(content)) {
208
+ // CRITICAL: admin route MUST have authenticateToken (in file OR via RouteManager group)
209
+ if (!hasAuthMiddleware(content) && !routeManagerHasGroupAuth) {
181
210
  results.findings.push({
182
211
  file: relRouteFile,
183
212
  line: 1,
@@ -192,8 +221,8 @@ export async function run(config, projectRoot) {
192
221
  results.details.violations++
193
222
  }
194
223
 
195
- // CRITICAL: admin route MUST have requireOrganizationAdmin
196
- if (!hasOrgAdminMiddleware(content)) {
224
+ // CRITICAL: admin route MUST have requireOrganizationAdmin (in file OR via RouteManager group)
225
+ if (!hasOrgAdminMiddleware(content) && !routeManagerHasGroupAuth) {
197
226
  results.findings.push({
198
227
  file: relRouteFile,
199
228
  line: 1,
@@ -86,7 +86,8 @@ const FORBIDDEN_FILE_PATTERNS = [
86
86
  const DEFAULT_MAX_WHITELIST_SIZE = 35
87
87
 
88
88
  export async function run(config, projectRoot) {
89
- const MAX_WHITELIST_SIZE = config?.supabase?.maxWhitelistEntries || DEFAULT_MAX_WHITELIST_SIZE
89
+ // Canonical: security.systemDbMaxEntries
90
+ const MAX_WHITELIST_SIZE = config?.security?.systemDbMaxEntries || config?.systemDB?.maxWhitelistEntries || DEFAULT_MAX_WHITELIST_SIZE
90
91
  const results = {
91
92
  passed: true,
92
93
  findings: [],
package/lib/config.js CHANGED
@@ -42,7 +42,13 @@ export const DEFAULT_CONFIG = {
42
42
  // Code patterns
43
43
  checkSqlInjection: true,
44
44
  checkEvalUsage: true,
45
- checkCommandInjection: true
45
+ checkCommandInjection: true,
46
+
47
+ // Whitelists — project-specific overrides in .tetra-quality.json
48
+ directSupabaseClientWhitelist: [], // Files allowed to import createClient directly
49
+ mixedDbWhitelist: [], // Controllers allowed to mix DB helper types
50
+ routeConfigIgnore: [], // Tables to skip in route↔config alignment check
51
+ systemDbMaxEntries: 35 // Max systemDB whitelist entries before warning
46
52
  },
47
53
 
48
54
  // Stability checks
package/lib/runner.js CHANGED
@@ -32,6 +32,56 @@ import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
32
32
  import * as fileOrganization from './checks/hygiene/file-organization.js'
33
33
  import * as stellaCompliance from './checks/hygiene/stella-compliance.js'
34
34
 
35
+ // Health checks (score-based) — wrapped as runner checks via adapter
36
+ import { check as checkTests } from './checks/health/tests.js'
37
+ import { check as checkEslintSecurity } from './checks/health/eslint-security.js'
38
+ import { check as checkTypescriptStrict } from './checks/health/typescript-strict.js'
39
+ import { check as checkCoverageThresholds } from './checks/health/coverage-thresholds.js'
40
+ import { check as checkKnip } from './checks/health/knip.js'
41
+ import { check as checkDependencyCruiser } from './checks/health/dependency-cruiser.js'
42
+ import { check as checkDependencyAutomation } from './checks/health/dependency-automation.js'
43
+ import { check as checkPrettier } from './checks/health/prettier.js'
44
+ import { check as checkConventionalCommits } from './checks/health/conventional-commits.js'
45
+ import { check as checkBundleSize } from './checks/health/bundle-size.js'
46
+ import { check as checkSast } from './checks/health/sast.js'
47
+ import { check as checkLicenseAudit } from './checks/health/license-audit.js'
48
+ import { check as checkSecurityLayers } from './checks/health/security-layers.js'
49
+
50
+ /**
51
+ * Adapt a health check (score-based) to the runner format (meta + run).
52
+ * A health check passes if it scores > 0 (has at least some infrastructure).
53
+ */
54
+ function adaptHealthCheck(id, name, severity, healthCheckFn) {
55
+ return {
56
+ meta: { id, name, severity },
57
+ async run(config, projectRoot) {
58
+ const result = await healthCheckFn(projectRoot)
59
+ const passed = result.score > 0
60
+ const findings = []
61
+
62
+ if (!passed && result.details?.message) {
63
+ findings.push({
64
+ file: 'project',
65
+ line: 0,
66
+ severity,
67
+ message: result.details.message
68
+ })
69
+ }
70
+
71
+ return {
72
+ passed,
73
+ findings,
74
+ summary: {
75
+ total: 1,
76
+ [severity]: passed ? 0 : 1,
77
+ score: result.score,
78
+ maxScore: result.maxScore
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
35
85
  // Register all checks
36
86
  const ALL_CHECKS = {
37
87
  security: [
@@ -51,7 +101,20 @@ const ALL_CHECKS = {
51
101
  stability: [
52
102
  huskyHooks,
53
103
  ciPipeline,
54
- npmAudit
104
+ npmAudit,
105
+ adaptHealthCheck('tests', 'Test Infrastructure', 'high', checkTests),
106
+ adaptHealthCheck('eslint-security', 'ESLint Security Plugins', 'high', checkEslintSecurity),
107
+ adaptHealthCheck('typescript-strict', 'TypeScript Strictness', 'medium', checkTypescriptStrict),
108
+ adaptHealthCheck('coverage-thresholds', 'Test Coverage Thresholds', 'medium', checkCoverageThresholds),
109
+ adaptHealthCheck('knip', 'Dead Code Detection (Knip)', 'medium', checkKnip),
110
+ adaptHealthCheck('dependency-cruiser', 'Dependency Architecture', 'medium', checkDependencyCruiser),
111
+ adaptHealthCheck('dependency-automation', 'Dependency Updates (Dependabot/Renovate)', 'medium', checkDependencyAutomation),
112
+ adaptHealthCheck('prettier', 'Code Formatting (Prettier)', 'low', checkPrettier),
113
+ adaptHealthCheck('conventional-commits', 'Conventional Commits', 'low', checkConventionalCommits),
114
+ adaptHealthCheck('bundle-size', 'Bundle Size Monitoring', 'low', checkBundleSize),
115
+ adaptHealthCheck('sast', 'Static Application Security Testing', 'medium', checkSast),
116
+ adaptHealthCheck('license-audit', 'License Compliance', 'low', checkLicenseAudit),
117
+ adaptHealthCheck('security-layers', 'Security Layer Coverage', 'high', checkSecurityLayers)
55
118
  ],
56
119
  codeQuality: [
57
120
  apiResponseFormat,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },