@soulbatical/tetra-dev-toolkit 1.8.9 → 1.9.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.
- package/README.md +199 -26
- package/bin/tetra-setup.js +25 -18
- package/lib/checks/hygiene/stella-compliance.js +208 -0
- package/lib/checks/security/direct-supabase-client.js +175 -0
- package/lib/checks/security/systemdb-whitelist.js +172 -42
- package/lib/runner.js +5 -1
- package/package.json +3 -2
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
|
|
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 (
|
|
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 `
|
|
61
|
-
|
|
|
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
|
|
194
|
+
| CI Pipeline | medium | Missing or incomplete CI config |
|
|
70
195
|
| NPM Audit | high | Known vulnerabilities in dependencies |
|
|
71
196
|
|
|
72
|
-
### Code Quality (
|
|
197
|
+
### Code Quality (4 checks)
|
|
73
198
|
|
|
74
199
|
| Check | Severity | What it catches |
|
|
75
200
|
|-------|----------|-----------------|
|
|
76
|
-
| API Response Format | medium | Non-standard
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/bin/tetra-setup.js
CHANGED
|
@@ -122,35 +122,42 @@ async function setupHooks(options) {
|
|
|
122
122
|
execSync('npx husky init', { stdio: 'inherit' })
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
// Create pre-commit hook
|
|
125
|
+
// Create or extend pre-commit hook with tetra-audit quick
|
|
126
126
|
const preCommitPath = join(huskyDir, 'pre-commit')
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
echo "🔍 Running Tetra quality checks..."
|
|
132
|
-
|
|
133
|
-
# Run quick security checks (fast, blocks commit on critical issues)
|
|
127
|
+
const tetraAuditBlock = `
|
|
128
|
+
# Tetra quick security checks (hardcoded secrets, direct createClient, service key exposure)
|
|
129
|
+
echo "🔍 Running Tetra security checks..."
|
|
134
130
|
npx tetra-audit quick
|
|
135
131
|
if [ $? -ne 0 ]; then
|
|
136
132
|
echo ""
|
|
137
133
|
echo "❌ Security issues found! Fix before committing."
|
|
138
|
-
echo " Run 'tetra-audit' for
|
|
134
|
+
echo " Run 'npx tetra-audit security --verbose' for details."
|
|
139
135
|
exit 1
|
|
140
136
|
fi
|
|
141
|
-
|
|
142
|
-
# Run lint-staged if configured
|
|
143
|
-
if [ -f "package.json" ] && grep -q "lint-staged" package.json; then
|
|
144
|
-
npx lint-staged
|
|
145
|
-
fi
|
|
146
|
-
|
|
147
|
-
echo "✅ Pre-commit checks passed"
|
|
148
137
|
`
|
|
138
|
+
|
|
139
|
+
if (!existsSync(preCommitPath)) {
|
|
140
|
+
// No pre-commit hook — create one
|
|
141
|
+
const preCommitContent = `#!/bin/sh\n${tetraAuditBlock}\necho "✅ Pre-commit checks passed"\n`
|
|
142
|
+
writeFileSync(preCommitPath, preCommitContent)
|
|
143
|
+
execSync(`chmod +x ${preCommitPath}`)
|
|
144
|
+
console.log(' ✅ Created .husky/pre-commit with tetra-audit quick')
|
|
145
|
+
} else if (options.force) {
|
|
146
|
+
// Force overwrite
|
|
147
|
+
const preCommitContent = `#!/bin/sh\n${tetraAuditBlock}\necho "✅ Pre-commit checks passed"\n`
|
|
149
148
|
writeFileSync(preCommitPath, preCommitContent)
|
|
150
149
|
execSync(`chmod +x ${preCommitPath}`)
|
|
151
|
-
console.log(' ✅
|
|
150
|
+
console.log(' ✅ Overwrote .husky/pre-commit with tetra-audit quick')
|
|
152
151
|
} else {
|
|
153
|
-
|
|
152
|
+
// Pre-commit exists — add tetra-audit quick if missing
|
|
153
|
+
const existing = readFileSync(preCommitPath, 'utf-8')
|
|
154
|
+
if (!existing.includes('tetra-audit')) {
|
|
155
|
+
const updated = existing.trimEnd() + '\n' + tetraAuditBlock
|
|
156
|
+
writeFileSync(preCommitPath, updated)
|
|
157
|
+
console.log(' ✅ Added tetra-audit quick to existing .husky/pre-commit')
|
|
158
|
+
} else {
|
|
159
|
+
console.log(' ⏭️ .husky/pre-commit already has tetra-audit')
|
|
160
|
+
}
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
// Create or extend pre-push hook with hygiene check + RLS security gate
|
|
@@ -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
|
-
*
|
|
2
|
+
* systemDB Usage Audit — Prevent unnecessary Service Role Key usage
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
29
|
+
name: 'systemDB Service Role Key Audit',
|
|
14
30
|
category: 'security',
|
|
15
|
-
severity: '
|
|
16
|
-
description: '
|
|
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
|
-
//
|
|
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
|
-
|
|
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: '
|
|
135
|
+
type: 'whitelist-too-large',
|
|
56
136
|
severity: 'critical',
|
|
57
|
-
message:
|
|
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
|
-
//
|
|
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',
|
|
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(
|
|
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
|
-
|
|
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:
|
|
115
|
-
type: '
|
|
238
|
+
line: lines.findIndex(l => l.includes('systemDB')) + 1,
|
|
239
|
+
type: 'systemdb-with-user-context',
|
|
116
240
|
severity: 'high',
|
|
117
|
-
message: `systemDB
|
|
118
|
-
|
|
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
|
-
//
|
|
258
|
+
// ─── Summary info ──────────────────────────────────────────
|
|
259
|
+
|
|
131
260
|
results.info = {
|
|
132
|
-
whitelistPath: systemDbPath,
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
3
|
+
"version": "1.9.1",
|
|
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"
|