Skip to content

EQL install: defer owner-scoped Supabase grants for non-postgres roles; add stash eql preflight - #902

Merged
coderdan merged 4 commits into
mainfrom
dan/eql-preflight-deferred-grants
Aug 18, 2026
Merged

EQL install: defer owner-scoped Supabase grants for non-postgres roles; add stash eql preflight#902
coderdan merged 4 commits into
mainfrom
dan/eql-preflight-deferred-grants

Conversation

@coderdan

@coderdan coderdan commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #887, fixes #888.

Reported by a Lovable agent whose database role is sandbox_exec — not postgres, and not a member of it. One owner-scoped grant rolled back the entire EQL install, and nothing could predict it beforehand.

What changed

The install no longer dies on the owner-scoped grants (#887)

  • packages/cli/src/installer/grants.ts splits each Supabase grants block into a plain-GRANT half and an owner-scoped ALTER DEFAULT PRIVILEGES FOR ROLE postgres half. SUPABASE_PERMISSIONS_SQL_V3 remains byte-identical — locked by a new unit test — so packages/stack-supabase/integration/grants.integration.test.ts still proves the exact shipped SQL.
  • EQLInstaller.install() now commits the bundle in its own transaction and runs the grants after it. A grants failure can no longer roll back a working install.
  • Membership of postgres is checked (guarded — pg_has_role raises when the role doesn't exist) before the owner-scoped statements. Non-member: the plain GRANTs run, covering all existing objects, and the install reports itself complete. The skipped statements print under "Optional SQL — requires postgres" — optional because they only cover EQL objects postgres might later create outside stash tooling: every stash eql install/eql upgrade re-runs the blanket grants, and the generated Supabase migration embeds them alongside the bundle. On platforms where nobody can act as postgres (Lovable), nothing is lost by never applying them.
  • Error messages now state recoverability in both directions: a bundle failure says nothing was applied; a grants failure says the install itself was kept.

stash eql preflight (#888)

Read-only role-capability report, exposed as a command and run at the head of eql install:

current_user        sandbox_exec
superuser           no
member of postgres  no  <- skips optional: ALTER DEFAULT PRIVILEGES FOR ROLE postgres
CREATE on database  yes
CREATE on public    yes
pgcrypto            present
eql_v3 schema       absent
eql_v3_internal     absent
  • Each blocked row names the statement it blocks. Exits 1 on blocking gaps; membership of postgres never blocks — a no row is annotated as skipping the optional statements, not as a blocker.
  • --json for agents — stdout stays pure JSON via a new quiet option on the DATABASE_URL resolver (suppresses info chrome and the interactive prompt tier).
  • Works with no stash.config.ts (falls back to the plain resolution chain), since preflight runs before anything is set up.
  • EQLInstaller.preflight() replaces checkPermissions(); eql status uses it too.

Verification

  • Unit: preflight arms, deferral, grant-failure-after-commit, byte-identity of the combined grants block, statement partition, report rendering.
  • Live (docker postgres-eql:17): the preflight SQL in both membership arms (member_of_postgres null / false / true), a full eql install, and a Supabase-mode --force install exercising the deferral end-to-end (deferred SQL printed, install kept).
  • E2E: help/smoke/manifest updated and green; eql preflight --help covered.
  • Skills: stash-cli (new command, deferral semantics, EQLInstaller surface) and stash-supabase (non-postgres-role note) updated; command names verified against stash manifest --json.

Groundwork: the immediate/deferred split is what #886 (--print-sql) needs to label the tail.

… add `stash eql preflight`

Fixes the Lovable-class install failure (#887) and adds the read-only role
preflight it called for (#888).

- Split the Supabase grants (installer/grants.ts) into plain-GRANT and
  owner-scoped ALTER DEFAULT PRIVILEGES halves. SUPABASE_PERMISSIONS_SQL_V3
  stays byte-identical (locked by a unit test) so the live proof in
  stack-supabase's grants integration test still covers the shipped SQL.
- EQLInstaller.install(): the bundle commits in its own transaction; grants
  run after it, so one refused grant no longer rolls back ~194 functions.
  When the connecting role is not a member of postgres, the owner-scoped
  statements are skipped and returned (InstallResult.deferredGrantsSql) with
  a header explaining what to do; eql install / eql upgrade print them under
  'Deferred SQL — run as postgres'. Failure messages now state recoverability
  in both directions.
- EQLInstaller.preflight() replaces checkPermissions(): one guarded catalogue
  query also reporting current_user, membership of postgres (null when no
  postgres role exists — pg_has_role raises otherwise, proven by a live
  test), and EQL v3 schema presence.
- New 'stash eql preflight' command (--json for agents, pure-JSON stdout via
  a new quiet resolver option), also run at the head of eql install.
- Skills: stash-cli (new command, deferral semantics, API surface) and
  stash-supabase (non-postgres-role note) updated; changeset added.
@coderdan
coderdan requested a review from a team as a code owner August 18, 2026 00:40
@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1c5bea3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Minor
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/stack-prisma Minor
@cipherstash/wizard Minor
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — the implementation is solid and I'd approve on the strength of it, but one shipped-artifact defect needs a fix first.

Blocking

skills/stash-cli/SKILL.md — the eql install flags table is broken. The new Non-postgres roles paragraph was inserted between the --supabase and --database-url rows, so the --database-url <url> | One-shot install (see below) row is orphaned below a paragraph and no longer renders as a table row — it comes out as a stray literal pipe-delimited line. Verified on the branch:

| `--supabase` | Supabase-compatible install; … |

**Non-`postgres` roles.** The Supabase grants include three owner-scoped …
| `--database-url <url>` | One-shot install (see below) |

Move the paragraph below the complete table (after the --database-url row). Per AGENTS.md these files ship in the stash tarball and get copied into customer repos, so a rendering defect here is a code defect. One-line fix.

What I verified (all good)

  • Byte-identity lock: the unit test pins SUPABASE_PERMISSIONS_SQL_V3 to the exact pre-split block, and the partition test proves every statement lands in exactly one half with the right shape (GRANT vs ALTER DEFAULT PRIVILEGES FOR ROLE postgres). The live Supabase grants integration test therefore still proves the shipped SQL.
  • Commit-then-grants ordering: the bundle commits in its own transaction before any grant runs; the grant-failure test asserts COMMIT happened and ROLLBACK didn't, and both failure messages state recoverability in the right direction.
  • pg_has_role CASE guard: genuinely load-bearing (it raises on a missing role rather than returning false), and the live test covers all three membership arms — including the compose image that bootstraps with no postgres role.
  • Preflight UX: superuser suppresses privilege annotations (tested), memberOfPostgres never blocks, --json stdout stays pure via the new quiet tier on the URL resolver, and the resolver falls back cleanly when no stash.config.ts exists.
  • Meta: registry entry, help/smoke e2e coverage, changeset (stash minor), and both skill files updated. CI fully green including the Supabase integration suites.

Fix the table placement and this is ready.

… deferred work

Review feedback on the deferral design: every path that creates new EQL
objects through stash tooling re-grants them itself — install --force and
eql upgrade re-run the blanket grants, and the generated Supabase migration
embeds them alongside the bundle. The ALTER DEFAULT PRIVILEGES FOR ROLE
postgres statements therefore only cover objects postgres might create in
the EQL schemas outside stash tooling, and on platforms where no operator
can act as postgres (Lovable) they can simply never be applied without
anything being lost.

Reword accordingly: the install reports itself complete, the skipped SQL
prints under 'Optional SQL — requires postgres' with the header explaining
when it would matter, the pre-install notice drops from warn to info, and
the preflight row annotation says 'skips optional' rather than 'blocks'.
Skills and changeset updated to match. No behavioural change to what runs
against the database.
@coderdan

Copy link
Copy Markdown
Contributor Author

Review findings

Analysis performed by UGPT-5.6 Solv.

  • P1 — Preserve compatibility when publishing this as a minor release (.changeset/eql-preflight-deferred-grants.md:2)

    Because stash is already 1.0.0 and exports this library surface, a minor upgrade will break consumers using EQLInstaller.checkPermissions() or PermissionCheckResult: the method becomes undefined at runtime and the type no longer compiles. Keep deprecated aliases or mark this as a major changeset.

  • P2 — Keep preflight error output valid JSON (packages/cli/src/commands/db/preflight.ts:36-40)

    When the URL is missing or malformed, resolution writes a Clack error to stdout and calls process.exit(1) before entering the try, so eql preflight --json emits non-JSON output instead of an error object. This violates the documented pure-JSON contract in skills/stash-cli/SKILL.md:353-355; URL resolution must use a JSON-safe error path.

  • P2 — Reject pgcrypto installed in an unsupported schema (packages/cli/src/installer/index.ts:99)

    When pgcrypto exists outside public or extensions, this check reports it as present and can return ok: true, but the pinned EQL bundle explicitly aborts for any other extension schema. Query the extension namespace and report unsupported placement as a blocker so preflight does not promise an install that will fail.

  • P2 — Check ownership of existing EQL schemas (packages/cli/src/installer/index.ts:100-101)

    When the EQL schemas were installed by an unrelated role, an otherwise CREATE-capable role receives ok: true, although the bundle immediately fails at DROP SCHEMA ... CASCADE because only the owner, an owning-role member, or a superuser may drop them. Include schema ownership and membership in the blocking checks rather than recording presence alone.

…ht, JSON contract, deprecated alias

Review findings from James, the Codex review, and /code-review, all fixed:

- Generated Supabase migrations no longer die for non-postgres roles: the
  owner-scoped ALTER DEFAULT PRIVILEGES statements ship inside a
  pg_has_role-guarded DO block (SUPABASE_MIGRATION_GRANTS_SQL_V3), so a
  migration applied by Lovable's sandbox_exec skips them instead of rolling
  back the whole file. Live-proven in both membership arms.
- PREFLIGHT_SQL guards has_schema_privilege against a dropped public schema
  (it raises 3F000, and the combined query took superusers down with it),
  reports pgcrypto's schema (outside extensions/public the bundle aborts —
  blocks even superusers), and reports drop-ownership of existing EQL
  schemas (reinstall opens with DROP SCHEMA ... CASCADE). Query-phase
  failures are now labelled as preflight failures, not connect failures.
- eql install on an already-installed Supabase database re-applies the role
  grants (new applySupabaseGrants(), idempotent) instead of early-exiting,
  so a grants failure after the committed bundle heals on a plain re-run.
- eql preflight --json keeps stdout pure JSON in every outcome: blockers are
  status 'blocked' (not 'ok'), and URL-resolution failures use the shared
  { status: 'error', code, message } envelope via a new jsonErrors resolver
  option. Human mode resolves the URL before any spinner exists (the
  interactive prompt was being redrawn over), and both modes warn when a
  config literal databaseUrl overrides --database-url, matching install.
- No breaking change at 1.0: checkPermissions() stays as a deprecated
  adapter over preflight() and PermissionCheckResult is still exported, so
  the changeset remains minor.
- Skills: the Non-postgres-roles paragraph no longer splits the eql install
  flags table (James's blocker); API/JSON-contract docs updated. README's
  EQLInstaller section rewritten for the current surface.
Both preflight.live and guarded-grants.live create the postgres role when
the compose database lacks one, and vitest runs the files in parallel
workers against the same database — the check-then-CREATE raced
(pg_authid_rolname_index duplicate key, observed locally). Creation is now
a duplicate_object-tolerant DO block in both files, neither drops the role
afterwards (the compose database is ephemeral, and a drop from one file
races the other's queries), and the missing-role assertion samples role
existence on both sides of the probe so a mid-probe appearance accepts
either membership arm. Three consecutive full live runs green.
@coderdan

Copy link
Copy Markdown
Contributor Author

All review feedback addressed in 54832b5 + 1c5bea3.

@freshtonic's blocker — fixed: the Non-postgres-roles paragraph now sits below the complete eql install flags table; the --database-url row renders as a table row again.

Codex review (posted by @coderdan):

  • P1 semver — kept as minor per maintainer decision: checkPermissions() remains as a @deprecated adapter over preflight() (identical PermissionCheckResult shape, still exported), removal deferred to the next major.
  • P2 pure-JSON errors — URL-resolution failures in --json mode now emit the shared { status: 'error', code, message } envelope (database_url_missing / database_url_invalid) via a new jsonErrors resolver option; verified against the built CLI in all outcomes.
  • P2 pgcrypto placement — preflight now reports pgcryptoSchema and blocks (even for superusers — the bundle's own DO-block raises regardless of role) when it is outside extensions/public, naming the ALTER EXTENSION pgcrypto SET SCHEMA extensions fix.
  • P2 EQL schema ownership — preflight reports canDropEqlV3Schema/canDropEqlV3InternalSchema (pg_has_role against the schema owner; null when absent) and blocks when a present schema cannot be dropped, since a reinstall opens with DROP SCHEMA ... CASCADE.

Further /code-review findings, also fixed:

  • The migration generated by eql migration --supabase no longer reintroduces the rollback: its owner-scoped statements ship inside a pg_has_role-guarded DO block (SUPABASE_MIGRATION_GRANTS_SQL_V3), live-proven in both membership arms against real Postgres.
  • has_schema_privilege(…, 'public', …) is now CASE-guarded (it raises 3F000 when public was dropped — and took superusers down with it); query-phase failures are labelled "Database preflight query failed", not "Failed to connect".
  • A plain re-run of eql install on an already-installed Supabase database re-applies the grants (new idempotent applySupabaseGrants()), so a grants failure after the committed bundle heals without --force.
  • --json blockers now report status: 'blocked' (never 'ok' with ok: false).
  • Preflight warns when a config-literal databaseUrl overrides --database-url (stderr in --json mode), matching eql install.
  • The interactive DATABASE_URL prompt no longer renders under a live spinner.
  • packages/cli/README.md rewritten for the current EQLInstaller surface.

Verification: 1296 unit+live tests (including two new live suites proving the guarded DO block and the hardened preflight SQL against real Postgres), 109 e2e, code:check clean. #903 rebased on top.

@coderdan
coderdan requested a review from freshtonic August 18, 2026 02:18
@coderdan
coderdan marked this pull request as ready for review August 18, 2026 02:18

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. The blocking issue from my previous review is fixed, and the follow-up commits go well beyond it — I re-reviewed the full delta (e68879b4..1c5bea39), not just the fix.

The blocker, resolved

The eql install flags table in skills/stash-cli/SKILL.md is intact again — --database-url is back inside the table and the Non-postgres roles paragraph sits below it. Verified on the branch.

The rest of the delta, reviewed

  • Guarded migration grants (SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3 / SUPABASE_MIGRATION_GRANTS_SQL_V3): the right fix for the gap the first revision left open — a generated --supabase migration previously shipped the unguarded owner-scoped statements and would have aborted (and rolled back the whole file) under exactly the Lovable-style role this PR exists to serve. The DO $$ … pg_has_role … $$ guard is live-proven in both membership arms via pg_default_acl counts, and the unit test asserts no bare ALTER DEFAULT PRIVILEGES line survives in the migration block.
  • "Optional", not "deferred": the reframing is honest and materially better UX — stash re-grants every object on each install/upgrade, so the owner-scoped statements genuinely aren't owed work, and telling a Lovable operator to "run this as postgres" when nobody can act as postgres was a dead end.
  • Heal-on-rerun: the already-installed path now re-applies the grants via applySupabaseGrants(), closing the hole where a committed-bundle-but-failed-grants database would previously early-exit past its missing grants forever.
  • checkPermissions() restored as a deprecated adapter with its shape unchanged and a test pinning it — the accidental break for existing stash@1.x consumers is gone, and the changeset now says so accurately.
  • Preflight hardening: the has_schema_privilege guard for databases without public (3F000), pgcrypto placement (blocks even superusers, correctly ungated), and the DROP SCHEMA ... CASCADE ownership probes are each guarded in SQL so a missing object reads as an answer rather than a query failure. Verified pg_has_role(name, oid, text) is a real overload for the nspowner probes, and the live suite asserts probes answer rather than raise.
  • JSON contract: status: 'ok' | 'blocked' as the agent-facing discriminator (blockers no longer masquerade as ok), the shared emitJsonError envelope on every failure path including unresolvable DATABASE_URL, and the --database-url-vs-config-literal precedence warning routed to stderr in json mode. loadStashConfig's knownConfigPath second parameter and the jsonErrors resolver option are wired correctly.
  • De-raced live suites: leaving the shared postgres role behind and sampling existence on both sides of the probe is the pragmatic call for an ephemeral compose database.

CI is fully green on 1c5bea39. Two non-blocking observations: the live suites are STASH_TEST_DATABASE_URL-gated and not wired into any CI workflow (consistent with applied.live.test.ts, but the guarded-grants proof only runs when someone remembers to); and renderPreflightReport restates the supported pgcrypto schemas as a literal rather than sharing SUPPORTED_PGCRYPTO_SCHEMAS. Neither needs to hold this up.

@coderdan
coderdan merged commit 71c802a into main Aug 18, 2026
15 checks passed
@coderdan
coderdan deleted the dan/eql-preflight-deferred-grants branch August 18, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants