@soulbatical/tetra-dev-toolkit 1.8.9 → 1.9.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
@@ -18,7 +18,7 @@ npx tetra-setup
18
18
 
19
19
  This creates:
20
20
  - `.husky/pre-commit` — quick security checks before every commit
21
- - `.husky/pre-push` — hygiene check before every push (blocks clutter)
21
+ - `.husky/pre-push` — hygiene + RLS security gate before every push
22
22
  - `.github/workflows/quality.yml` — full audit on PR/push to main
23
23
  - `.tetra-quality.json` — project config (override defaults)
24
24
 
@@ -32,6 +32,106 @@ npx tetra-setup config # Config file only
32
32
 
33
33
  Re-running `tetra-setup hooks` on an existing project adds missing hooks without overwriting existing ones.
34
34
 
35
+ ---
36
+
37
+ ## 3-Layer Security Model
38
+
39
+ Every Tetra project is protected by three layers. All three must pass before code reaches production. No fallbacks. No soft warnings. Hard errors only.
40
+
41
+ ```
42
+ LAYER 1: PRE-COMMIT (tetra-audit quick)
43
+ Blocks: hardcoded secrets, service key exposure, direct createClient imports
44
+
45
+ LAYER 2: PRE-PUSH (tetra-check-rls + tetra-audit hygiene)
46
+ Blocks: RLS violations on live DB, repo clutter, missing FORCE RLS
47
+
48
+ LAYER 3: BUILD (Railway/deploy — tetra-check-rls --errors-only)
49
+ Blocks: anything that bypassed --no-verify. Last line of defense.
50
+ ```
51
+
52
+ ### Layer 1: Pre-commit
53
+
54
+ Installed by `tetra-setup hooks`. Runs `tetra-audit quick` which executes critical security checks:
55
+
56
+ | Check | What it catches |
57
+ |-------|-----------------|
58
+ | Hardcoded Secrets | API keys, tokens, JWTs in source code |
59
+ | Service Key Exposure | Supabase service role keys in frontend code |
60
+ | Direct Supabase Client | `createClient()` imports outside core db wrappers |
61
+
62
+ If any check fails, the commit is **blocked**. No `--force`, no workaround.
63
+
64
+ ### Layer 2: Pre-push (RLS Security Gate)
65
+
66
+ Installed by `tetra-setup hooks`. Runs before every `git push`. Two checks:
67
+
68
+ 1. **Repo hygiene** — `tetra-audit hygiene` blocks clutter
69
+ 2. **RLS Security Gate** — `tetra-check-rls --errors-only` against the live Supabase database
70
+
71
+ The RLS gate auto-detects your Doppler project from `doppler.yaml` and runs 23 checks across 6 categories:
72
+
73
+ | Category | Checks | What it catches |
74
+ |----------|--------|-----------------|
75
+ | Foundation | 3 | Tables without RLS, missing FORCE RLS, RLS without policies |
76
+ | Policy Quality | 5 | Public role mutations, always-true policies, missing WITH CHECK |
77
+ | Auth Functions | 4 | Missing auth functions, wrong SECURITY DEFINER, bad search_path |
78
+ | Bypass Routes | 5 | SECURITY DEFINER data functions, anon grants, views bypassing RLS |
79
+ | Data Exposure | 1 | Realtime enabled on sensitive tables |
80
+ | Migration State | 4 | Non-Tetra auth patterns, missing org columns |
81
+
82
+ If the RLS gate fails, the push is **blocked**. No `--no-verify` escape — Layer 3 catches that.
83
+
84
+ ### Layer 3: Build-time check
85
+
86
+ Add to your Railway/deploy build command:
87
+
88
+ ```bash
89
+ # Railway build command
90
+ doppler run -- npx tetra-check-rls --errors-only && npm run build
91
+ ```
92
+
93
+ This catches developers who bypass git hooks with `--no-verify`. If RLS is broken, the build fails, the deploy never happens.
94
+
95
+ ---
96
+
97
+ ## Supabase Client Architecture
98
+
99
+ Every Tetra project MUST use the 5 db helpers. Direct `createClient` imports are **blocked** by the `direct-supabase-client` check.
100
+
101
+ ### The 5 DB Helpers
102
+
103
+ | Helper | Key Used | RLS | When to Use |
104
+ |--------|----------|-----|-------------|
105
+ | `adminDB(req)` | Anon + user JWT | Enforced | Admin panel queries (org-scoped) |
106
+ | `userDB(req)` | Anon + user JWT | Enforced | User's own data |
107
+ | `publicDB()` | Anon key | Enforced | Public/anonymous access |
108
+ | `superadminDB(req)` | Service Role | Bypassed | Cross-org operations (superadmin only, audit logged) |
109
+ | `systemDB(context)` | Service Role | Bypassed | Webhooks, crons, system ops (whitelisted, audit logged) |
110
+
111
+ ### Why this matters
112
+
113
+ ```
114
+ createClient(url, SERVICE_ROLE_KEY) → Bypasses ALL RLS. No audit trail. No access control.
115
+ adminDB(req) → Uses user's JWT. RLS enforces org boundaries. Audit logged.
116
+ ```
117
+
118
+ A single `createClient` call with the service role key can leak data across all organizations. The db helpers enforce the security boundary at the application layer.
119
+
120
+ ### Allowed exceptions
121
+
122
+ These files MAY import `createClient` directly because they ARE the wrappers:
123
+
124
+ - `core/systemDb.ts`, `core/publicDb.ts`, `core/adminDb.ts`, `core/userDb.ts`, `core/superadminDb.ts`
125
+ - `core/Application.ts` (bootstrap)
126
+ - `features/database/services/SupabaseUserClient.ts`
127
+ - `auth/controllers/AuthController.ts` (needs `auth.admin` API)
128
+ - `backend-mcp/src/supabase-client.ts`, `org-scoped-client.ts`, `api-key-service.ts`, `http-server.ts`
129
+ - `scripts/` (not production code)
130
+
131
+ Everything else gets a **hard error** at commit time.
132
+
133
+ ---
134
+
35
135
  ## Usage
