@everystack/cli 0.4.49 → 0.4.50

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.49",
3
+ "version": "0.4.50",
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>",
@@ -64,8 +64,10 @@ const DML: Record<string, 'read' | 'create' | 'update' | 'delete'> = {
64
64
  DELETE: 'delete',
65
65
  };
66
66
 
67
- /** The roles the compiler itself emits policies for; anything else is app-specific. */
68
- const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
67
+ /** The roles the compiler itself emits policies for; anything else is app-specific.
68
+ * Exported so the DERIVED renderer applies the identical rule — the views path once had
69
+ * its own idea of which grantees to render, and the two disagreed inside one pull. */
70
+ export const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
69
71
 
70
72
  /**
71
73
  * The grantees a rendered model GOVERNS — the three vocabulary roles plus PUBLIC, which is
@@ -75,7 +77,7 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
75
77
  * an ungoverned grantee alone, so there is no REVOKE to prevent, and naming the role in a model
76
78
  * would GOVERN it — turning a rendering decision into an access decision for every table.
77
79
  */
78
- const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
80
+ export const GOVERNED_VOCABULARY = new Set([...KNOWN_ROLES, 'PUBLIC', 'public']);
79
81
 
80
82
  /** The beyond-CRUD privileges a governed role holds live — what `can()` cannot say. */
