@everystack/server 0.4.29 → 0.4.31
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 +35 -0
- package/package.json +3 -3
- package/src/db.ts +43 -2
- package/src/model-wire.ts +94 -0
- package/src/owner-adoption.ts +75 -4
- package/src/owner-enactment.ts +355 -20
- package/src/plugin.ts +280 -60
- package/src/provision.ts +265 -16
- package/src/role-chain.ts +114 -0
- package/src/sql-lex.ts +217 -0
package/src/plugin.ts
CHANGED
|
@@ -13,6 +13,10 @@ import type {
|
|
|
13
13
|
APIGatewayProxyStructuredResultV2,
|
|
14
14
|
} from 'aws-lambda';
|
|
15
15
|
import type { ApplyPlanOptions } from '@everystack/cli/apply';
|
|
16
|
+
// The Model-derived wire payloads, declared once in @everystack/model/wire and re-exported by
|
|
17
|
+
// @everystack/cli/wire (the CLI is the only @everystack/* package this bundle can resolve the
|
|
18
|
+
// declaration through — see packages/cli/src/wire.ts). Type-only, so nothing new is bundled.
|
|
19
|
+
import type { ReconcileWirePayload } from '@everystack/cli/wire';
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* The stage lane's apply options, with `checkDescent` made REQUIRED. The fast-forward rule is the
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
responseToResult,
|
|
31
35
|
log,
|
|
32
36
|
} from './index';
|
|
37
|
+
import { splitSqlStatements, stripSqlComments } from './sql-lex';
|
|
33
38
|
|
|
34
39
|
// --- Plugin Types ---
|
|
35
40
|
|
|
@@ -208,12 +213,19 @@ export function createPluginLambdaHandler(
|
|
|
208
213
|
// post-write alarm). Stamped on BOTH branches, so a stage whose DB init is failing still
|
|
209
214
|
// tells the CLI its contract — an absent field means the deployed server predates the
|
|
210
215
|
// handshake. Old servers simply omit it; the CLI reads absent as "redeploy the stage".
|
|
216
|
+
//
|
|
217
|
+
// `modelContract` is the same handshake for the MODEL-DERIVED payloads (db:provision,
|
|
218
|
+
// db:converge-ownership, db:reconcile). Two INDEPENDENT numbers, deliberately: applyContract
|
|
219
|
+
// versions db:apply's persisted plan artifact, modelContract versions an ephemeral payload
|
|
220
|
+
// computed and consumed in one round trip. A CLI checks whichever one its verb rides.
|
|
211
221
|
const { APPLY_CONTRACT_VERSION } = await import('@everystack/cli/apply');
|
|
222
|
+
const { MODEL_WIRE_CONTRACT } = await import('@everystack/cli/wire');
|
|
223
|
+
const contracts = { applyContract: APPLY_CONTRACT_VERSION, modelContract: MODEL_WIRE_CONTRACT };
|
|
212
224
|
try {
|
|
213
225
|
const { actions } = await ensureInitialized();
|
|
214
|
-
return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort(),
|
|
226
|
+
return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort(), ...contracts };
|
|
215
227
|
} catch (error) {
|
|
216
|
-
return { ok: false, error: String(error),
|
|
228
|
+
return { ok: false, error: String(error), ...contracts };
|
|
217
229
|
}
|
|
218
230
|
}
|
|
219
231
|
const { ctx, actions } = await ensureInitialized();
|
|
@@ -487,6 +499,56 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
|
|
|
487
499
|
|
|
488
500
|
// --- Database Actions Plugin ---
|
|
489
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Run `fn` inside a transaction that ALWAYS rolls back, and return what `fn` returned.
|
|
504
|
+
*
|
|
505
|
+
* Three actions need exactly this: `db:query` (read-only by construction), `db:authz:probe`
|
|
506
|
+
* (writes on purpose, keeps none) and the console sandbox.
|
|
507
|
+
*
|
|
508
|
+
* The abort is a private object thrown by THIS function and recognised by IDENTITY. Nothing
|
|
509
|
+
* else can reference `signal`, so `err !== signal` is exact: the only error this swallows is
|
|
510
|
+
* its own, and every other error propagates untouched — a failed statement is still a failure.
|
|
511
|
+
*
|
|
512
|
+
* Why not `tx.rollback()`. drizzle's `rollback()` is nothing but `throw new
|
|
513
|
+
* TransactionRollbackError()` — there is no database call behind it, and postgres.js rolls the
|
|
514
|
+
* transaction back because the callback THREW, whatever it threw (proven live in
|
|
515
|
+
* `__tests__/tx-rollback-live.test.ts`). So the method buys nothing, and it costs: the console
|
|
516
|
+
* sandbox binds the expression's `db` to this very `tx`, so an expression can call
|
|
517
|
+
* `db.rollback()` — indistinguishable from ours under any check on the ERROR CLASS, which
|
|
518
|
+
* turned a failed expression into a silent `{ result: undefined }` — or replace it,
|
|
519
|
+
* `db.rollback = () => {}`, after which the callback returns normally and the driver COMMITS.
|
|
520
|
+
* Throwing our own value keeps the mechanism out of the work's reach entirely.
|
|
521
|
+
*
|
|
522
|
+
* The class stays drizzle's `TransactionRollbackError` so the abort reads correctly in
|
|
523
|
+
* drizzle's own taxonomy and in logs; identity, not the class, is what decides.
|
|
524
|
+
*
|
|
525
|
+
* `value!` is honest: `signal` is thrown only after the assignment above it.
|
|
526
|
+
*
|
|
527
|
+
* The direct-connection twin of `db:authz:probe` — `createUrlProbeRunner`'s `probe()` in
|
|
528
|
+
* `packages/cli/src/cli/db-source.ts` — keeps its own identity-Symbol version of this on
|
|
529
|
+
* purpose: it runs on raw postgres.js `sql.begin()`, with no drizzle and no
|
|
530
|
+
* `TransactionRollbackError` in reach.
|
|
531
|
+
*/
|
|
532
|
+
async function runRolledBack<T>(
|
|
533
|
+
db: any,
|
|
534
|
+
fn: (tx: any) => Promise<T>,
|
|
535
|
+
config?: Record<string, unknown>,
|
|
536
|
+
): Promise<T> {
|
|
537
|
+
const { TransactionRollbackError } = await import('drizzle-orm');
|
|
538
|
+
// Per call, and never handed to `fn` — the work cannot reference it, so it cannot forge it.
|
|
539
|
+
const signal = new TransactionRollbackError();
|
|
540
|
+
let value!: T;
|
|
541
|
+
try {
|
|
542
|
+
await db.transaction(async (tx: any) => {
|
|
543
|
+
value = await fn(tx);
|
|
544
|
+
throw signal; // any throw aborts the transaction; this one is ours
|
|
545
|
+
}, config);
|
|
546
|
+
} catch (err: unknown) {
|
|
547
|
+
if (err !== signal) throw err;
|
|
548
|
+
}
|
|
549
|
+
return value;
|
|
550
|
+
}
|
|
551
|
+
|
|
490
552
|
export interface DbPluginOptions {
|
|
491
553
|
/** Absolute path to drizzle migrations folder. Optional — a db:sync app has no migrations;
|
|
492
554
|
* migrate/db:reset then answer with a clear error while provision/console/psql still work. */
|
|
@@ -520,6 +582,23 @@ export interface DbPluginOptions {
|
|
|
520
582
|
/** Optional read-replica URL for db:query. When unset, db:query runs on the operator
|
|
521
583
|
* connection. (No env fallback — pass it explicitly if you have a replica.) */
|
|
522
584
|
readDatabaseUrl?: string;
|
|
585
|
+
/**
|
|
586
|
+
* The SELECT-only connection `db:query` runs on — the `reader` login `everystack db:provision
|
|
587
|
+
* --with-reader` creates (QUERY_DATABASE_URL). Takes precedence over `readDatabaseUrl`.
|
|
588
|
+
*
|
|
589
|
+
* WHY it exists, and what it is NOT: db:query already runs its statement inside an explicit
|
|
590
|
+
* `READ ONLY` transaction that is always rolled back, so a plain statement cannot write. What
|
|
591
|
+
* that transaction does not fence is a function that opens its OWN connection (`dblink_exec`,
|
|
592
|
+
* `pg_background`): on a database where such an extension exists, a "read-only" query issued on
|
|
593
|
+
* the OPERATOR connection can still reach the operator's write privilege. Pointing db:query at a
|
|
594
|
+
* role that HAS no write privilege closes that side channel by construction.
|
|
595
|
+
*
|
|
596
|
+
* UNSET IS A SUPPORTED, DOCUMENTED FALLBACK, NOT AN ERROR: db:query then runs on the operator
|
|
597
|
+
* connection, protected by the read-only transaction alone. That is the pre-existing behaviour and
|
|
598
|
+
* it is fine for a database without those extensions — but the caveat is real, so it is stated in
|
|
599
|
+
* `db:psql --help` and docs/cli.md rather than buried here.
|
|
600
|
+
*/
|
|
601
|
+
queryDatabaseUrl?: string;
|
|
523
602
|
/** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
|
|
524
603
|
pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
|
|
525
604
|
/**
|
|
@@ -582,6 +661,30 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
582
661
|
// connection can be least-privilege and RLS-gated.
|
|
583
662
|
const opsDb = ctx.adminDb ?? ctx.db;
|
|
584
663
|
|
|
664
|
+
/**
|
|
665
|
+
* The Model-derived wire-contract gate — run FIRST in every action that consumes a payload the
|
|
666
|
+
* CLI computed from the Models barrel (db:provision, db:converge-ownership, db:reconcile).
|
|
667
|
+
* Returns the action's `{ error }` when the caller's contract does not match this server's,
|
|
668
|
+
* and null when it does. See model-wire.ts for why missing is a refusal and not a default.
|
|
669
|
+
*
|
|
670
|
+
* PRE-DISPATCH by construction: it resolves no connection and runs no SQL, so calling it
|
|
671
|
+
* before any other work in the action is what keeps a skewed payload away from the database.
|
|
672
|
+
* A `@everystack/cli/wire` that will not load is ALSO a refusal — a server that cannot state
|
|
673
|
+
* its own contract must not accept a payload on the caller's say-so.
|
|
674
|
+
*/
|
|
675
|
+
const modelWireGate = async (verb: string, payload: unknown): Promise<{ error: string } | null> => {
|
|
676
|
+
const { modelWireRefusal, modelWireUnavailableRefusal } = await import('./model-wire');
|
|
677
|
+
let enforced: number;
|
|
678
|
+
try {
|
|
679
|
+
({ MODEL_WIRE_CONTRACT: enforced } = await import('@everystack/cli/wire'));
|
|
680
|
+
} catch (err) {
|
|
681
|
+
return { error: modelWireUnavailableRefusal(verb, err) };
|
|
682
|
+
}
|
|
683
|
+
const declared = (payload as { modelContract?: unknown } | null | undefined)?.modelContract;
|
|
684
|
+
const refusal = modelWireRefusal(verb, declared, enforced);
|
|
685
|
+
return refusal ? { error: refusal } : null;
|
|
686
|
+
};
|
|
687
|
+
|
|
585
688
|
// Resolve the logical-backup dependencies (bucket + operator URL) once —
|
|
586
689
|
// shared by db:backup/db:backups/db:restore and by db:apply's auto-snapshot
|
|
587
690
|
// before a destructive staged apply. Returns `{ error }` when unconfigured.
|
|
@@ -692,34 +795,142 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
692
795
|
};
|
|
693
796
|
|
|
694
797
|
// --- db:query ---
|
|
798
|
+
//
|
|
799
|
+
// READ-ONLY IS ENFORCED BY POSTGRESQL, NOT BY THIS FILE.
|
|
800
|
+
//
|
|
801
|
+
// The guard used to be a keyword blocklist over the statement text. A red team against a
|
|
802
|
+
// real PostgreSQL 16 proved that is not a security boundary, and cannot be made into one:
|
|
803
|
+
//
|
|
804
|
+
// - The blocklist is structurally incomplete. `WITH x AS (DELETE FROM users RETURNING 1)
|
|
805
|
+
// SELECT count(*) FROM x`, `SELECT ... INTO new_table`, `EXPLAIN ANALYZE <write>`,
|
|
806
|
+
// `COPY ... FROM`, `CALL <procedure>`, `MERGE`, and `DO $$ ... DELETE ... $$` all begin
|
|
807
|
+
// with a word outside the list and write for real. Postgres' grammar is open; a
|
|
808
|
+
// blocklist over it is a guess.
|
|
809
|
+
// - Checking one string and executing another is a bypass by construction. The old code
|
|
810
|
+
// tested `stripSqlComments(statement)` and ran the ORIGINAL, and comment removal used
|
|
811
|
+
// to delete the comment with no replacement — so `INSERT/**/INTO users VALUES (1)`
|
|
812
|
+
// checked as `INSERTINTO ...` (no match) and executed as a plain INSERT.
|
|
813
|
+
// - `\s` after a keyword is not a word boundary. `TRUNCATE"users"` and
|
|
814
|
+
// `UPDATE"users"SET name=1` are valid SQL with NO whitespace after the keyword.
|
|
815
|
+
//
|
|
816
|
+
// So the statement now runs inside an explicit READ ONLY transaction that ALWAYS rolls
|
|
817
|
+
// back. Postgres refuses every write above AFTER parsing, from inside the engine, where no
|
|
818
|
+
// text-level trick reaches. Two properties of that transaction are load-bearing:
|
|
819
|
+
//
|
|
820
|
+
// 1. It must be an explicit transaction block with the access mode ON THE BLOCK — not a
|
|
821
|
+
// session-level `SET SESSION CHARACTERISTICS` and not only a role-level
|
|
822
|
+
// `default_transaction_read_only`. Verified: under session characteristics alone, a
|
|
823
|
+
// `CALL` to a procedure that does `SET default_transaction_read_only = off; COMMIT;
|
|
824
|
+
// DELETE ...` escapes and deletes rows for real. Inside a real `BEGIN READ ONLY`
|
|
825
|
+
// block the same CALL errors.
|
|
826
|
+
// 2. Exactly ONE statement, still. Verified: inside a read-only block,
|
|
827
|
+
// `SET TRANSACTION READ WRITE` succeeds as the FIRST statement (no snapshot taken
|
|
828
|
+
// yet) and a following write commits. The one-statement rule is what forecloses that.
|
|
829
|
+
//
|
|
830
|
+
// The forced rollback (`runRolledBack`, shared with `db:authz:probe` below and the console
|
|
831
|
+
// sandbox) is belt and braces: nothing a read-only transaction could still have changed
|
|
832
|
+
// survives the call.
|
|
833
|
+
//
|
|
834
|
+
// ACCEPTED NARROWING: a few quasi-read constructs Postgres itself refuses inside ANY
|
|
835
|
+
// explicit transaction, or specifically inside a READ ONLY one, now fail here where they
|
|
836
|
+
// used to run under the old autocommit execution: `VACUUM`/bare `ANALYZE`/`REINDEX`/
|
|
837
|
+
// `CHECKPOINT` (forbidden in a transaction block, full stop) and `SELECT ... FOR UPDATE`/
|
|
838
|
+
// `FOR SHARE`/`nextval()` (forbidden specifically under read-only). None of these are
|
|
839
|
+
// writes this guard exists to stop; they're a real capability loss for an operator who
|
|
840
|
+
// used `db:query` for one of them. Use a direct connection (`db:psql` without `-c`, or
|
|
841
|
+
// `db:exec`) for those.
|
|
695
842
|
actions['db:query'] = async (payload, _ctx) => {
|
|
696
843
|
const { sql: querySql } = payload as { sql: string };
|
|
697
844
|
if (!querySql) {
|
|
698
845
|
return { error: 'SQL query is required' };
|
|
699
846
|
}
|
|
847
|
+
// Exactly one statement, always — see property 2 above, and: a `^`-anchored check
|
|
848
|
+
// against the raw payload is defeated by a leading `-- comment` and by a second
|
|
849
|
+
// `;`-joined statement, which Postgres' simple query protocol runs regardless of what
|
|
850
|
+
// this code inspected. Comment-aware, dollar-quote-aware splitting closes both.
|
|
851
|
+
const statements = splitSqlStatements(querySql);
|
|
852
|
+
if (statements.length !== 1) {
|
|
853
|
+
return {
|
|
854
|
+
// Distinct from the `!querySql` case above: a payload WAS supplied, it just carried
|
|
855
|
+
// no executable statement (e.g. all comments) — telling the two apart matters to a
|
|
856
|
+
// caller branching on the message ("you forgot sql" vs "your sql was empty content").
|
|
857
|
+
error: statements.length === 0
|
|
858
|
+
? 'SQL query contained no executable statement (comments/whitespace only)'
|
|
859
|
+
: `db:query runs exactly one statement (found ${statements.length}) — a second statement can otherwise re-arm the read-only transaction and write. Use db:exec for a multi-statement script.`,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
const [statement] = statements;
|
|
863
|
+
// ADVISORY PRE-CHECK ONLY — NOT the security boundary.
|
|
864
|
+
// Its whole job is a fast, friendly error for the obvious cases before a connection is
|
|
865
|
+
// even opened. The read-only transaction below is what actually enforces read-only, and
|
|
866
|
+
// it catches everything this list misses. Do not add patterns here believing you are
|
|
867
|
+
// closing a hole; a hole here is not a hole in the guard. `\b`, not `\s`, so
|
|
868
|
+
// `TRUNCATE"users"` is still recognised.
|
|
700
869
|
const writePatterns = [
|
|
701
|
-
/^INSERT\
|
|
702
|
-
/^CREATE\
|
|
870
|
+
/^INSERT\b/i, /^UPDATE\b/i, /^DELETE\b/i, /^DROP\b/i,
|
|
871
|
+
/^CREATE\b/i, /^ALTER\b/i, /^TRUNCATE\b/i, /^GRANT\b/i, /^REVOKE\b/i,
|
|
703
872
|
];
|
|
704
|
-
if (writePatterns.some(p => p.test(
|
|
873
|
+
if (writePatterns.some(p => p.test(stripSqlComments(statement).trim()))) {
|
|
705
874
|
return {
|
|
706
875
|
error: 'Write operations are not allowed via db:query. Use db:migrate, seed, or console for write operations.',
|
|
707
876
|
};
|
|
708
877
|
}
|
|
878
|
+
let queryDb: any = opsDb;
|
|
879
|
+
let adhocClient: { end: () => Promise<void> } | undefined;
|
|
880
|
+
const { sql } = await import('drizzle-orm');
|
|
709
881
|
try {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
882
|
+
// The SELECT-only reader first (a role with no write privilege to reach), then a read
|
|
883
|
+
// replica, then the operator connection — the documented fallback, fenced only by the
|
|
884
|
+
// read-only transaction below.
|
|
885
|
+
const readUrl = options.queryDatabaseUrl || options.readDatabaseUrl;
|
|
713
886
|
if (readUrl) {
|
|
714
887
|
const { drizzle } = await import('drizzle-orm/postgres-js');
|
|
715
888
|
const postgres = (await import('postgres')).default;
|
|
716
|
-
|
|
889
|
+
const client = postgres(readUrl, { max: 1, idle_timeout: 20, connect_timeout: 10 });
|
|
890
|
+
adhocClient = client;
|
|
891
|
+
queryDb = drizzle(client);
|
|
717
892
|
}
|
|
718
|
-
|
|
719
|
-
const rows =
|
|
893
|
+
// Never commit, not even a read — runRolledBack forces the rollback.
|
|
894
|
+
const rows = await runRolledBack<any[]>(
|
|
895
|
+
queryDb,
|
|
896
|
+
async (tx: any) => {
|
|
897
|
+
const result: any = await tx.execute(sql.raw(statement));
|
|
898
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
899
|
+
},
|
|
900
|
+
{ accessMode: 'read only' },
|
|
901
|
+
);
|
|
720
902
|
return { rows };
|
|
721
903
|
} catch (err: any) {
|
|
722
904
|
return { error: err.message || String(err) };
|
|
905
|
+
} finally {
|
|
906
|
+
// A SESSION-level advisory lock survives ROLLBACK — `SELECT pg_advisory_lock(1)` is a
|
|
907
|
+
// read as far as the read-only transaction is concerned, and the lock outlives the
|
|
908
|
+
// transaction that took it. The ops connection is one long-lived connection per
|
|
909
|
+
// container (db.ts: `max: 1`), so a lock left behind here would be held until the
|
|
910
|
+
// container dies. Release everything this statement could have taken. Best-effort by
|
|
911
|
+
// design: a failure here must not mask the query's own result.
|
|
912
|
+
//
|
|
913
|
+
// THIS RELEASE ASSUMES ONE PHYSICAL CONNECTION. `pg_advisory_unlock_all()` only frees
|
|
914
|
+
// locks held by the backend it runs on, and session state (`SET search_path`, a temp
|
|
915
|
+
// table) is likewise per-backend. If `opsDb` were a pool, the release could land on a
|
|
916
|
+
// different connection than the one that took the lock — leaking it — and leftover
|
|
917
|
+
// session state could surface in an unrelated later call. `createAdminDb` pins the
|
|
918
|
+
// operator connection to `max: 1` for exactly these two reasons; the read-replica
|
|
919
|
+
// client built above is `max: 1` and closed per invocation for the same reason.
|
|
920
|
+
try {
|
|
921
|
+
await queryDb.execute(sql.raw('SELECT pg_advisory_unlock_all()'));
|
|
922
|
+
} catch {
|
|
923
|
+
// connection already gone / mock connection — nothing to release
|
|
924
|
+
}
|
|
925
|
+
// The read-replica client is built per invocation and used once; closing it is both
|
|
926
|
+
// the leak fix and a second guarantee that no session state outlives the call.
|
|
927
|
+
if (adhocClient) {
|
|
928
|
+
try {
|
|
929
|
+
await adhocClient.end();
|
|
930
|
+
} catch {
|
|
931
|
+
// already closed
|
|
932
|
+
}
|
|
933
|
+
}
|
|
723
934
|
}
|
|
724
935
|
};
|
|
725
936
|
|
|
@@ -736,19 +947,16 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
736
947
|
return { error: 'db:authz:probe requires { setup, read } SQL strings' };
|
|
737
948
|
}
|
|
738
949
|
const { sql } = await import('drizzle-orm');
|
|
739
|
-
|
|
740
|
-
let rows: any[] = [];
|
|
950
|
+
let rows: any[];
|
|
741
951
|
try {
|
|
742
|
-
|
|
952
|
+
// Every probe write is discarded — runRolledBack forces the rollback.
|
|
953
|
+
rows = await runRolledBack<any[]>(opsDb, async (tx: any) => {
|
|
743
954
|
await tx.execute(sql.raw(setup));
|
|
744
955
|
const result: any = await tx.execute(sql.raw(read));
|
|
745
|
-
|
|
746
|
-
throw PROBE_ROLLBACK; // discard every probe write
|
|
956
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
747
957
|
});
|
|
748
958
|
} catch (err: unknown) {
|
|
749
|
-
|
|
750
|
-
return { error: (err as any)?.message || String(err) };
|
|
751
|
-
}
|
|
959
|
+
return { error: (err as any)?.message || String(err) };
|
|
752
960
|
}
|
|
753
961
|
return { rows };
|
|
754
962
|
};
|
|
@@ -824,6 +1032,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
824
1032
|
// real connectivity BEFORE the authenticator password is set — the ordering that
|
|
825
1033
|
// keeps a BYO-database app from sawing off its own ops plane (see provision.ts).
|
|
826
1034
|
actions['db:provision'] = async (payload, _ctx) => {
|
|
1035
|
+
// FIRST, before any connection work: this payload's searchPath — and the declaredOwners /
|
|
1036
|
+
// declaredObjects its type accepts — are computed by the CLI from the Models barrel, so a
|
|
1037
|
+
// shape skew between that CLI and the one bundled here must refuse, not ride through.
|
|
1038
|
+
const skew = await modelWireGate('db:provision', payload);
|
|
1039
|
+
if (skew) return skew;
|
|
1040
|
+
|
|
827
1041
|
const { sql } = await import('drizzle-orm');
|
|
828
1042
|
const { runProvision } = await import('./provision');
|
|
829
1043
|
|
|
@@ -880,9 +1094,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
880
1094
|
// is safely in the secret store: credentials (db:provision) → secret write (CLI) → this. A hard
|
|
881
1095
|
// kill here strands nothing — the worst case is an un-converged owner db:reconcile refuses on.
|
|
882
1096
|
actions['db:converge-ownership'] = async (payload, _ctx) => {
|
|
1097
|
+
// FIRST, before the master client is opened: declaredOwners/declaredObjects come straight
|
|
1098
|
+
// from the CLI's read of the Models barrel, and this action REASSIGNS OWNERSHIP from them —
|
|
1099
|
+
// a misread field here changes which role a SECURITY DEFINER object runs as.
|
|
1100
|
+
const skew = await modelWireGate('db:converge-ownership', payload);
|
|
1101
|
+
if (skew) return skew;
|
|
1102
|
+
|
|
883
1103
|
const { sql } = await import('drizzle-orm');
|
|
884
1104
|
const { convergeOwnership } = await import('./provision');
|
|
885
|
-
const {
|
|
1105
|
+
const { getAdoptMasterUrl } = await import('./db');
|
|
886
1106
|
|
|
887
1107
|
const queryRows = async (statement: string): Promise<any[]> => {
|
|
888
1108
|
const res: any = await opsDb.execute(sql.raw(statement));
|
|
@@ -891,10 +1111,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
891
1111
|
return [];
|
|
892
1112
|
};
|
|
893
1113
|
|
|
894
|
-
// The MASTER connection
|
|
895
|
-
// operator
|
|
896
|
-
//
|
|
897
|
-
|
|
1114
|
+
// The MASTER connection ADOPT reassigns through: the linked SST Postgres master, else the
|
|
1115
|
+
// ADMIN_DATABASE_URL operator secret (a brownfield/external-RDS app has no component but wires
|
|
1116
|
+
// a master-capable admin — see getAdoptMasterUrl). ENACT also routes hard grants here. Null
|
|
1117
|
+
// only when NEITHER exists; then ADOPT refuses. Whether the resolved connection can actually
|
|
1118
|
+
// reassign is decided by ADOPT's live probe, not by which source won. Closed in the finally.
|
|
1119
|
+
const masterUrl = getAdoptMasterUrl();
|
|
898
1120
|
let masterClient: any = null;
|
|
899
1121
|
let master: { execute: (s: string) => Promise<unknown>; query: (s: string) => Promise<any[]> } | null = null;
|
|
900
1122
|
if (masterUrl) {
|
|
@@ -1032,8 +1254,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1032
1254
|
* later descent ignores this row's git_ref. */
|
|
1033
1255
|
dirty?: boolean;
|
|
1034
1256
|
descentVerdict?: { ok: boolean; reason?: string };
|
|
1035
|
-
/**
|
|
1036
|
-
*
|
|
1257
|
+
/** What the CLI's descent step decided, to record in the schema_log outcome
|
|
1258
|
+
* (`applied — <note>`): a honoured --attest-lineage-lost waiver, or a PROVEN DRIFT
|
|
1259
|
+
* origin (`descent: drift-origin proven (never-declared | hand-edited since … | applied
|
|
1260
|
+
* from a DIRTY worktree at …)`) — the
|
|
1261
|
+
* record that makes this row the origin every later apply descends from. */
|
|
1037
1262
|
descentAttested?: string;
|
|
1038
1263
|
authorityVerdict?: { ok: boolean; reason?: string };
|
|
1039
1264
|
stage?: string;
|
|
@@ -1094,7 +1319,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1094
1319
|
gitRef: gitRef ?? null,
|
|
1095
1320
|
dirty: dirty === true,
|
|
1096
1321
|
checkDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }),
|
|
1097
|
-
//
|
|
1322
|
+
// The CLI's descent record (a honoured waiver, or a proven-drift origin) lands in the
|
|
1323
|
+
// schema_log outcome; the witness query matches `applied%`, so the row stays a witness.
|
|
1098
1324
|
...(descentAttested ? { descentNote: () => descentAttested } : {}),
|
|
1099
1325
|
...(destructive && authorityVerdict
|
|
1100
1326
|
? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
|
|
@@ -1142,18 +1368,16 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1142
1368
|
// barrel), and this introspects → plans → applies, emitting its own SET search_path. The DDL
|
|
1143
1369
|
// batch and its provenance/schema_log land in one transaction, same as the direct path.
|
|
1144
1370
|
actions['db:reconcile'] = async (payload, _ctx) => {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
gitRef?: string | null;
|
|
1156
|
-
};
|
|
1371
|
+
// FIRST, before the catalog introspection: `declared` is the derived layer compiled by the
|
|
1372
|
+
// CLI from the Models barrel, and this action CREATES AND REPLACES database objects from it.
|
|
1373
|
+
// It used to be `any[]` on the wire — no shape, no version, nothing to disagree about.
|
|
1374
|
+
const skew = await modelWireGate('db:reconcile', payload);
|
|
1375
|
+
if (skew) return skew;
|
|
1376
|
+
|
|
1377
|
+
// ReconcileWirePayload is the SHARED declaration (@everystack/model/wire, re-exported by
|
|
1378
|
+
// @everystack/cli/wire) — the same type the CLI builds this payload against, so the two
|
|
1379
|
+
// sides no longer agree by convention alone.
|
|
1380
|
+
const p = (payload ?? {}) as ReconcileWirePayload;
|
|
1157
1381
|
try {
|
|
1158
1382
|
const { executeReconcile } = await import('@everystack/cli/reconcile');
|
|
1159
1383
|
const { sql } = await import('drizzle-orm');
|
|
@@ -1161,6 +1385,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1161
1385
|
const result: any = await opsDb.execute(sql.raw(query));
|
|
1162
1386
|
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
1163
1387
|
};
|
|
1388
|
+
// No `as any` on this call any more: `p.declared` is DerivedDescriptor[], which IS the
|
|
1389
|
+
// CLI's SourceObject[] (one declaration, in @everystack/model/wire), so the reconcile
|
|
1390
|
+
// core's options type accepts it directly. The cast used to hide exactly the kind of
|
|
1391
|
+
// mismatch this whole contract exists to catch.
|
|
1164
1392
|
const run = await executeReconcile(runner, sessionOverOpsDb(sql), {
|
|
1165
1393
|
declared: p.declared,
|
|
1166
1394
|
renamedTables: p.renamedTables,
|
|
@@ -1172,7 +1400,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1172
1400
|
only: p.only,
|
|
1173
1401
|
actor: p.actor ?? null,
|
|
1174
1402
|
gitRef: p.gitRef ?? null,
|
|
1175
|
-
}
|
|
1403
|
+
});
|
|
1176
1404
|
return { plan: run.plan, applied: run.applied, statements: run.statements, refusal: run.refusal ?? null };
|
|
1177
1405
|
} catch (err: any) {
|
|
1178
1406
|
return { error: err?.message || String(err) };
|
|
@@ -1301,6 +1529,13 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1301
1529
|
// DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
|
|
1302
1530
|
// the operator connection: the CLI ships the declared matview identities in dependency order,
|
|
1303
1531
|
// this REFRESHes each. A least-privileged operator never holds a URL for the routine refresh.
|
|
1532
|
+
// NOT guarded by MODEL_WIRE_CONTRACT, deliberately. The payload is a flat list of
|
|
1533
|
+
// `schema.name` identity strings plus one boolean — there is no descriptor here, so there is
|
|
1534
|
+
// no field that could be renamed or reinterpreted between two CLI versions, and a name this
|
|
1535
|
+
// database does not have fails loudly as a REFRESH against a missing relation. Contrast
|
|
1536
|
+
// db:reconcile's `declared[]`, whose seventeen fields decide whether an object is rebuilt and
|
|
1537
|
+
// under whose ownership. IF this payload ever grows a descriptor shape, it joins the guarded
|
|
1538
|
+
// set — add the modelWireGate call here and bump MODEL_WIRE_CONTRACT.
|
|
1304
1539
|
actions['db:refresh'] = async (payload, _ctx) => {
|
|
1305
1540
|
const p = (payload ?? {}) as { matviews?: string[]; verifyNonempty?: boolean };
|
|
1306
1541
|
try {
|
|
@@ -1392,9 +1627,6 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1392
1627
|
...options.consoleAuth,
|
|
1393
1628
|
});
|
|
1394
1629
|
|
|
1395
|
-
// Sentinel for sandbox rollback
|
|
1396
|
-
const SANDBOX_ROLLBACK = Symbol('sandbox_rollback');
|
|
1397
|
-
|
|
1398
1630
|
async function evalExpression(db: any) {
|
|
1399
1631
|
const models = createModels(db, ctx.schema);
|
|
1400
1632
|
const evalContext: Record<string, unknown> = {
|
|
@@ -1438,15 +1670,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1438
1670
|
}
|
|
1439
1671
|
|
|
1440
1672
|
if (sandbox) {
|
|
1441
|
-
|
|
1442
|
-
await
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
throw SANDBOX_ROLLBACK;
|
|
1446
|
-
});
|
|
1447
|
-
} catch (err: unknown) {
|
|
1448
|
-
if (err !== SANDBOX_ROLLBACK) throw err;
|
|
1449
|
-
}
|
|
1673
|
+
result = await runRolledBack(opsDb, async (tx: any) => {
|
|
1674
|
+
await applySettings(tx);
|
|
1675
|
+
return evalExpression(tx);
|
|
1676
|
+
});
|
|
1450
1677
|
} else {
|
|
1451
1678
|
result = await opsDb.transaction(async (tx: any) => {
|
|
1452
1679
|
await applySettings(tx);
|
|
@@ -1454,14 +1681,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1454
1681
|
});
|
|
1455
1682
|
}
|
|
1456
1683
|
} else if (sandbox) {
|
|
1457
|
-
|
|
1458
|
-
await opsDb.transaction(async (tx: any) => {
|
|
1459
|
-
result = await evalExpression(tx);
|
|
1460
|
-
throw SANDBOX_ROLLBACK;
|
|
1461
|
-
});
|
|
1462
|
-
} catch (err: unknown) {
|
|
1463
|
-
if (err !== SANDBOX_ROLLBACK) throw err;
|
|
1464
|
-
}
|
|
1684
|
+
result = await runRolledBack(opsDb, (tx: any) => evalExpression(tx));
|
|
1465
1685
|
} else {
|
|
1466
1686
|
result = await evalExpression(opsDb);
|
|
1467
1687
|
}
|