@everystack/cli 0.4.35 → 0.4.38

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,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `everystack` entry point.
4
+ *
5
+ * This package ships TypeScript source deliberately — every `exports` target is a
6
+ * `./src/*.ts` file — so the CLI needs a TypeScript loader at runtime. It used to get one
7
+ * from `#!/usr/bin/env tsx` on `src/cli/index.ts`, which cannot work once the package is
8
+ * installed: `env` searches the CONSUMER's PATH, and tsx is a dependency of
9
+ * @everystack/cli, so under pnpm's isolated layout it lives in this package's own
10
+ * node_modules and is never on that PATH. `everystack --help` died with
11
+ * "env: tsx: No such file or directory"; only `pnpm exec everystack` worked, because that
12
+ * puts the local .bin on PATH first.
13
+ *
14
+ * So: boot under plain `node` — always present, it is what runs npm — and find tsx by
15
+ * MODULE RESOLUTION from this file rather than by PATH lookup.
16
+ *
17
+ * The loader is installed by re-executing node with `--import`, not by calling tsx's
18
+ * register() in-process. register() followed by `import()` of the TypeScript entry makes
19
+ * the entry load through a require(esm) path and Node rejects it with
20
+ * ERR_REQUIRE_CYCLE_MODULE. `--import` installs the hooks before any module graph exists,
21
+ * which is the only ordering that works. The extra process is the price of shipping
22
+ * source, and it is paid once per invocation.
23
+ */
24
+ import { createRequire } from 'node:module';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { pathToFileURL, fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ let tsx;
31
+ try {
32
+ tsx = pathToFileURL(require.resolve('tsx')).href;
33
+ } catch {
34
+ console.error('everystack: could not resolve the "tsx" TypeScript loader from this package.');
35
+ console.error('This usually means a partial install — try reinstalling @everystack/cli.');
36
+ process.exit(1);
37
+ }
38
+
39
+ const entry = fileURLToPath(new URL('../src/cli/index.ts', import.meta.url));
40
+
41
+ const result = spawnSync(process.execPath, ['--import', tsx, entry, ...process.argv.slice(2)], {
42
+ stdio: 'inherit',
43
+ });
44
+
45
+ // Re-raise a signal death as a signal death so `everystack … &` + Ctrl-C behaves, and
46
+ // otherwise pass the child's exit code through unchanged — the CLI's non-zero exits are
47
+ // load-bearing (db:check, db:authz:diff and friends are CI gates).
48
+ if (result.signal) {
49
+ process.kill(process.pid, result.signal);
50
+ } else {
51
+ process.exit(result.status ?? 1);
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.35",
3
+ "version": "0.4.38",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -17,6 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "files": [
20
+ "bin",
20
21
  "src",
21
22
  "README.md"
22
23
  ],
@@ -91,7 +92,7 @@
91
92
  }
92
93
  },
93
94
  "bin": {
94
- "everystack": "./src/cli/index.ts"
95
+ "everystack": "./bin/everystack.mjs"
95
96
  },
96
97
  "dependencies": {
97
98
  "@aws-sdk/client-cloudfront": "3.1053.0",
@@ -111,7 +112,7 @@
111
112
  "@everystack/model": "0.4.5"
112
113
  },
113
114
  "peerDependencies": {
114
- "@everystack/server": ">=0.1.0",
115
+ "@everystack/server": ">=0.4.0",
115
116
  "@aws-sdk/client-cloudwatch": "3.1053.0",
116
117
  "@aws-sdk/client-rds": "3.1053.0",
117
118
  "@aws-sdk/s3-request-presigner": "3.1053.0",
@@ -9,9 +9,20 @@
9
9
  * can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
10
10
  * audit and the CI gate (non-zero exit on any drift).
11
11
  *
12
- * Both introspect through the ops Lambda `db:query` action (read-only SQL), the same
13
- * path security:audit uses. The core (introspect/assemble/diff) is backend-agnostic and
14
- * pure; only the runner here is AWS-bound.
12
+ * Two venues, one verdict. The core (introspect/assemble/diff/evaluate) is backend-agnostic
13
+ * and pure only the VENUE differs, and both satisfy the same `AuthzVenue` contract, so
14
+ * every command below runs identical evaluation code either way:
15
+ *
16
+ * - **Deployed** (`--stage`, the default): the ops Lambda's `db:query` + `db:authz:probe`
17
+ * actions. Credentials never leave AWS.
18
+ * - **Direct** (`--database-url`, or an inherited ADMIN_DATABASE_URL / DATABASE_URL): a
19
+ * postgres.js connection from this process.
20
+ *
21
+ * The direct venue is what makes a brownfield authz migration rehearsable. Translating
22
+ * hand-written RLS into Model abilities is the hardest part of an adoption, and until the
23
+ * local venue existed it was the ONLY part that required a deployed stage to iterate
24
+ * against. Every run names its venue, because a security verdict against an unintended
25
+ * database is worse than no verdict.
15
26
  */
16
27
 
17
28
  import path from 'node:path';
@@ -43,6 +54,7 @@ import { renderContractMarkdown } from '../authz-render.js';
43
54
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
44
55
  import { resolveConfig, opsFunction } from '../config.js';
45
56
  import { resolveModelsPath } from '../models-path.js';
57
+ import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
46
58
  import { invokeAction } from '../aws.js';
47
59
  import { step, success, fail, info, warn } from '../output.js';
48
60
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
@@ -59,20 +71,65 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
59
71
  };