81
83
  function deriveExtraPrivileges(contract: TableContract): Record<string, string[]> {
@@ -2,7 +2,7 @@
2
2
  * `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
3
3
  *
4
4
  * db:pull [--stage <name>] [--database-url <url>] [--schema public] [--out <dir | file.ts>]
5
- * [--derived-out <file.ts>] [--abilities public-read]
5
+ * [--derived-out <file.ts>] [--abilities live|public-read]
6
6
  *
7
7
  * `--stage` and `--database-url` COMPOSE: the URL picks the connection, the stage names the
8
8
  * baseline entry (`db/authz-baseline.json` is per-stage — a local adoption pulls with
@@ -358,7 +358,21 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
358
358
  const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →|verbatim:)/g) ?? []).length;
359
359
  if (flagged) caution(`${flagged} inline comment(s) flag things to review (verbatim types, checks, cross-schema FKs).`);
360
360
  if (abilities === 'commented') {
361
- note(`Each model scaffolds its authz decision as comments author them (db:check fails until every model declares), or stamp the common case: db:pull --abilities public-read.`);
361
+ // `live` FIRST, and named. It is the mode brownfield adoption needs derive the authz
362
+ // from grants and policies the database already has — and it was advertised nowhere:
363
+ // not in the usage line, not in --help, not here. The only place an operator met the
364
+ // word was the error text for an unknown preset, so the discovery path for the right
365
+ // flag was to guess a wrong one. A consumer and their agent both walked past it in one
366
+ // session, and it cost a misdiagnosis plus a needless overwrite of 30 model files.
367
+ //
368
+ // `public-read` is deliberately no longer the headline: on an existing database it
369
+ // stamps can('read') on EVERY table, which on a real schema means public read of
370
+ // credit_cards, transactions, emails and users.
371
+ note(
372
+ `Each model scaffolds its authz decision as comments — db:check fails until every model declares. `
373
+ + `For an EXISTING database, re-pull with --abilities live to derive them from the grants and policies `
374
+ + `already there. --abilities public-read stamps public-read/admin-write on every table — greenfield only.`,
375
+ );
362
376
  } else {
363
377
  note(`Stamped '${abilities}' abilities into every model — review the generated stanzas; they are code, not defaults.`);
364
378
  }
@@ -32,6 +32,7 @@ import type {
32
32
  FunctionDescriptor, DependsOnRef,
33
33
  } from '@everystack/model';
34
34
  import { parseQualified } from './derived-source.js';
35
+ import { GOVERNED_VOCABULARY } from './authz-derive.js';
35
36
 
36
37
  export interface DerivedAuthzGap {
37
38
  /** `schema.name` of the descriptor that fails the gate. */
@@ -60,6 +61,10 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
60
61
  * as zero-reach, which bricked every invoker view over a private table.
61
62
  */
62
63
  export function tableReaches(m: ModelDescriptor, role: string): boolean {
64
+ // A recorded grant is reach too. PUBLIC SELECT is held by every role; a named grantee
65
+ // reaches itself. See grantsPublic — this is the half the gate kept forgetting.
66
+ if (grantsPublic(m.privileges, 'SELECT')) return true;
67
+ if (privilegeRoles(m.privileges, 'SELECT').includes(role)) return true;
63
68
  for (const a of m.abilities) {
64
69
  if (isColumnAbility(a)) {
65
70
  // Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
@@ -79,6 +84,30 @@ export function tableReaches(m: ModelDescriptor, role: string): boolean {
79
84
  return false;
80
85
  }
81
86
 
87
+ /**
88
+ * REACH comes from abilities AND from recorded privileges. Forgetting the second half is a
89
+ * bug this repo has now made three times — the 63-byte ACL truncation, the SECDEF caller
90
+ * gate, and this one — always the same shape: a fact recorded as `privileges` falls out of
91
+ * a downstream read and lands in neither set.
92
+ *
93
+ * `PUBLIC` is the case that matters. `GRANT … TO PUBLIC` is held by EVERY role, so a
94
+ * dependency carrying one is reachable by all of them, and a live PUBLIC grant is exactly
95
+ * what `db:pull` RECORDS rather than inventing an audience for. Reading abilities alone
96
+ * made a faithful pull fail to COMPILE, with no hand-edit available to fix it.
97
+ */
98
+ function grantsPublic(privileges: Record<string, string[]> | undefined, privilege: string): boolean {
99
+ return Object.entries(privileges ?? {}).some(
100
+ ([grantee, privs]) => grantee.toUpperCase() === 'PUBLIC' && privs.some((p) => p.toUpperCase() === privilege),
101
+ );
102
+ }
103
+
104
+ /** Grantees a recorded privilege names directly — reach for that role, nobody else. */
105
+ function privilegeRoles(privileges: Record<string, string[]> | undefined, privilege: string): string[] {
106
+ return Object.entries(privileges ?? {})
107
+ .filter(([, privs]) => privs.some((p) => p.toUpperCase() === privilege))
108
+ .map(([grantee]) => grantee);
109
+ }
110
+
82
111
  /** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
83
112
  function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
84
113
  const roles = new Set<string>();
@@ -89,12 +118,15 @@ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<stri
89
118
  roles.add('authenticated');
90
119
  }
91
120
  }
121
+ for (const r of privilegeRoles(d.privileges, 'SELECT')) roles.add(r);
92
122
  return roles;
93
123
  }
94
124
 
95
125
  function functionRoles(fn: FunctionDescriptor): Set<string> {
96
126
  // define-time already rejected bare can('execute') — every ability carries a role.
97
- return new Set(fn.abilities.map((a) => a.condition.role!));
127
+ const roles = new Set(fn.abilities.map((a) => a.condition.role!));
128
+ for (const r of privilegeRoles(fn.privileges, 'EXECUTE')) roles.add(r);
129
+ return roles;
98
130
  }
99
131
 
100
132
  // ---------------------------------------------------------------------------
@@ -262,7 +294,7 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
262
294
  }
263
295
  switch (ref.kind) {
264
296
  case 'view': {
265
- if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
297
+ if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
266
298
  // An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
267
299
  if (ref.securityInvoker) {
268
300
  const deeper = findUnreachable(role, ref.dependsOn, seen);
@@ -272,11 +304,11 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
272
304
  }
273
305
  case 'materialized view':
274
306
  // A matview is its own snapshot: SELECT on the matview is the whole requirement.
275
- if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
307
+ if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
276
308
  break;
277
309
  case 'function':
278
310
  // The body calls it with the caller's rights — EXECUTE is part of reach.
279
- if (!functionRoles(ref).has(role)) return identityOf(ref);
311
+ if (!functionRoles(ref).has(role) && !grantsPublic(ref.privileges, 'EXECUTE')) return identityOf(ref);
280
312
  break;
281
313
  case 'sql':
282
314
  break; // opaque — never a false positive
@@ -296,7 +328,12 @@ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[
296
328
  for (const d of derived) {
297
329
  if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
298
330
  const identity = identityOf(d);
299
- for (const role of [...relationRoles(d)].sort()) {
331
+ // Only roles the declared world can SATISFY. A grantee outside the compiler's
332
+ // vocabulary is recorded fact on the view AND on the table (the tables path never
333
+ // renders one as an ability), so table-side reach for it is not expressible — checking
334
+ // it is a demand no model can ever meet. Their reach is adopted via authz-baseline,
335
+ // outside this gate. PUBLIC is governed and stays checked.
336
+ for (const role of [...relationRoles(d)].filter((r) => GOVERNED_VOCABULARY.has(r)).sort()) {
300
337
  const unreachable = findUnreachable(role, d.dependsOn, new Set());
301
338
  if (unreachable) {
302
339
  gaps.push({
@@ -18,6 +18,7 @@ import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introsp
18
18
  import { parseIndexDefinition } from './schema-introspect.js';
19
19
  import { modelFileName, renderFieldLines } from './model-render.js';
20
20
  import { splitFunctionIdentity } from './pg-argtypes.js';
21
+ import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive.js';
21
22
 
22
23
  export interface DerivedRenderResult {
23
24
  /** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
@@ -100,12 +101,28 @@ function tsString(s: string): string {
100
101
  */
101
102
  function splitRelationGrants(
102
103
  grants: Record<string, string[]>,
103
- ): { abilities: string[]; privileges: Record<string, string[]> } {
104
+ ): { abilities: string[]; privileges: Record<string, string[]>; foreign: string[] } {
104
105
  const readRoles = new Set<string>();
105
106
  const privileges: Record<string, string[]> = {};
107
+ const foreign: string[] = [];
106
108
  for (const [grantee, privs] of Object.entries(grants)) {
107
109
  const selectOnly = privs.length === 1 && privs[0] === 'SELECT';
108
- if (selectOnly && grantee.toUpperCase() !== 'PUBLIC') readRoles.add(grantee);
110
+ // ABILITIES are limited to the compiler's own vocabulary (authz-derive's KNOWN_ROLES).
111
+ // A foreign grantee is FACT, not opinion, so it is RECORDED — never declared as an
112
+ // intended audience. The views path used to render any select-only grantee as an
113
+ // ability, so one pull could declare a role as an ABILITY on a view while the table
114
+ // recorded it as a foreign grantee, and the reachability gate then demanded table-side
115
+ // reach the tables path will never render. The contract contradicted itself.
116
+ //
117
+ // RECORDED, not dropped — and that is the part the obvious fix gets wrong. On a TABLE,
118
+ // "not rendered" is safe because the table reconciler leaves an ungoverned grantee
119
+ // alone. On a DERIVED object there is no such carve-out: diffObjectGrants unions the
120
+ // declared and live grantees, so a grantee we omit is a grantee we REVOKE. Measured:
121
+ // declared {anon,authenticated} against live +outbound_migrator plans
122
+ // `REVOKE SELECT, UPDATE ON public.v FROM outbound_migrator`. Dropping them would
123
+ // reintroduce the silent revoke this whole branch exists to prevent.
124
+ if (!GOVERNED_VOCABULARY.has(grantee)) foreign.push(grantee);
125
+ if (selectOnly && KNOWN_ROLES.has(grantee)) readRoles.add(grantee);
109
126
  else privileges[grantee] = [...privs].sort();
110
127
  }
111
128
  const abilities: string[] = [];
@@ -115,7 +132,7 @@ function splitRelationGrants(
115
132
  readRoles.delete('authenticated');
116
133
  }
117
134
  for (const role of [...readRoles].sort()) abilities.push(`can('read', { role: ${tsString(role)} })`);
118
- return { abilities, privileges };
135
+ return { abilities, privileges, foreign: foreign.sort() };
119
136
  }
120
137
 
121
138
  /**
@@ -363,7 +380,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
363
380
  // the abilities we can express would leave the write grants undeclared, so the next
364
381
  // generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
365
382
  // Declared == live means nothing is planned at all.
366
- const { abilities, privileges } = splitRelationGrants(o.grants ?? {});
383
+ const { abilities, privileges, foreign } = splitRelationGrants(o.grants ?? {});
384
+ if (foreign.length) {
385
+ warnings.push(
386
+ `${o.identity}: grants exist for ${foreign.join(', ')} — recorded as \`privileges\`, not declared as ` +
387
+ 'abilities. Abilities name the roles the model governs (anon/authenticated/admin); a grantee outside ' +
388
+ 'that vocabulary is fact, not an intended audience. Recorded rather than dropped because a derived ' +
389
+ 'object REVOKES any live grantee the declaration omits.',
390
+ )
391
+ }
367
392
  const writeGrants = Object.values(privileges).some((ps) =>
368
393
  ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
369
394
  );