36
136
 
37
137
  ```bash
@@ -47,18 +147,43 @@ npx tetra-audit --json # JSON output
47
147
  npx tetra-audit --verbose # Detailed output with fix suggestions
48
148
  ```
49
149
 
50
- Exit codes: `0` = passed, `1` = failed, `2` = error.
150
+ Exit codes: `0` = passed, `1` = failed, `2` = error. No middle ground.
151
+
152
+ ## RLS Security Gate (standalone)
153
+
154
+ ```bash
155
+ npx tetra-check-rls # Auto-detect from doppler.yaml
156
+ npx tetra-check-rls --url <url> --key <key> # Explicit credentials
157
+ npx tetra-check-rls --errors-only # CI/build mode
158
+ npx tetra-check-rls --json # JSON output for automation
159
+ npx tetra-check-rls --fix # Generate hardening migration SQL
160
+ npx tetra-check-rls --check-exec-sql # Verify exec_sql function is secure
161
+ ```
162
+
163
+ ### Programmatic usage
164
+
165
+ ```typescript
166
+ import { runRLSCheck } from '@soulbatical/tetra-core';
167
+
168
+ const report = await runRLSCheck(supabaseServiceClient);
169
+ if (!report.passed) {
170
+ throw new Error(report.summary); // Hard error. No soft fail.
171
+ }
172
+ ```
173
+
174
+ ---
51
175
 
52
176
  ## Check Suites
53
177
 
54
- ### Security (5 checks)
178
+ ### Security (6 checks)
55
179
 
56
180
  | Check | Severity | What it catches |
57
181
  |-------|----------|-----------------|
58
182
  | Hardcoded Secrets | critical | API keys, tokens, JWTs in source code |
59
183
  | Service Key Exposure | critical | Supabase service role keys in frontend |
60
- | Deprecated Supabase Admin | high | Legacy `createClient(serviceKey)` patterns |
61
- | SystemDB Whitelist | high | Unauthorized system database access |
184
+ | Deprecated Supabase Admin | high | Legacy `supabaseAdmin` patterns |
185
+ | Direct Supabase Client | critical | Direct `createClient` imports outside core wrappers |
186
+ | SystemDB Whitelist | critical | Unauthorized service role key usage in authenticated routes |
62
187
  | Gitignore Validation | high | Missing .gitignore entries, tracked .env files |
63
188
 
64
189
  ### Stability (3 checks)
@@ -66,31 +191,38 @@ Exit codes: `0` = passed, `1` = failed, `2` = error.
66
191
  | Check | Severity | What it catches |
67
192
  |-------|----------|-----------------|
68
193
  | Husky Hooks | medium | Missing pre-commit/pre-push hooks |
69
- | CI Pipeline | medium | Missing or incomplete GitHub Actions config |
194
+ | CI Pipeline | medium | Missing or incomplete CI config |
70
195
  | NPM Audit | high | Known vulnerabilities in dependencies |
71
196
 
72
- ### Code Quality (1 check)
197
+ ### Code Quality (4 checks)
73
198
 
74
199
  | Check | Severity | What it catches |
75
200
  |-------|----------|-----------------|
76
- | API Response Format | medium | Non-standard `{ success, data }` response format |
201
+ | API Response Format | medium | Non-standard response format |
202
+ | File Size | medium | Files exceeding line limits |
203
+ | Naming Conventions | medium | Inconsistent file/dir naming |
204
+ | Route Separation | high | Business logic in route files |
77
205
 
78
- ### Supabase (2 checks, auto-detected)
206
+ ### Supabase (3 checks, auto-detected)
79
207
 
80
208
  | Check | Severity | What it catches |
81
209
  |-------|----------|-----------------|
82
210
  | RLS Policy Audit | critical | Tables without Row Level Security |
83
211
  | RPC Param Mismatch | critical | TypeScript `.rpc()` calls with wrong parameter names vs SQL |
212
+ | RPC Generator Origin | high | RPC functions not generated by Tetra SQL Generator |
84
213
 
85
- ### Hygiene (1 check)
214
+ ### Hygiene (2 checks)
86
215
 
87
216
  | Check | Severity | What it catches |
88
217
  |-------|----------|-----------------|
89
- | File Organization | high | Stray .md, .sh, clutter in code dirs, root mess, nested docs/ |
218
+ | File Organization | high | Stray .md, .sh, clutter in code dirs |
219
+ | Stella Compliance | medium | Missing Stella integration |
220
+
221
+ ---
90
222
 
91
223
  ## Health Checks
92
224
 
93
- Separate from audit suites, health checks provide a scored assessment (0-N points) used by the Ralph Manager dashboard:
225
+ Scored assessment (0-N points) used by the Ralph Manager dashboard:
94
226
 
95
227
  | Check | Max | What it measures |
96
228
  |-------|-----|------------------|
@@ -111,6 +243,8 @@ Separate from audit suites, health checks provide a scored assessment (0-N point
111
243
  | Plugins | 2pt | Claude Code plugin config |
112
244
  | VinciFox Widget | 1pt | Widget installation |
113
245
 
246
+ ---
247
+
114
248
  ## Auto-fix: Cleanup Script
115
249
 
116
250
  For hygiene issues, an auto-fix script is included:
@@ -123,13 +257,7 @@ bash node_modules/@soulbatical/tetra-dev-toolkit/bin/cleanup-repos.sh
123
257
  bash node_modules/@soulbatical/tetra-dev-toolkit/bin/cleanup-repos.sh --execute
124
258
  ```
125
259
 
126
- What it does:
127
- - `.md` files in code dirs -> `docs/_moved/`
128
- - `.sh` scripts in code dirs -> `scripts/_moved/`
129
- - Root clutter (`.txt`, `.png`, `.csv`) -> `docs/_cleanup/`
130
- - Code dir clutter -> `docs/_cleanup/{dir}/`
131
- - `.env` secrets in code dirs -> deleted
132
- - `tmp/`, `logs/`, `data/` dirs -> added to `.gitignore`
260
+ ---
133
261
 
134
262
  ## Configuration
135
263
 
@@ -158,20 +286,71 @@ Override defaults in `.tetra-quality.json` or `"tetra-quality"` key in `package.
158
286
  }