60
72
  }
61
73
 
62
- async function introspectStage(flags: Record<string, string>): Promise<AuthzContract> {
74
+ /**
75
+ * Where an authz command runs. Both venues expose the same two capabilities, so the
76
+ * commands never branch on venue after this point — that is what keeps the local
77
+ * rehearsal honest: same SQL, same evaluation, same verdict.
78
+ */
79
+ interface AuthzVenue {
80
+ /** Read-only introspection. */
81
+ runner: QueryRunner;
82
+ /** Self-reverting red-team probe (writes, always rolled back). */
83
+ probe: (setup: string, read: string) => Promise<any[]>;
84
+ /** Human-readable venue, printed with every verdict. */
85
+ label: string;
86
+ /** Release any connection this venue holds. */
87
+ end: () => Promise<void>;
88
+ }
89
+
90
+ /**
91
+ * Precedence is `resolveDbSource`'s, shared with db:pull / db:generate / db:check:
92
+ * `--database-url` > explicit `--stage` > ADMIN_DATABASE_URL > DATABASE_URL > default stage.
93
+ */
94
+ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue> {
95
+ const source = resolveDbSource(flags);
96
+ if (source.kind === 'url') {
97
+ step(connectingVia(source));
98
+ const { runner, probe, end } = await createUrlProbeRunner(source.url);
99
+ return { runner, probe, end, label: 'direct connection' };
100
+ }
63
101
  step('Resolving deployed config...');
64
102
  const config = await resolveConfig(flags.stage);
65
- info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
66
- step('Introspecting authorization (rls + grants + policies + secdef)...');
67
- const runner = lambdaRunner(config.region, opsFunction(config));
68
- return introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
103
+ const fn = opsFunction(config);
104
+ info(`Region: ${config.region}, Function: ${fn}`);
105
+ return {
106
+ runner: lambdaRunner(config.region, fn),
107
+ probe: async (setup: string, read: string) => {
108
+ const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
109
+ if (result?.error) throw new Error(result.error);
110
+ return result?.rows ?? [];
111
+ },
112
+ label: flags.stage ? `stage ${flags.stage}` : 'deployed stage (.sst/outputs.json)',
113
+ end: async () => {},
114
+ };
115
+ }
116
+
117
+ async function introspectVenue(flags: Record<string, string>): Promise<{ contract: AuthzContract; label: string }> {
118
+ const venue = await resolveVenue(flags);
119
+ try {
120
+ step('Introspecting authorization (rls + grants + policies + secdef)...');
121
+ return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
122
+ } finally {
123
+ await venue.end();
124
+ }
69
125
  }
70
126
 
71
127
  export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
72
128
  const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
73
129
  let contract: AuthzContract;
130
+ let venueLabel: string;
74
131
  try {
75
- contract = await introspectStage(flags);
132
+ ({ contract, label: venueLabel } = await introspectVenue(flags));
76
133
  } catch (err: any) {
77
134
  fail(err.message);
78
135
  for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
@@ -82,7 +139,7 @@ export async function dbAuthzPullCommand(flags: Record<string, string>): Promise
82
139
  const files = await writeContract(dir, contract);
83
140
  const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
84
141
  console.log('');
85
- success(`Wrote ${files.length} file(s) to ${dir}`);
142
+ success(`Wrote ${files.length} file(s) to ${dir} (from ${venueLabel})`);
86
143
  info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
87
144
  console.log('');
88
145
  warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
@@ -97,9 +154,10 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
97
154
 
98
155
  let declared: AuthzContract;
99
156
  let live: AuthzContract;
157
+ let venueLabel: string;
100
158
  try {
101
159
  declared = await loadContract(dir);
102
- live = await introspectStage(flags);
160
+ ({ contract: live, label: venueLabel } = await introspectVenue(flags));
103
161
  } catch (err: any) {
104
162
  fail(err.message);
105
163
  process.exit(1);
@@ -113,11 +171,11 @@ export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise
113
171
  const findings = diffContracts(declared, live);
114
172
  console.log('');
115
173
  if (findings.length === 0) {
116
- success(`db:authz:diff — live database matches the declared contract (${declared.tables.length} tables)`);
174
+ success(`db:authz:diff — ${venueLabel} matches the declared contract (${declared.tables.length} tables)`);
117
175
  process.exit(0);
118
176
  }
119
177
 
120
- fail(`db:authz:diff — ${findings.length} drift finding(s): the live database does NOT match the declared contract`);
178
+ fail(`db:authz:diff — ${findings.length} drift finding(s): ${venueLabel} does NOT match the declared contract`);
121
179
  console.log('');
122
180
  for (const f of findings) {
123
181
  warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
@@ -163,24 +221,23 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
163
221
 
164
222
  let rows: any[];
165
223
  let gapRows: any[];
224
+ let venueLabel: string;
225
+ let venue: AuthzVenue | undefined;
166
226
  try {
167
- step('Resolving deployed config...');
168
- const config = await resolveConfig(flags.stage);
169
- const fn = opsFunction(config);
170
- info(`Region: ${config.region}, Function: ${fn}`);
227
+ venue = await resolveVenue(flags);
228
+ venueLabel = venue.label;
171
229
  step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
172
230
  const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
173
- const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read: PROBE_SELECT_SQL });
174
- if (result?.error) throw new Error(result.error);
175
- rows = result?.rows ?? [];
231
+ rows = await venue.probe(setup, PROBE_SELECT_SQL);
176
232
  step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
177
- const gc: any = await invokeAction(config.region, fn, 'db:query', { sql: GRANT_COMPLETENESS_SQL });
178
- if (gc?.error) throw new Error(gc.error);
179
- gapRows = gc?.rows ?? [];
233
+ gapRows = await venue.runner(GRANT_COMPLETENESS_SQL);
180
234
  } catch (err: any) {
181
235
  fail(`db:authz:test failed: ${err.message}`);
182
- info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
236
+ info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
237
+ info('or rehearse locally: db:authz:test --database-url <url>.');
183
238
  process.exit(1);
239
+ } finally {
240
+ await venue?.end();
184
241
  }
185
242
 
186
243
  const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
@@ -200,10 +257,10 @@ export async function dbAuthzTestCommand(flags: Record<string, string>): Promise
200
257
  console.log('');
201
258
 
202
259
  if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
203
- success(`db:authz:test — enforcement matches the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
260
+ success(`db:authz:test — ${venueLabel} enforces the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
204
261
  process.exit(0);
205
262
  }
206
- fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). The database does not enforce the contract.`);
263
+ fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). ${venueLabel} does not enforce the contract.`);
207
264
  process.exit(1);