package/src/cli/index.ts CHANGED
@@ -359,7 +359,7 @@ Usage:
359
359
  everystack db:export --schema <name> [--stage <name> | --database-url <url> [--out <file.dump>]] [--models <barrel>] Schema-scoped pg_dump artifact, stamped with the DECLARED schema fingerprint (the canonical-sync export; db:swap gates on that stamp). --stage dumps the stage's private DB via the ops Lambda → S3; --database-url (explicit flag, never the env) dumps a reachable DB to a local .dump + .meta.json — the build-locally → publish → swap on-ramp
360
360
  everystack db:swap --schema <name> --from <artifact.dump | artifact-id> [--stage <name> --direct | --database-url <url>] --confirm [--fingerprint <hash>] [--snapshot physical|logical|none] [--snapshot-ref <id>] [--rebuild-derived] [--dump-build <file.json>] Land a schema artifact atomically: fingerprint gate → pre-flight refusals → CONFIRMED snapshot → restore into <schema>_incoming (COPY-safe rewrite) → build the paired derived layer → one txn (drop+rename+recreate app→schema FKs, re-apply authz + schema USAGE) → assertions → drop retiring. Refresh-free; app.* untouched; the derived layer is never absent. DESTRUCTIVE. The venue is EXPLICIT — DATABASE_URL in the env is refused, and --stage requires --direct (a multi-GB restore exceeds the ops-Lambda 900s clock). The rollback point defaults to a PHYSICAL RDS snapshot on a stage that exposes databaseInstanceId (no locks, no pg_dump contending with the restore) and a WAITED logical db:backup otherwise; a bare --database-url refuses without --snapshot-ref <id> or --snapshot none. docs/schema-swap.md
