@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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs N] [--path /]`
3
+ *
4
+ * Mobile Lighthouse audit (Perf / A11y / Best-Practices / SEO) reported as a
5
+ * distribution over N runs, because the Performance score swings run-to-run with
6
+ * LCP. Warms the CloudFront edge first, then measures the warm-HTML path.
7
+ *
8
+ * Config-free: the target URL is the deployed router URL from SST outputs
9
+ * (resolveConfig), or --host. Requires `npx lighthouse` and a headless Chrome.
10
+ */
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import {
17
+ CATEGORIES,
18
+ extractRun,
19
+ aggregate,
20
+ formatLighthouseReport,
21
+ type ExtractedRun,
22
+ type LhReport,
23
+ } from '../lighthouse.js';
24
+ import { resolveConfig } from '../config.js';
25
+ import { step, info, fail, warn } from '../output.js';
26
+
27
+ function runLighthouse(url: string, outPath: string): LhReport {
28
+ // Warm the edge so we measure the real-user (cached-HTML) experience.
29
+ try {
30
+ execFileSync('curl', ['-s', '-o', '/dev/null', url], { timeout: 30_000 });
31
+ } catch {
32
+ // warming is best-effort
33
+ }
34
+ execFileSync(
35
+ 'npx',
36
+ [
37
+ 'lighthouse',
38
+ url,
39
+ `--only-categories=${CATEGORIES.join(',')}`,
40
+ '--chrome-flags=--headless=new --no-sandbox',
41
+ '--output=json',
42
+ `--output-path=${outPath}`,
43
+ '--quiet',
44
+ ],
45
+ { stdio: ['ignore', 'ignore', 'ignore'], timeout: 120_000 },
46
+ );
47
+ return JSON.parse(readFileSync(outPath, 'utf8'));
48
+ }
49
+
50
+ /** Run Lighthouse N times and return the per-run extracts. Shared with the
51
+ * `audit` capstone. */
52
+ export async function collectLighthouseRuns(url: string, runs: number): Promise<ExtractedRun[]> {
53
+ const tmp = mkdtempSync(join(tmpdir(), 'everystack-lh-'));
54
+ const extracts: ExtractedRun[] = [];
55
+ for (let i = 0; i < runs; i++) {
56
+ step(`Lighthouse run ${i + 1}/${runs} on ${url} ...`);
57
+ try {
58
+ const report = runLighthouse(url, join(tmp, `lh-${i}.json`));
59
+ extracts.push(extractRun(report));
60
+ } catch (err: any) {
61
+ warn(`run ${i + 1} failed: ${err.message?.split('\n')[0] ?? err}`);
62
+ }
63
+ }
64
+ return extracts;
65
+ }
66
+
67
+ export async function auditLighthouseCommand(flags: Record<string, string>): Promise<void> {
68
+ let host = flags.host;
69
+ if (!host) {
70
+ try {
71
+ const config = await resolveConfig(flags.stage);
72
+ host = config.baseUrl;
73
+ } catch (err: any) {
74
+ fail(err.message);
75
+ info('Pass --host <url> to audit a specific URL without resolving a stage.');
76
+ process.exit(1);
77
+ }
78
+ }
79
+ if (!host) {
80
+ fail('No URL to audit. Pass --host <url> or --stage <name>.');
81
+ process.exit(1);
82
+ }
83
+
84
+ const runs = flags.runs ? parseInt(flags.runs, 10) : 3;
85
+ const pathName = flags.path || '/';
86
+ const url = host.replace(/\/+$/, '') + (pathName.startsWith('/') ? pathName : '/' + pathName);
87
+
88
+ const extracts = await collectLighthouseRuns(url, runs);
89
+
90
+ if (extracts.length === 0) {
91
+ fail('All Lighthouse runs failed. Is `npx lighthouse` installed and Chrome available?');
92
+ process.exit(1);
93
+ }
94
+
95
+ const agg = aggregate(extracts);
96
+ console.log('');
97
+ console.log(formatLighthouseReport(url, extracts.length, agg).join('\n'));
98
+
99
+ if (flags.json) {
100
+ writeFileSync(flags.json, JSON.stringify(agg, null, 2));
101
+ info(`Raw results → ${flags.json}`);
102
+ }
103
+ process.exit(0);
104
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `everystack security:probe --stage <name>` — live black-box security probe.
3
+ *
4
+ * Config-free. Two instruments meeting in the middle:
5
+ * 1. White-box introspection: reads the live DB catalog (grants, columns, RLS)
6
+ * via the ops-Lambda `db:query` path to learn what the deployed app exposes.
7
+ * 2. Black-box probing: hits the deployed router URL and asserts the surface
8
+ * behaves safely against that discovered truth.
9
+ *
10
+ * No fixtures, no config — the plan is derived from the database itself. When the
11
+ * v3 Model lands, the plan source moves from catalog to the Model's abilities and
12
+ * this command becomes the generated `diff(declared, live)` red-team.
13
+ */
14
+
15
+ import {
16
+ buildProbePlan,
17
+ assessRlsCoverage,
18
+ assessColumnExposure,
19
+ assessLimitCap,
20
+ assessInjection,
21
+ assessEmbedDepth,
22
+ summarizeProbe,
23
+ type Catalog,
24
+ type CatalogTable,
25
+ type CatalogColumn,
26
+ type CatalogGrant,
27
+ type ProbeResult,
28
+ } from '../security-probe.js';
29
+ import { resolveConfig, opsFunction } from '../config.js';
30
+ import { invokeAction } from '../aws.js';
31
+ import { step, info, fail, success, warn } from '../output.js';
32
+
33
+ const SYS_SCHEMAS = "('pg_catalog','information_schema','pg_toast')";
34
+
35
+ const TABLES_SQL = `
36
+ SELECT n.nspname AS schema, c.relname AS "table", c.relrowsecurity AS rls_enabled
37
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
38
+ WHERE c.relkind = 'r' AND n.nspname NOT IN ${SYS_SCHEMAS}`;
39
+
40
+ const COLUMNS_SQL = `
41
+ SELECT table_schema AS schema, table_name AS "table", column_name AS "column", data_type
42
+ FROM information_schema.columns
43
+ WHERE table_schema NOT IN ${SYS_SCHEMAS}`;
44
+
45
+ const GRANTS_SQL = `
46
+ SELECT table_schema AS schema, table_name AS "table", grantee, privilege_type AS privilege
47
+ FROM information_schema.role_table_grants
48
+ WHERE table_schema NOT IN ${SYS_SCHEMAS}`;
49
+
50
+ async function catalogQuery(region: string, fn: string, sql: string): Promise<any[]> {
51
+ const result: any = await invokeAction(region, fn, 'db:query', { sql });
52
+ if (result?.error) throw new Error(`Catalog query failed: ${result.error}`);
53
+ return result?.rows ?? [];
54
+ }
55
+
56
+ function toCatalog(tables: any[], columns: any[], grants: any[]): Catalog {
57
+ return {
58
+ tables: tables.map(
59
+ (r): CatalogTable => ({ schema: r.schema, table: r.table, rlsEnabled: r.rls_enabled === true }),
60
+ ),
61
+ columns: columns.map(
62
+ (r): CatalogColumn => ({ schema: r.schema, table: r.table, column: r.column, dataType: r.data_type }),
63
+ ),
64
+ grants: grants.map(
65
+ (r): CatalogGrant => ({ schema: r.schema, table: r.table, grantee: r.grantee, privilege: r.privilege }),
66
+ ),
67
+ };
68
+ }
69
+
70
+ interface GetResult {
71
+ status: number;
72
+ rows: Record<string, unknown>[];
73
+ }
74
+
75
+ async function apiGet(base: string, pathAndQuery: string): Promise<GetResult> {
76
+ const res = await fetch(`${base}${pathAndQuery}`, { redirect: 'follow' });
77
+ let rows: Record<string, unknown>[] = [];
78
+ try {
79
+ const body = await res.json();
80
+ if (Array.isArray(body)) rows = body;
81
+ else if (Array.isArray((body as any)?.data)) rows = (body as any).data;
82
+ } catch {
83
+ // non-JSON body
84
+ }
85
+ return { status: res.status, rows };
86
+ }
87
+
88
+ export interface ProbeRunOptions {
89
+ region: string;
90
+ opsFn: string;
91
+ base: string;
92
+ maxLimit?: number;
93
+ }
94
+
95
+ /** Result-producing core, shared by the command wrapper and the `audit` capstone. */
96
+ export async function runSecurityProbe(opts: ProbeRunOptions): Promise<ProbeResult[]> {
97
+ const { region, opsFn, base } = opts;
98
+
99
+ step('Introspecting database catalog (grants, columns, RLS)...');
100
+ const [t, c, g] = await Promise.all([
101
+ catalogQuery(region, opsFn, TABLES_SQL),
102
+ catalogQuery(region, opsFn, COLUMNS_SQL),
103
+ catalogQuery(region, opsFn, GRANTS_SQL),
104
+ ]);
105
+ const plan = buildProbePlan(toCatalog(t, c, g));
106
+ info(
107
+ `Surface: ${plan.anonReadableTables.length} anon-readable, ${plan.authReadableTables.length} auth-readable table(s).`,
108
+ );
109
+
110
+ const results: ProbeResult[] = [];
111
+ const maxLimit = opts.maxLimit ?? 10000;
112
+
113
+ // 1. RLS coverage — pure, from the catalog (no HTTP).
114
+ results.push(...assessRlsCoverage(plan));
115
+
116
+ // 2. Sensitive-column exposure — anon GET each readable table that has them.
117
+ step('Probing anon column exposure...');
118
+ for (const table of plan.anonReadableTables) {
119
+ const sensitive = plan.sensitiveColumns[table];
120
+ if (!sensitive?.length) continue;
121
+ try {
122
+ const { status, rows } = await apiGet(base, `/api/${table}?limit=5`);
123
+ if (status === 404) {
124
+ warn(`/api/${table} → 404 (route naming differs from DB table; skipped)`);
125
+ continue;
126
+ }
127
+ results.push(assessColumnExposure(table, rows, sensitive));
128
+ } catch (err: any) {
129
+ warn(`column-exposure ${table} inconclusive: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ // 3/4/5 — pick a representative anon-readable table for the protocol probes.
134
+ const probeTable = plan.anonReadableTables[0];
135
+ if (probeTable) {
136
+ step(`Probing query protocol on /api/${probeTable}...`);
137
+ try {
138
+ const cap = await apiGet(base, `/api/${probeTable}?limit=999999999`);
139
+ results.push(assessLimitCap(cap.status, cap.rows.length, maxLimit));
140
+ } catch (err: any) {
141
+ warn(`limit-cap inconclusive: ${err.message}`);
142
+ }
143
+
144
+ const jsonCol = plan.jsonbColumns[probeTable]?.[0];
145
+ if (jsonCol) {
146
+ try {
147
+ const payload = encodeURIComponent("' OR '1'='1");
148
+ const inj = await apiGet(base, `/api/${probeTable}?${jsonCol}->>x=eq.${payload}`);
149
+ results.push(assessInjection(inj.status));
150
+ } catch (err: any) {
151
+ warn(`json-path-injection inconclusive: ${err.message}`);
152
+ }
153
+ }
154
+
155
+ try {
156
+ const deep = 'select=*,a(*,b(*,c(*,d(*,e(*)))))';
157
+ const depth = await apiGet(base, `/api/${probeTable}?${deep}`);
158
+ results.push(assessEmbedDepth(depth.status));
159
+ } catch (err: any) {
160
+ warn(`embed-depth inconclusive: ${err.message}`);
161
+ }
162
+ }
163
+
164
+ return results;
165
+ }
166
+
167
+ /** Print probe results; returns the number of failures (for exit-code logic). */
168
+ export function reportProbeResults(results: ProbeResult[]): number {
169
+ for (const r of results) {
170
+ const tag = `[${r.severity}]`;
171
+ if (r.pass) success(`${tag} ${r.name} — ${r.detail}`);
172
+ else fail(`${tag} ${r.name} — ${r.detail}`);
173
+ }
174
+ const s = summarizeProbe(results);
175
+ console.log('');
176
+ const summary = `security:probe — ${s.pass} passed, ${s.fail} failed (${s.critical} critical)`;
177
+ if (s.fail > 0) fail(summary);
178
+ else success(summary);
179
+ return s.fail;
180
+ }
181
+
182
+ export async function securityProbeCommand(flags: Record<string, string>): Promise<void> {
183
+ step('Resolving deployed config...');
184
+ let config;
185
+ try {
186
+ config = await resolveConfig(flags.stage);
187
+ } catch (err: any) {
188
+ fail(err.message);
189
+ process.exit(1);
190
+ }
191
+ const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
192
+ if (!base) {
193
+ fail('No router URL to probe. Pass --host or ensure the stage has a deployed URL.');
194
+ process.exit(1);
195
+ }
196
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}, URL: ${base}`);
197
+
198
+ let results: ProbeResult[];
199
+ try {
200
+ results = await runSecurityProbe({
201
+ region: config.region,
202
+ opsFn: opsFunction(config),
203
+ base,
204
+ maxLimit: flags.maxLimit ? Number(flags.maxLimit) : undefined,
205
+ });
206
+ } catch (err: any) {
207
+ fail(err.message);
208
+ process.exit(1);
209
+ }
210
+
211
+ console.log('');
212
+ process.exit(reportProbeResults(results) > 0 ? 1 : 0);
213
+ }
@@ -104,10 +104,18 @@ async function runCatalogAudit(flags: Record<string, string>, waivers: Waivers):
104
104
  step('Resolving deployed config...');
105
105
  const config = await resolveConfig(flags.stage);
106
106
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
107
+ return auditDeployedSql(config.region, opsFunction(config), waivers);
108
+ }
107
109
 
110
+ /** Catalog-audit core, shared by the command and the `audit` capstone. */
111
+ export async function auditDeployedSql(
112
+ region: string,
113
+ opsFn: string,
114
+ waivers: Waivers = {},
115
+ ): Promise<AuditReport> {
108
116
  step('Introspecting database catalog (functions + relations)...');
109
- const fnRows = await catalogQuery(config.region, opsFunction(config), FUNCTIONS_SQL);
110
- const relRows = await catalogQuery(config.region, opsFunction(config), RELATIONS_SQL);
117
+ const fnRows = await catalogQuery(region, opsFn, FUNCTIONS_SQL);
118
+ const relRows = await catalogQuery(region, opsFn, RELATIONS_SQL);
111
119
 
112
120
  const functions: FunctionDescriptor[] = fnRows.map(catalogFunctionToDescriptor);
113
121
  const views: ViewDescriptor[] = relRows.map(catalogRelationToDescriptor);
@@ -125,7 +133,7 @@ async function catalogQuery(region: string, fn: string, sql: string): Promise<an
125
133
  // Waivers + reporting.
126
134
  // ---------------------------------------------------------------------------
127
135
 
128
- async function loadWaivers(flag?: string): Promise<Waivers> {
136
+ export async function loadWaivers(flag?: string): Promise<Waivers> {
129
137
  const candidates = flag ? [flag] : WAIVER_FILES;
130
138
  for (const candidate of candidates) {
131
139
  const abs = path.resolve(candidate);
@@ -141,7 +149,7 @@ async function loadWaivers(flag?: string): Promise<Waivers> {
141
149
  return {};
142
150
  }
143
151
 
144
- function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
152
+ export function printReport(report: AuditReport, instrument: 'static' | 'catalog'): void {
145
153
  const reds = [
146
154
  ...report.functions.filter((f) => f.severity === 'red'),
147
155
  ...report.views.filter((v) => v.severity === 'red'),
package/src/cli/index.ts CHANGED
@@ -21,6 +21,10 @@ import { analyzeSSRCommand } from './commands/analyze.js';
21
21
  import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
22
22
  import { secretsCommand } from './commands/secrets.js';
23
23
  import { securityAuditCommand } from './commands/security.js';
24
+ import { securityProbeCommand } from './commands/security-probe.js';
25
+ import { bundleAnalyzeCommand, bundleAuditCommand } from './commands/bundle.js';
26
+ import { auditLighthouseCommand } from './commands/lighthouse.js';
27
+ import { auditCommand } from './commands/audit.js';
24
28
  import { fail } from './output.js';
25
29
 
26
30
  const args = process.argv.slice(2);
@@ -206,6 +210,25 @@ async function main() {
206
210
  case 'security:audit':
207
211
  await securityAuditCommand(flags);
208
212
  break;
213
+ case 'security:probe':
214
+ await securityProbeCommand(flags);
215
+ break;
216
+ case 'bundle:analyze': {
217
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
218
+ await bundleAnalyzeCommand(positional, flags);
219
+ break;
220
+ }
221
+ case 'audit':
222
+ await auditCommand(flags);
223
+ break;
224
+ case 'audit:lighthouse':
225
+ await auditLighthouseCommand(flags);
226
+ break;
227
+ case 'bundle:audit': {
228
+ const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
229
+ await bundleAuditCommand(positional, flags);
230
+ break;
231
+ }
209
232
  case 'secrets': {
210
233
  // secrets <subcommand> [positional...] [--flags]
211
234
  // Extract positional args: everything after subcommand that isn't a flag or flag value
@@ -262,6 +285,11 @@ Usage:
262
285
  everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
263
286
  everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
264
287
  everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
288
+ everystack security:probe --stage <name> [--host <url>] Live black-box probe: introspects RLS/grants, then tests the deployed surface (RLS coverage, column exposure, injection, limit cap, embed depth)
289
+ everystack audit --stage <name> [--host <url>] [--dir <export>] [--lighthouse] Capstone: security:audit + security:probe + bundle:audit (+ optional lighthouse), combined report + exit code (CI gate); --dir adds the weight/size gate over a local export
290
+ everystack bundle:analyze [dir] Client web-bundle size + composition (run after \`expo export -p web --source-maps\`)
291
+ everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs 3] [--path /] [--json out.json] Mobile Lighthouse (perf/a11y/bp/seo), median over N runs
292
+ everystack bundle:audit [url|--dir <export>] [--stage <name>] [--values-from <.env>] [--leak-only] [--max-file-mb N] [--max-total-mb N] [--max-js-mb N] [--max-data-mb N] Audit a bundle for leaked secrets AND weight (oversized assets, data that belongs in the DB, JS bloat). A URL/stage weighs the remote bundle (assets sized via HEAD, no download); --dir scans a local export; --leak-only skips the weight gate
265
293
  everystack certs:generate [--output ./certs]
266
294
  everystack certs:configure [--input ./certs] [--keyid main]
267
295
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Lighthouse audit — pure, Lighthouse-free parsing/aggregation logic for
3
+ * `everystack audit:lighthouse`.
4
+ *
5
+ * Why N runs: the category scores are stable but Performance swings run-to-run
6
+ * because LCP does under throttled-mobile simulation. A single run can read 92
7
+ * or 65. We run each host several times and report the DISTRIBUTION (median +
8
+ * min/max) — the median is the honest number, the spread shows how noisy LCP is.
9
+ *
10
+ * Measurement rule (in the command): warm the CloudFront edge first, then
11
+ * measure WITHOUT a cache-buster, so we score the real-user warm-HTML path, not
12
+ * a pessimistic cold-origin SSR render.
13
+ *
14
+ * Config-free: the target URL comes from the deployed SST outputs (or --host),
15
+ * never a hand-maintained env map.
16
+ */
17
+
18
+ export const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'] as const;
19
+ export const METRICS = [
20
+ 'first-contentful-paint',
21
+ 'largest-contentful-paint',
22
+ 'total-blocking-time',
23
+ 'cumulative-layout-shift',
24
+ 'speed-index',
25
+ ] as const;
26
+
27
+ export type Category = (typeof CATEGORIES)[number];
28
+ export type Metric = (typeof METRICS)[number];
29
+
30
+ export interface LhAudit {
31
+ title: string;
32
+ score: number | null;
33
+ numericValue?: number;
34
+ scoreDisplayMode?: string;
35
+ }
36
+
37
+ export interface LhReport {
38
+ categories: Record<string, { score: number; auditRefs: { id: string }[] }>;
39
+ audits: Record<string, LhAudit>;
40
+ }
41
+
42
+ export interface ExtractedRun {
43
+ scores: Record<string, number>; // category → 0..100
44
+ metrics: Record<string, number>; // metric → numericValue
45
+ imperfect: { id: string; title: string; cat: string }[];
46
+ }
47
+
48
+ export interface Aggregate {
49
+ scores: Record<string, number[]>;
50
+ metrics: Record<string, number[]>;
51
+ failing: { id: string; title: string; cat: string; count: number }[];
52
+ noindex: boolean;
53
+ }
54
+
55
+ export function median(xs: number[]): number {
56
+ const s = [...xs].sort((a, b) => a - b);
57
+ const m = Math.floor(s.length / 2);
58
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
59
+ }
60
+
61
+ /** Pull scores, metrics, and imperfect audits out of one Lighthouse report. */
62
+ export function extractRun(report: LhReport): ExtractedRun {
63
+ const scores: Record<string, number> = {};
64
+ const metrics: Record<string, number> = {};
65
+ const imperfect: { id: string; title: string; cat: string }[] = [];
66
+
67
+ for (const c of CATEGORIES) {
68
+ scores[c] = Math.round((report.categories[c]?.score ?? 0) * 100);
69
+ }
70
+ for (const m of METRICS) {
71
+ metrics[m] = report.audits[m]?.numericValue ?? 0;
72
+ }
73
+ for (const c of CATEGORIES) {
74
+ for (const ref of report.categories[c]?.auditRefs ?? []) {
75
+ const au = report.audits[ref.id];
76
+ if (au && au.score !== null && au.score < 1 && au.scoreDisplayMode !== 'informative') {
77
+ imperfect.push({ id: ref.id, title: au.title, cat: c });
78
+ }
79
+ }
80
+ }
81
+ return { scores, metrics, imperfect };
82
+ }
83
+
84
+ /** Aggregate the per-run extracts into score/metric distributions + flags. */
85
+ export function aggregate(runs: ExtractedRun[]): Aggregate {
86
+ const scores: Record<string, number[]> = Object.fromEntries(CATEGORIES.map((c) => [c, []]));
87
+ const metrics: Record<string, number[]> = Object.fromEntries(METRICS.map((m) => [m, []]));
88
+ const failingMap = new Map<string, { title: string; cat: string; count: number }>();
89
+ let noindex = false;
90
+
91
+ for (const run of runs) {
92
+ for (const c of CATEGORIES) scores[c].push(run.scores[c] ?? 0);
93
+ for (const m of METRICS) metrics[m].push(run.metrics[m] ?? 0);
94
+ for (const imp of run.imperfect) {
95
+ if (imp.id === 'is-crawlable') noindex = true;
96
+ const e = failingMap.get(imp.id) || { title: imp.title, cat: imp.cat, count: 0 };
97
+ e.count++;
98
+ failingMap.set(imp.id, e);
99
+ }
100
+ }
101
+
102
+ const failing = [...failingMap.entries()]
103
+ .map(([id, e]) => ({ id, ...e }))
104
+ .sort((a, b) => b.count - a.count);
105
+ return { scores, metrics, failing, noindex };
106
+ }
107
+
108
+ /** extractRun + aggregate over a set of raw reports. */
109
+ export function summarize(reports: LhReport[]): Aggregate {
110
+ return aggregate(reports.map(extractRun));
111
+ }
112
+
113
+ function fmtMs(v: number): string {
114
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`;
115
+ }
116
+
117
+ export function formatLighthouseReport(url: string, runs: number, agg: Aggregate): string[] {
118
+ const lines: string[] = [];
119
+ lines.push('='.repeat(64));
120
+ lines.push(`${url} (${runs} run${runs === 1 ? '' : 's'})`);
121
+ lines.push('='.repeat(64));
122
+ lines.push('Category median runs');
123
+ for (const c of CATEGORIES) {
124
+ const xs = agg.scores[c];
125
+ const spread = xs.length > 1 ? ` [${Math.min(...xs)}–${Math.max(...xs)}]` : '';
126
+ lines.push(` ${c.padEnd(14)} ${String(median(xs)).padStart(3)} ${xs.join(' ')}${spread}`);
127
+ }
128
+ lines.push('');
129
+ lines.push('Metric median spread');
130
+ for (const m of METRICS) {
131
+ const xs = agg.metrics[m];
132
+ const cls = m === 'cumulative-layout-shift';
133
+ const f = cls ? (v: number) => v.toFixed(3) : fmtMs;
134
+ lines.push(` ${m.padEnd(24)} ${f(median(xs)).padStart(7)} ${f(Math.min(...xs))}–${f(Math.max(...xs))}`);
135
+ }
136
+ if (agg.failing.length) {
137
+ lines.push('');
138
+ lines.push('Imperfect audits (count = runs flagged):');
139
+ for (const e of agg.failing) {
140
+ const note =
141
+ e.id === 'is-crawlable'
142
+ ? ' ← expected when the stage is noindex (true SEO only on the indexable domain)'
143
+ : '';
144
+ lines.push(` [${e.cat}] ${e.id} (${e.count}/${runs}) — ${e.title}${note}`);
145
+ }
146
+ }
147
+ if (agg.noindex) {
148
+ lines.push('');
149
+ lines.push(
150
+ 'NOTE: is-crawlable fails because this stage is noindex (robots Disallow: /).',
151
+ );
152
+ lines.push(' That suppresses SEO ~20pts. Re-measure the indexable production domain for the true score.');
153
+ }
154
+ return lines;
155
+ }
@@ -19,9 +19,9 @@
19
19
  * lands with whole-DB introspection. The emission is identical either way.
