@soulbatical/tetra-dev-toolkit 1.8.3 → 1.8.5
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.
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tetra Dev Toolkit - RLS Gate
|
|
5
|
+
*
|
|
6
|
+
* Pre-deploy gate that verifies RLS security on a live Supabase project.
|
|
7
|
+
* Exit code 0 = all checks pass, exit code 1 = violations found.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* tetra-check-rls # Auto-detect Supabase config from env/doppler
|
|
11
|
+
* tetra-check-rls --url <url> --key <key> # Explicit credentials
|
|
12
|
+
* tetra-check-rls --errors-only # Only show errors, skip warnings
|
|
13
|
+
* tetra-check-rls --json # JSON output for CI
|
|
14
|
+
* tetra-check-rls --fix # Generate hardening migration SQL
|
|
15
|
+
*
|
|
16
|
+
* Environment variables:
|
|
17
|
+
* SUPABASE_URL - Supabase project URL
|
|
18
|
+
* SUPABASE_SERVICE_KEY - Service role key (NOT anon key!)
|
|
19
|
+
*
|
|
20
|
+
* CI Usage (GitHub Actions):
|
|
21
|
+
* - name: RLS Security Gate
|
|
22
|
+
* run: npx tetra-check-rls --errors-only
|
|
23
|
+
* env:
|
|
24
|
+
* SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
|
25
|
+
* SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { program } from 'commander'
|
|
29
|
+
import { createClient } from '@supabase/supabase-js'
|
|
30
|
+
import { readFileSync, existsSync } from 'fs'
|
|
31
|
+
import { join, resolve, dirname } from 'path'
|
|
32
|
+
import { config as dotenvConfig } from 'dotenv'
|
|
33
|
+
import chalk from 'chalk'
|
|
34
|
+
|
|
35
|
+
// Load tetra-core dynamically (it's a peer/workspace dep)
|
|
36
|
+
async function loadTetraCore() {
|
|
37
|
+
try {
|
|
38
|
+
return await import('@soulbatical/tetra-core')
|
|
39
|
+
} catch {
|
|
40
|
+
// Fallback: try relative path in monorepo
|
|
41
|
+
try {
|
|
42
|
+
return await import('../../core/dist/index.js')
|
|
43
|
+
} catch {
|
|
44
|
+
console.error(chalk.red('ERROR: @soulbatical/tetra-core not found. Install it first.'))
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findSupabaseConfig() {
|
|
51
|
+
// Try .env, .env.local, backend/.env, backend/.env.local
|
|
52
|
+
const cwd = process.cwd()
|
|
53
|
+
const envPaths = [
|
|
54
|
+
join(cwd, '.env'),
|
|
55
|
+
join(cwd, '.env.local'),
|
|
56
|
+
join(cwd, 'backend', '.env'),
|
|
57
|
+
join(cwd, 'backend', '.env.local'),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for (const p of envPaths) {
|
|
61
|
+
if (existsSync(p)) dotenvConfig({ path: p })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const url = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL
|
|
65
|
+
const key = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
66
|
+
|
|
67
|
+
return { url, key }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
program
|
|
71
|
+
.name('tetra-check-rls')
|
|
72
|
+
.description('Pre-deploy RLS security gate — checks live Supabase project')
|
|
73
|
+
.version('1.0.0')
|
|
74
|
+
.option('--url <url>', 'Supabase project URL')
|
|
75
|
+
.option('--key <key>', 'Supabase service role key')
|
|
76
|
+
.option('--errors-only', 'Only fail on errors, ignore warnings')
|
|
77
|
+
.option('--json', 'Output as JSON')
|
|
78
|
+
.option('--fix', 'Generate hardening migration SQL to stdout')
|
|
79
|
+
.option('--check-exec-sql', 'Only check if exec_sql is installed and secure')
|
|
80
|
+
.action(async (options) => {
|
|
81
|
+
try {
|
|
82
|
+
const tetra = await loadTetraCore()
|
|
83
|
+
const config = findSupabaseConfig()
|
|
84
|
+
|
|
85
|
+
const url = options.url || config.url
|
|
86
|
+
const key = options.key || config.key
|
|
87
|
+
|
|
88
|
+
if (!url || !key) {
|
|
89
|
+
console.error(chalk.red('ERROR: Missing Supabase credentials.'))
|
|
90
|
+
console.error(chalk.gray('Set SUPABASE_URL + SUPABASE_SERVICE_KEY, or use --url and --key'))
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!key.startsWith('eyJ')) {
|
|
95
|
+
console.error(chalk.red('ERROR: Key does not look like a service_role key (should start with eyJ).'))
|
|
96
|
+
console.error(chalk.gray('Make sure you are using the SERVICE_ROLE key, not the anon key.'))
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const supabase = createClient(url, key)
|
|
101
|
+
|
|
102
|
+
// --- Check exec_sql exists and is secure ---
|
|
103
|
+
if (tetra.checkExecSQLExists) {
|
|
104
|
+
const execCheck = await tetra.checkExecSQLExists(supabase)
|
|
105
|
+
|
|
106
|
+
if (!execCheck.exists) {
|
|
107
|
+
console.error(chalk.red.bold('\n BLOCKED: exec_sql function not found\n'))
|
|
108
|
+
console.error(chalk.yellow(' The RLS checker requires the exec_sql function to be deployed.'))
|
|
109
|
+
console.error(chalk.yellow(' This function is SAFE — it uses SECURITY INVOKER and only service_role can call it.\n'))
|
|
110
|
+
console.error(chalk.white(' To deploy it, run this migration:'))
|
|
111
|
+
console.error(chalk.cyan(' npx tetra-check-rls --fix | head -50\n'))
|
|
112
|
+
console.error(chalk.white(' Or get the full SQL:'))
|
|
113
|
+
console.error(chalk.cyan(' import { generateExecSQL } from "@soulbatical/tetra-core"'))
|
|
114
|
+
console.error(chalk.cyan(' console.log(generateExecSQL())\n'))
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!execCheck.secure) {
|
|
119
|
+
console.error(chalk.red.bold('\n CRITICAL: exec_sql exists but is NOT SECURE\n'))
|
|
120
|
+
for (const issue of execCheck.issues) {
|
|
121
|
+
console.error(chalk.red(` - ${issue}`))
|
|
122
|
+
}
|
|
123
|
+
console.error(chalk.yellow('\n Redeploy the Tetra version to fix:'))
|
|
124
|
+
console.error(chalk.cyan(' npx tetra-check-rls --fix\n'))
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (options.checkExecSql) {
|
|
129
|
+
console.log(chalk.green(' exec_sql is installed and secure.'))
|
|
130
|
+
process.exit(0)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Generate fix migration ---
|
|
135
|
+
if (options.fix) {
|
|
136
|
+
if (tetra.generateExecSQL) {
|
|
137
|
+
console.log(tetra.generateExecSQL())
|
|
138
|
+
}
|
|
139
|
+
// Also generate the audit SQL for reference
|
|
140
|
+
if (tetra.generateAuditSQL) {
|
|
141
|
+
console.log('\n-- To audit manually, run this SQL in the Supabase SQL editor:')
|
|
142
|
+
console.log(tetra.generateAuditSQL())
|
|
143
|
+
}
|
|
144
|
+
process.exit(0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Run full RLS audit ---
|
|
148
|
+
console.log(chalk.blue.bold('\n Tetra RLS Security Gate\n'))
|
|
149
|
+
|
|
150
|
+
const report = await tetra.runRLSCheck(supabase, {
|
|
151
|
+
severities: options.errorsOnly ? ['error'] : undefined,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (options.json) {
|
|
155
|
+
console.log(JSON.stringify(report, null, 2))
|
|
156
|
+
} else {
|
|
157
|
+
console.log(report.text)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!report.passed) {
|
|
161
|
+
console.error(chalk.red.bold(`\n DEPLOY BLOCKED: ${report.errorCount} error(s) found\n`))
|
|
162
|
+
console.error(chalk.yellow(' Fix all errors before deploying. Warnings are advisory.\n'))
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (report.warnCount > 0) {
|
|
167
|
+
console.log(chalk.yellow(`\n PASS with ${report.warnCount} warning(s) — review recommended\n`))
|
|
168
|
+
} else {
|
|
169
|
+
console.log(chalk.green.bold('\n ALL CLEAR — RLS security verified\n'))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
process.exit(0)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
175
|
+
if (err.stack && process.env.DEBUG) console.error(err.stack)
|
|
176
|
+
process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
program.parse()
|
|
@@ -29,7 +29,8 @@ export async function run(config, projectRoot) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const ignoreMigrations = config.codeQuality?.namingConventions?.ignoreMigrations || []
|
|
32
|
-
const
|
|
32
|
+
const ignoreViolations = config.codeQuality?.namingConventions?.ignoreViolations || []
|
|
33
|
+
const health = await healthCheck(projectRoot, { ignoreMigrations, ignoreViolations })
|
|
33
34
|
result.details = health.details
|
|
34
35
|
|
|
35
36
|
const dbViolations = health.details.database?.violations || []
|
|
@@ -28,6 +28,7 @@ function scanDatabaseNaming(projectPath, options = {}) {
|
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
const ignoreMigrations = options.ignoreMigrations || []
|
|
31
|
+
const ignoreViolations = options.ignoreViolations || []
|
|
31
32
|
|
|
32
33
|
const sqlFiles = []
|
|
33
34
|
for (const dir of migrationDirs) {
|
|
@@ -235,9 +236,20 @@ function scanDatabaseNaming(projectPath, options = {}) {
|
|
|
235
236
|
|
|
236
237
|
// Index violations: 'Index "name" should use idx_...'
|
|
237
238
|
const idxMatch = v.match(/Index\s+"(\w+)"\s+should/)
|
|
238
|
-
if (idxMatch
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
if (idxMatch) {
|
|
240
|
+
const idxName = idxMatch[1]
|
|
241
|
+
// Direct rename match
|
|
242
|
+
if (isIndexRenamed(idxName)) {
|
|
243
|
+
compliant++
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
// If the index name starts with a renamed table prefix, it's implicitly resolved
|
|
247
|
+
for (const [oldTable] of renamedTables) {
|
|
248
|
+
if (idxName.startsWith(oldTable + '_')) {
|
|
249
|
+
compliant++
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
241
253
|
}
|
|
242
254
|
|
|
243
255
|
// Missing timestamp violations: 'Table "x" missing created_at, updated_at'
|
|
@@ -280,10 +292,19 @@ function scanDatabaseNaming(projectPath, options = {}) {
|
|
|
280
292
|
}
|
|
281
293
|
}
|
|
282
294
|
|
|
295
|
+
// Apply ignoreViolations — suppress violations matching any pattern
|
|
296
|
+
const finalViolations = ignoreViolations.length > 0
|
|
297
|
+
? violations.filter(v => {
|
|
298
|
+
const ignored = ignoreViolations.some(pattern => v.includes(pattern))
|
|
299
|
+
if (ignored) compliant++
|
|
300
|
+
return !ignored
|
|
301
|
+
})
|
|
302
|
+
: violations
|
|
303
|
+
|
|
283
304
|
return {
|
|
284
305
|
totalFields,
|
|
285
306
|
compliant,
|
|
286
|
-
violations:
|
|
307
|
+
violations: finalViolations.slice(0, 50),
|
|
287
308
|
compliancePercent: totalFields > 0 ? Math.round((compliant / totalFields) * 100) : 100
|
|
288
309
|
}
|
|
289
310
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-dev-toolkit",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.5",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"tetra-audit": "./bin/tetra-audit.js",
|
|
29
29
|
"tetra-init": "./bin/tetra-init.js",
|
|
30
30
|
"tetra-setup": "./bin/tetra-setup.js",
|
|
31
|
-
"tetra-dev-token": "./bin/tetra-dev-token.js"
|
|
31
|
+
"tetra-dev-token": "./bin/tetra-dev-token.js",
|
|
32
|
+
"tetra-check-rls": "./bin/tetra-check-rls.js"
|
|
32
33
|
},
|
|
33
34
|
"files": [
|
|
34
35
|
"bin/",
|