@everystack/server 0.2.21 → 0.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -365,4 +365,8 @@ Part of [everystack](https://github.com/scalable-technology/everystack) — a se
365
365
 
366
366
  ## License
367
367
 
368
- MIT
368
+ [AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
369
+
370
+ A commercial license is available for organizations that cannot or do not wish to
371
+ comply with the AGPL-3.0 terms. For commercial licensing, contact
372
+ licensing@scalable.technology.
package/jest-preset.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Drop-in jest config for a consumer's server-boundary test project (the one
3
+ * that imports `@everystack/server/testing`).
4
+ *
5
+ * // jest.config.js
6
+ * module.exports = { preset: '@everystack/server' };
7
+ *
8
+ * Note the value is the package name, NOT '@everystack/server/jest-preset':
9
+ * jest appends `/jest-preset.js` to the preset string itself, so it resolves
10
+ * this file as `@everystack/server/jest-preset.js`.
11
+ *
12
+ * Why a preset is needed
13
+ * ----------------------
14
+ * @everystack packages ship TypeScript source, and their subpaths (like
15
+ * `@everystack/server/testing`) are reachable only through the package
16
+ * `exports` map. Two things have to be true for a consumer to import them:
17
+ *
18
+ * 1. TypeScript must read the `exports` map. Only `node16`/`nodenext`/`bundler`
19
+ * resolution does. ts-jest under `module: commonjs` silently falls back to
20
+ * classic `node` resolution, which ignores `exports` → TS2307. NodeNext is
21
+ * the one mode that reads `exports` AND emits CommonJS for jest. Its hybrid
22
+ * module kind makes ts-jest warn TS151002; we silence only that one code
23
+ * (NOT via `isolatedModules: true`, which would switch ts-jest to
24
+ * transpile-only and drop type-checking entirely).
25
+ *
26
+ * 2. jest must TRANSFORM the shipped `.ts` source. Source in `node_modules` is
27
+ * ignored by default, so the `transformIgnorePatterns` allowlist below opts
28
+ * `@everystack/*` back in. (Inside this monorepo the pnpm symlink escapes
29
+ * `node_modules` and hides this requirement — real installs need it.)
30
+ *
31
+ * Requires `ts-jest` and `typescript` in the consuming project (already present
32
+ * for any TS jest setup).
33
+ */
34
+ module.exports = {
35
+ testEnvironment: 'node',
36
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
37
+ transform: {
38
+ '^.+\\.tsx?$': [
39
+ 'ts-jest',
40
+ {
41
+ tsconfig: {
42
+ module: 'NodeNext',
43
+ moduleResolution: 'NodeNext',
44
+ target: 'ES2022',
45
+ jsx: 'react-jsx',
46
+ esModuleInterop: true,
47
+ skipLibCheck: true,
48
+ types: ['jest', 'node'],
49
+ },
50
+ // 151002: "hybrid module kind needs isolatedModules" — expected under
51
+ // NodeNext; suppressing it keeps full type-checking on (real type errors
52
+ // in the consumer's tests still fail the suite).
53
+ diagnostics: { ignoreCodes: [151002] },
54
+ },
55
+ ],
56
+ },
57
+ // @everystack packages ship source, not built dist — transform them.
58
+ transformIgnorePatterns: ['/node_modules/(?!@everystack/)'],
59
+ // NodeNext consumers may write `.js` on relative ESM specifiers; map to source.
60
+ moduleNameMapper: {
61
+ '^(\\.{1,2}/.*)\\.js$': '$1',
62
+ },
63
+ };
package/package.json CHANGED
@@ -1,14 +1,25 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.21",
3
+ "version": "0.2.24",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
+ "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/scalable-technology/everystack.git",
10
+ "directory": "packages/server"
11
+ },
12
+ "homepage": "https://github.com/scalable-technology/everystack#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/scalable-technology/everystack/issues"
15
+ },
6
16
  "publishConfig": {
7
17
  "access": "public"
8
18
  },