361
361
  everystack db:generate [--stage <name> | --database-url <url>] [--name <label>] [--models db/models/index.ts] [--schema-out db/schema.generated.ts] [--allow-drops] [--apply] [--dry-run] Diff models vs the live DB → next migration file, or with --apply execute it directly (one transaction, schema_log recorded, verified by re-diff — no drizzle folder needed; direct connection only; DROPs held back unless --allow-drops). --dry-run prints the edge and writes NOTHING (no migration, no journal entry, no schema refresh) — the preview verb; db:diff computes a models-vs-models edge with no database at all. The resolved --schema-out is recorded in the migration journal: later flag-less runs reuse it (flag > recorded > default), a differing flag updates the record and says so
362
- everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
362
+ everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities live|public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored). **--abilities live is the brownfield mode**: derive each model's authz from the grants and policies the database ALREADY has, and write the foreign-grantee baseline (db/authz-baseline.json) — this is what you want when adopting an existing schema. --abilities public-read stamps the common stanza (public read, admin write) uncommented — greenfield only, since on an existing database it declares public read of every table. Both are explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
363
363
  Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
364
364
  everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
365
365
  everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--only a,b] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive. --only <schema.name,…> restricts the run to the named objects (surgical); with --rebuild it FORCES those to rebuild from source even when the hashes show no diff — the recovery exit when a mistaken --rebaseline left a self-consistent-but-wrong provenance row (the dependency cascade rebuilds their live dependents).
package/src/plugin.ts CHANGED
@@ -9,17 +9,35 @@
9
9
 
10
10
  import type { StorageAdapter } from './storage/index';
11
11
 
12
- /** Minimal plugin context (compatible with @everystack/server/plugin PluginContext) */
12
+ /**
13
+ * These types MIRROR @everystack/server/plugin. They are not imported from it, and that
14
+ * is deliberate: server imports `@everystack/cli/apply`, `/reconcile`, `/exec` and more,
15
+ * so a type import in the other direction closes a package cycle. Both packages ship
16
+ * TypeScript source, so a type-only import still drags the whole module graph in.
17
+ *
18
+ * The copy going stale is what broke a consumer: server made `publishJob` optional and
19
+ * gave it an options argument, nothing here compared the two declarations, and `Plugin`
20
+ * is contravariant in `ctx` — so cli's `Plugin` silently stopped being assignable to
21
+ * server's and the TS2322 landed in their build instead of ours.
22
+ *
23
+ * **The link is `__tests__/cli/plugin-type-compat.test.ts`**, which imports BOTH and
24
+ * asserts assignability at compile time. The test can cross the boundary because it is
25
+ * not part of either package's published graph. Drift now fails there, in this repo, on
26
+ * the commit that causes it. If you change a shape below, that test is the gate.
27
+ */
28
+
29
+ /** Mirrors @everystack/server/plugin PluginContext. `publishJob` is OPTIONAL (a lean app
30
+ * with no @everystack/jobs never sets it) and takes an options argument. */
13
31
  interface PluginContext {
14
32
  db: any;
15
33
  schema: Record<string, any>;
16
34
  verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
17
35
  environment: string;
18
- publishJob: (type: string, payload: unknown) => Promise<string>;
36
+ publishJob?: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
19
37
  [key: string]: unknown;
20
38
  }
21
39
 
22
- /** Minimal route type (compatible with @everystack/server Route) */
40
+ /** Mirrors @everystack/server Route. */
23
41
  interface Route {
24
42
  path: string;
25
43
  method?: string;
@@ -27,10 +45,10 @@ interface Route {
27
45
  handler: (req: Request) => Promise<Response>;
28
46
  }
29
47
 
30
- /** Action handler type (compatible with @everystack/server/plugin ActionHandler) */
48
+ /** Mirrors @everystack/server/plugin ActionHandler. */
31
49
  type ActionHandler = (payload: unknown, ctx: PluginContext) => Promise<unknown>;
32
50
 
33
- /** Plugin factory function */
51
+ /** Mirrors @everystack/server/plugin Plugin. */
34
52
  type Plugin = (ctx: PluginContext) => Promise<{
35
53
  routes?: Route[];
36
54
  actions?: Record<string, ActionHandler>;