159
287
  ```
160
288
 
289
+ ---
290
+
161
291
  ## CLI Tools
162
292
 
163
293
  | Command | Description |
164
294
  |---------|-------------|
165
295
  | `tetra-audit` | Run quality/security/hygiene checks |
166
296
  | `tetra-setup` | Install hooks, CI, and config |
297
+ | `tetra-check-rls` | RLS security gate against live Supabase |
298
+ | `tetra-init` | Initialize project config files |
167
299
  | `tetra-dev-token` | Generate development tokens |
168
300
 
301
+ ---
302
+
303
+ ## New Project Checklist
304
+
305
+ After `npm install` in a new Tetra project:
306
+
307
+ ```bash
308
+ # 1. Install hooks (creates pre-commit + pre-push)
309
+ npx tetra-setup hooks
310
+
311
+ # 2. Verify hooks are active
312
+ cat .husky/pre-commit # Should contain tetra-audit quick
313
+ cat .husky/pre-push # Should contain tetra-check-rls
314
+
315
+ # 3. Run full audit
316
+ npx tetra-audit
317
+
318
+ # 4. Run RLS gate manually once
319
+ doppler run -- npx tetra-check-rls
320
+
321
+ # 5. Add to Railway build command
322
+ # doppler run -- npx tetra-check-rls --errors-only && npm run build
323
+ ```
324
+
325
+ If any step fails, fix it before writing code. No exceptions.
326
+
327
+ ---
328
+
169
329
  ## Changelog
170
330
 
331
+ ### 1.9.0
332
+
333
+ **New: Direct Supabase Client check (CRITICAL)**
334
+ - Added `direct-supabase-client` check in security suite
335
+ - Blocks any `createClient` import from `@supabase/supabase-js` outside core db wrappers
336
+ - Hard error at commit time — no fallback, no override
337
+ - Type-only imports (`import type { SupabaseClient }`) are allowed
338
+ - Allowed files whitelist: core wrappers, auth controller, MCP server internals, scripts
339
+
340
+ **New: 3-Layer Security Model documentation**
341
+ - Layer 1: Pre-commit (tetra-audit quick)
342
+ - Layer 2: Pre-push (tetra-check-rls + hygiene)
343
+ - Layer 3: Build-time (tetra-check-rls --errors-only in Railway)
344
+ - Complete Supabase Client Architecture docs with the 5 db helpers
345
+
346
+ **Improved: prepublishOnly hooks**
347
+ - tetra-core: `npm run build && npm run typecheck` before publish
348
+ - tetra-dev-toolkit: `npm test` before publish
349
+
171
350
  ### 1.3.0 (2025-02-21)
172
351
 
173
352
  **New: Hygiene suite**
174
- - Added `tetra-audit hygiene` — detects stray docs, scripts, clutter in code dirs, root mess
353
+ - Added `tetra-audit hygiene` — detects stray docs, scripts, clutter in code dirs
175
354
  - Added `cleanup-repos.sh` auto-fix script in `bin/`
176
355
  - `tetra-setup hooks` now creates pre-push hook with hygiene gate
177
356
  - Re-running `tetra-setup hooks` on existing repos adds hygiene check without overwriting
@@ -181,12 +360,6 @@ Override defaults in `.tetra-quality.json` or `"tetra-quality"` key in `package.
181
360
  - Statically compares `.rpc()` calls in TypeScript with SQL function parameter names
182
361
  - Catches PGRST202 errors before they hit production
183
362
 
184
- **Improved: File Organization health check**
185
- - Extended from 5pt to 6pt — added root clutter detection
186
- - Root clutter: `.txt`, `.png`, `.csv`, `.pdf`, `.py` files and `tmp/`, `logs/`, `data/` dirs
187
- - Gitignored dirs no longer counted as clutter
188
- - `CLAUDE.md` allowed anywhere (not just root)
189
-
190
363
  ### 1.2.0
191
364
 
