@everystack/cli 0.3.0 → 0.3.4
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/package.json +6 -2
- package/src/cli/audit-api.ts +54 -0
- package/src/cli/authz-compile.ts +186 -34
- package/src/cli/authz-owner-probe.ts +22 -4
- package/src/cli/authz-reconcile.ts +25 -0
- package/src/cli/bundle-audit.ts +185 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +118 -0
- package/src/cli/commands/bundle.ts +452 -0
- package/src/cli/commands/db-authz.ts +13 -5
- package/src/cli/commands/db-generate.ts +79 -1
- package/src/cli/commands/lighthouse.ts +104 -0
- package/src/cli/commands/security-probe.ts +213 -0
- package/src/cli/commands/security.ts +12 -4
- package/src/cli/index.ts +28 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/migration-compile.ts +71 -7
- package/src/cli/migration-generate.ts +4 -1
- package/src/cli/model-api.ts +1 -0
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +4 -1
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-probe.ts +243 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack bundle:analyze [dir]` — client web-bundle size + composition.
|
|
3
|
+
*
|
|
4
|
+
* Run after a web export with source maps:
|
|
5
|
+
* npx expo export -p web --source-maps --output-dir dist
|
|
6
|
+
* everystack bundle:analyze dist
|
|
7
|
+
*
|
|
8
|
+
* Config-free: the JS bundle is located by Expo convention, and composition is
|
|
9
|
+
* derived from the source map itself.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import {
|
|
15
|
+
measureChunks,
|
|
16
|
+
composeSourceMap,
|
|
17
|
+
formatBundleReport,
|
|
18
|
+
type ChunkInput,
|
|
19
|
+
type Composition,
|
|
20
|
+
} from '../bundle.js';
|
|
21
|
+
import {
|
|
22
|
+
scanSecretShapes,
|
|
23
|
+
extractBundleUrls,
|
|
24
|
+
extractAssetUrls,
|
|
25
|
+
extractExtraEnvKeys,
|
|
26
|
+
classifyPrivateEnvKeys,
|
|
27
|
+
scanSecretValue,
|
|
28
|
+
summarize,
|
|
29
|
+
type AuditFinding,
|
|
30
|
+
} from '../bundle-audit.js';
|
|
31
|
+
import {
|
|
32
|
+
scanAssetWeights,
|
|
33
|
+
scanTotalWeight,
|
|
34
|
+
scanJsWeight,
|
|
35
|
+
scanSourceMapsPresent,
|
|
36
|
+
scanDependencyWeight,
|
|
37
|
+
analyzeChunkBloat,
|
|
38
|
+
largestFiles,
|
|
39
|
+
formatBytes,
|
|
40
|
+
resolveWeightBudget,
|
|
41
|
+
classifyAsset,
|
|
42
|
+
DEFAULT_WEIGHT_BUDGET,
|
|
43
|
+
type AssetFile,
|
|
44
|
+
type WeightBudget,
|
|
45
|
+
} from '../bundle-weight.js';
|
|
46
|
+
import { resolveConfig } from '../config.js';
|
|
47
|
+
import { fail, info, step, success, warn } from '../output.js';
|
|
48
|
+
|
|
49
|
+
// Expo Router web export puts client chunks under client/_expo/...; a plain
|
|
50
|
+
// `expo export` omits the client/ prefix. Convention, not config.
|
|
51
|
+
const JS_DIR_CANDIDATES = [
|
|
52
|
+
'client/_expo/static/js/web',
|
|
53
|
+
'_expo/static/js/web',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function resolveJsDir(dir: string): string | null {
|
|
57
|
+
for (const candidate of JS_DIR_CANDIDATES) {
|
|
58
|
+
const abs = path.join(dir, candidate);
|
|
59
|
+
try {
|
|
60
|
+
if (fs.statSync(abs).isDirectory()) return abs;
|
|
61
|
+
} catch {
|
|
62
|
+
// try next candidate
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function bundleAnalyzeCommand(
|
|
69
|
+
positional: string | undefined,
|
|
70
|
+
flags: Record<string, string>,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const dir = positional || flags.dir || 'dist';
|
|
73
|
+
const jsDir = resolveJsDir(dir);
|
|
74
|
+
if (!jsDir) {
|
|
75
|
+
fail(
|
|
76
|
+
`No client JS bundle found under ${dir} (looked for ${JS_DIR_CANDIDATES.join(', ')}).`,
|
|
77
|
+
);
|
|
78
|
+
info('Run `npx expo export -p web --source-maps --output-dir dist` first.');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
step(`Analyzing client bundle in ${jsDir} ...`);
|
|
83
|
+
const files = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js'));
|
|
84
|
+
if (files.length === 0) {
|
|
85
|
+
fail(`No .js chunks in ${jsDir}.`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const chunks: ChunkInput[] = files.map((name) => ({
|
|
90
|
+
name,
|
|
91
|
+
buf: fs.readFileSync(path.join(jsDir, name)),
|
|
92
|
+
}));
|
|
93
|
+
const sized = measureChunks(chunks);
|
|
94
|
+
|
|
95
|
+
// Composition comes from the largest chunk's source map, when present.
|
|
96
|
+
let composition: Composition | null = null;
|
|
97
|
+
const mapPath = path.join(jsDir, sized[0].name + '.map');
|
|
98
|
+
if (fs.existsSync(mapPath)) {
|
|
99
|
+
try {
|
|
100
|
+
composition = composeSourceMap(JSON.parse(fs.readFileSync(mapPath, 'utf8')));
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
info(`(could not parse source map: ${err.message})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('');
|
|
107
|
+
console.log(formatBundleReport(sized, composition).join('\n'));
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// bundle:audit — served-bundle secret-leak scan.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async function fetchText(
|
|
116
|
+
url: string,
|
|
117
|
+
): Promise<{ ok: boolean; status: number; text: string; bytes: number }> {
|
|
118
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
119
|
+
const text = res.ok ? await res.text() : '';
|
|
120
|
+
const cl = Number(res.headers.get('content-length'));
|
|
121
|
+
const bytes = Number.isFinite(cl) && cl > 0 ? cl : Buffer.byteLength(text, 'utf8');
|
|
122
|
+
return { ok: res.ok, status: res.status, text, bytes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Size of a remote asset via HEAD (Content-Length) — measures a large file
|
|
127
|
+
* (e.g. a 180MB JSON) without downloading it. Falls back to a ranged GET when
|
|
128
|
+
* the origin omits Content-Length on HEAD; null if neither works.
|
|
129
|
+
*/
|
|
130
|
+
async function headSize(url: string): Promise<number | null> {
|
|
131
|
+
try {
|
|
132
|
+
const head = await fetch(url, { method: 'HEAD', redirect: 'follow' });
|
|
133
|
+
const cl = Number(head.headers.get('content-length'));
|
|
134
|
+
if (head.ok && Number.isFinite(cl) && cl > 0) return cl;
|
|
135
|
+
} catch {
|
|
136
|
+
// fall through to ranged GET
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(url, { headers: { Range: 'bytes=0-0' }, redirect: 'follow' });
|
|
140
|
+
const range = res.headers.get('content-range'); // e.g. "bytes 0-0/184000000"
|
|
141
|
+
const total = range && range.includes('/') ? Number(range.split('/')[1]) : NaN;
|
|
142
|
+
if (Number.isFinite(total) && total > 0) return total;
|
|
143
|
+
const cl = Number(res.headers.get('content-length'));
|
|
144
|
+
if (Number.isFinite(cl) && cl > 0) return cl;
|
|
145
|
+
} catch {
|
|
146
|
+
// give up — best-effort
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse KEY=VALUE dotenv lines (operator-produced via `everystack secrets export`). */
|
|
152
|
+
function parseDotenv(raw: string): { key: string; value: string }[] {
|
|
153
|
+
const out: { key: string; value: string }[] = [];
|
|
154
|
+
for (const line of raw.split('\n')) {
|
|
155
|
+
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
156
|
+
if (!m) continue;
|
|
157
|
+
let value = m[2].trim();
|
|
158
|
+
if (
|
|
159
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
160
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
161
|
+
) {
|
|
162
|
+
value = value.slice(1, -1);
|
|
163
|
+
}
|
|
164
|
+
out.push({ key: m[1], value });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface BundleAuditResult {
|
|
170
|
+
findings: AuditFinding[];
|
|
171
|
+
chunkCount: number;
|
|
172
|
+
/** Biggest served/exported files, largest first — always shown, gate or not. */
|
|
173
|
+
largest?: AssetFile[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Result-producing core, shared by the command wrapper and the `audit` capstone. */
|
|
177
|
+
export async function runBundleAudit(
|
|
178
|
+
base: string,
|
|
179
|
+
opts: { valuesFrom?: string; weight?: boolean; budget?: WeightBudget } = {},
|
|
180
|
+
): Promise<BundleAuditResult> {
|
|
181
|
+
step(`Fetching ${base} ...`);
|
|
182
|
+
const root = await fetchText(base);
|
|
183
|
+
if (!root.ok) {
|
|
184
|
+
throw new Error(`${base} returned HTTP ${root.status}.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Discover client chunks from the HTML, then follow lazy chunks referenced
|
|
188
|
+
// inside the downloaded JS (Expo code-splitting). Convention-located.
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
const queue = extractBundleUrls(root.text);
|
|
191
|
+
const artifacts: { source: string; text: string }[] = [{ source: 'index.html', text: root.text }];
|
|
192
|
+
// Served file → byte size, for the weight scan (HTML + JS measured from the GET).
|
|
193
|
+
const sizes = new Map<string, number>([['index.html', root.bytes]]);
|
|
194
|
+
while (queue.length) {
|
|
195
|
+
const rel = queue.shift()!;
|
|
196
|
+
if (seen.has(rel)) continue;
|
|
197
|
+
seen.add(rel);
|
|
198
|
+
const chunk = await fetchText(`${base}/${rel}`);
|
|
199
|
+
if (!chunk.ok) {
|
|
200
|
+
warn(`could not fetch ${rel} (HTTP ${chunk.status})`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
artifacts.push({ source: rel, text: chunk.text });
|
|
204
|
+
sizes.set(rel, chunk.bytes);
|
|
205
|
+
for (const next of extractBundleUrls(chunk.text)) {
|
|
206
|
+
if (!seen.has(next)) queue.push(next);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
step(`Scanning ${artifacts.length} artifact(s) (1 HTML + ${seen.size} JS chunk(s)) ...`);
|
|
210
|
+
|
|
211
|
+
const findings: AuditFinding[] = [];
|
|
212
|
+
|
|
213
|
+
// 1. Source maps must not be publicly served.
|
|
214
|
+
const firstChunk = [...seen][0];
|
|
215
|
+
if (firstChunk) {
|
|
216
|
+
const map = await fetchText(`${base}/${firstChunk}.map`);
|
|
217
|
+
if (map.ok) {
|
|
218
|
+
findings.push({
|
|
219
|
+
severity: 'fail',
|
|
220
|
+
check: 'source-map',
|
|
221
|
+
source: `${firstChunk}.map`,
|
|
222
|
+
detail: 'source map is publicly served — exposes original source + comments',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 2. Generic secret shapes + 3. private (non-EXPO_PUBLIC) env keys in extra.
|
|
228
|
+
for (const a of artifacts) {
|
|
229
|
+
findings.push(...scanSecretShapes(a.text, a.source));
|
|
230
|
+
findings.push(...classifyPrivateEnvKeys(extractExtraEnvKeys(a.text), a.source));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 4. Operator-run value canary (reads secret values; never run by Claude).
|
|
234
|
+
if (opts.valuesFrom) {
|
|
235
|
+
step(`Value canary against secrets in ${opts.valuesFrom} ...`);
|
|
236
|
+
const secrets = parseDotenv(fs.readFileSync(path.resolve(opts.valuesFrom), 'utf8'));
|
|
237
|
+
for (const { key, value } of secrets) {
|
|
238
|
+
for (const a of artifacts) {
|
|
239
|
+
const hit = scanSecretValue(a.text, key, value, a.source);
|
|
240
|
+
if (hit) findings.push(hit);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 5. Weight/size gate over the *served* bundle. Static assets (images, data
|
|
246
|
+
// files) are discovered from the crawled text and sized via HEAD — a 180MB
|
|
247
|
+
// data file is gated without downloading it.
|
|
248
|
+
let largest: AssetFile[] | undefined;
|
|
249
|
+
if (opts.weight !== false) {
|
|
250
|
+
const budget = { ...DEFAULT_WEIGHT_BUDGET, ...(opts.budget || {}) };
|
|
251
|
+
const assetRefs = new Set<string>();
|
|
252
|
+
for (const a of artifacts) for (const u of extractAssetUrls(a.text)) assetRefs.add(u);
|
|
253
|
+
step(`Weighing ${seen.size} JS chunk(s) + ${assetRefs.size} static asset(s) ...`);
|
|
254
|
+
for (const rel of assetRefs) {
|
|
255
|
+
const size = await headSize(`${base}/${rel}`);
|
|
256
|
+
if (size != null) sizes.set(rel, size);
|
|
257
|
+
}
|
|
258
|
+
const files: AssetFile[] = [...sizes].map(([p, size]) => ({ path: p, size }));
|
|
259
|
+
findings.push(...scanAssetWeights(files, budget));
|
|
260
|
+
findings.push(...scanTotalWeight(files, budget));
|
|
261
|
+
findings.push(...scanJsWeight(files, budget));
|
|
262
|
+
// Name the data inlined into JS chunks (the most useful diagnosis).
|
|
263
|
+
for (const a of artifacts) {
|
|
264
|
+
if (classifyAsset(a.source) === 'js') {
|
|
265
|
+
findings.push(...analyzeChunkBloat(a.text, a.source, budget));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
largest = largestFiles(files);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { findings, chunkCount: seen.size, largest };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Print bundle-audit findings; returns the number of failures (fail severity). */
|
|
275
|
+
export function reportBundleAudit(result: BundleAuditResult): number {
|
|
276
|
+
const { findings, chunkCount, largest } = result;
|
|
277
|
+
const { fail: fails, warn: warns } = summarize(findings);
|
|
278
|
+
|
|
279
|
+
// Always list the largest files — useful even when nothing trips a budget.
|
|
280
|
+
if (largest && largest.length) {
|
|
281
|
+
info(`Largest files (top ${largest.length}):`);
|
|
282
|
+
for (const f of largest) info(` ${formatBytes(f.size).padStart(10)} ${f.path}`);
|
|
283
|
+
console.log('');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (findings.length === 0) {
|
|
287
|
+
success(`bundle:audit — clean. No leaks or weight issues across ${chunkCount} artifact(s).`);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
for (const f of findings) {
|
|
291
|
+
if (f.severity === 'fail') fail(`[${f.check}] ${f.source}: ${f.detail}`);
|
|
292
|
+
else warn(`[${f.check}] ${f.source}: ${f.detail}`);
|
|
293
|
+
}
|
|
294
|
+
console.log('');
|
|
295
|
+
const summary = `bundle:audit — ${fails} failure(s), ${warns} warning(s)`;
|
|
296
|
+
if (fails > 0) fail(summary);
|
|
297
|
+
else warn(summary);
|
|
298
|
+
return fails;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Filesystem path — scan a local Expo export (leak + weight together).
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** Recursively list every file under `root` as {path (relative), size}. */
|
|
306
|
+
export function walkExport(root: string): AssetFile[] {
|
|
307
|
+
const out: AssetFile[] = [];
|
|
308
|
+
const walk = (abs: string): void => {
|
|
309
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
310
|
+
const childAbs = path.join(abs, entry.name);
|
|
311
|
+
if (entry.isDirectory()) {
|
|
312
|
+
walk(childAbs);
|
|
313
|
+
} else if (entry.isFile()) {
|
|
314
|
+
out.push({
|
|
315
|
+
path: path.relative(root, childAbs),
|
|
316
|
+
size: fs.statSync(childAbs).size,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
walk(root);
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Audit a local Expo export directory: weight/size gates over every file, plus
|
|
327
|
+
* the leak scanners over the on-disk HTML + JS text (no deploy/network needed).
|
|
328
|
+
* This is the gate the governance layer runs before publish.
|
|
329
|
+
*/
|
|
330
|
+
export async function runBundleWeightAudit(
|
|
331
|
+
dir: string,
|
|
332
|
+
opts: { budget?: WeightBudget; valuesFrom?: string } = {},
|
|
333
|
+
): Promise<BundleAuditResult> {
|
|
334
|
+
const root = path.resolve(dir);
|
|
335
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
336
|
+
throw new Error(`${dir} is not a directory — pass an Expo export output dir.`);
|
|
337
|
+
}
|
|
338
|
+
const budget = { ...DEFAULT_WEIGHT_BUDGET, ...(opts.budget || {}) };
|
|
339
|
+
|
|
340
|
+
const files = walkExport(root);
|
|
341
|
+
if (files.length === 0) throw new Error(`${dir} is empty — run \`npx expo export\` first.`);
|
|
342
|
+
step(`Scanning ${files.length} file(s) in ${dir} ...`);
|
|
343
|
+
|
|
344
|
+
const findings: AuditFinding[] = [];
|
|
345
|
+
|
|
346
|
+
// --- weight/size gates ---------------------------------------------------
|
|
347
|
+
findings.push(...scanAssetWeights(files, budget));
|
|
348
|
+
findings.push(...scanTotalWeight(files, budget));
|
|
349
|
+
findings.push(...scanJsWeight(files, budget));
|
|
350
|
+
findings.push(...scanSourceMapsPresent(files));
|
|
351
|
+
|
|
352
|
+
// --- leak gates over on-disk text (HTML + JS) ----------------------------
|
|
353
|
+
const textFiles = files.filter(
|
|
354
|
+
(f) => f.path.endsWith('.html') || classifyAsset(f.path) === 'js',
|
|
355
|
+
);
|
|
356
|
+
for (const f of textFiles) {
|
|
357
|
+
const text = fs.readFileSync(path.join(root, f.path), 'utf8');
|
|
358
|
+
findings.push(...scanSecretShapes(text, f.path));
|
|
359
|
+
findings.push(...classifyPrivateEnvKeys(extractExtraEnvKeys(text), f.path));
|
|
360
|
+
if (classifyAsset(f.path) === 'js') {
|
|
361
|
+
findings.push(...analyzeChunkBloat(text, f.path, budget));
|
|
362
|
+
}
|
|
363
|
+
if (opts.valuesFrom) {
|
|
364
|
+
const secrets = parseDotenv(fs.readFileSync(path.resolve(opts.valuesFrom), 'utf8'));
|
|
365
|
+
for (const { key, value } of secrets) {
|
|
366
|
+
const hit = scanSecretValue(text, key, value, f.path);
|
|
367
|
+
if (hit) findings.push(hit);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// --- dependency weight (best-effort, from the largest chunk's source map) -
|
|
373
|
+
const jsDir = resolveJsDir(root);
|
|
374
|
+
if (jsDir) {
|
|
375
|
+
const jsFiles = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js'));
|
|
376
|
+
if (jsFiles.length) {
|
|
377
|
+
const sized = measureChunks(
|
|
378
|
+
jsFiles.map((name) => ({ name, buf: fs.readFileSync(path.join(jsDir, name)) })),
|
|
379
|
+
);
|
|
380
|
+
const mapPath = path.join(jsDir, sized[0].name + '.map');
|
|
381
|
+
if (fs.existsSync(mapPath)) {
|
|
382
|
+
try {
|
|
383
|
+
const composition = composeSourceMap(JSON.parse(fs.readFileSync(mapPath, 'utf8')));
|
|
384
|
+
findings.push(...scanDependencyWeight(composition, budget));
|
|
385
|
+
} catch {
|
|
386
|
+
// unparseable map — dep-weight is best-effort, skip silently
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return { findings, chunkCount: files.length, largest: largestFiles(files) };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function bundleAuditCommand(
|
|
396
|
+
positional: string | undefined,
|
|
397
|
+
flags: Record<string, string>,
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
// Local-export mode: --dir <export> (or a positional that is a directory) runs
|
|
400
|
+
// leak + weight gates over the export on disk — no deploy needed.
|
|
401
|
+
const localDir =
|
|
402
|
+
flags.dir ||
|
|
403
|
+
(positional && fs.existsSync(positional) && fs.statSync(positional).isDirectory()
|
|
404
|
+
? positional
|
|
405
|
+
: undefined);
|
|
406
|
+
if (localDir) {
|
|
407
|
+
let result: BundleAuditResult;
|
|
408
|
+
try {
|
|
409
|
+
result = await runBundleWeightAudit(localDir, {
|
|
410
|
+
budget: resolveWeightBudget(flags),
|
|
411
|
+
valuesFrom: flags['values-from'],
|
|
412
|
+
});
|
|
413
|
+
} catch (err: any) {
|
|
414
|
+
fail(err.message);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
console.log('');
|
|
418
|
+
process.exit(reportBundleAudit(result) > 0 ? 1 : 0);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let target = positional || flags.url;
|
|
422
|
+
if (!target) {
|
|
423
|
+
try {
|
|
424
|
+
target = (await resolveConfig(flags.stage)).baseUrl;
|
|
425
|
+
} catch (err: any) {
|
|
426
|
+
fail(err.message);
|
|
427
|
+
info('Pass a URL positionally or use --stage <name>.');
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const base = target.replace(/\/+$/, '');
|
|
432
|
+
|
|
433
|
+
if (!flags['values-from'] && flags.values) {
|
|
434
|
+
info('Value canary: run `everystack secrets export --stage <name> > .env` then re-run with --values-from .env');
|
|
435
|
+
info('(everystack never reads secret values itself; the export is your explicit step.)');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let result: BundleAuditResult;
|
|
439
|
+
try {
|
|
440
|
+
result = await runBundleAudit(base, {
|
|
441
|
+
valuesFrom: flags['values-from'],
|
|
442
|
+
weight: !flags['leak-only'],
|
|
443
|
+
budget: resolveWeightBudget(flags),
|
|
444
|
+
});
|
|
445
|
+
} catch (err: any) {
|
|
446
|
+
fail(err.message);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
console.log('');
|
|
451
|
+
process.exit(reportBundleAudit(result) > 0 ? 1 : 0);
|
|
452
|
+
}
|
|
@@ -243,14 +243,20 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
243
243
|
const schema = flags.schema || 'public';
|
|
244
244
|
|
|
245
245
|
let probes: OwnerProbe[];
|
|
246
|
+
let publicReadTables = new Set<string>();
|
|
246
247
|
let rows: any[];
|
|
247
248
|
try {
|
|
248
249
|
step(`Loading models from ${modelsPath}...`);
|
|
249
250
|
const models = await loadModels(modelsPath);
|
|
250
|
-
|
|
251
|
-
.map((m) => modelOwner(m, { schema }))
|
|
252
|
-
.filter((
|
|
253
|
-
|
|
251
|
+
const ownerModels = models
|
|
252
|
+
.map((m) => ({ m, owner: modelOwner(m, { schema }) }))
|
|
253
|
+
.filter((x): x is { m: (typeof models)[number]; owner: NonNullable<typeof x.owner> } => x.owner !== null);
|
|
254
|
+
// A table with a public read (`can('read')`, no owner/role/via) is intentionally not
|
|
255
|
+
// reader-isolated — flag it so the probe reports its cross-owner reads as expected, not IDOR.
|
|
256
|
+
const isPublicRead = (m: (typeof models)[number]): boolean =>
|
|
257
|
+
m.abilities.some((a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via);
|
|
258
|
+
publicReadTables = new Set(ownerModels.filter(({ m }) => isPublicRead(m)).map(({ owner }) => owner.table));
|
|
259
|
+
probes = ownerModels.map(({ owner }) => ({ table: owner.table, ownerColumn: owner.ownerColumn, claim: owner.claim }));
|
|
254
260
|
info(`${probes.length} owner-scoped model(s).`);
|
|
255
261
|
if (probes.length === 0) {
|
|
256
262
|
success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
|
|
@@ -272,14 +278,16 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
|
|
|
272
278
|
process.exit(1);
|
|
273
279
|
}
|
|
274
280
|
|
|
275
|
-
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult));
|
|
281
|
+
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
|
|
276
282
|
const leaks = findings.filter((f) => f.severity === 'read-leak' || f.severity === 'write-leak');
|
|
277
283
|
const vacuous = findings.filter((f) => f.severity === 'vacuous');
|
|
278
284
|
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
285
|
+
const publicReads = findings.filter((f) => f.severity === 'public-read');
|
|
279
286
|
|
|
280
287
|
console.log('');
|
|
281
288
|
for (const f of leaks) fail(`[LEAK] ${f.detail}`);
|
|
282
289
|
for (const f of vacuous) fail(`[VACUOUS] ${f.detail}`);
|
|
290
|
+
for (const f of publicReads) info(`[public] ${f.detail}`);
|
|
283
291
|
for (const f of unprobed) info(`[unprobed] ${f.detail}`);
|
|
284
292
|
console.log('');
|
|
285
293
|
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
import fs from 'node:fs/promises';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
|
-
import type { ModelDescriptor } from '@everystack/model';
|
|
23
|
+
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
24
24
|
import { introspectSchema } from '../schema-introspect.js';
|
|
25
|
+
import { compileDrizzleSource } from '../schema-source.js';
|
|
26
|
+
import { compileModuleMigration } from '../migration-compile.js';
|
|
25
27
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
26
28
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
27
29
|
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
@@ -31,6 +33,7 @@ import { step, success, fail, info, warn } from '../output.js';
|
|
|
31
33
|
|
|
32
34
|
const DEFAULT_MODELS = 'models/index.ts';
|
|
33
35
|
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
36
|
+
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
34
37
|
|
|
35
38
|
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
36
39
|
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
@@ -61,12 +64,82 @@ async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
|
61
64
|
return models;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Import the app's barrel and return its `modules` array — the greenfield (`--init`) input. A
|
|
69
|
+
* Module carries the package's extensions + functions on top of its models, so it can emit the
|
|
70
|
+
* COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
|
|
71
|
+
* older models-only barrel still gets a schema+authz init, just no package functions).
|
|
72
|
+
*/
|
|
73
|
+
async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
74
|
+
const abs = path.resolve(modelsPath);
|
|
75
|
+
let mod: any;
|
|
76
|
+
try {
|
|
77
|
+
mod = await import(pathToFileURL(abs).href);
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(mod.modules)) return mod.modules;
|
|
82
|
+
const models = mod.models ?? mod.default;
|
|
83
|
+
if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
|
|
84
|
+
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `db:generate --init` — the greenfield generator. Compiles the app's Modules into ONE
|
|
89
|
+
* complete migration (extensions + schema + authz + package functions/triggers) with NO live
|
|
90
|
+
* database — the model IS the source. Writes it as the next migration in a (typically empty)
|
|
91
|
+
* `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
|
|
92
|
+
* a clean migration history: clear `drizzle/`, then `db:generate --init`.
|
|
93
|
+
*/
|
|
94
|
+
async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
|
|
95
|
+
step(`Loading modules from ${modelsPath}...`);
|
|
96
|
+
const modules = await loadModules(modelsPath);
|
|
97
|
+
const models = modules.flatMap((m) => m.models);
|
|
98
|
+
info(`${modules.length} module(s), ${models.length} model(s).`);
|
|
99
|
+
|
|
100
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
101
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
102
|
+
|
|
103
|
+
const statements = compileModuleMigration(modules);
|
|
104
|
+
|
|
105
|
+
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
106
|
+
let journal: Journal;
|
|
107
|
+
try {
|
|
108
|
+
journal = JSON.parse(await fs.readFile(journalPath, 'utf8'));
|
|
109
|
+
} catch {
|
|
110
|
+
journal = { version: '7', dialect: 'postgresql', entries: [] };
|
|
111
|
+
}
|
|
112
|
+
const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
|
|
113
|
+
const filePath = path.join(migrationsDir, filename);
|
|
114
|
+
await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
|
|
115
|
+
await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
|
|
116
|
+
await fs.writeFile(journalPath, JSON.stringify(nextJournal, null, 2) + '\n', 'utf8');
|
|
117
|
+
|
|
118
|
+
console.log('');
|
|
119
|
+
success(`Wrote ${path.relative(process.cwd(), filePath)} (${statements.length} statement(s)) — the complete greenfield migration.`);
|
|
120
|
+
warn('Roles are NOT in this migration — run `everystack db:provision` (the role chain) before `db:migrate`.');
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
64
124
|
export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
|
|
65
125
|
const modelsPath = flags.models || DEFAULT_MODELS;
|
|
66
126
|
const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
|
|
127
|
+
const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
|
|
67
128
|
const name = flags.name || 'generated';
|
|
68
129
|
const allowDrops = flags['allow-drops'] === 'true';
|
|
69
130
|
|
|
131
|
+
// --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
|
|
132
|
+
// authz + package functions). The brownfield path below introspects the deployed DB and diffs.
|
|
133
|
+
if (flags.init === 'true') {
|
|
134
|
+
try {
|
|
135
|
+
await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
fail(err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
70
143
|
let models: ModelDescriptor[];
|
|
71
144
|
let current;
|
|
72
145
|
let liveAuthz;
|
|
@@ -74,6 +147,11 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
74
147
|
step(`Loading models from ${modelsPath}...`);
|
|
75
148
|
models = await loadModels(modelsPath);
|
|
76
149
|
info(`${models.length} model(s).`);
|
|
150
|
+
// The drizzle runtime schema is a derived artifact — refresh it from the models
|
|
151
|
+
// every run (it needs no DB), so a relation-only change with no DDL diff still
|
|
152
|
+
// updates it. The drift-guard test asserts this file matches the models.
|
|
153
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
154
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
77
155
|
step('Resolving deployed config...');
|
|
78
156
|
const config = await resolveConfig(flags.stage);
|
|
79
157
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|