20
20
  */
21
21
 
22
- import type { ModelDescriptor } from '@everystack/model';
22
+ import type { ModelDescriptor, Module } from '@everystack/model';
23
23
  import type { AuthzContract, TableContract } from './authz-contract.js';
24
- import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, type CompileTableOptions } from './schema-compile.js';
24
+ import { compileCreateTable, compileEnums, foreignKeySql, modelConstraintSpecs, qualifiedTable, type CompileTableOptions } from './schema-compile.js';
25
25
  import { compileTableContract } from './authz-compile.js';
26
26
  import { emitReconcileSql } from './authz-reconcile.js';
27
27
  import { emitSchemaSql, type SchemaChange } from './schema-diff.js';
@@ -38,8 +38,36 @@ function emptyTable(table: string): TableContract {
38
38
  */
39
39
  export function compileMigration(models: ModelDescriptor[], opts: CompileTableOptions = {}): string[] {
40
40
  const sql: string[] = [];
41
+ const schemaOf = (m: ModelDescriptor): string => m.schema ?? 'public';
41
42
 
42
- // 0. Enum types — `CREATE TYPE AS ENUM`, before any table/column references them.
43
+ // 0a. Non-public schemas — `CREATE SCHEMA` for each distinct one a model lives in,
44
+ // before any table is created in it. public is the implicit default, never emitted.
45
+ const schemas = [...new Set(models.map(schemaOf))].filter((s) => s !== 'public').sort();
46
+ sql.push(...schemas.map((s) => `CREATE SCHEMA IF NOT EXISTS "${s}";`));
47
+
48
+ // The authz contracts — computed once, used for the schema USAGE grants here and the
49
+ // table-level reconcile below.
50
+ const contracts = models.map((m) => compileTableContract(m));
51
+
52
+ // 0a-bis. Schema USAGE — a role can't reach a table in a non-public schema without USAGE on
53
+ // it, so a table/column grant there is dead without this. Derived from the grants: every
54
+ // role that holds any privilege on a table in a non-public schema gets USAGE on that schema
55
+ // (public USAGE is granted to PUBLIC by default, so it is never emitted). This replaces the
56
+ // hand-written `GRANT USAGE ON SCHEMA auth/ops/dist …` the old bootstrap carried.
57
+ for (const schema of schemas) {
58
+ const roles = new Set<string>();
59
+ for (const c of contracts) {
60
+ if (!c.table.startsWith(`${schema}.`)) continue;
61
+ for (const r of Object.keys(c.grants)) roles.add(r);
62
+ for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
63
+ }
64
+ if (roles.size) {
65
+ const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
66
+ sql.push(`GRANT USAGE ON SCHEMA "${schema}" TO ${targets};`);
67
+ }
68
+ }
69
+
70
+ // 0b. Enum types — `CREATE TYPE … AS ENUM`, before any table/column references them.
43
71
  const enumCreates: SchemaChange[] = compileEnums(models).map((e) => ({ kind: 'createType', name: e.name, values: e.values }));
44
72
  sql.push(...emitSchemaSql(enumCreates));
45
73
 
@@ -49,11 +77,12 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
49
77
  // 2. Foreign keys — after every table exists.
50
78
  for (const model of models) sql.push(...foreignKeySql(model));
51
79
 
52
- // 2b. Standalone indexes — after the columns they cover exist.
80
+ // 2b. Standalone indexes — after the columns they cover exist. The ON clause is the
81
+ // schema-qualified table, so a non-public index targets the right table.
53
82
  const indexCreates: SchemaChange[] = [];
54
83
  for (const model of models) {
55
84
  for (const ix of modelConstraintSpecs(model).indexes) {
56
- indexCreates.push({ kind: 'createIndex', table: `"${model.table}"`, name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
85
+ indexCreates.push({ kind: 'createIndex', table: qualifiedTable(model), name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) });
57
86
  }
58
87
  }
59
88
  sql.push(...emitSchemaSql(indexCreates));
@@ -61,9 +90,44 @@ export function compileMigration(models: ModelDescriptor[], opts: CompileTableOp
61
90
  // 3. Authz — RLS + policies + grants, after the columns they reference. Reuse
62
91
  // the reconcile emitter against the empty baseline: every policy/grant is a
63
92
  // pure creation, in the same dependency-correct order reconcile already uses.
64
- const desired: AuthzContract = { tables: models.map((m) => compileTableContract(m)), functions: [] };
65
- const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`public.${m.table}`)), functions: [] };
93
+ // The baseline keys on each model's resolved schema, so a non-public table
94
+ // diffs against its own empty state, not a phantom public one.
95
+ const desired: AuthzContract = { tables: contracts, functions: [] };
96
+ const baseline: AuthzContract = { tables: models.map((m) => emptyTable(`${schemaOf(m)}.${m.table}`)), functions: [] };
66
97
  sql.push(...emitReconcileSql(desired, baseline));
67
98
 
68
99
  return sql;
69
100
  }
