@everystack/mcp 0.4.7 → 0.4.8
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/CHANGELOG.md +105 -0
- package/dist/cli.md +25 -0
- package/dist/database-operations.md +44 -9
- package/dist/expo-server-deploy.md +47 -2
- package/dist/index.cjs +1 -1
- package/dist/schema-patterns.md +27 -3
- package/dist/security.md +56 -0
- package/package.json +7 -5
- package/src/resources/cli.md +25 -0
- package/src/resources/database-operations.md +44 -9
- package/src/resources/expo-server-deploy.md +47 -2
- package/src/resources/schema-patterns.md +27 -3
- package/src/resources/security.md +56 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@everystack/mcp` are documented here.
|
|
4
|
+
|
|
5
|
+
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
|
+
|
|
7
|
+
## [0.4.8] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- pull a published Task image instead of building it at deploy
|
|
12
|
+
- auto-generated changelogs from conventional commits
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- the task image no longer computes the fingerprint it is judged by
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
- the operator credential is the ops channel only
|
|
21
|
+
- every db verb has a reference entry, and a test says so
|
|
22
|
+
- the disclosed gap names what actually compiles, and a test pins it
|
|
23
|
+
- the last two rollback-point sentences say what the code does; the retargeting edge is named
|
|
24
|
+
|
|
25
|
+
## [0.4.7] - 2026-08-12
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- the guidance taught a model that throws, and the config it replaced
|
|
30
|
+
|
|
31
|
+
## [0.4.6] - 2026-08-05
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- a cheat gate for stage-name-as-trust-boundary, and the sweep that found two more
|
|
36
|
+
|
|
37
|
+
## [0.4.5] - 2026-08-05
|
|
38
|
+
|
|
39
|
+
### Documentation
|
|
40
|
+
|
|
41
|
+
- the debugging path for a 500 is the request id
|
|
42
|
+
|
|
43
|
+
## [0.4.4] - 2026-08-01
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- dbTask BYO-VPC support for external/public RDS (0.4.2)
|
|
48
|
+
- scope dbTask SSM param under /sst/<app>/<stage>/ (0.4.3)
|
|
49
|
+
- db:reconcile applies an authz-only change as a regrant, not a matview rebuild (0.4.34)
|
|
50
|
+
- db:authz gets a local venue, and the published bin actually runs
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- db:swap stops fighting its own backup, and its snapshot is now a fact
|
|
55
|
+
- soft-delete visibility is declared, never inferred from a column name
|
|
56
|
+
|
|
57
|
+
## [0.4.2] - 2026-07-23
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- everystack://expo-server-deploy — the Expo Router server app deploy chapter, self-contained
|
|
62
|
+
- db data-movement — restore→Fargate + delete the pg-tools layer
|
|
63
|
+
- expose WAF/caching as an MCP resource — fix the 403-every-PostgREST trap
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- deploy resource wires the backups path so db:backup isn't unreachable
|
|
68
|
+
|
|
69
|
+
## [0.4.0] - 2026-07-10
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- retire db/sql — descriptors are the single home (Brick B7, read-model-everywhere)
|
|
74
|
+
- 0.4.0 guidance — descriptors, safe remote migration, and the real toolset
|
|
75
|
+
|
|
76
|
+
## [0.3.3] - 2026-07-04
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
|
|
80
|
+
- the gates and generators teach the migration-authority workflow
|
|
81
|
+
|
|
82
|
+
## [0.3.1] - 2026-07-02
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- governance layer — grounding + cheat gates (0.3.0)
|
|
87
|
+
- scaffold + reconcile the project CLAUDE.md
|
|
88
|
+
- runbook prompt — drive the runbook compiler, fill slots only; cross-link in CLAUDE.md template
|
|
89
|
+
|
|
90
|
+
## [0.2.2] - 2026-06-18
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- add @everystack/mcp package, IAM docs, .mcp.json
|
|
95
|
+
- start-static beginner flow, check_environment tools, beginner resources
|
|
96
|
+
- S3 origin cache for processed images
|
|
97
|
+
- dual-export OTA updates, SSR validation, eas.json env resolution
|
|
98
|
+
|
|
99
|
+
### Fixed
|
|
100
|
+
|
|
101
|
+
- fail closed on cookie auth without allowedOrigins
|
|
102
|
+
|
|
103
|
+
### Documentation
|
|
104
|
+
|
|
105
|
+
- scope the SST deploy IAM policy to the app's resources
|
package/dist/cli.md
CHANGED
|
@@ -37,11 +37,20 @@ these verbs move a database. Full operational guidance: everystack://database-op
|
|
|
37
37
|
everystack db:sync # Make a dev DB match your checkout — state + authz + compute, one verb
|
|
38
38
|
everystack db:generate # STATE layer (tables) → next migration file (--dry-run prints the SQL, writes nothing)
|
|
39
39
|
everystack db:reconcile # Deploy the compute layer only (views/matviews/functions/triggers)
|
|
40
|
+
everystack db:refresh # Refresh the declared matviews in dependency order, credential-free (--only, --verify-nonempty, --list)
|
|
40
41
|
everystack db:check # CI gate: declared state composes + generated artifacts match regeneration
|
|
41
42
|
everystack db:fingerprint # Content-address the live base schema vs the Models — MATCH/MISMATCH
|
|
42
43
|
everystack db:diff # The state edge between two declared states — no DB, CI-pure
|
|
44
|
+
everystack db:build # Build a database FROM the models and keep it (fresh target only; --database-url, never the env)
|
|
45
|
+
everystack db:pull # Introspect a live DB → render field() Models: THE on-ramp for an existing database
|
|
43
46
|
```
|
|
44
47
|
|
|
48
|
+
**Bringing an existing database in** — `db:pull --stage <s> --abilities live --out db/models`, commit
|
|
49
|
+
what it renders, then `db:plan --stage <s>`. An EMPTY plan means adopted as-is (that commit declares
|
|
50
|
+
the database; every later `db:apply` descends from it). A NON-EMPTY plan is the hardening edge:
|
|
51
|
+
review it, `db:apply` it — no commit declares the database yet, so the reviewed plan is the origin.
|
|
52
|
+
Render from the stage, never from a local restore of a dump.
|
|
53
|
+
|
|
45
54
|
**Authorization (the brownfield on-ramp + the red team)**
|
|
46
55
|
```bash
|
|
47
56
|
everystack db:authz:pull # Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
@@ -78,6 +87,21 @@ everystack db:backup:download <id> --stage X # Presigned download URL (1h)
|
|
|
78
87
|
everystack db:restore --from <id> --stage X --confirm # Restore a backup INTO a stage (destructive)
|
|
79
88
|
```
|
|
80
89
|
|
|
90
|
+
**Schema artifacts (build elsewhere, land atomically)**
|
|
91
|
+
```bash
|
|
92
|
+
everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped .dump (+ .meta.json)
|
|
93
|
+
everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # fingerprint gate → confirmed snapshot → restore → one txn → verify; destructive
|
|
94
|
+
```
|
|
95
|
+
`db:swap`'s venue is explicit (`DATABASE_URL` in the env is refused; `--stage` needs `--direct`), and the
|
|
96
|
+
rollback point is confirmed before the restore begins. Full guide: docs/schema-swap.md.
|
|
97
|
+
|
|
98
|
+
**Roles & health**
|
|
99
|
+
```bash
|
|
100
|
+
everystack db:provision --stage X # Create the least-privilege role chain (idempotent); --rotate swaps A/B credentials live
|
|
101
|
+
everystack db:doctor --stage X # Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
102
|
+
everystack db:reset --stage dev # Drop all schemas + re-run migrations (dev only; migration-era apps)
|
|
103
|
+
```
|
|
104
|
+
|
|
81
105
|
**Local dev databases**
|
|
82
106
|
```bash
|
|
83
107
|
everystack db:template:refresh # Build the dev template from declared state + seed
|
|
@@ -88,6 +112,7 @@ everystack db:fork --from-stage src --stage tgt --confirm # Fork a deployed st
|
|
|
88
112
|
**Data**
|
|
89
113
|
```bash
|
|
90
114
|
everystack db:seed --stage dev # Declarative seed script (dev only; refuses on production)
|
|
115
|
+
everystack db:exec fix.sql --stage X # Credential-free DML-only write, one txn; ANY schema change rolls it back (--confirm on prod-tier)
|
|
91
116
|
everystack db:backfill --apply # One-shot data moves from db/backfills/*.sql (own record)
|
|
92
117
|
everystack pipeline:run --stage X # Ingest pipeline (credential-free in the ops Lambda)
|
|
93
118
|
everystack db:migrate # Run the generated migration on a deployed stage via Lambda invoke
|
|
@@ -133,11 +133,28 @@ the database URL**. `db:apply` verifies → applies → verifies, and **refuses*
|
|
|
133
133
|
|
|
134
134
|
- **the live fingerprint still equals `plan.from`** (the concurrency lock — nobody changed
|
|
135
135
|
the target since you minted the plan; if they did, re-mint), AND
|
|
136
|
+
- **the schema's default privileges still hash to `plan.defaultAclHash`** (an
|
|
137
|
+
`ALTER DEFAULT PRIVILEGES` since minting changes what a created object lands with; the
|
|
138
|
+
refusal happens BEFORE dispatch, nothing lands — re-mint), AND
|
|
136
139
|
- **your checkout descends from the commit that declares the target's state** (the
|
|
137
140
|
fast-forward rule — "rebase first"; a stale checkout can't silently revert merged work).
|
|
141
|
+
When NO commit on any ref declares the live state — an existing database being hardened
|
|
142
|
+
in, or a stage hand-edited since its last apply — that is PROVEN drift over a full, current
|
|
143
|
+
clone, and it PASSES: the reviewed plan is the origin every later apply descends from,
|
|
144
|
+
recorded on the `schema_log` row. Shallow or behind-remote clones are UNVERIFIABLE and
|
|
145
|
+
refuse with a fetch runbook.
|
|
138
146
|
|
|
139
147
|
Every refusal is recorded in `everystack.schema_log`. `--database-url` is local-dev-only;
|
|
140
|
-
protected stages always go through `--stage`.
|
|
148
|
+
protected stages always go through `--stage`. Every refusal is **staleness** (re-mint, never
|
|
149
|
+
a flag) or **intent** (a named attestation, `--confirm`, an approver) — there is no `--force`.
|
|
150
|
+
|
|
151
|
+
The plan's `to` is the FULL endpoint: it carries the grants of roles your models do not
|
|
152
|
+
govern (a BI reader, an ETL role) through unchanged, so on a brownfield stage an empty plan
|
|
153
|
+
prints `from == to` and still names those roles and the baseline that admitted them.
|
|
154
|
+
|
|
155
|
+
**After upgrading the CLI, redeploy the stage before a staged apply.** `db:apply --stage`
|
|
156
|
+
is a wire contract between the CLI and the ops Lambda; a version-skewed Lambda is refused
|
|
157
|
+
before any write, with the redeploy named.
|
|
141
158
|
|
|
142
159
|
### 5. Destructive plans carry more gates
|
|
143
160
|
|
|
@@ -145,8 +162,15 @@ A plan is DESTRUCTIVE if it drops (table/column/type) or narrows a type (a lossy
|
|
|
145
162
|
`SET DATA TYPE`). Those additionally require, all of them:
|
|
146
163
|
|
|
147
164
|
- **`--confirm`** — always, explicitly.
|
|
148
|
-
- **A
|
|
149
|
-
|
|
165
|
+
- **A confirmed rollback point** — on a stage that exposes `databaseInstanceId`, a physical RDS
|
|
166
|
+
snapshot taken and waited to `available` before the write (the default); otherwise a logical
|
|
167
|
+
`db:backup` waited to completion; or one you attest with `--snapshot-ref <id>`. No rollback
|
|
168
|
+
point, no destructive apply.
|
|
169
|
+
- **The `--direct` lane.** The ops-Lambda lane (`--stage X` alone) REFUSES destructive plans
|
|
170
|
+
outright. Run them `--stage X --direct`: the CLI resolves the stage's operator connection
|
|
171
|
+
from its ops Lambda, holds it in memory only (never on argv), and runs the full ceremony
|
|
172
|
+
on one real connection with an unbounded clock. `--database-url` is not the venue for a
|
|
173
|
+
deployed stage's destruction.
|
|
150
174
|
- **The stage's approver set** — if declared, `db:apply` verifies the caller's AWS identity
|
|
151
175
|
(STS) against it. Set it with `db:approvers`:
|
|
152
176
|
```bash
|
|
@@ -178,11 +202,13 @@ everystack db:backups --stage production # list logical backups
|
|
|
178
202
|
everystack db:backup:download <id> --stage production # presigned download URL (1h)
|
|
179
203
|
```
|
|
180
204
|
|
|
181
|
-
**RDS snapshot first?**
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
205
|
+
**RDS snapshot first?** A destructive `db:apply` confirms a rollback point BEFORE it writes: on
|
|
206
|
+
a stage exposing `databaseInstanceId` that is a physical RDS snapshot waited to `available`
|
|
207
|
+
(the default), otherwise a logical `db:backup` waited to completion, or one you attest with
|
|
208
|
+
`--snapshot-ref <id>`. The only way to destroy without one is to say so on the record with
|
|
209
|
+
`--snapshot none`. So a separate `db:snapshot` first is redundant on the default lane — take one
|
|
210
|
+
before a large or risky migration only when the stage has no `databaseInstanceId`, or when you
|
|
211
|
+
want a named restore point that outlives the apply.
|
|
186
212
|
|
|
187
213
|
## Importing data into a REMOTE stage
|
|
188
214
|
|
|
@@ -207,22 +233,30 @@ can't dump production. `--database-url` exists only for local dev databases you
|
|
|
207
233
|
## Command index
|
|
208
234
|
|
|
209
235
|
```bash
|
|
236
|
+
# Bring an existing database in:
|
|
237
|
+
everystack db:pull --stage X --abilities live --out db/models # render field() Models from live; commit; then db:plan (EMPTY = adopted)
|
|
238
|
+
|
|
210
239
|
# Declare (edit files), then:
|
|
211
240
|
everystack db:sync # dev DB ← your checkout (state + compute), one verb
|
|
212
241
|
everystack db:reconcile # compute layer only (views/matviews/functions/triggers)
|
|
242
|
+
everystack db:refresh # repopulate the declared matviews in dependency order (credential-free)
|
|
213
243
|
everystack db:generate # STATE layer → migration file / --apply / --dry-run
|
|
244
|
+
everystack db:build --database-url "$FRESH" # build a database FROM the models and keep it (empty target only)
|
|
214
245
|
everystack db:check # CI gate: declared state composes; artifacts match
|
|
215
246
|
everystack db:fingerprint # content-address live schema vs models — MATCH/MISMATCH
|
|
216
247
|
|
|
217
248
|
# Reach a protected stage safely:
|
|
218
249
|
everystack db:plan --stage X --out X.plan.json # mint a reviewable edge (read-only)
|
|
219
250
|
everystack db:apply --plan X.plan.json --stage X # verify → apply → verify (credential-free)
|
|
251
|
+
everystack db:apply --plan X.plan.json --stage X --direct --confirm # a DESTRUCTIVE plan: the direct lane, full ceremony
|
|
220
252
|
everystack db:approvers --stage X --set "…" # who may run destructive applies
|
|
221
253
|
|
|
222
|
-
# Back up / restore:
|
|
254
|
+
# Back up / restore / move a schema:
|
|
223
255
|
everystack db:snapshot --stage X # physical RDS snapshot (instant DR)
|
|
224
256
|
everystack db:backup --stage X # logical pg_dump → S3
|
|
225
257
|
everystack db:restore --from <id> --stage X --confirm
|
|
258
|
+
everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped artifact
|
|
259
|
+
everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # land it atomically (destructive)
|
|
226
260
|
|
|
227
261
|
# Local dev databases:
|
|
228
262
|
everystack db:template:refresh # build the dev template from declared state + seed
|
|
@@ -230,6 +264,7 @@ everystack db:branch # per-branch dev DB from the template
|
|
|
230
264
|
|
|
231
265
|
# Data:
|
|
232
266
|
everystack db:seed --stage dev # declarative seed (dev only)
|
|
267
|
+
everystack db:exec fix.sql --stage X # credential-free DML-only write, one txn (any schema change rolls it back)
|
|
233
268
|
everystack db:backfill --apply # one-shot data moves (db/backfills/*.sql)
|
|
234
269
|
everystack pipeline:run --stage X # ingest pipeline (credential-free)
|
|
235
270
|
everystack db:fork --from-stage prod --stage feature --confirm # copy a stage's data
|
|
@@ -254,8 +254,53 @@ Two requirements on the AWS side:
|
|
|
254
254
|
- **Deploy IAM (`>= 0.4.3`):** dbTask writes its operator-URL SecureString to
|
|
255
255
|
`/sst/<app>/<stage>/DbTaskOperatorUrl`, under the `parameter/sst/*` prefix an SST deploy role is
|
|
256
256
|
already scoped to. A least-privilege deploy principal also needs ECS (cluster + task-def),
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
EC2 `Describe*` (subnets/SGs), and `logs:CreateLogGroup` for the task's log group. Override the
|
|
258
|
+
param path with `paramName` to fit a different scoped prefix. (ECR auth + repo push is needed
|
|
259
|
+
only with `build: true` — see below. The default pulls a published image and pushes nothing.)
|
|
260
|
+
|
|
261
|
+
### The Task image — no Docker on the deploy machine
|
|
262
|
+
|
|
263
|
+
`dbTask` pulls a **published, digest-pinned** image by default, so `everystack deploy` needs no
|
|
264
|
+
Docker daemon. Earlier versions built the image locally on every deploy: a daemon that died
|
|
265
|
+
mid-run took the deploy down *after* other resources had already changed.
|
|
266
|
+
|
|
267
|
+
The image is a public GHCR package, so Fargate pulls it with no credential — nothing to rotate,
|
|
268
|
+
and no registry token in your AWS account. It carries no secret; the operator URL still arrives at
|
|
269
|
+
container start via ECS `secrets`/`valueFrom`.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
dbTask({ sst, aws }, 'DbTask', { vpc, adminDatabaseUrl, pgMajor: 16 }); // pulls, no Docker
|
|
273
|
+
dbTask({ sst, aws }, 'DbTask', { …, build: true }); // builds locally
|
|
274
|
+
dbTask({ sst, aws }, 'DbTask', { …, image: 'my-mirror/db-task@sha256:…' }); // your own copy
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- **`pgMajor` selects the image.** A major with no published image is REFUSED at deploy-plan time,
|
|
278
|
+
before any resource is created — it never silently falls back to a local build, because a silent
|
|
279
|
+
fallback is how the invisible Docker dependency came back.
|
|
280
|
+
- **`build: true`** is the escape hatch for a custom entrypoint or an unpublished major. It needs a
|
|
281
|
+
Docker daemon, and it checks for one up front rather than failing at the build step.
|
|
282
|
+
- **`image`** takes any reference verbatim: a mirror, an air-gapped copy, a digest you pinned.
|
|
283
|
+
- **Pinned by digest, never by tag.** A tag can be repointed at different bytes after a deploy was
|
|
284
|
+
reviewed; `repo@sha256:…` is the same bytes forever.
|
|
285
|
+
- **`architecture` costs nothing on the pull path.** The published image is a multi-arch manifest,
|
|
286
|
+
so the registry resolves arm64 vs x86_64 at pull time regardless of your machine. Only
|
|
287
|
+
`build: true` re-couples the image to the deploy machine's architecture.
|
|
288
|
+
|
|
289
|
+
**The image is an appliance — it runs pg binaries, it does not compute policy.** A backup's
|
|
290
|
+
fingerprint (what the destructive-apply gate compares against `plan.from`) is computed by the CLI,
|
|
291
|
+
before and after the dump, and passed into the task. The task never computes one. A published,
|
|
292
|
+
digest-pinned image that computed it would be running CLI internals frozen at publish time while
|
|
293
|
+
your checkout moved on, and the skew would surface only when the gate trusted a fingerprint your
|
|
294
|
+
CLI would have computed differently.
|
|
295
|
+
|
|
296
|
+
Two consequences:
|
|
297
|
+
|
|
298
|
+
- **`everystack db:backup` can now refuse.** If the schema changes during the dump, the artifact is
|
|
299
|
+
retracted — a dump spanning a migration matches neither endpoint, so it is not a safety net.
|
|
300
|
+
`db:fork` and `db:swap --snapshot logical` take the same path.
|
|
301
|
+
- **Upgrade the CLI and the ops function together.** The ops action refuses a backup that arrives
|
|
302
|
+
with no fingerprint, so an older `@everystack/cli` against a newer deploy fails with a named
|
|
303
|
+
error telling you to upgrade. It does not silently write an unstamped artifact.
|
|
259
304
|
|
|
260
305
|
**Keep `publicIp`, never `public: true`.** SST marks `publicIp` `@deprecated` in favor of `public`,
|
|
261
306
|
but do NOT swap it here: `public: true` attaches an inbound security group open to `0.0.0.0/0` on
|
package/dist/index.cjs
CHANGED
|
@@ -23764,7 +23764,7 @@ async function runGovernanceCli(argv) {
|
|
|
23764
23764
|
}
|
|
23765
23765
|
|
|
23766
23766
|
// src/index.ts
|
|
23767
|
-
var version2 = (true ? "0.4.
|
|
23767
|
+
var version2 = (true ? "0.4.8" : null) ?? "0.3.0-dev";
|
|
23768
23768
|
var INSTRUCTIONS = [
|
|
23769
23769
|
"You govern how any agent builds everystack \u2014 a self-hosted application stack for Expo apps on AWS.",
|
|
23770
23770
|
"Your job is not only to advise but to keep the build on-script: the architecture the maintainer",
|
package/dist/schema-patterns.md
CHANGED
|
@@ -146,13 +146,37 @@ export const Post = defineModel('posts', {
|
|
|
146
146
|
fields: { /* … */ },
|
|
147
147
|
abilities: [
|
|
148
148
|
can('read'), // anon + authenticated
|
|
149
|
-
can('create', { role: 'authenticated' }),
|
|
150
|
-
can('update', {
|
|
151
|
-
can('
|
|
149
|
+
can('create', { role: 'authenticated' }), // signed-in users insert rows THEY OWN — the model has an owner (below), so WITH CHECK (author_id = caller)
|
|
150
|
+
can('update', { owner: 'authorId' }), // row ownership: only the author, via RLS
|
|
151
|
+
can('manage', { role: 'admin' }), // the admin bypass: one ALL policy, true/true
|
|
152
152
|
],
|
|
153
153
|
});
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
**How a write compiles — read this before naming a role.** A write ability is a GRANT plus an
|
|
157
|
+
RLS policy, and RLS is on for every model with abilities, so a grant with no policy is dead:
|
|
158
|
+
an INSERT fails loudly (`new row violates row-level security policy`), while an UPDATE or
|
|
159
|
+
DELETE with no policy affects **0 rows silently** — no error, nothing changes.
|
|
160
|
+
|
|
161
|
+
- `owner:` / `via:` writes compile the owner-checked policy (`<t>_insert_own`, `_update_own`,
|
|
162
|
+
`_delete_own`) for `authenticated`. On such a model a bare `can('create')` or
|
|
163
|
+
`can('create', { role: 'authenticated' })` is ALSO the owner-checked insert — the example above.
|
|
164
|
+
- `can('manage', { role: 'admin' })` is the admin bypass: one `ALL` policy, `true`/`true`.
|
|
165
|
+
- **Known gap (cli 0.4.80):** a role-scoped or bare write with NO owner on the model and no
|
|
166
|
+
`where:` — `can('create', { role: 'authenticated' })` on an owner-less table, `can('delete',
|
|
167
|
+
{ role: 'admin' })`, `can('manage', { role: 'editor' })` — compiles the grant and NO policy. It
|
|
168
|
+
reads as access and grants none. A vacuous `where: sql\`true\`` does NOT help: the compiler
|
|
169
|
+
discards a `true` predicate and still emits nothing. What compiles today: `owner:` / `via:`
|
|
170
|
+
for row-scoped writes; `can('manage', { role: 'admin' })` for the admin bypass; and, for
|
|
171
|
+
`authenticated` only, a real (non-vacuous) `where:` on a `create`/`update`/`delete`, which
|
|
172
|
+
compiles a predicate-only policy for `authenticated`. There is no in-model spelling for a
|
|
173
|
+
role-wide write by any other role — and do not try a `where:` on another role's write: the
|
|
174
|
+
write compiler never reads the declared role, so `can('update', { role: 'editor', where })`
|
|
175
|
+
grants editor and lands the predicate on the **`authenticated`** policy. Run that write on the
|
|
176
|
+
trusted admin connection until the write-policy matrix ships.
|
|
177
|
+
- `check:` is the WITH CHECK half verbatim — it REPLACES the owner term, it does not narrow it —
|
|
178
|
+
so a `check:` on an owner model must carry the owner term itself.
|
|
179
|
+
|
|
156
180
|
## Handler Config for Schema
|
|
157
181
|
|
|
158
182
|
The handler config is **derived** from the Models (`deriveHandlerConfig(models)`), so
|
package/dist/security.md
CHANGED
|
@@ -80,6 +80,56 @@ GRANT admin TO authenticator;
|
|
|
80
80
|
|
|
81
81
|
Why `NOINHERIT`: authenticator has zero privileges. All access comes through the granted roles. This prevents accidental privilege leakage.
|
|
82
82
|
|
|
83
|
+
### The Two Credential Channels
|
|
84
|
+
|
|
85
|
+
There are exactly two database credentials, and they are not interchangeable.
|
|
86
|
+
|
|
87
|
+
| | Role | Used by | Never |
|
|
88
|
+
| --- | --- | --- | --- |
|
|
89
|
+
| `DATABASE_URL` | `authenticator` (least privilege) | The app plane. Every request-serving read and write, always through `withRole` / `pgSettings`. | — |
|
|
90
|
+
| `ADMIN_DATABASE_URL` | `migrator` (owns the schema) | The ops channel ONLY: migrate, seed, console, backup. | **Never held by request-serving code.** |
|
|
91
|
+
|
|
92
|
+
**The rule: the operator credential is the ops channel only.** It owns the schema, so RLS
|
|
93
|
+
does not apply to it. A request handler holding that credential has unrestricted database
|
|
94
|
+
access, and every RLS policy you wrote is decoration. RLS is only fail-closed once the
|
|
95
|
+
credentials are split — a single privileged URL means every policy fails *open*.
|
|
96
|
+
|
|
97
|
+
This is the mistake to watch for, because it does not look like one. `createAdminDb()` and
|
|
98
|
+
`getAdminSql()` are easy to reach from app code and will work. Working is exactly the
|
|
99
|
+
problem: the failure is silent and total.
|
|
100
|
+
|
|
101
|
+
#### Trusted writes: use a service role, not the operator credential
|
|
102
|
+
|
|
103
|
+
Some writes legitimately need to bypass a user's RLS scope — issuing a credential at signup,
|
|
104
|
+
recording a webhook, a background reconcile. **Do not reach for `ADMIN_DATABASE_URL`.** The
|
|
105
|
+
lawful pattern is a NOLOGIN service role, granted to `authenticator`, entered through
|
|
106
|
+
`withRole` *after* the handler has done its own authorization:
|
|
107
|
+
|
|
108
|
+
```sql
|
|
109
|
+
-- A NOLOGIN role with exactly the privileges the trusted lane needs, and no more.
|
|
110
|
+
CREATE ROLE service NOLOGIN;
|
|
111
|
+
GRANT service TO authenticator;
|
|
112
|
+
|
|
113
|
+
GRANT SELECT, INSERT, UPDATE ON credentials TO service;
|
|
114
|
+
-- Give it a policy, or leave the table RLS-enabled with a policy that admits `service`.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// The handler authorizes FIRST — the role is never derived from the token.
|
|
119
|
+
if (!(await mayIssueCredential(user))) throw new HttpError(403);
|
|
120
|
+
|
|
121
|
+
await withRole(sql, 'service', { sub: user.sub, role: 'service' }, async (tx) => {
|
|
122
|
+
await tx`INSERT INTO credentials ${tx(row)}`;
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Note that `roleFor()` returns only `anon` and `authenticated` — the roles a token may
|
|
127
|
+
produce. `service` is never returned from a token, so a forged or absent JWT cannot reach it.
|
|
128
|
+
Server code sets it explicitly, on the same least-privilege connection, after its own check.
|
|
129
|
+
|
|
130
|
+
The result: the trusted lane is scoped to the privileges that lane actually needs, and it is
|
|
131
|
+
still on the app plane. The operator credential stays in the ops channel where it belongs.
|
|
132
|
+
|
|
83
133
|
### Table Grants
|
|
84
134
|
|
|
85
135
|
```sql
|
|
@@ -260,6 +310,12 @@ RESET ROLE;
|
|
|
260
310
|
- [ ] ENABLE + FORCE ROW LEVEL SECURITY on all public tables
|
|
261
311
|
- [ ] Write RLS policies per table x role
|
|
262
312
|
- [ ] Connect as `authenticator` (never use RDS master/superuser in production)
|
|
313
|
+
- [ ] Split the credentials: `DATABASE_URL` = authenticator, `ADMIN_DATABASE_URL` = migrator
|
|
314
|
+
- [ ] No request-serving code holds the operator credential — grep the server for
|
|
315
|
+
`createAdminDb` / `getAdminSql` and confirm every hit is an ops path (migrate, seed,
|
|
316
|
+
console, backup), not a request handler
|
|
317
|
+
- [ ] Trusted writes go through a NOLOGIN service role via `withRole`, not the operator
|
|
318
|
+
credential
|
|
263
319
|
- [ ] Test: SET ROLE authenticated; SELECT returns only expected rows
|
|
264
320
|
- [ ] Test: a 500 returns only `error` and `requestId` — no relation or column names
|
|
265
321
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "Governance layer that governs how any agent builds everystack — grounding, cheat gates, and Model-aware tooling over MCP",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"src",
|
|
22
|
-
"README.md"
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
23
24
|
],
|
|
24
25
|
"exports": {
|
|
25
26
|
".": {
|
|
27
|
+
"source": "./src/index.ts",
|
|
26
28
|
"types": "./src/index.ts",
|
|
27
29
|
"default": "./src/index.ts"
|
|
28
30
|
}
|
|
@@ -40,9 +42,9 @@
|
|
|
40
42
|
"tsx": "4.21.0",
|
|
41
43
|
"typescript": "5.9.3",
|
|
42
44
|
"zod": "3.25.67",
|
|
43
|
-
"@everystack/cli": "0.4.
|
|
44
|
-
"@everystack/
|
|
45
|
-
"@everystack/
|
|
45
|
+
"@everystack/cli": "0.4.80",
|
|
46
|
+
"@everystack/server": "0.4.30",
|
|
47
|
+
"@everystack/model": "0.4.17"
|
|
46
48
|
},
|
|
47
49
|
"scripts": {
|
|
48
50
|
"test": "jest",
|
package/src/resources/cli.md
CHANGED
|
@@ -37,11 +37,20 @@ these verbs move a database. Full operational guidance: everystack://database-op
|
|
|
37
37
|
everystack db:sync # Make a dev DB match your checkout — state + authz + compute, one verb
|
|
38
38
|
everystack db:generate # STATE layer (tables) → next migration file (--dry-run prints the SQL, writes nothing)
|
|
39
39
|
everystack db:reconcile # Deploy the compute layer only (views/matviews/functions/triggers)
|
|
40
|
+
everystack db:refresh # Refresh the declared matviews in dependency order, credential-free (--only, --verify-nonempty, --list)
|
|
40
41
|
everystack db:check # CI gate: declared state composes + generated artifacts match regeneration
|
|
41
42
|
everystack db:fingerprint # Content-address the live base schema vs the Models — MATCH/MISMATCH
|
|
42
43
|
everystack db:diff # The state edge between two declared states — no DB, CI-pure
|
|
44
|
+
everystack db:build # Build a database FROM the models and keep it (fresh target only; --database-url, never the env)
|
|
45
|
+
everystack db:pull # Introspect a live DB → render field() Models: THE on-ramp for an existing database
|
|
43
46
|
```
|
|
44
47
|
|
|
48
|
+
**Bringing an existing database in** — `db:pull --stage <s> --abilities live --out db/models`, commit
|
|
49
|
+
what it renders, then `db:plan --stage <s>`. An EMPTY plan means adopted as-is (that commit declares
|
|
50
|
+
the database; every later `db:apply` descends from it). A NON-EMPTY plan is the hardening edge:
|
|
51
|
+
review it, `db:apply` it — no commit declares the database yet, so the reviewed plan is the origin.
|
|
52
|
+
Render from the stage, never from a local restore of a dump.
|
|
53
|
+
|
|
45
54
|
**Authorization (the brownfield on-ramp + the red team)**
|
|
46
55
|
```bash
|
|
47
56
|
everystack db:authz:pull # Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
@@ -78,6 +87,21 @@ everystack db:backup:download <id> --stage X # Presigned download URL (1h)
|
|
|
78
87
|
everystack db:restore --from <id> --stage X --confirm # Restore a backup INTO a stage (destructive)
|
|
79
88
|
```
|
|
80
89
|
|
|
90
|
+
**Schema artifacts (build elsewhere, land atomically)**
|
|
91
|
+
```bash
|
|
92
|
+
everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped .dump (+ .meta.json)
|
|
93
|
+
everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # fingerprint gate → confirmed snapshot → restore → one txn → verify; destructive
|
|
94
|
+
```
|
|
95
|
+
`db:swap`'s venue is explicit (`DATABASE_URL` in the env is refused; `--stage` needs `--direct`), and the
|
|
96
|
+
rollback point is confirmed before the restore begins. Full guide: docs/schema-swap.md.
|
|
97
|
+
|
|
98
|
+
**Roles & health**
|
|
99
|
+
```bash
|
|
100
|
+
everystack db:provision --stage X # Create the least-privilege role chain (idempotent); --rotate swaps A/B credentials live
|
|
101
|
+
everystack db:doctor --stage X # Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
102
|
+
everystack db:reset --stage dev # Drop all schemas + re-run migrations (dev only; migration-era apps)
|
|
103
|
+
```
|
|
104
|
+
|
|
81
105
|
**Local dev databases**
|
|
82
106
|
```bash
|
|
83
107
|
everystack db:template:refresh # Build the dev template from declared state + seed
|
|
@@ -88,6 +112,7 @@ everystack db:fork --from-stage src --stage tgt --confirm # Fork a deployed st
|
|
|
88
112
|
**Data**
|
|
89
113
|
```bash
|
|
90
114
|
everystack db:seed --stage dev # Declarative seed script (dev only; refuses on production)
|
|
115
|
+
everystack db:exec fix.sql --stage X # Credential-free DML-only write, one txn; ANY schema change rolls it back (--confirm on prod-tier)
|
|
91
116
|
everystack db:backfill --apply # One-shot data moves from db/backfills/*.sql (own record)
|
|
92
117
|
everystack pipeline:run --stage X # Ingest pipeline (credential-free in the ops Lambda)
|
|
93
118
|
everystack db:migrate # Run the generated migration on a deployed stage via Lambda invoke
|
|
@@ -133,11 +133,28 @@ the database URL**. `db:apply` verifies → applies → verifies, and **refuses*
|
|
|
133
133
|
|
|
134
134
|
- **the live fingerprint still equals `plan.from`** (the concurrency lock — nobody changed
|
|
135
135
|
the target since you minted the plan; if they did, re-mint), AND
|
|
136
|
+
- **the schema's default privileges still hash to `plan.defaultAclHash`** (an
|
|
137
|
+
`ALTER DEFAULT PRIVILEGES` since minting changes what a created object lands with; the
|
|
138
|
+
refusal happens BEFORE dispatch, nothing lands — re-mint), AND
|
|
136
139
|
- **your checkout descends from the commit that declares the target's state** (the
|
|
137
140
|
fast-forward rule — "rebase first"; a stale checkout can't silently revert merged work).
|
|
141
|
+
When NO commit on any ref declares the live state — an existing database being hardened
|
|
142
|
+
in, or a stage hand-edited since its last apply — that is PROVEN drift over a full, current
|
|
143
|
+
clone, and it PASSES: the reviewed plan is the origin every later apply descends from,
|
|
144
|
+
recorded on the `schema_log` row. Shallow or behind-remote clones are UNVERIFIABLE and
|
|
145
|
+
refuse with a fetch runbook.
|
|
138
146
|
|
|
139
147
|
Every refusal is recorded in `everystack.schema_log`. `--database-url` is local-dev-only;
|
|
140
|
-
protected stages always go through `--stage`.
|
|
148
|
+
protected stages always go through `--stage`. Every refusal is **staleness** (re-mint, never
|
|
149
|
+
a flag) or **intent** (a named attestation, `--confirm`, an approver) — there is no `--force`.
|
|
150
|
+
|
|
151
|
+
The plan's `to` is the FULL endpoint: it carries the grants of roles your models do not
|
|
152
|
+
govern (a BI reader, an ETL role) through unchanged, so on a brownfield stage an empty plan
|
|
153
|
+
prints `from == to` and still names those roles and the baseline that admitted them.
|
|
154
|
+
|
|
155
|
+
**After upgrading the CLI, redeploy the stage before a staged apply.** `db:apply --stage`
|
|
156
|
+
is a wire contract between the CLI and the ops Lambda; a version-skewed Lambda is refused
|
|
157
|
+
before any write, with the redeploy named.
|
|
141
158
|
|
|
142
159
|
### 5. Destructive plans carry more gates
|
|
143
160
|
|
|
@@ -145,8 +162,15 @@ A plan is DESTRUCTIVE if it drops (table/column/type) or narrows a type (a lossy
|
|
|
145
162
|
`SET DATA TYPE`). Those additionally require, all of them:
|
|
146
163
|
|
|
147
164
|
- **`--confirm`** — always, explicitly.
|
|
148
|
-
- **A
|
|
149
|
-
|
|
165
|
+
- **A confirmed rollback point** — on a stage that exposes `databaseInstanceId`, a physical RDS
|
|
166
|
+
snapshot taken and waited to `available` before the write (the default); otherwise a logical
|
|
167
|
+
`db:backup` waited to completion; or one you attest with `--snapshot-ref <id>`. No rollback
|
|
168
|
+
point, no destructive apply.
|
|
169
|
+
- **The `--direct` lane.** The ops-Lambda lane (`--stage X` alone) REFUSES destructive plans
|
|
170
|
+
outright. Run them `--stage X --direct`: the CLI resolves the stage's operator connection
|
|
171
|
+
from its ops Lambda, holds it in memory only (never on argv), and runs the full ceremony
|
|
172
|
+
on one real connection with an unbounded clock. `--database-url` is not the venue for a
|
|
173
|
+
deployed stage's destruction.
|
|
150
174
|
- **The stage's approver set** — if declared, `db:apply` verifies the caller's AWS identity
|
|
151
175
|
(STS) against it. Set it with `db:approvers`:
|
|
152
176
|
```bash
|
|
@@ -178,11 +202,13 @@ everystack db:backups --stage production # list logical backups
|
|
|
178
202
|
everystack db:backup:download <id> --stage production # presigned download URL (1h)
|
|
179
203
|
```
|
|
180
204
|
|
|
181
|
-
**RDS snapshot first?**
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
205
|
+
**RDS snapshot first?** A destructive `db:apply` confirms a rollback point BEFORE it writes: on
|
|
206
|
+
a stage exposing `databaseInstanceId` that is a physical RDS snapshot waited to `available`
|
|
207
|
+
(the default), otherwise a logical `db:backup` waited to completion, or one you attest with
|
|
208
|
+
`--snapshot-ref <id>`. The only way to destroy without one is to say so on the record with
|
|
209
|
+
`--snapshot none`. So a separate `db:snapshot` first is redundant on the default lane — take one
|
|
210
|
+
before a large or risky migration only when the stage has no `databaseInstanceId`, or when you
|
|
211
|
+
want a named restore point that outlives the apply.
|
|
186
212
|
|
|
187
213
|
## Importing data into a REMOTE stage
|
|
188
214
|
|
|
@@ -207,22 +233,30 @@ can't dump production. `--database-url` exists only for local dev databases you
|
|
|
207
233
|
## Command index
|
|
208
234
|
|
|
209
235
|
```bash
|
|
236
|
+
# Bring an existing database in:
|
|
237
|
+
everystack db:pull --stage X --abilities live --out db/models # render field() Models from live; commit; then db:plan (EMPTY = adopted)
|
|
238
|
+
|
|
210
239
|
# Declare (edit files), then:
|
|
211
240
|
everystack db:sync # dev DB ← your checkout (state + compute), one verb
|
|
212
241
|
everystack db:reconcile # compute layer only (views/matviews/functions/triggers)
|
|
242
|
+
everystack db:refresh # repopulate the declared matviews in dependency order (credential-free)
|
|
213
243
|
everystack db:generate # STATE layer → migration file / --apply / --dry-run
|
|
244
|
+
everystack db:build --database-url "$FRESH" # build a database FROM the models and keep it (empty target only)
|
|
214
245
|
everystack db:check # CI gate: declared state composes; artifacts match
|
|
215
246
|
everystack db:fingerprint # content-address live schema vs models — MATCH/MISMATCH
|
|
216
247
|
|
|
217
248
|
# Reach a protected stage safely:
|
|
218
249
|
everystack db:plan --stage X --out X.plan.json # mint a reviewable edge (read-only)
|
|
219
250
|
everystack db:apply --plan X.plan.json --stage X # verify → apply → verify (credential-free)
|
|
251
|
+
everystack db:apply --plan X.plan.json --stage X --direct --confirm # a DESTRUCTIVE plan: the direct lane, full ceremony
|
|
220
252
|
everystack db:approvers --stage X --set "…" # who may run destructive applies
|
|
221
253
|
|
|
222
|
-
# Back up / restore:
|
|
254
|
+
# Back up / restore / move a schema:
|
|
223
255
|
everystack db:snapshot --stage X # physical RDS snapshot (instant DR)
|
|
224
256
|
everystack db:backup --stage X # logical pg_dump → S3
|
|
225
257
|
everystack db:restore --from <id> --stage X --confirm
|
|
258
|
+
everystack db:export --schema stats --stage X # ONE schema as a fingerprint-stamped artifact
|
|
259
|
+
everystack db:swap --schema stats --from <artifact> --stage X --direct --confirm # land it atomically (destructive)
|
|
226
260
|
|
|
227
261
|
# Local dev databases:
|
|
228
262
|
everystack db:template:refresh # build the dev template from declared state + seed
|
|
@@ -230,6 +264,7 @@ everystack db:branch # per-branch dev DB from the template
|
|
|
230
264
|
|
|
231
265
|
# Data:
|
|
232
266
|
everystack db:seed --stage dev # declarative seed (dev only)
|
|
267
|
+
everystack db:exec fix.sql --stage X # credential-free DML-only write, one txn (any schema change rolls it back)
|
|
233
268
|
everystack db:backfill --apply # one-shot data moves (db/backfills/*.sql)
|
|
234
269
|
everystack pipeline:run --stage X # ingest pipeline (credential-free)
|
|
235
270
|
everystack db:fork --from-stage prod --stage feature --confirm # copy a stage's data
|
|
@@ -254,8 +254,53 @@ Two requirements on the AWS side:
|
|
|
254
254
|
- **Deploy IAM (`>= 0.4.3`):** dbTask writes its operator-URL SecureString to
|
|
255
255
|
`/sst/<app>/<stage>/DbTaskOperatorUrl`, under the `parameter/sst/*` prefix an SST deploy role is
|
|
256
256
|
already scoped to. A least-privilege deploy principal also needs ECS (cluster + task-def),
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
EC2 `Describe*` (subnets/SGs), and `logs:CreateLogGroup` for the task's log group. Override the
|
|
258
|
+
param path with `paramName` to fit a different scoped prefix. (ECR auth + repo push is needed
|
|
259
|
+
only with `build: true` — see below. The default pulls a published image and pushes nothing.)
|
|
260
|
+
|
|
261
|
+
### The Task image — no Docker on the deploy machine
|
|
262
|
+
|
|
263
|
+
`dbTask` pulls a **published, digest-pinned** image by default, so `everystack deploy` needs no
|
|
264
|
+
Docker daemon. Earlier versions built the image locally on every deploy: a daemon that died
|
|
265
|
+
mid-run took the deploy down *after* other resources had already changed.
|
|
266
|
+
|
|
267
|
+
The image is a public GHCR package, so Fargate pulls it with no credential — nothing to rotate,
|
|
268
|
+
and no registry token in your AWS account. It carries no secret; the operator URL still arrives at
|
|
269
|
+
container start via ECS `secrets`/`valueFrom`.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
dbTask({ sst, aws }, 'DbTask', { vpc, adminDatabaseUrl, pgMajor: 16 }); // pulls, no Docker
|
|
273
|
+
dbTask({ sst, aws }, 'DbTask', { …, build: true }); // builds locally
|
|
274
|
+
dbTask({ sst, aws }, 'DbTask', { …, image: 'my-mirror/db-task@sha256:…' }); // your own copy
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- **`pgMajor` selects the image.** A major with no published image is REFUSED at deploy-plan time,
|
|
278
|
+
before any resource is created — it never silently falls back to a local build, because a silent
|
|
279
|
+
fallback is how the invisible Docker dependency came back.
|
|
280
|
+
- **`build: true`** is the escape hatch for a custom entrypoint or an unpublished major. It needs a
|
|
281
|
+
Docker daemon, and it checks for one up front rather than failing at the build step.
|
|
282
|
+
- **`image`** takes any reference verbatim: a mirror, an air-gapped copy, a digest you pinned.
|
|
283
|
+
- **Pinned by digest, never by tag.** A tag can be repointed at different bytes after a deploy was
|
|
284
|
+
reviewed; `repo@sha256:…` is the same bytes forever.
|
|
285
|
+
- **`architecture` costs nothing on the pull path.** The published image is a multi-arch manifest,
|
|
286
|
+
so the registry resolves arm64 vs x86_64 at pull time regardless of your machine. Only
|
|
287
|
+
`build: true` re-couples the image to the deploy machine's architecture.
|
|
288
|
+
|
|
289
|
+
**The image is an appliance — it runs pg binaries, it does not compute policy.** A backup's
|
|
290
|
+
fingerprint (what the destructive-apply gate compares against `plan.from`) is computed by the CLI,
|
|
291
|
+
before and after the dump, and passed into the task. The task never computes one. A published,
|
|
292
|
+
digest-pinned image that computed it would be running CLI internals frozen at publish time while
|
|
293
|
+
your checkout moved on, and the skew would surface only when the gate trusted a fingerprint your
|
|
294
|
+
CLI would have computed differently.
|
|
295
|
+
|
|
296
|
+
Two consequences:
|
|
297
|
+
|
|
298
|
+
- **`everystack db:backup` can now refuse.** If the schema changes during the dump, the artifact is
|
|
299
|
+
retracted — a dump spanning a migration matches neither endpoint, so it is not a safety net.
|
|
300
|
+
`db:fork` and `db:swap --snapshot logical` take the same path.
|
|
301
|
+
- **Upgrade the CLI and the ops function together.** The ops action refuses a backup that arrives
|
|
302
|
+
with no fingerprint, so an older `@everystack/cli` against a newer deploy fails with a named
|
|
303
|
+
error telling you to upgrade. It does not silently write an unstamped artifact.
|
|
259
304
|
|
|
260
305
|
**Keep `publicIp`, never `public: true`.** SST marks `publicIp` `@deprecated` in favor of `public`,
|
|
261
306
|
but do NOT swap it here: `public: true` attaches an inbound security group open to `0.0.0.0/0` on
|
|
@@ -146,13 +146,37 @@ export const Post = defineModel('posts', {
|
|
|
146
146
|
fields: { /* … */ },
|
|
147
147
|
abilities: [
|
|
148
148
|
can('read'), // anon + authenticated
|
|
149
|
-
can('create', { role: 'authenticated' }),
|
|
150
|
-
can('update', {
|
|
151
|
-
can('
|
|
149
|
+
can('create', { role: 'authenticated' }), // signed-in users insert rows THEY OWN — the model has an owner (below), so WITH CHECK (author_id = caller)
|
|
150
|
+
can('update', { owner: 'authorId' }), // row ownership: only the author, via RLS
|
|
151
|
+
can('manage', { role: 'admin' }), // the admin bypass: one ALL policy, true/true
|
|
152
152
|
],
|
|
153
153
|
});
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
**How a write compiles — read this before naming a role.** A write ability is a GRANT plus an
|
|
157
|
+
RLS policy, and RLS is on for every model with abilities, so a grant with no policy is dead:
|
|
158
|
+
an INSERT fails loudly (`new row violates row-level security policy`), while an UPDATE or
|
|
159
|
+
DELETE with no policy affects **0 rows silently** — no error, nothing changes.
|
|
160
|
+
|
|
161
|
+
- `owner:` / `via:` writes compile the owner-checked policy (`<t>_insert_own`, `_update_own`,
|
|
162
|
+
`_delete_own`) for `authenticated`. On such a model a bare `can('create')` or
|
|
163
|
+
`can('create', { role: 'authenticated' })` is ALSO the owner-checked insert — the example above.
|
|
164
|
+
- `can('manage', { role: 'admin' })` is the admin bypass: one `ALL` policy, `true`/`true`.
|
|
165
|
+
- **Known gap (cli 0.4.80):** a role-scoped or bare write with NO owner on the model and no
|
|
166
|
+
`where:` — `can('create', { role: 'authenticated' })` on an owner-less table, `can('delete',
|
|
167
|
+
{ role: 'admin' })`, `can('manage', { role: 'editor' })` — compiles the grant and NO policy. It
|
|
168
|
+
reads as access and grants none. A vacuous `where: sql\`true\`` does NOT help: the compiler
|
|
169
|
+
discards a `true` predicate and still emits nothing. What compiles today: `owner:` / `via:`
|
|
170
|
+
for row-scoped writes; `can('manage', { role: 'admin' })` for the admin bypass; and, for
|
|
171
|
+
`authenticated` only, a real (non-vacuous) `where:` on a `create`/`update`/`delete`, which
|
|
172
|
+
compiles a predicate-only policy for `authenticated`. There is no in-model spelling for a
|
|
173
|
+
role-wide write by any other role — and do not try a `where:` on another role's write: the
|
|
174
|
+
write compiler never reads the declared role, so `can('update', { role: 'editor', where })`
|
|
175
|
+
grants editor and lands the predicate on the **`authenticated`** policy. Run that write on the
|
|
176
|
+
trusted admin connection until the write-policy matrix ships.
|
|
177
|
+
- `check:` is the WITH CHECK half verbatim — it REPLACES the owner term, it does not narrow it —
|
|
178
|
+
so a `check:` on an owner model must carry the owner term itself.
|
|
179
|
+
|
|
156
180
|
## Handler Config for Schema
|
|
157
181
|
|
|
158
182
|
The handler config is **derived** from the Models (`deriveHandlerConfig(models)`), so
|
|
@@ -80,6 +80,56 @@ GRANT admin TO authenticator;
|
|
|
80
80
|
|
|
81
81
|
Why `NOINHERIT`: authenticator has zero privileges. All access comes through the granted roles. This prevents accidental privilege leakage.
|
|
82
82
|
|
|
83
|
+
### The Two Credential Channels
|
|
84
|
+
|
|
85
|
+
There are exactly two database credentials, and they are not interchangeable.
|
|
86
|
+
|
|
87
|
+
| | Role | Used by | Never |
|
|
88
|
+
| --- | --- | --- | --- |
|
|
89
|
+
| `DATABASE_URL` | `authenticator` (least privilege) | The app plane. Every request-serving read and write, always through `withRole` / `pgSettings`. | — |
|
|
90
|
+
| `ADMIN_DATABASE_URL` | `migrator` (owns the schema) | The ops channel ONLY: migrate, seed, console, backup. | **Never held by request-serving code.** |
|
|
91
|
+
|
|
92
|
+
**The rule: the operator credential is the ops channel only.** It owns the schema, so RLS
|
|
93
|
+
does not apply to it. A request handler holding that credential has unrestricted database
|
|
94
|
+
access, and every RLS policy you wrote is decoration. RLS is only fail-closed once the
|
|
95
|
+
credentials are split — a single privileged URL means every policy fails *open*.
|
|
96
|
+
|
|
97
|
+
This is the mistake to watch for, because it does not look like one. `createAdminDb()` and
|
|
98
|
+
`getAdminSql()` are easy to reach from app code and will work. Working is exactly the
|
|
99
|
+
problem: the failure is silent and total.
|
|
100
|
+
|
|
101
|
+
#### Trusted writes: use a service role, not the operator credential
|
|
102
|
+
|
|
103
|
+
Some writes legitimately need to bypass a user's RLS scope — issuing a credential at signup,
|
|
104
|
+
recording a webhook, a background reconcile. **Do not reach for `ADMIN_DATABASE_URL`.** The
|
|
105
|
+
lawful pattern is a NOLOGIN service role, granted to `authenticator`, entered through
|
|
106
|
+
`withRole` *after* the handler has done its own authorization:
|
|
107
|
+
|
|
108
|
+
```sql
|
|
109
|
+
-- A NOLOGIN role with exactly the privileges the trusted lane needs, and no more.
|
|
110
|
+
CREATE ROLE service NOLOGIN;
|
|
111
|
+
GRANT service TO authenticator;
|
|
112
|
+
|
|
113
|
+
GRANT SELECT, INSERT, UPDATE ON credentials TO service;
|
|
114
|
+
-- Give it a policy, or leave the table RLS-enabled with a policy that admits `service`.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// The handler authorizes FIRST — the role is never derived from the token.
|
|
119
|
+
if (!(await mayIssueCredential(user))) throw new HttpError(403);
|
|
120
|
+
|
|
121
|
+
await withRole(sql, 'service', { sub: user.sub, role: 'service' }, async (tx) => {
|
|
122
|
+
await tx`INSERT INTO credentials ${tx(row)}`;
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Note that `roleFor()` returns only `anon` and `authenticated` — the roles a token may
|
|
127
|
+
produce. `service` is never returned from a token, so a forged or absent JWT cannot reach it.
|
|
128
|
+
Server code sets it explicitly, on the same least-privilege connection, after its own check.
|
|
129
|
+
|
|
130
|
+
The result: the trusted lane is scoped to the privileges that lane actually needs, and it is
|
|
131
|
+
still on the app plane. The operator credential stays in the ops channel where it belongs.
|
|
132
|
+
|
|
83
133
|
### Table Grants
|
|
84
134
|
|
|
85
135
|
```sql
|
|
@@ -260,6 +310,12 @@ RESET ROLE;
|
|
|
260
310
|
- [ ] ENABLE + FORCE ROW LEVEL SECURITY on all public tables
|
|
261
311
|
- [ ] Write RLS policies per table x role
|
|
262
312
|
- [ ] Connect as `authenticator` (never use RDS master/superuser in production)
|
|
313
|
+
- [ ] Split the credentials: `DATABASE_URL` = authenticator, `ADMIN_DATABASE_URL` = migrator
|
|
314
|
+
- [ ] No request-serving code holds the operator credential — grep the server for
|
|
315
|
+
`createAdminDb` / `getAdminSql` and confirm every hit is an ops path (migrate, seed,
|
|
316
|
+
console, backup), not a request handler
|
|
317
|
+
- [ ] Trusted writes go through a NOLOGIN service role via `withRole`, not the operator
|
|
318
|
+
credential
|
|
263
319
|
- [ ] Test: SET ROLE authenticated; SELECT returns only expected rows
|
|
264
320
|
- [ ] Test: a 500 returns only `error` and `requestId` — no relation or column names
|
|
265
321
|
|