9
19
  "files": [
10
20
  "src",
11
21
  "stubs",
22
+ "jest-preset.js",
12
23
  "README.md"
13
24
  ],
14
25
  "exports": {
@@ -44,6 +55,8 @@
44
55
  "types": "./src/testing/index.ts",
45
56
  "default": "./src/testing/index.ts"
46
57
  },
58
+ "./jest-preset": "./jest-preset.js",
59
+ "./jest-preset.js": "./jest-preset.js",
47
60
  "./plugin": {
48
61
  "types": "./src/plugin.ts",
49
62
  "default": "./src/plugin.ts"
@@ -61,11 +74,6 @@
61
74
  "default": "./src/media.ts"
62
75
  }
63
76
  },
64
- "scripts": {
65
- "test": "jest",
66
- "build": "tsc --build",
67
- "lint": "tsc --noEmit"
68
- },
69
77
  "peerDependencies": {
70
78
  "esbuild": ">=0.20.0",
71
79
  "@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
@@ -115,8 +123,6 @@
115
123
  "devDependencies": {
116
124
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
117
125
  "@aws-sdk/signature-v4a": "3.1063.0",
118
- "@everystack/auth": "workspace:*",
119
- "@everystack/cli": "workspace:*",
120
126
  "@types/aws-lambda": "8.10.161",
121
127
  "@types/jest": "29.5.14",
122
128
  "@types/node": "22.19.18",
@@ -126,6 +132,13 @@
126
132
  "postgres": "3.4.9",
127
133
  "sst": "4.13.1",
128
134
  "ts-jest": "29.4.9",
129
- "typescript": "5.9.3"
135
+ "typescript": "5.9.3",
136
+ "@everystack/auth": "0.2.6",
137
+ "@everystack/cli": "0.2.39"
138
+ },
139
+ "scripts": {
140
+ "test": "jest",
141
+ "build": "tsc --build",
142
+ "lint": "tsc --noEmit"
130
143
  }
131
- }
144
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * db:doctor — "is my database actually secure?" in one command.
3
+ *
4
+ * The credential split is invisible until something tells you which role each Lambda
5
+ * really connects as. This is that instrument: it probes the api connection (createDb)
6
+ * and the admin connection (createAdminDb) from inside the VPC and reports, green/red,
7
+ * whether the api path is least-privilege and RLS-subject. It catches the exact traps
8
+ * that bit us by hand — an unwired DATABASE_URL secret (api still connects as the
9
+ * master), a missing FORCE ROW LEVEL SECURITY, a BYPASSRLS role on the api path.
10
+ *
11
+ * Pairs with `security:audit` (the SECDEF surface). This module is the pure classifier;
12
+ * the dbPlugin `db:doctor` action gathers the probe and the CLI prints the report.
13
+ *
14
+ * See docs/plans/secure-by-default-database.md.
15
+ */
16
+
17
+ export interface ConnInfo {
18
+ /** current_user the connection runs as. */
19
+ role: string;
20
+ isSuperuser: boolean;
21
+ bypassRls: boolean;
22
+ }
23
+
24
+ export interface ForceRlsRow {
25
+ schema: string;
26
+ table: string;
27
+ /** RLS enabled on the table. */
28
+ enabled: boolean;
29
+ /** FORCE ROW LEVEL SECURITY (applies even to the owner). */
30
+ forced: boolean;
31
+ }
32
+
33
+ export interface DoctorProbe {
34
+ /** The api credential's connection facts (createDb / DATABASE_URL). */
35
+ api: ConnInfo;
36
+ /** The operator connection's facts (createAdminDb / ADMIN_DATABASE_URL). */
37
+ admin: ConnInfo;
38
+ /** Tables with RLS enabled, and whether it's FORCEd. Gathered as admin. */
39
+ forceRls: ForceRlsRow[];
40
+ /**
41
+ * Result of a bare `SELECT` on an RLS table as the api connection, WITHOUT setting a
42
+ * role. A least-privilege role (NOINHERIT, no grants of its own) must fail closed here;
43
+ * a role that returns rows carries ambient privileges (e.g. INHERIT + membership in a
44
+ * `USING(true)` role like the master). Absent when there is no RLS table to probe.
45
+ */
46
+ apiAmbientRead?: { table: string; accessible: boolean };
47
+ }
48
+
49
+ export type CheckStatus = 'pass' | 'fail' | 'warn';
50
+
51
+ export interface Check {
52
+ name: string;
53
+ status: CheckStatus;
54
+ detail: string;
55
+ /** When the check fails, concrete guidance on how to fix it (shown by the CLI). */
56
+ remediation?: string;
57
+ }
58
+
59
+ export interface DoctorReport {
60
+ api: ConnInfo;
61
+ admin: ConnInfo;
62
+ checks: Check[];
63
+ /** True when no check failed (warnings are allowed). */
64
+ ok: boolean;
65
+ }
66
+
67
+ /** SQL run as a single connection to read its own security facts. */
68
+ export const CONN_SQL = `
69
+ SELECT
70
+ current_user AS role,
71
+ current_setting('is_superuser') = 'on' AS is_superuser,
72
+ COALESCE((SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user), false) AS bypass_rls
73
+ `.trim();
74
+
75
+ /** SQL run as admin: every table with RLS enabled, and whether it is FORCEd. */
76
+ export const FORCE_RLS_SQL = `
77
+ SELECT
78
+ n.nspname AS schema,
79
+ c.relname AS "table",
80
+ c.relrowsecurity AS enabled,
81
+ c.relforcerowsecurity AS forced
82
+ FROM pg_class c
83
+ JOIN pg_namespace n ON n.oid = c.relnamespace
84
+ WHERE c.relkind = 'r'
85
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
86
+ AND n.nspname NOT LIKE 'pg_%'
87
+ AND c.relrowsecurity
88
+ ORDER BY n.nspname, c.relname
89
+ `.trim();
90
+
91
+ /** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
92
+ export function pgBool(v: unknown): boolean {
93
+ return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
94
+ }
95
+
96
+ /** Options for the report builder. */
97
+ export interface DoctorReportOptions {
98
+ /**
99
+ * App-owned tables that are intentionally ENABLE-not-FORCE: an operational table whose
100
+ * owner (e.g. the worker, or a SECURITY DEFINER function) must read/write it, where FORCE
101
+ * would block the owner. These are added to the framework default (`auth.users`) and not
102
+ * flagged by the FORCE check. Example: `['ops.runs']` (the jobs queue the worker writes).
103
+ */
104
+ forceRlsCarveouts?: string[];
105
+ }
106
+
107
+ /** Build the green/red report from a gathered probe. Pure. */
108
+ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptions = {}): DoctorReport {
109
+ const checks: Check[] = [];
110
+ const { api, admin } = probe;
111
+
112
+ // The headline check — does the api path actually run as a distinct least-privilege
113
+ // role, or is it the operator/master? Same role on both = the split isn't active
114
+ // (the classic unwired-secret trap: the api still connects as the master).
115
+ if (api.role === admin.role) {
116
+ checks.push({
117
+ name: 'API connection is least-privilege',
118
+ status: 'fail',
119
+ detail: `the api and operator connections both run as "${api.role}" — the credential split is not active (DATABASE_URL likely unwired; the API is using the operator/master credential)`,
120
+ remediation:
121
+ 'Run `everystack db:provision --stage <stage>` — it creates the least-privilege role chain on this '
122
+ + 'database (idempotent, creates no database) AND writes the DATABASE_URL secret for you, without ever '
123
+ + 'printing the credential. Then in sst.config.ts declare `new sst.Secret("DatabaseUrl")`, link it to the '
124
+ + 'API function, and drop the raw Postgres component from the API link (keep it on ops/worker); redeploy. '
125
+ + 'New apps get all of this automatically from the `everystack.Postgres` construct.',
126
+ });
127
+ } else {
128
+ checks.push({
129
+ name: 'API connection is least-privilege',
130
+ status: 'pass',
131
+ detail: `api runs as "${api.role}", operator as "${admin.role}"`,
132
+ });
133
+ }
134
+
135
+ checks.push({
136
+ name: 'API role is not a superuser',
137
+ status: api.isSuperuser ? 'fail' : 'pass',
138
+ detail: api.isSuperuser ? `"${api.role}" is a superuser — it bypasses RLS entirely` : `"${api.role}" is not a superuser`,
139
+ });
140
+
141
+ checks.push({
142
+ name: 'API role has no BYPASSRLS',
143
+ status: api.bypassRls ? 'fail' : 'pass',
144
+ detail: api.bypassRls ? `"${api.role}" has the BYPASSRLS attribute — every policy is skipped` : `"${api.role}" is subject to RLS`,
145
+ });
146
+
147
+ // The definitive least-privilege test: can the api connection read an RLS table with no
148
+ // SET ROLE? A role can pass "not superuser / no bypassrls / FORCE on" and still see every
149
+ // row through INHERITed membership in a USING(true) role (the master's leak path). Only a
150
+ // bare read proves it fails closed.
151
+ if (probe.apiAmbientRead) {
152
+ const { table, accessible } = probe.apiAmbientRead;
153
+ checks.push({
154
+ name: 'API connection fails closed (no ambient table access)',
155
+ status: accessible ? 'fail' : 'pass',
156
+ detail: accessible
157
+ ? `the api connection read "${table}" without setting a role — it carries ambient privileges (likely INHERIT + membership in a USING(true) role). A least-privilege role has no grants of its own and must SET ROLE first.`
158
+ : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior`,
159
+ remediation: accessible
160
+ ? `Connect the API as a NOINHERIT role with no table grants of its own (e.g. \`authenticator\`), so memberships only apply via SET ROLE: \`ALTER ROLE <api_role> NOINHERIT;\` and revoke any direct table grants. The handler/SSR then escalate per-request via withRole().`
161
+ : undefined,
162
+ });
163
+ }
164
+
165
+ // FORCE coverage — RLS enabled but not FORCEd means the owner silently bypasses it.
166
+ //
167
+ // Carve-out: a credential table whose SECURITY DEFINER auth functions (verify the
168
+ // password, create the user) run as the table owner and MUST read/write it. FORCE would
169
+ // subject the owner to RLS and break sign_in/sign_up — and on managed Postgres (e.g. RDS)
170
+ // the owner is not a superuser, so it can't bypass FORCE either. Such a table is correctly
171
+ // ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
172
+ // policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
173
+ // the framework's one such table.
174
+ // auth.users is the framework's own credential carve-out (always); apps add operational
175
+ // tables whose owner must write them (e.g. ops.runs, the worker-written jobs queue).
176
+ const FORCE_RLS_CARVEOUTS = new Set(['auth.users', ...(options.forceRlsCarveouts ?? [])]);
177
+ const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
178
+ const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
179
+ const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
180
+ const carveNote = carved.length > 0
181
+ ? ` (${carved.map((r) => `${r.schema}.${r.table}`).join(', ')} intentionally ENABLE-not-FORCE: owner-written table(s) whose owner — a SECURITY DEFINER function or the worker — must bypass)`
182
+ : '';
183
+ if (probe.forceRls.length === 0) {
184
+ checks.push({
185
+ name: 'FORCE ROW LEVEL SECURITY on RLS tables',
186
+ status: 'warn',
187
+ detail: 'no tables have RLS enabled yet (nothing to force)',
188
+ });
189
+ } else if (unforced.length > 0) {
190
+ checks.push({
191
+ name: 'FORCE ROW LEVEL SECURITY on RLS tables',
192
+ status: 'fail',
193
+ detail: `RLS enabled but not FORCEd (owner bypasses policies): ${unforced.map((r) => `${r.schema}.${r.table}`).join(', ')}`,
194
+ remediation: unforced.map((r) => `ALTER TABLE ${r.schema}.${r.table} FORCE ROW LEVEL SECURITY;`).join(' '),
195
+ });
196
+ } else {
197
+ checks.push({
198
+ name: 'FORCE ROW LEVEL SECURITY on RLS tables',
199
+ status: 'pass',
200
+ detail: `all ${probe.forceRls.length - carved.length} protected RLS table(s) are FORCEd${carveNote}`,
201
+ });
202
+ }
203
+
204
+ return {
205
+ api,
206
+ admin,
207
+ checks,
208
+ ok: !checks.some((c) => c.status === 'fail'),
209
+ };
210
+ }
211
+
212
+ /** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
213
+ export function rowToConnInfo(row: any): ConnInfo {
214
+ return {
215
+ role: String(row.role),
216
+ isSuperuser: pgBool(row.is_superuser),
217
+ bypassRls: pgBool(row.bypass_rls),
218
+ };
219
+ }
220
+
221
+ /** Map a FORCE_RLS_SQL row to ForceRlsRow. */
222
+ export function rowToForceRls(row: any): ForceRlsRow {
223
+ return {
224
+ schema: String(row.schema),
225
+ table: String(row.table),
226
+ enabled: pgBool(row.enabled),
227
+ forced: pgBool(row.forced),
228
+ };
229
+ }
package/src/db.ts CHANGED
@@ -26,11 +26,12 @@ function tryResource<T>(fn: () => T): T | undefined {
26
26
  }
27
27
 
28
28
  export function getDatabaseUrl(): string {
29
- // SST Postgres component (Resource.Database)
30
- const db = tryResource(() => Resource.Database);
31
- if (db?.host) {
32
- return `postgresql://${db.username}:${db.password}@${db.host}:${db.port}/${db.database}`;
33
- }
29
+ // An EXPLICIT DATABASE_URL wins over the component-derived master. This is what
30
+ // lets the API connection be a least-privilege role (`authenticator`) while the SST
31
+ // Postgres component still exists to back the owner/ops credential. If the master
32
+ // resolved first, setting DATABASE_URL to a least-privilege role would be silently
33
+ // ignored. See docs/plans/credential-split-rls-everywhere.md.
34
+
34
35
  // SST Secret — PascalCase (Resource.DatabaseUrl.value)
35
36
  const pascal = tryResource(() => (Resource as any).DatabaseUrl?.value as string);
36
37
  if (pascal) return pascal;
@@ -39,9 +40,28 @@ export function getDatabaseUrl(): string {
39
40
  if (raw) return raw;
40
41
  // process.env fallback
41
42
  if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
43
+ // Fallback: the SST Postgres component's master credential. Single-credential apps
44
+ // (no explicit DATABASE_URL) keep working; this is the privileged owner, so the
45
+ // createAdminDb fallback warning applies until the credentials are split.
46
+ const master = getMasterDatabaseUrl();
47
+ if (master) return master;
42
48
  throw new Error('DATABASE_URL not set — link an sst.aws.Postgres or sst.Secret, or set process.env.DATABASE_URL');
43
49
  }
44
50
 
51
+ /**
52
+ * The SST Postgres component's master credential URL (the privileged owner), or null when no
53
+ * Postgres component is linked. This is the owner credential operator tasks fall back to when
54
+ * no dedicated ADMIN_DATABASE_URL is provisioned — kept separate from getDatabaseUrl() so the
55
+ * operator path never accidentally resolves the least-privilege DATABASE_URL secret.
56
+ */
57
+ export function getMasterDatabaseUrl(): string | null {
58
+ const db = tryResource(() => Resource.Database);
59
+ if (db?.host) {
60
+ return `postgresql://${db.username}:${db.password}@${db.host}:${db.port}/${db.database}`;
61
+ }
62
+ return null;
63
+ }
64
+
45
65
  /**
46
66
  * Resolve the operator (privileged) database URL, if configured.
47
67
  *
@@ -117,6 +137,69 @@ export function getSql(): ReturnType<typeof postgres> {
117
137
  return sqlClient;
118
138
  }
119
139
 
140
+ // ---------------------------------------------------------------------------
141
+ // withRole — the access primitive (RLS on every path)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * The postgres.js transaction handle is callable as a tagged template, but its type
146
+ * (`TransactionSql` via `Omit<Sql, …>`) drops the call signature. Type callbacks
147
+ * against the plain callable `Sql` handle instead.
148
+ */
149
+ export type RoleSql = ReturnType<typeof postgres>;
150
+
151
+ // Postgres identifier rule — the only value ever interpolated into SET ROLE. Roles
152
+ // come from server code, never user input, but validate defensively against injection.
153
+ const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
154
+
155
+ /**
156
+ * Run `fn` inside a transaction with the effective Postgres role and JWT claims set
157
+ * for its duration. This is THE access primitive on a least-privilege connection: RLS
158
+ * policies and column grants evaluate against `role`, and SQL functions read `auth.*`
159
+ * from the claims. A path that never calls this runs as the grant-less login role and
160
+ * **fails closed** — nothing here bypasses RLS.
161
+ *
162
+ * role one of your application roles (e.g. anon | authenticated | service) —
163
+ * validated against the Postgres identifier rule, never user input.
164
+ * claims the `request.jwt.claims` payload; defaults to `{ role }` for roleless paths.
165
+ *
166
+ * `SET LOCAL` / `set_config(…, true)` are transaction-scoped, so they reset when the
167
+ * pooled connection returns — roles never leak between requests.
168
+ */
169
+ export async function withRole<T>(
170
+ sql: ReturnType<typeof postgres>,
171
+ role: string,
172
+ claims: Record<string, unknown> | null,
173
+ fn: (tx: RoleSql) => Promise<T>,
174
+ ): Promise<T> {
175
+ if (!ROLE_NAME.test(role)) {
176
+ throw new Error(`Invalid role name: ${role}`);
177
+ }
178
+ const claimsJson = JSON.stringify(claims ?? { role });
179
+ return sql.begin(async (tx: any) => {
180
+ await tx.unsafe(`SET LOCAL ROLE ${role}`);
181
+ await tx.unsafe(`SELECT set_config('request.jwt.claims', $1, true)`, [claimsJson]);
182
+ return fn(tx as unknown as RoleSql);
183
+ }) as Promise<T>;
184
+ }
185
+
186
+ /**
187
+ * Effective role + claims for a request, derived from the verified JWT (or anon).
188
+ *
189
+ * Returns only `anon`/`authenticated` — the roles that may come from a token. The
190
+ * privileged `service` role is NEVER returned here; server code sets it explicitly via
191
+ * `withRole(sql, 'service', …)` after its own authorization check, so a forged or
192
+ * absent JWT can never reach it.
193
+ */
194
+ export function roleFor(user?: Record<string, unknown> | null): {
195
+ role: 'anon' | 'authenticated';
196
+ claims: Record<string, unknown>;
197
+ } {
198
+ return user
199
+ ? { role: 'authenticated', claims: user }
200
+ : { role: 'anon', claims: { role: 'anon' } };
201
+ }
202
+
120
203
  // Lazy singleton operator DB connection (separate from the API connection)
121
204
  let adminDbInstance: ReturnType<typeof drizzle> | null = null;
122
205
  let adminSqlClient: ReturnType<typeof postgres> | null = null;
@@ -139,16 +222,23 @@ export function createAdminDb<T extends Record<string, unknown>>(
139
222
  } {
140
223
  if (adminDbInstance) return { db: adminDbInstance, schema };
141
224
 
142
- const adminUrl = getAdminDatabaseUrl();
225
+ // Resolve the operator credential: an explicit ADMIN_DATABASE_URL, else the Postgres master
226
+ // (the privileged owner). NOT getDatabaseUrl()/createDb — post-split that resolves the
227
+ // least-privilege DATABASE_URL (authenticator), which cannot run operator tasks (migrate/
228
+ // seed/DDL) and fails "permission denied".
229
+ let adminUrl = getAdminDatabaseUrl();
143
230
  if (!adminUrl) {
144
- if (!adminFallbackWarned) {
231
+ adminUrl = getMasterDatabaseUrl();
232
+ if (adminUrl && !adminFallbackWarned) {
145
233
  adminFallbackWarned = true;
146
234
  console.warn(
147
- '[everystack/db] ADMIN_DATABASE_URL not set — operator tasks are using the '
148
- + 'API connection (DATABASE_URL). If that role has BYPASSRLS, your RLS policies '
149
- + 'are not enforced. Split the credentials: docs/plans/admin-database-url.md'
235
+ '[everystack/db] ADMIN_DATABASE_URL not set — operator tasks use the Postgres master '
236
+ + 'credential. Provision a dedicated ops owner role for full credential separation.'
150
237
  );
151
238
  }
239
+ }
240
+ if (!adminUrl) {
241
+ // No Postgres component at all (e.g. local dev with no DB linked) — last resort.
152
242
  return createDb(schema, options);
153
243
  }
154
244
 
package/src/plugin.ts CHANGED
@@ -457,10 +457,17 @@ export interface DbPluginOptions {
457
457
  connectionInfo?: () => Promise<Record<string, string>>;
458
458
  /** Enable console action (default: true) */
459
459
  allowConsole?: boolean;
460
- /** Read replica URL for db:query (default: process.env.READ_DATABASE_URL) */
460
+ /** Optional read-replica URL for db:query. When unset, db:query runs on the operator
461
+ * connection. (No env fallback — pass it explicitly if you have a replica.) */
461
462
  readDatabaseUrl?: string;
462
463
  /** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
463
464
  pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
465
+ /**
466
+ * App-owned tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor
467
+ * does not flag them. The framework always carves out `auth.users`; add operational tables
468
+ * whose owner must write them, e.g. `['ops.runs']` (the worker-written jobs queue).
469
+ */
470
+ forceRlsCarveouts?: string[];
464
471
  /** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
465
472
  consoleAuth?: {
466
473
  usersTable?: string;
@@ -561,8 +568,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
561
568
  try {
562
569
  const { sql } = await import('drizzle-orm');
563
570
  let queryDb = opsDb;
564
- const readUrl = options.readDatabaseUrl ?? process.env.READ_DATABASE_URL;
565
- if (readUrl && readUrl !== process.env.DATABASE_URL) {
571
+ const readUrl = options.readDatabaseUrl;
572
+ if (readUrl) {
566
573
  const { drizzle } = await import('drizzle-orm/postgres-js');
567
574
  const postgres = (await import('postgres')).default;
568
575
  queryDb = drizzle(postgres(readUrl, { max: 1, idle_timeout: 20, connect_timeout: 10 }));
@@ -575,6 +582,94 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
575
582
  }
576
583
  };
577
584
 
585
+ // --- db:doctor — "is my database actually secure?" ---
586
+ // Probes the api connection (createDb / DATABASE_URL) and the operator connection
587
+ // (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path
588
+ // is a distinct least-privilege, RLS-subject role. Catches the unwired-secret trap
589
+ // (api still on the master), BYPASSRLS, and un-FORCEd RLS. See db-doctor.ts.
590
+ actions['db:doctor'] = async (_payload, ctx) => {
591
+ const { sql } = await import('drizzle-orm');
592
+ const {
593
+ buildDoctorReport, rowToConnInfo, rowToForceRls, CONN_SQL, FORCE_RLS_SQL,
594
+ } = await import('./db-doctor');
595
+ const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
596
+
597
+ const quoteIdent = (s: string) => '"' + String(s).replace(/"/g, '""') + '"';
598
+
599
+ let apiDb: any;
600
+ let api;
601
+ try {
602
+ const { createDb } = await import('./db');
603
+ apiDb = createDb(ctx.schema).db;
604
+ api = rowToConnInfo(rowsOf(await apiDb.execute(sql.raw(CONN_SQL)))[0]);
605
+ } catch (err: any) {
606
+ return { error: `api connection probe failed (DATABASE_URL): ${err?.message || String(err)}` };
607
+ }
608
+
609
+ const admin = rowToConnInfo(rowsOf(await opsDb.execute(sql.raw(CONN_SQL)))[0]);
610
+ const forceRls = rowsOf(await opsDb.execute(sql.raw(FORCE_RLS_SQL))).map(rowToForceRls);
611
+
612
+ // Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
613
+ let apiAmbientRead: { table: string; accessible: boolean } | undefined;
614
+ if (forceRls.length > 0) {
615
+ const t = forceRls[0];
616
+ const ref = `${t.schema}.${t.table}`;
617
+ try {
618
+ await apiDb.execute(sql.raw(`SELECT 1 FROM ${quoteIdent(t.schema)}.${quoteIdent(t.table)} LIMIT 1`));
619
+ apiAmbientRead = { table: ref, accessible: true };
620
+ } catch {
621
+ apiAmbientRead = { table: ref, accessible: false };
622
+ }
623
+ }
624
+
625
+ return buildDoctorReport(
626
+ { api, admin, forceRls, apiAmbientRead },
627
+ { forceRlsCarveouts: options.forceRlsCarveouts },
628
+ );
629
+ };
630
+
631
+ // --- db:provision — create the role chain + set the login password ---
632
+ // Runs as the operator and creates ONLY roles (no database, no tables, no schema), so
633
+ // it is safe on an existing database: an app that brings its own DATABASE_URL can run
634
+ // this to adopt the least-privilege split without re-provisioning anything. The
635
+ // auto-provisioning construct invokes it at deploy time with a generated password.
636
+ actions['db:provision'] = async (payload, _ctx) => {
637
+ const { authPassword, loginRole, appRoles } = (payload || {}) as {
638
+ authPassword?: string;
639
+ loginRole?: string;
640
+ appRoles?: string[];
641
+ };
642
+ const { sql } = await import('drizzle-orm');
643
+ const { generateRoleChainSQL } = await import('./role-chain');
644
+
645
+ let roleSql: string;
646
+ try {
647
+ roleSql = generateRoleChainSQL({ loginRole, appRoles });
648
+ } catch (err: any) {
649
+ return { error: err?.message || String(err) };
650
+ }
651
+ await opsDb.execute(sql.raw(roleSql));
652
+
653
+ const role = loginRole ?? 'authenticator';
654
+ let passwordSet = false;
655
+ if (authPassword) {
656
+ // Generated via RandomPassword(special:false) — alphanumeric. Reject anything
657
+ // else so nothing untrusted is interpolated into the ALTER ROLE literal.
658
+ if (!/^[A-Za-z0-9]+$/.test(authPassword)) {
659
+ return { error: 'authPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
660
+ }
661
+ await opsDb.execute(sql.raw(`ALTER ROLE ${role} WITH LOGIN PASSWORD '${authPassword}';`));
662
+ passwordSet = true;
663
+ }
664
+
665
+ return {
666
+ provisioned: true,
667
+ loginRole: role,
668
+ appRoles: appRoles ?? ['anon', 'authenticated', 'admin'],
669
+ passwordSet,
670
+ };
671
+ };
672
+
578
673
  // --- console ---
579
674
  if (options.allowConsole !== false) {
580
675
  actions['console:meta'] = async (_payload, ctx) => {