101
+
102
+ /**
103
+ * Compile a set of Modules into one COMPLETE greenfield migration — the whole deploy, no
104
+ * hand-written bootstrap left:
105
+ *
106
+ * 1. CREATE EXTENSION (deduped, sorted) — the bootstrap a package's SQL needs
107
+ * 2. compileMigration(flattened models) — schemas, tables, FKs, indexes, RLS, authz
108
+ * 3. package functions/triggers (each thunk) — imperative package SQL, AFTER the tables
109
+ *
110
+ * Roles are NOT emitted — they are cluster-global (`db:provision`), and the GRANTs in step 2
111
+ * assume they exist (as the dogfood pre-creates them). `compileMigration` is left untouched as
112
+ * the schema+authz core the contract round-trips through; the functions are emitted verbatim
113
+ * here, outside that round-trip (they are imperative, package-owned — not modeled).
114
+ */
115
+ export function compileModuleMigration(modules: Module[], opts: CompileTableOptions = {}): string[] {
116
+ const sql: string[] = [];
117
+
118
+ // 1. Extensions — deduped + sorted, quoted so a hyphenated name (`uuid-ossp`) is valid.
119
+ const extensions = [...new Set(modules.flatMap((m) => m.extensions))].sort();
120
+ sql.push(...extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS "${e}";`));
121
+
122
+ // 2. Schema + authz for every modeled table.
123
+ sql.push(...compileMigration(modules.flatMap((m) => m.models), opts));
124
+
125
+ // 3. Package SQL — functions + triggers, after the tables they reference. Each thunk's
126
+ // output is a self-contained multi-statement block applied as one unit.
127
+ for (const fn of modules.flatMap((m) => m.functions)) {
128
+ const text = fn().trim();
129
+ if (text) sql.push(text);
130
+ }
131
+
132
+ return sql;
133
+ }