192
365
  - Initial public version
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Hygiene Check: Stella Compliance
3
+ *
4
+ * Ensures MCP servers use shared Stella tools instead of duplicating code.
5
+ * Checks:
6
+ * - No local telegram tool implementations (must come from @soulbatical/stella)
7
+ * - No local detectWorkspaceRef copies (must use Stella's export)
8
+ * - No local gatewaySend for telegram (must use Stella's telegram module)
9
+ * - Stella version consistency across consumers
10
+ * - workspace_ref is always passed in telegram gateway calls
11
+ *
12
+ * Severity: high — duplicate code leads to bugs that only get fixed in one place
13
+ */
14
+
15
+ import { readFile, access } from 'node:fs/promises'
16
+ import { join, relative } from 'node:path'
17
+ import { glob } from 'glob'
18
+
19
+ export const meta = {
20
+ id: 'stella-compliance',
21
+ name: 'Stella Compliance',
22
+ category: 'hygiene',
23
+ severity: 'high',
24
+ description: 'Checks that MCP servers use shared Stella tools and do not duplicate code'
25
+ }
26
+
27
+ // Patterns that indicate duplicate code — should only exist in Stella itself
28
+ const DUPLICATE_PATTERNS = [
29
+ {
30
+ pattern: /const QUESTIONS_DIR\s*=\s*join\(tmpdir\(\)/,
31
+ label: 'Local QUESTIONS_DIR (telegram question store)',
32
+ allowedIn: ['stella/src/telegram.ts']
33
+ },
34
+ {
35
+ pattern: /function detectWorkspaceRef\(\)/,
36
+ label: 'Local detectWorkspaceRef implementation',
37
+ allowedIn: ['stella/src/permissions.ts']
38
+ },
39
+ {
40
+ pattern: /async function gatewaySend\(/,
41
+ label: 'Local gatewaySend helper',
42
+ allowedIn: ['stella/src/telegram.ts']
43
+ },
44
+ {
45
+ pattern: /\/api\/internal\/telegram\//,
46
+ label: 'Direct telegram gateway URL',
47
+ allowedIn: ['stella/src/telegram.ts', 'backend/src/', 'ralph-manager/backend/src/']
48
+ },
49
+ {
50
+ pattern: /function splitMessage\(text:\s*string/,
51
+ label: 'Local splitMessage helper (for telegram)',
52
+ allowedIn: ['stella/src/telegram.ts', 'tools/helpers.ts']
53
+ },
54
+ {
55
+ pattern: /function playMacAlert\(\)/,
56
+ label: 'Local playMacAlert helper',
57
+ allowedIn: ['stella/src/telegram.ts']
58
+ }
59
+ ]
60
+
61
+ // Files that should be a thin re-export from Stella, not a full implementation
62
+ const EXPECTED_REEXPORTS = [
63
+ {
64
+ glob: '**/backend-mcp/src/tools/telegram.ts',
65
+ mustContain: /@soulbatical\/stella/,
66
+ mustNotContain: /gatewaySend|QUESTIONS_DIR|QuestionState/,
67
+ label: 'telegram.ts should re-export from @soulbatical/stella'
68
+ }
69
+ ]
70
+
71
+ // Stella version consistency check
72
+ async function getInstalledStellaVersion(projectRoot) {
73
+ try {
74
+ const pkgPath = join(projectRoot, 'node_modules', '@soulbatical', 'stella', 'package.json')
75
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
76
+ return pkg.version
77
+ } catch {
78
+ // Try in backend-mcp subdir
79
+ try {
80
+ const pkgPath = join(projectRoot, 'backend-mcp', 'node_modules', '@soulbatical', 'stella', 'package.json')
81
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
82
+ return pkg.version
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+ }
88
+
89
+ async function getLatestStellaVersion() {
90
+ try {
91
+ const pkgPath = join(process.env.HOME, 'projecten', 'stella', 'package.json')
92
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
93
+ return pkg.version
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ export async function run(config, projectRoot) {
100
+ const result = {
101
+ passed: true,
102
+ findings: [],
103
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
104
+ details: {}
105
+ }
106
+
107
+ const projectName = relative(join(process.env.HOME, 'projecten'), projectRoot)
108
+
109
+ // Skip Stella itself
110
+ if (projectName === 'stella') {
111
+ result.details.skipped = 'Stella project — checks not applicable'
112
+ return result
113
+ }
114
+
115
+ // 1. Check for duplicate patterns in MCP tool files
116
+ const tsFiles = await glob('**/src/**/*.ts', {
117
+ cwd: projectRoot,
118
+ ignore: ['**/node_modules/**', '**/dist/**', '**/bot/**']
119
+ })
120
+
121
+ for (const file of tsFiles) {
122
+ let content
123
+ try {
124
+ content = await readFile(join(projectRoot, file), 'utf-8')
125
+ } catch {
126
+ continue
127
+ }
128
+
129
+ for (const { pattern, label, allowedIn } of DUPLICATE_PATTERNS) {
130
+ if (pattern.test(content)) {
131
+ const isAllowed = allowedIn.some(a => file.includes(a) || join(projectName, file).includes(a))
132
+ if (!isAllowed) {
133
+ result.findings.push({
134
+ type: 'Duplicate Stella code',
135
+ severity: 'high',
136
+ message: `${label} found in ${file} — should use @soulbatical/stella export instead`,
137
+ files: [file]
138
+ })
139
+ result.summary.high++
140
+ result.summary.total++
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ // 2. Check that telegram.ts files are thin re-exports
147
+ for (const { glob: pattern, mustContain, mustNotContain, label } of EXPECTED_REEXPORTS) {
148
+ const matches = await glob(pattern, {
149
+ cwd: projectRoot,
150
+ ignore: ['**/node_modules/**', '**/dist/**']
151
+ })
152
+
153
+ for (const file of matches) {
154
+ let content
155
+ try {
156
+ content = await readFile(join(projectRoot, file), 'utf-8')
157
+ } catch {
158
+ continue
159
+ }
160
+
161
+ if (!mustContain.test(content)) {
162
+ result.findings.push({
163
+ type: 'Missing Stella import',
164
+ severity: 'high',
165
+ message: `${file}: ${label}`,
166
+ files: [file]
167
+ })
168
+ result.summary.high++
169
+ result.summary.total++
170
+ }
171
+
172
+ if (mustNotContain.test(content)) {
173
+ result.findings.push({
174
+ type: 'Local implementation instead of Stella',
175
+ severity: 'high',
176
+ message: `${file} contains local implementation code — should be a thin re-export from @soulbatical/stella`,
177
+ files: [file]
178
+ })
179
+ result.summary.high++
180
+ result.summary.total++
181
+ }
182
+ }
183
+ }
184
+
185
+ // 3. Check Stella version consistency
186
+ const latestVersion = await getLatestStellaVersion()
187
+ const installedVersion = await getInstalledStellaVersion(projectRoot)
188
+
189
+ if (latestVersion && installedVersion) {
190
+ result.details.stellaVersion = { installed: installedVersion, latest: latestVersion }
191
+
192
+ if (installedVersion !== latestVersion) {
193
+ result.findings.push({
194
+ type: 'Outdated Stella version',
195
+ severity: 'medium',
196
+ message: `Installed @soulbatical/stella@${installedVersion}, latest is ${latestVersion} — run npm update @soulbatical/stella`,
197
+ files: ['package.json']
198
+ })
199
+ result.summary.medium++
200
+ result.summary.total++
201
+ }
202
+ }
203
+
204
+ // Fail if any high+ findings
205
+ result.passed = result.summary.critical === 0 && result.summary.high === 0
206
+
207
+ return result
208
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Direct Supabase Client Detection — HARD BLOCK
3
+ *
4
+ * Detects files that import createClient from @supabase/supabase-js directly.
5
+ * This bypasses ALL Tetra security layers (RLS enforcement, audit logging, access control).
6
+ *
7
+ * ALLOWED locations (these ARE the wrappers):
8
+ * - core/systemDb.ts
9
+ * - core/publicDb.ts
10
+ * - core/adminDb.ts
11
+ * - core/userDb.ts
12
+ * - core/superadminDb.ts
13
+ * - core/Application.ts (bootstrap only)
14
+ * - features/database/services/SupabaseUserClient.ts
15
+ * - auth/controllers/AuthController.ts (needs auth.admin API)
16
+ *
17
+ * EVERYTHING ELSE is a violation. No fallbacks. No exceptions.
18
+ *
19
+ * The correct pattern:
20
+ * - adminDB(req) → org-scoped admin queries (RLS enforced)
21
+ * - userDB(req) → user's own data (RLS enforced)
22
+ * - publicDB() → anonymous/public data (RLS enforced)
23
+ * - superadminDB(req) → cross-org operations (audit logged, superadmin only)
24
+ * - systemDB(context) → webhooks/crons/system ops (audit logged, whitelisted)
25
+ */
26
+
27
+ import { glob } from 'glob'
28
+ import { readFileSync, existsSync } from 'fs'
29
+
30
+ export const meta = {
31
+ id: 'direct-supabase-client',
32
+ name: 'Direct Supabase Client Import',
33
+ category: 'security',
34
+ severity: 'critical',
35
+ description: 'Blocks direct createClient imports from @supabase/supabase-js — all access must go through Tetra db helpers'
36
+ }
37
+
38
+ /**
39
+ * Files that ARE the Supabase wrappers — allowed to import createClient.
40
+ * Everything else is a hard error.
41
+ */
42
+ const ALLOWED_FILES = [
43
+ // Core db wrappers
44
+ /core\/systemDb\.ts$/,
45
+ /core\/publicDb\.ts$/,
46
+ /core\/adminDb\.ts$/,
47
+ /core\/userDb\.ts$/,
48
+ /core\/superadminDb\.ts$/,
49
+ /core\/Application\.ts$/,
50
+
51
+ // The user client wrapper itself
52
+ /SupabaseUserClient\.ts$/,
53
+
54
+ // Auth controller needs auth.admin API (requires service role)
55
+ /auth\/controllers\/AuthController\.ts$/,
56
+
57
+ // MCP server has its own client management (org-scoped-client.ts, supabase-client.ts, api-key-service.ts)
58
+ /backend-mcp\/src\/supabase-client\.ts$/,
59
+ /backend-mcp\/src\/org-scoped-client\.ts$/,
60
+ /backend-mcp\/src\/auth\/api-key-service\.ts$/,
61
+ /backend-mcp\/src\/http-server\.ts$/,
62
+
63
+ // Domain middleware (sets RLS session vars — needs direct client)
64
+ /middleware\/domainOrganizationMiddleware\.ts$/,
65
+
66
+ // Scripts (not production code)
67
+ /scripts\//,
68
+ ]
69
+
70
+ export async function run(config, projectRoot) {
71
+ const results = {
72
+ passed: true,
73
+ findings: [],
74
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
75
+ }
76
+
77
+ // Get all TypeScript files
78
+ const files = await glob('**/*.ts', {
79
+ cwd: projectRoot,
80
+ ignore: [
81
+ ...config.ignore,
82
+ '**/*.test.ts',
83
+ '**/*.spec.ts',
84
+ '**/*.d.ts',
85
+ ]
86
+ })
87
+
88
+ for (const file of files) {
89
+ try {
90
+ const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
91
+ const lines = content.split('\n')
92
+
93
+ // Pattern 1: import { createClient } from '@supabase/supabase-js'
94
+ // Pattern 2: import { createClient as X } from '@supabase/supabase-js'
95
+ // We ALLOW: import { SupabaseClient } (type-only imports are fine)
96
+ // We ALLOW: import type { ... } from '@supabase/supabase-js'
97
+
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i]
100
+
101
+ // Skip type-only imports
102
+ if (/import\s+type\s/.test(line)) continue
103
+
104
+ // Check for createClient import from supabase
105
+ if (/import\s*\{[^}]*createClient[^}]*\}\s*from\s*['"]@supabase\/supabase-js['"]/.test(line)) {
106
+ // Check if this file is in the allowed list
107
+ const isAllowed = ALLOWED_FILES.some(pattern => pattern.test(file))
108
+
109
+ if (!isAllowed) {
110
+ results.passed = false
111
+ results.findings.push({
112
+ file,
113
+ line: i + 1,
114
+ type: 'direct-createClient-import',
115
+ severity: 'critical',
116
+ message: `BLOCKED: Direct createClient import from @supabase/supabase-js. Use Tetra db helpers instead.`,
117
+ snippet: line.trim().substring(0, 120),
118
+ fix: getFixSuggestion(file, content)
119
+ })
120
+ results.summary.critical++
121
+ results.summary.total++
122
+ }
123
+ }
124
+
125
+ // Also catch: const supabase = createClient(url, key) without the import
126
+ // (in case someone imports it via re-export or variable)
127
+ if (/createClient\s*\(\s*(?:process\.env|supabase|SUPABASE)/.test(line)) {
128
+ const isAllowed = ALLOWED_FILES.some(pattern => pattern.test(file))
129
+ const isImportLine = /import/.test(line)
130
+
131
+ if (!isAllowed && !isImportLine) {
132
+ results.passed = false
133
+ results.findings.push({
134
+ file,
135
+ line: i + 1,
136
+ type: 'direct-createClient-call',
137
+ severity: 'critical',
138
+ message: `BLOCKED: Direct createClient() call with env vars. Use Tetra db helpers instead.`,
139
+ snippet: line.trim().substring(0, 120),
140
+ fix: getFixSuggestion(file, content)
141
+ })
142
+ results.summary.critical++
143
+ results.summary.total++
144
+ }
145
+ }
146
+ }
147
+ } catch (e) {
148
+ // Skip unreadable files
149
+ }
150
+ }
151
+
152
+ return results
153
+ }
154
+
155
+ /**
156
+ * Suggest the correct Tetra db helper based on the file's context.
157
+ */
158
+ function getFixSuggestion(file, content) {
159
+ if (/Admin.*Controller/.test(file) || /adminRoutes/.test(file)) {
160
+ return `Use: import { adminDB } from '../../core/adminDb';\nconst supabase = await adminDB(req);`
161
+ }
162
+ if (/User.*Controller/.test(file) || /userRoutes/.test(file)) {
163
+ return `Use: import { userDB } from '../../core/userDb';\nconst supabase = await userDB(req);`
164
+ }
165
+ if (/Superadmin/.test(file)) {
166
+ return `Use: import { superadminDB } from '../../core/superadminDb';\nconst supabase = await superadminDB(req);`
167
+ }
168
+ if (/cron/i.test(file) || /webhook/i.test(file)) {
169
+ return `Use: import { systemDB } from '../../core/systemDb';\nconst supabase = systemDB('your-context');`
170
+ }
171
+ if (content.includes('AuthenticatedRequest') || content.includes('req.userToken')) {
172
+ return `User context available — use adminDB(req) or userDB(req) instead of createClient()`
173
+ }
174
+ return `Use one of: adminDB(req), userDB(req), publicDB(), superadminDB(req), or systemDB(context)`
175
+ }
@@ -1,21 +1,89 @@
1
1
  /**
2
- * Check that all systemDB() calls use whitelisted contexts
2
+ * systemDB Usage Audit Prevent unnecessary Service Role Key usage
3
3
  *
4
- * This prevents accidental RLS bypass by requiring explicit context strings
5
- * that are defined in the systemDb.ts whitelist.
4
+ * The Service Role Key bypasses ALL Row Level Security. This check ensures:
5
+ * 1. systemDB() is NEVER used in authenticated routes (Admin/User controllers & routes)
6
+ * 2. systemDB() is ONLY allowed in legitimate no-user-context scenarios
7
+ * 3. Whitelist size is capped — large whitelists indicate architectural problems
8
+ *
9
+ * ALLOWED locations (no user context available):
10
+ * - Cron services (files matching *Cron*.ts, *cron*.ts)
11
+ * - Webhook controllers (files matching System*Controller.ts, *webhook*.ts)
12
+ * - OAuth callback controllers (files matching *OAuth*Controller.ts)
13
+ * - Public routes (files in routes/publicRoutes.ts)
14
+ * - Core auth services (auth-guards, session services)
15
+ *
16
+ * FORBIDDEN locations (user context IS available via req.userToken):
17
+ * - Admin controllers (Admin*Controller.ts)
18
+ * - Admin routes (routes/adminRoutes.ts)
19
+ * - User controllers (User*Controller.ts)
20
+ * - Any file that imports AuthenticatedRequest
6
21
  */
7
22
 
8
23
  import { glob } from 'glob'
9
24
  import { readFileSync, existsSync } from 'fs'
25
+ import { basename, dirname } from 'path'
10
26
 
11
27
  export const meta = {
12
28
  id: 'systemdb-whitelist',
13
- name: 'systemDB Context Whitelist',
29
+ name: 'systemDB Service Role Key Audit',
14
30
  category: 'security',
15
- severity: 'high',
16
- description: 'Ensures all systemDB() calls use whitelisted context strings'
31
+ severity: 'critical',
32
+ description: 'Prevents unnecessary Service Role Key usage — systemDB() must not be used where user context is available'
17
33
  }
18
34
 
35
+ /**
36
+ * Patterns for files where systemDB is ALLOWED (no user context)
37
+ */
38
+ const ALLOWED_FILE_PATTERNS = [
39
+ // Cron jobs — no user context, runs on timer
40
+ /Cron/i,
41
+ // Webhook controllers from external systems — no auth header
42
+ /System.*Controller/,
43
+ // OAuth callback controllers — browser redirect, no JWT
44
+ /OAuth.*Controller/,
45
+ /OAuthController/,
46
+ // Public routes — unauthenticated endpoints
47
+ /publicRoutes/,
48
+ // Core infrastructure
49
+ /systemDb\.ts$/,
50
+ /superadminDb\.ts$/,
51
+ /webhookDb\.ts$/,
52
+ /adminDb\.ts$/,
53
+ /userDb\.ts$/,
54
+ // Auth infrastructure (needs SRK for auth.admin calls)
55
+ /auth-guards/,
56
+ /authService/,
57
+ /UserSessionService/,
58
+ // Platform services that run token refreshes in background
59
+ /TokenRefreshService/,
60
+ /PlatformSyncService/,
61
+ /BasePlatformService/,
62
+ // Base cron service
63
+ /BaseCronService/,
64
+ // Internal service-to-service routes (API key auth, no user JWT)
65
+ /internalRoutes/,
66
+ ]
67
+
68
+ /**
69
+ * Patterns for files where systemDB is FORBIDDEN (user context available)
70
+ */
71
+ const FORBIDDEN_FILE_PATTERNS = [
72
+ // Admin controllers — always have req.userToken from requireAuth middleware
73
+ { pattern: /Admin.*Controller\.ts$/, reason: 'Admin controllers have req.userToken — use SupabaseUserClient.createForUser() or adminDB(req) instead' },
74
+ // Admin routes — always behind requireAuth
75
+ { pattern: /adminRoutes\.ts$/, reason: 'Admin routes are behind requireAuth — use SupabaseUserClient.createForUser(req.userToken!) instead' },
76
+ // User controllers — always have user context
77
+ { pattern: /User.*Controller\.ts$/, reason: 'User controllers have req.userToken — use SupabaseUserClient.createForUser() instead' },
78
+ // User routes
79
+ { pattern: /userRoutes\.ts$/, reason: 'User routes are behind requireAuth — use SupabaseUserClient.createForUser(req.userToken!) instead' },
80
+ ]
81
+
82
+ /**
83
+ * Maximum allowed whitelist size — anything above indicates architectural problems
84
+ */
85
+ const MAX_WHITELIST_SIZE = 35
86
+
19
87
  export async function run(config, projectRoot) {
20
88
  const results = {
21
89
  passed: true,
@@ -23,7 +91,8 @@ export async function run(config, projectRoot) {
23
91
  summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
24
92
  }
25
93
 
26
- // Find systemDb.ts to extract whitelist
94
+ // ─── Check 1: Whitelist size cap ─────────────────────────────
95
+
27
96
  const systemDbPaths = [
28
97
  `${projectRoot}/backend/src/core/systemDb.ts`,
29
98
  `${projectRoot}/src/core/systemDb.ts`
@@ -43,42 +112,44 @@ export async function run(config, projectRoot) {
43
112
  return results
44
113
  }
45
114
 
46
- // Extract whitelist from systemDb.ts
47
115
  const systemDbContent = readFileSync(systemDbPath, 'utf-8')
48
- const whitelistMatch = systemDbContent.match(/new Set\s*\(\s*\[([\s\S]*?)\]\s*\)/m)
116
+ const whitelistMatch = systemDbContent.match(/new Set\s*(?:<[^>]+>)?\s*\(\s*\[([\s\S]*?)\]\s*\)/m)
49
117
 
50
- if (!whitelistMatch) {
118
+ let whitelist = new Set()
119
+ if (whitelistMatch) {
120
+ const entries = whitelistMatch[1]
121
+ .split('\n')
122
+ .map(line => {
123
+ const m = line.match(/['"]([^'"]+)['"]/)
124
+ return m ? m[1] : null
125
+ })
126
+ .filter(Boolean)
127
+ whitelist = new Set(entries)
128
+ }
129
+
130
+ if (whitelist.size > MAX_WHITELIST_SIZE) {
51
131
  results.passed = false
52
132
  results.findings.push({
53
- file: systemDbPath,
133
+ file: systemDbPath.replace(projectRoot + '/', ''),
54
134
  line: 1,
55
- type: 'missing-whitelist',
135
+ type: 'whitelist-too-large',
56
136
  severity: 'critical',
57
- message: 'systemDb.ts does not contain a whitelist Set'
137
+ message: `systemDB whitelist has ${whitelist.size} entries (max: ${MAX_WHITELIST_SIZE}). This indicates systemDB is being used where SupabaseUserClient should be used instead. Refactor services to accept a supabase client parameter instead of calling systemDB() directly.`,
138
+ fix: `Refactor: services should receive a supabase client via dependency injection, not create their own via systemDB(). Only cron jobs, webhooks, and OAuth callbacks should use systemDB.`
58
139
  })
59
140
  results.summary.critical++
60
141
  results.summary.total++
61
- return results
62
142
  }
63
143
 
64
- // Parse whitelist entries
65
- const whitelistStr = whitelistMatch[1]
66
- const whitelist = new Set(
67
- whitelistStr
68
- .split('\n')
69
- .map(line => {
70
- const match = line.match(/['"]([^'"]+)['"]/)
71
- return match ? match[1] : null
72
- })
73
- .filter(Boolean)
74
- )
144
+ // ─── Check 2: systemDB in forbidden locations ───────────────
75
145
 
76
- // Find all systemDB() calls in the codebase
77
146
  const files = await glob('**/*.ts', {
78
147
  cwd: projectRoot,
79
148
  ignore: [
80
149
  ...config.ignore,
81
- '**/systemDb.ts', // Skip the definition file
150
+ '**/systemDb.ts',
151
+ '**/superadminDb.ts',
152
+ '**/webhookDb.ts',
82
153
  '**/*.test.ts',
83
154
  '**/*.spec.ts'
84
155
  ]
@@ -89,8 +160,9 @@ export async function run(config, projectRoot) {
89
160
  const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
90
161
  const lines = content.split('\n')
91
162
 
92
- // Find systemDB('context') calls
93
- const pattern = /systemDB\s*\(\s*['"]([^'"]+)['"]\s*\)/g
163
+ // Find systemDB() calls
164
+ const pattern = /systemDB\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g
165
+ const fileName = basename(file)
94
166
 
95
167
  let match
96
168
  while ((match = pattern.exec(content)) !== null) {
@@ -100,26 +172,82 @@ export async function run(config, projectRoot) {
100
172
  let lineNumber = 1
101
173
  let pos = 0
102
174
  for (const line of lines) {
103
- if (pos + line.length >= match.index) {
104
- break
105
- }
175
+ if (pos + line.length >= match.index) break
106
176
  pos += line.length + 1
107
177
  lineNumber++
108
178
  }
109
179
 
180
+ // Check if file is in a FORBIDDEN location
181
+ const forbidden = FORBIDDEN_FILE_PATTERNS.find(f => f.pattern.test(fileName))
182
+ if (forbidden) {
183
+ // Check if it's also in an allowed pattern (e.g., SystemActiveCampaignController is both System* and Controller)
184
+ const isAlsoAllowed = ALLOWED_FILE_PATTERNS.some(p => p.test(fileName))
185
+
186
+ if (!isAlsoAllowed) {
187
+ results.passed = false
188
+ results.findings.push({
189
+ file,
190
+ line: lineNumber,
191
+ type: 'systemdb-in-authenticated-route',
192
+ severity: 'critical',
193
+ message: `systemDB('${context}') used in ${fileName} — ${forbidden.reason}`,
194
+ snippet: lines[lineNumber - 1]?.trim().substring(0, 120),
195
+ fix: `Replace systemDB('${context}') with SupabaseUserClient.createForUser(req.userToken!) — RLS will handle authorization`
196
+ })
197
+ results.summary.critical++
198
+ results.summary.total++
199
+ }
200
+ }
201
+
202
+ // Check 3: Even in allowed files, flag calls that don't match whitelist
110
203
  if (!whitelist.has(context)) {
111
- results.passed = false
204
+ // Dynamic contexts like platform-token-refresh-{name} are OK
205
+ const isDynamic = context.includes('${') ||
206
+ Array.from(whitelist).some(w => {
207
+ // Check if whitelist has a matching prefix pattern
208
+ const prefix = w.replace(/-[^-]+$/, '-')
209
+ return context.startsWith(prefix) && prefix.length > 5
210
+ })
211
+
212
+ if (!isDynamic) {
213
+ results.findings.push({
214
+ file,
215
+ line: lineNumber,
216
+ type: 'unknown-context',
217
+ severity: 'medium',
218
+ message: `systemDB context '${context}' is not in whitelist`,
219
+ snippet: lines[lineNumber - 1]?.trim().substring(0, 100),
220
+ fix: `If this is a legitimate system operation (cron/webhook/oauth callback), add '${context}' to the whitelist. Otherwise, refactor to use SupabaseUserClient.`
221
+ })
222
+ results.summary.medium++
223
+ results.summary.total++
224
+ }
225
+ }
226
+ }
227
+
228
+ // ─── Check 4: Files importing systemDB that shouldn't ────
229
+
230
+ if (content.includes("import") && content.includes("systemDB")) {
231
+ const hasAuthenticatedRequest = content.includes('AuthenticatedRequest') || content.includes('req.userToken') || content.includes('req.user!')
232
+ const isAllowed = ALLOWED_FILE_PATTERNS.some(p => p.test(file))
233
+ const isForbidden = FORBIDDEN_FILE_PATTERNS.some(f => f.pattern.test(fileName))
234
+
235
+ if (hasAuthenticatedRequest && !isAllowed) {
112
236
  results.findings.push({
113
237
  file,
114
- line: lineNumber,
115
- type: 'unknown-context',
238
+ line: lines.findIndex(l => l.includes('systemDB')) + 1,
239
+ type: 'systemdb-with-user-context',
116
240
  severity: 'high',
117
- message: `systemDB context '${context}' is not in whitelist`,
118
- snippet: lines[lineNumber - 1]?.trim().substring(0, 100),
119
- fix: `Add '${context}' to ALLOWED_CONTEXTS in systemDb.ts`
241
+ message: `File imports systemDB but also uses AuthenticatedRequest/req.userToken — this means user context IS available. Use SupabaseUserClient instead.`,
242
+ fix: `Refactor: pass supabase client as parameter to services, created via SupabaseUserClient.createForUser(req.userToken!) in the route/controller`
120
243
  })
121
244
  results.summary.high++
122
245
  results.summary.total++
246
+
247
+ if (!isForbidden) {
248
+ // Don't double-fail if already caught by forbidden check
249
+ results.passed = false
250
+ }
123
251
  }
124
252
  }
125
253
  } catch (e) {
@@ -127,11 +255,13 @@ export async function run(config, projectRoot) {
127
255
  }
128
256
  }
129
257
 
130
- // Add info about whitelist
258
+ // ─── Summary info ──────────────────────────────────────────
259
+
131
260
  results.info = {
132
- whitelistPath: systemDbPath,
133
- whitelistCount: whitelist.size,
134
- contexts: Array.from(whitelist)
261
+ whitelistPath: systemDbPath?.replace(projectRoot + '/', ''),
262
+ whitelistSize: whitelist.size,
263
+ maxAllowed: MAX_WHITELIST_SIZE,
264
+ architecture: 'systemDB should ONLY be used in: cron jobs, external webhooks, OAuth callbacks. All authenticated routes must use SupabaseUserClient.createForUser(req.userToken!).'
135
265
  }
136
266
 
137
267
  return results
package/lib/runner.js CHANGED
@@ -10,6 +10,7 @@ import { loadConfig, detectSupabase } from './config.js'
10
10
  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
+ import * as directSupabaseClient from './checks/security/direct-supabase-client.js'
13
14
  import * as systemdbWhitelist from './checks/security/systemdb-whitelist.js'
14
15
  import * as huskyHooks from './checks/stability/husky-hooks.js'
15
16
  import * as ciPipeline from './checks/stability/ci-pipeline.js'
@@ -23,6 +24,7 @@ import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
23
24
  import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
24
25
  import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
25
26
  import * as fileOrganization from './checks/hygiene/file-organization.js'
27
+ import * as stellaCompliance from './checks/hygiene/stella-compliance.js'
26
28
 
27
29
  // Register all checks
28
30
  const ALL_CHECKS = {
@@ -30,6 +32,7 @@ const ALL_CHECKS = {
30
32
  hardcodedSecrets,
31
33
  serviceKeyExposure,
32
34
  deprecatedSupabaseAdmin,
35
+ directSupabaseClient,
33
36
  systemdbWhitelist,
34
37
  gitignoreValidation
35
38
  ],
@@ -50,7 +53,8 @@ const ALL_CHECKS = {
50
53
  rpcGeneratorOrigin
51
54
  ],
52
55
  hygiene: [
53
- fileOrganization
56
+ fileOrganization,
57
+ stellaCompliance
54
58
  ]
55
59
  }
56
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.8.9",
3
+ "version": "1.9.0",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -40,7 +40,8 @@
40
40
  "test": "vitest run",
41
41
  "test:watch": "vitest",
42
42
  "lint": "eslint src/ lib/ bin/",
43
- "build": "echo 'No build step needed'"
43
+ "build": "echo 'No build step needed'",
44
+ "prepublishOnly": "npm test"
44
45
  },
45
46
  "engines": {
46
47
  "node": ">=18.0.0"