@everystack/cli 0.2.39 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -168,17 +168,40 @@ everystack channels create --name staging
168
168
  everystack channels create --name production
169
169
  ```
170
170
 
171
+ ### Deploy
172
+
173
+ ```bash
174
+ everystack deploy --stage dev # provision infrastructure (wraps `sst deploy`)
175
+ ```
176
+
177
+ A thin wrapper over SST so you never type `sst` directly. Infrastructure only — migrations and bundle publishing stay explicit (`db:migrate`, `update`).
178
+
171
179
  ### Database Management
172
180
 
173
- These commands invoke your Lambda function directly via IAM (no database credentials exposed):
181
+ These commands invoke your Lambda directly via IAM (no database credentials ever leave AWS):
174
182
 
175
183
  ```bash
176
- everystack db:migrate # Run Drizzle migrations
177
- everystack db:seed # Seed database (dev only)
178
- everystack db:psql --stage dev # Open a psql session via Lambda
184
+ # Migrations
185
+ everystack db:migrate # apply Drizzle migrations
186
+ everystack db:seed # seed (dev only)
187
+ everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via Lambda
188
+
189
+ # Model-driven schema (@everystack/model)
190
+ everystack db:generate # diff Models vs the live DB → next migration
191
+ everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
192
+
193
+ # Security
194
+ everystack db:doctor # is the DB least-privilege + RLS-subject?
195
+ everystack db:provision # create the least-privilege role chain (idempotent)
196
+ everystack db:authz:pull|diff|test|owner|report # the authorization contract + red-team
197
+
198
+ # Backups (see docs/backups.md)
199
+ everystack db:backup | db:backups | db:restore --from <id> --confirm # logical pg_dump → S3
200
+ everystack db:backup:download <id> # presigned URL to a dump (1h)
201
+ everystack db:snapshot | db:snapshots # physical RDS snapshots
179
202
  ```
180
203
 
181
- `db:migrate` and `db:seed` dispatch to your Lambda's `onAction` handler. `db:psql` proxies a PostgreSQL session through the Lambda, so your database credentials never leave AWS.
204
+ The full command reference including `secrets`, `logs:*`, `status`, `cache:purge`, `branches`, and every flag is in [docs/cli.md](../../docs/cli.md).
182
205
 
183
206
  ### Interactive Console
184
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.39",
3
+ "version": "0.3.3",
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>",
@@ -25,6 +25,10 @@
25
25
  "types": "./src/index.ts",
26
26
  "default": "./src/index.ts"
27
27
  },
28
+ "./model": {
29
+ "types": "./src/cli/model-api.ts",
30
+ "default": "./src/cli/model-api.ts"
31
+ },
28
32
  "./client": {
29
33
  "types": "./src/client/index.ts",
30
34
  "default": "./src/client/index.ts"
@@ -60,13 +64,15 @@
60
64
  "glob": "13.0.6",
61
65
  "node-forge": "1.4.0",
62
66
  "structured-headers": "1.0.1",
63
- "tsx": "4.21.0"
67
+ "tsx": "4.21.0",
68
+ "@everystack/model": "0.3.3"
64
69
  },
65
70
  "peerDependencies": {
66
71
  "@everystack/server": ">=0.1.0",
67
72
  "@aws-sdk/client-cloudwatch": "3.1053.0",
68
73
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
69
74
  "@aws-sdk/client-lambda": "3.1053.0",
75
+ "@aws-sdk/client-rds": "3.1053.0",
70
76
  "@aws-sdk/client-s3": "3.1053.0",
71
77
  "@aws-sdk/client-ssm": "3.1053.0",
72
78
  "@aws-sdk/s3-request-presigner": "3.1053.0",
@@ -91,6 +97,9 @@
91
97
  "@aws-sdk/client-lambda": {
92
98
  "optional": true
93
99
  },
100
+ "@aws-sdk/client-rds": {
101
+ "optional": true
102
+ },
94
103
  "@aws-sdk/client-ssm": {
95
104
  "optional": true
96
105
  },
@@ -111,6 +120,7 @@
111
120
  "@aws-sdk/client-cloudwatch": "3.1053.0",
112
121
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
113
122
  "@aws-sdk/client-lambda": "3.1053.0",
123
+ "@aws-sdk/client-rds": "3.1053.0",
114
124
  "@aws-sdk/client-s3": "3.1053.0",
115
125
  "@aws-sdk/client-ssm": "3.1053.0",
116
126
  "@aws-sdk/s3-request-presigner": "3.1053.0",
@@ -0,0 +1,364 @@
1
+ /**
2
+ * The Model -> Authorization Contract compiler (the second producer).
3
+ *
4
+ * Introspection (authz-contract.ts) reads the live database INTO a contract. This
5
+ * compiles a `defineModel` declaration the other way: `abilities` OUT to the same
6
+ * contract shape, so the two meet at `diffContracts` — declared (Model) vs live
7
+ * (DB). That is the whole leverage of the v3 Model: it is mostly a new producer of
8
+ * an existing format, and `db:authz:reconcile` is the diff turned back into SQL.
9
+ *
10
+ * The make-or-break constraint (proven against apps/example/authz): `diffContracts`
11
+ * compares predicate text VERBATIM, and the contract stores the Postgres deparser
12
+ * (`pg_get_expr`) normal form — which differs from hand-written source SQL (it adds
13
+ * outer parens, an explicit `::text` cast on the claims key, spaces around `->>`,
14
+ * and a `'sub'::text` cast). So the compiler emits that exact normal form, by
15
+ * template, for the shapes that make up ~96% of a real app: owner (uuid or text),
16
+ * soft-delete guard, admin-bypass, the soft-delete-OR-owner read, and the
17
+ * owner-only read. The cross-table `via:` parent subquery and `rawPolicy` escape
18
+ * hatch are the tail — emitted verbatim, round-trip only after a live deparse.
19
+ *
20
+ * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
21
+ */
22
+
23
+ import type { ModelDescriptor, Ability } from '@everystack/model';
24
+ import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
+
26
+ export interface CompileOptions {
27
+ /** Schema the table lives in. Default: `public`. */
28
+ schema?: string;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Predicate templates — the deparser normal form, parameterized.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** `authorId` -> `author_id`. The contract predicates use SQL column names. */
36
+ function toSnakeCase(name: string): string {
37
+ return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
38
+ }
39
+
40
+ /**
41
+ * The Postgres cast a JWT-claim string is coerced to for a non-text owner column.
42
+ * The claim arrives as text (`->> 'sub'`); a uuid/bigint owner needs the cast, a
43
+ * text owner does not (see ownerPredicate). Mapped for completeness; only uuid and
44
+ * text are exercised by the example.
45
+ */
46
+ function castForType(type: string): string {
47
+ switch (type) {
48
+ case 'uuid': return 'uuid';
49
+ case 'bigint':
50
+ case 'bigserial': return 'bigint';
51
+ case 'integer': return 'integer';
52
+ default: return type;
53
+ }
54
+ }
55
+
56
+ /** The signed-in user's id from the JWT claims, in deparser normal form. */
57
+ function claimExpr(claim: string): string {
58
+ return `((current_setting('request.jwt.claims'::text, true))::jsonb ->> '${claim}'::text)`;
59
+ }
60
+
61
+ /**
62
+ * The owner predicate, in deparser normal form. The shape depends on the owner
63
+ * column's type, because the deparser drops a redundant cast:
64
+ * uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
65
+ * text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
66
+ */
67
+ function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
68
+ const ce = claimExpr(claim);
69
+ if (type === 'text') return `(${sqlColumn} = ${ce})`;
70
+ return `(${sqlColumn} = (${ce})::${castForType(type)})`;
71
+ }
72
+
73
+ /** The soft-delete guard, in deparser normal form: `(deleted_at IS NULL)`. */
74
+ function softDeleteGuard(sqlColumn: string): string {
75
+ return `(${sqlColumn} IS NULL)`;
76
+ }
77
+
78
+ /**
79
+ * The transitive-owner (`via:`) predicate, in deparser normal form — a parent
80
+ * subquery that scopes the child to rows whose FK points at a parent the caller
81
+ * owns. The exact whitespace (3 spaces before FROM, 2 before WHERE, the newlines)
82
+ * is what `pg_get_expr` emits and the contract stores, so it is reproduced byte
83
+ * for byte; the inner owner predicate reuses {@link ownerPredicate} (table-qualified):
84
+ *
85
+ * (upload_id IN ( SELECT uploads.id
86
+ * FROM uploads
87
+ * WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
88
+ */
89
+ function viaPredicate(v: ViaRef): string {
90
+ const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
91
+ return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Ability classification.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ interface OwnerRef {
99
+ sqlColumn: string;
100
+ /** The field's Postgres type — drives the cast in ownerPredicate. */
101
+ type: string;
102
+ /** JWT claim holding the owner id. */
103
+ claim: string;
104
+ }
105
+
106
+ /**
107
+ * The owner-scoped probe spec a model declares — its schema-qualified table, owner SQL column,
108
+ * and JWT claim — or null when the model has no owner ability. This is what drives the
109
+ * owner-aware red-team (`db:authz:owner`): the Model already names the owner via `can({ owner })`,
110
+ * so isolation testing needs no extra declaration.
111
+ */
112
+ export function modelOwner(model: ModelDescriptor, opts: CompileOptions = {}): { table: string; ownerColumn: string; claim: string } | null {
113
+ const owner = resolveOwner(model);
114
+ if (!owner) return null;
115
+ const schema = resolveSchema(model, opts);
116
+ return { table: `${schema}.${model.table}`, ownerColumn: owner.sqlColumn, claim: owner.claim };
117
+ }
118
+
119
+ /**
120
+ * The schema a model's table lives in. The model's own `schema` wins (a 'dist'
121
+ * model is dist in any run), then the generate call's default, then 'public'.
122
+ */
123
+ function resolveSchema(model: ModelDescriptor, opts: CompileOptions): string {
124
+ return model.schema ?? opts.schema ?? 'public';
125
+ }
126
+
127
+ /** Resolve the owner column (SQL name + type + claim) from the first owner ability. */
128
+ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
129
+ const ownerAbility = model.abilities.find((a) => a.condition.owner);
130
+ if (!ownerAbility) return null;
131
+ const fieldKey = ownerAbility.condition.owner!;
132
+ return {
133
+ sqlColumn: toSnakeCase(fieldKey),
134
+ type: model.fields[fieldKey]?.spec.type ?? 'uuid',
135
+ claim: ownerAbility.condition.userField ?? 'sub',
136
+ };
137
+ }
138
+
139
+ interface ViaRef {
140
+ /** The child's FK column, snake_cased (e.g. `upload_id`). */
141
+ fkColumn: string;
142
+ /** The referenced parent table (e.g. `uploads`). */
143
+ parentTable: string;
144
+ /** The parent's primary-key column, snake_cased (e.g. `id`). */
145
+ parentPk: string;
146
+ /** The parent's resolved owner — the predicate scopes the subquery on this. */
147
+ parentOwner: OwnerRef;
148
+ }
149
+
150
+ /**
151
+ * Resolve the transitive-owner spec from the first `via:` ability. The named field
152
+ * must carry `.references()`; the compiler follows that thunk to the parent
153
+ * descriptor and resolves the parent's own `owner` ability. Throws a clear error
154
+ * when the field has no FK or the parent declares no owner — a `via:` that cannot
155
+ * reach an owner is a model bug, not a silently-empty policy.
156
+ */
157
+ function resolveVia(model: ModelDescriptor): ViaRef | null {
158
+ const viaAbility = model.abilities.find((a) => a.condition.via);
159
+ if (!viaAbility) return null;
160
+ const fieldKey = viaAbility.condition.via!;
161
+ const ref = model.fields[fieldKey]?.spec.references;
162
+ if (!ref) {
163
+ throw new Error(`via: '${fieldKey}' on '${model.table}' must name a field declared with .references()`);
164
+ }
165
+ const parent = ref() as ModelDescriptor;
166
+ const parentOwner = resolveOwner(parent);
167
+ if (!parentOwner) {
168
+ throw new Error(`via: parent model '${parent.table}' (from '${model.table}.${fieldKey}') declares no owner ability`);
169
+ }
170
+ return {
171
+ fkColumn: toSnakeCase(fieldKey),
172
+ parentTable: parent.table,
173
+ parentPk: toSnakeCase(parent.primaryKey[0] ?? 'id'),
174
+ parentOwner,
175
+ };
176
+ }
177
+
178
+ /** The conventional soft-delete column SQL name, when the model has one. */
179
+ function softDeleteColumn(model: ModelDescriptor): string | null {
180
+ return 'deletedAt' in model.fields ? 'deleted_at' : null;
181
+ }
182
+
183
+ /**
184
+ * A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
185
+ * grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
186
+ * NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
187
+ * column list (`auth.users` → id/email/role to the owner, never the password hash). It is
188
+ * excluded from the table-grant and owner-read paths so the two never double-emit.
189
+ */
190
+ function isColumnRead(a: Ability): boolean {
191
+ return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
192
+ }
193
+
194
+ /**
195
+ * Column grants from the column-scoped read abilities: the read role (`authenticated`, since
196
+ * the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
197
+ * to SQL columns and sorted, matching the introspected contract. Returns undefined when no
198
+ * ability declares columns — an introspected contract omits the key rather than carry an empty
199
+ * object, so the compiled one must too (or the round-trip diff would false-fire).
200
+ */
201
+ function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
202
+ const out: Record<string, Record<string, string[]>> = {};
203
+ for (const a of abilities) {
204
+ if (!isColumnRead(a)) continue;
205
+ const role = a.condition.role ?? 'authenticated';
206
+ const cols = [...a.condition.columns!].map(toSnakeCase).sort();
207
+ (out[role] ??= {}).SELECT = cols;
208
+ }
209
+ return Object.keys(out).length ? out : undefined;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Compile.
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Compile one model's `abilities` into its TableContract (rls + grants + policies).
218
+ * Deterministic: policies sorted by name, grant privilege lists sorted — so the
219
+ * output is byte-comparable with an introspected contract.
220
+ */
221
+ export function compileTableContract(model: ModelDescriptor, opts: CompileOptions = {}): TableContract {
222
+ const schema = resolveSchema(model, opts);
223
+ const table = `${schema}.${model.table}`;
224
+ const owner = resolveOwner(model);
225
+ const via = resolveVia(model);
226
+ const sdCol = softDeleteColumn(model);
227
+ // The row-scoping predicate: a direct owner column, else a transitive `via:`
228
+ // parent subquery. Both drive the same owner-gated policies (select/insert/
229
+ // update/delete_own); only the predicate text differs.
230
+ const rowPred = owner
231
+ ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
232
+ : via
233
+ ? viaPredicate(via)
234
+ : null;
235
+ const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
236
+
237
+ // A "row-scoped" condition is either a direct owner or a transitive via.
238
+ const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
239
+
240
+ const abilities = model.abilities;
241
+ const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
242
+ const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
243
+ // A column-scoped read is row-scoped but emits a self policy + column grant, not the
244
+ // full-row owner read — so it is excluded here and handled by its own branch.
245
+ const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnRead(a));
246
+ const hasColumnRead = abilities.some(isColumnRead);
247
+ const canCreate = abilities.some((a) => a.action === 'create');
248
+ const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
249
+ const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
250
+
251
+ const t = model.table;
252
+ const policies: PolicyContract[] = [];
253
+ const policy = (
254
+ name: string, command: PolicyCommand, roles: string[],
255
+ using: string | null, check: string | null,
256
+ ): void => {
257
+ policies.push({ name, command, roles, permissive: true, using, check });
258
+ };
259
+
260
+ // admin-bypass — one ALL policy, true/true.
261
+ if (hasAdminManage) policy(`${t}_admin`, 'ALL', ['admin'], 'true', 'true');
262
+
263
+ if (hasPublicRead) {
264
+ // public read on a soft-delete table — split per role; authenticated owners
265
+ // also see their own (soft-deleted) rows, hence the OR'd owner check.
266
+ const anonUsing = sdGuard ?? 'true';
267
+ policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
268
+
269
+ let authedUsing = anonUsing;
270
+ if (sdGuard && rowPred) authedUsing = `(${sdGuard} OR ${rowPred})`;
271
+ else if (rowPred && !sdGuard) authedUsing = rowPred;
272
+ policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
273
+ } else if (hasOwnerRead && rowPred) {
274
+ // owner-scoped read — no anon visibility; you see only the rows you own
275
+ // (directly, or transitively through a `via:` parent).
276
+ policy(`${t}_select_own`, 'SELECT', ['authenticated'], rowPred, null);
277
+ } else if (hasColumnRead && rowPred) {
278
+ // column-scoped self read — `authenticated` reads only its OWN row, and only the
279
+ // granted columns (the GRANT scopes fields; this policy scopes rows). Named
280
+ // `_select_self`, distinct from the full-row `_select_own`.
281
+ policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
282
+ }
283
+
284
+ // owner-gated writes. INSERT checks ownership (you can only create rows you own);
285
+ // UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
286
+ if (rowPred) {
287
+ if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, rowPred);
288
+ if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
289
+ if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'], rowPred, null);
290
+ }
291
+
292
+ policies.sort((a, b) => a.name.localeCompare(b.name));
293
+
294
+ return {
295
+ table,
296
+ // The FORCE axiom: FORCE iff the write principal is subject to its own RLS
297
+ // policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
298
+ // write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
299
+ // owner is not a superuser, so a FORCEd table would block the owner's own writes).
300
+ rls: { enabled: true, forced: model.writtenBy === 'app' },
301
+ grants: compileGrants(abilities),
302
+ ...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
303
+ policies,
304
+ };
305
+ }
306
+
307
+ /** The four DML privileges, sorted — the expansion of a `manage` ability. */
308
+ const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
309
+
310
+ /** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
311
+ const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
312
+ read: 'SELECT',
313
+ create: 'INSERT',
314
+ update: 'UPDATE',
315
+ delete: 'DELETE',
316
+ };
317
+
318
+ /** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
319
+ function verbsFor(action: Ability['action']): string[] {
320
+ return action === 'manage' ? [...CRUD] : [VERB[action]];
321
+ }
322
+
323
+ /**
324
+ * Precise grants — derive each role's privilege set from the abilities, not a
325
+ * uniform tier. everystack's model is coarse grants + fine-grained RLS: the grant
326
+ * is the gate (which roles may touch the table at all), RLS restricts which rows.
327
+ * A role gets a privilege iff some ability grants that verb to it:
328
+ *
329
+ * - `can(action, { role })` -> that role gets the verb(s)
330
+ * - public/owner/via ability (no role) -> `authenticated` gets the verb(s)
331
+ * - a *public* read (no role, no owner/via) -> `anon` also gets SELECT
332
+ *
333
+ * No ability for a role means no grant for that role: an ability-less (internal)
334
+ * table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
335
+ * table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
336
+ * so the output is byte-comparable with an introspected contract.
337
+ */
338
+ function compileGrants(abilities: readonly Ability[]): Record<string, string[]> {
339
+ const grants: Record<string, Set<string>> = {};
340
+ const add = (role: string, verbs: string[]): void => {
341
+ const set = (grants[role] ??= new Set<string>());
342
+ for (const v of verbs) set.add(v);
343
+ };
344
+ for (const a of abilities) {
345
+ // A column-scoped read grants `SELECT (cols)` (a column grant), not a table-level
346
+ // privilege — emitted by compileColumnGrants, skipped here so it never leaks a
347
+ // whole-table grant that would defeat the column scoping.
348
+ if (isColumnRead(a)) continue;
349
+ const verbs = verbsFor(a.action);
350
+ if (a.condition.role) {
351
+ add(a.condition.role, verbs);
352
+ } else {
353
+ add('authenticated', verbs);
354
+ // A public read (no role, not owner/via-scoped) is anon-visible.
355
+ if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
356
+ }
357
+ }
358
+ const out: Record<string, string[]> = {};
359
+ for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
360
+ return out;
361
+ }
362
+
363
+ // Kept exported for callers that want just the privilege set for a role tier.
364
+ export type { Ability };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Filesystem IO for the Authorization Contract — write/load the per-table files.
3
+ *
4
+ * Kept out of authz-contract.ts so the core (introspect/assemble/diff) stays pure and
5
+ * fs-free. The serialized form is deterministic and byte-stable: a re-pull of an
6
+ * unchanged database produces identical files, so a contract change is always a real,
7
+ * reviewable git diff and never reformatting noise.
8
+ */
9
+
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import type { AuthzContract, TableContract, FunctionContract } from './authz-contract.js';
13
+ import { renderContractMarkdown } from './authz-render.js';
14
+
15
+ /** The human-readable review doc written alongside the JSON. */
16
+ export const REVIEW_FILE = 'AUTHORIZATION.md';
17
+
18
+ /** Stable JSON: object keys sorted recursively, 2-space indent, trailing newline. */
19
+ export function stableStringify(value: unknown): string {
20
+ const sortKeys = (v: any): any => {
21
+ if (Array.isArray(v)) return v.map(sortKeys);
22
+ if (v && typeof v === 'object') {
23
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
24
+ }
25
+ return v;
26
+ };
27
+ return JSON.stringify(sortKeys(value), null, 2) + '\n';
28
+ }
29
+
30
+ /** `public.posts` -> `public.posts.contract.json`. */
31
+ export function tableFileName(table: string): string {
32
+ return `${table}.contract.json`;
33
+ }
34
+
35
+ export const FUNCTIONS_FILE = '_functions.contract.json';
36
+
37
+ /**
38
+ * Write the contract as one file per table plus `_functions.contract.json`. Prunes any
39
+ * stale `*.contract.json` for a table no longer in the contract, so a dropped table
40
+ * leaves no orphan file. The directory is created if absent.
41
+ */
42
+ export async function writeContract(dir: string, contract: AuthzContract): Promise<string[]> {
43
+ await fs.mkdir(dir, { recursive: true });
44
+
45
+ const written = new Set<string>();
46
+ for (const table of contract.tables) {
47
+ const file = tableFileName(table.table);
48
+ await fs.writeFile(path.join(dir, file), stableStringify(table), 'utf8');
49
+ written.add(file);
50
+ }
51
+ await fs.writeFile(path.join(dir, FUNCTIONS_FILE), stableStringify({ functions: contract.functions }), 'utf8');
52
+ written.add(FUNCTIONS_FILE);
53
+
54
+ for (const existing of await listContractFiles(dir)) {
55
+ if (!written.has(existing)) await fs.rm(path.join(dir, existing));
56
+ }
57
+
58
+ // The human-readable review doc, regenerated from the same contract so it can't drift.
59
+ await fs.writeFile(path.join(dir, REVIEW_FILE), renderContractMarkdown(contract), 'utf8');
60
+ written.add(REVIEW_FILE);
61
+ return [...written].sort();
62
+ }
63
+
64
+ /** Load the per-table files back into a contract. Missing dir -> empty contract. */
65
+ export async function loadContract(dir: string): Promise<AuthzContract> {
66
+ let files: string[];
67
+ try {
68
+ files = await listContractFiles(dir);
69
+ } catch {
70
+ return { tables: [], functions: [] };
71
+ }
72
+
73
+ const tables: TableContract[] = [];
74
+ let functions: FunctionContract[] = [];
75
+ for (const file of files) {
76
+ const raw = await fs.readFile(path.join(dir, file), 'utf8');
77
+ const parsed = JSON.parse(raw);
78
+ if (file === FUNCTIONS_FILE) {
79
+ functions = parsed.functions ?? [];
80
+ } else {
81
+ tables.push(parsed as TableContract);
82
+ }
83
+ }
84
+ tables.sort((a, b) => a.table.localeCompare(b.table));
85
+ functions.sort((a, b) => a.name.localeCompare(b.name));
86
+ return { tables, functions };
87
+ }
88
+
89
+ async function listContractFiles(dir: string): Promise<string[]> {
90
+ const entries = await fs.readdir(dir);
91
+ return entries.filter((f) => f.endsWith('.contract.json'));
92
+ }