208
265
  }
209
266
 
@@ -223,6 +280,8 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
223
280
  let probes: OwnerProbe[];
224
281
  let publicReadTables = new Set<string>();
225
282
  let rows: any[];
283
+ let venueLabel = '';
284
+ let venue: AuthzVenue | undefined;
226
285
  try {
227
286
  step(`Loading models from ${modelsPath}...`);
228
287
  const models = await loadModels(modelsPath);
@@ -240,20 +299,17 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
240
299
  success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
241
300
  process.exit(0);
242
301
  }
243
- step('Resolving deployed config...');
244
- const config = await resolveConfig(flags.stage);
245
- const fn = opsFunction(config);
246
- info(`Region: ${config.region}, Function: ${fn}`);
302
+ venue = await resolveVenue(flags);
303
+ venueLabel = venue.label;
247
304
  step('Red-teaming owner isolation (two JWT identities per table, rolled back)...');
248
- const result: any = await invokeAction(config.region, fn, 'db:authz:probe', {
249
- setup: buildOwnerProbeSql(probes), read: OWNER_PROBE_SELECT_SQL,
250
- });
251
- if (result?.error) throw new Error(result.error);
252
- rows = result?.rows ?? [];
305
+ rows = await venue.probe(buildOwnerProbeSql(probes), OWNER_PROBE_SELECT_SQL);
253
306
  } catch (err: any) {
254
307
  fail(`db:authz:owner failed: ${err.message}`);
255
- info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
308
+ info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe,');
309
+ info('or rehearse locally: db:authz:owner --database-url <url>.');
256
310
  process.exit(1);
311
+ } finally {
312
+ await venue?.end();
257
313
  }
258
314
 
259
315
  const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
@@ -270,9 +326,9 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
270
326
  console.log('');
271
327
 
272
328
  if (leaks.length === 0 && vacuous.length === 0) {
273
- success(`db:authz:owner — owner isolation holds (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
329
+ success(`db:authz:owner — owner isolation holds on ${venueLabel} (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
274
330
  process.exit(0);
275
331
  }
276
- fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies). One user can reach another's rows.`);
332
+ fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies) on ${venueLabel}. One user can reach another's rows.`);
277
333
  process.exit(1);
278
334
  }