@everystack/cli 0.2.39 → 0.3.0
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 +28 -5
- package/package.json +12 -2
- package/src/cli/authz-compile.ts +212 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +200 -0
- package/src/cli/authz-reconcile.ts +127 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +292 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +133 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +68 -0
- package/src/cli/migration-compile.ts +69 -0
- package/src/cli/migration-generate.ts +175 -0
- package/src/cli/model-api.ts +35 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +439 -0
- package/src/cli/schema-diff.ts +605 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
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
|
|
181
|
+
These commands invoke your Lambda directly via IAM (no database credentials ever leave AWS):
|
|
174
182
|
|
|
175
183
|
```bash
|
|
176
|
-
|
|
177
|
-
everystack db:
|
|
178
|
-
everystack db:
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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.0"
|
|
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,212 @@
|
|
|
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
|
+
// Ability classification.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
interface OwnerRef {
|
|
83
|
+
sqlColumn: string;
|
|
84
|
+
/** The field's Postgres type — drives the cast in ownerPredicate. */
|
|
85
|
+
type: string;
|
|
86
|
+
/** JWT claim holding the owner id. */
|
|
87
|
+
claim: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The owner-scoped probe spec a model declares — its schema-qualified table, owner SQL column,
|
|
92
|
+
* and JWT claim — or null when the model has no owner ability. This is what drives the
|
|
93
|
+
* owner-aware red-team (`db:authz:owner`): the Model already names the owner via `can({ owner })`,
|
|
94
|
+
* so isolation testing needs no extra declaration.
|
|
95
|
+
*/
|
|
96
|
+
export function modelOwner(model: ModelDescriptor, opts: CompileOptions = {}): { table: string; ownerColumn: string; claim: string } | null {
|
|
97
|
+
const owner = resolveOwner(model);
|
|
98
|
+
if (!owner) return null;
|
|
99
|
+
const schema = opts.schema ?? 'public';
|
|
100
|
+
return { table: `${schema}.${model.table}`, ownerColumn: owner.sqlColumn, claim: owner.claim };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve the owner column (SQL name + type + claim) from the first owner ability. */
|
|
104
|
+
function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
105
|
+
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
106
|
+
if (!ownerAbility) return null;
|
|
107
|
+
const fieldKey = ownerAbility.condition.owner!;
|
|
108
|
+
return {
|
|
109
|
+
sqlColumn: toSnakeCase(fieldKey),
|
|
110
|
+
type: model.fields[fieldKey]?.spec.type ?? 'uuid',
|
|
111
|
+
claim: ownerAbility.condition.userField ?? 'sub',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The conventional soft-delete column SQL name, when the model has one. */
|
|
116
|
+
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
117
|
+
return 'deletedAt' in model.fields ? 'deleted_at' : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Compile.
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compile one model's `abilities` into its TableContract (rls + grants + policies).
|
|
126
|
+
* Deterministic: policies sorted by name, grant privilege lists sorted — so the
|
|
127
|
+
* output is byte-comparable with an introspected contract.
|
|
128
|
+
*/
|
|
129
|
+
export function compileTableContract(model: ModelDescriptor, opts: CompileOptions = {}): TableContract {
|
|
130
|
+
const schema = opts.schema ?? 'public';
|
|
131
|
+
const table = `${schema}.${model.table}`;
|
|
132
|
+
const owner = resolveOwner(model);
|
|
133
|
+
const sdCol = softDeleteColumn(model);
|
|
134
|
+
const ownerPred = owner ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim) : null;
|
|
135
|
+
const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
|
|
136
|
+
|
|
137
|
+
const abilities = model.abilities;
|
|
138
|
+
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
139
|
+
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !a.condition.owner);
|
|
140
|
+
const hasOwnerRead = abilities.some((a) => a.action === 'read' && a.condition.owner);
|
|
141
|
+
const canCreate = abilities.some((a) => a.action === 'create');
|
|
142
|
+
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && a.condition.owner);
|
|
143
|
+
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && a.condition.owner);
|
|
144
|
+
|
|
145
|
+
const t = model.table;
|
|
146
|
+
const policies: PolicyContract[] = [];
|
|
147
|
+
const policy = (
|
|
148
|
+
name: string, command: PolicyCommand, roles: string[],
|
|
149
|
+
using: string | null, check: string | null,
|
|
150
|
+
): void => {
|
|
151
|
+
policies.push({ name, command, roles, permissive: true, using, check });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// admin-bypass — one ALL policy, true/true.
|
|
155
|
+
if (hasAdminManage) policy(`${t}_admin`, 'ALL', ['admin'], 'true', 'true');
|
|
156
|
+
|
|
157
|
+
if (hasPublicRead) {
|
|
158
|
+
// public read on a soft-delete table — split per role; authenticated owners
|
|
159
|
+
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
160
|
+
const anonUsing = sdGuard ?? 'true';
|
|
161
|
+
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
162
|
+
|
|
163
|
+
let authedUsing = anonUsing;
|
|
164
|
+
if (sdGuard && ownerPred) authedUsing = `(${sdGuard} OR ${ownerPred})`;
|
|
165
|
+
else if (ownerPred && !sdGuard) authedUsing = ownerPred;
|
|
166
|
+
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
167
|
+
} else if (hasOwnerRead && ownerPred) {
|
|
168
|
+
// owner-only read — no anon visibility; you see only your own rows.
|
|
169
|
+
policy(`${t}_select_own`, 'SELECT', ['authenticated'], ownerPred, null);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
173
|
+
// UPDATE gates + checks; DELETE gates.
|
|
174
|
+
if (ownerPred) {
|
|
175
|
+
if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, ownerPred);
|
|
176
|
+
if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'], ownerPred, ownerPred);
|
|
177
|
+
if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'], ownerPred, null);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
table,
|
|
184
|
+
rls: { enabled: true, forced: true },
|
|
185
|
+
grants: compileGrants(hasAdminManage),
|
|
186
|
+
policies,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The grant tier for a public app table. everystack's model is coarse grants +
|
|
192
|
+
* fine-grained RLS: anon may read, authenticated may CRUD, admin may CRUD — and
|
|
193
|
+
* RLS (the policies above) is what actually restricts which rows. So the grants
|
|
194
|
+
* are uniform across app tables, not derived per-ability (profiles has no create
|
|
195
|
+
* ability yet is granted INSERT; uploads is owner-only yet grants anon SELECT).
|
|
196
|
+
*
|
|
197
|
+
* A precise, least-exposure grant set (drop the anon SELECT on an owner-only
|
|
198
|
+
* table) is a deliberate future refinement — it would tighten the live DB, which
|
|
199
|
+
* the diff would surface and reconcile would apply. Today this reproduces the
|
|
200
|
+
* deployed grants so the contract round-trips.
|
|
201
|
+
*/
|
|
202
|
+
function compileGrants(hasAdminManage: boolean): Record<string, string[]> {
|
|
203
|
+
const crud = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
204
|
+
return {
|
|
205
|
+
anon: ['SELECT'],
|
|
206
|
+
authenticated: [...crud],
|
|
207
|
+
...(hasAdminManage ? { admin: [...crud] } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Kept exported for callers that want just the privilege set for a role tier.
|
|
212
|
+
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
|
+
}
|