@everystack/server 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -144,7 +144,7 @@
144
144
  "ts-jest": "29.4.9",
145
145
  "typescript": "5.9.3",
146
146
  "@everystack/auth": "0.4.0",
147
- "@everystack/cli": "0.4.3"
147
+ "@everystack/cli": "0.4.5"
148
148
  },
149
149
  "scripts": {
150
150
  "test": "jest",
package/src/db.ts CHANGED
@@ -262,3 +262,75 @@ export function getAdminSql(): ReturnType<typeof postgres> {
262
262
  if (adminSqlClient) return adminSqlClient;
263
263
  return getSql();
264
264
  }
265
+
266
+ /**
267
+ * Resolve the read-replica database URL, if configured.
268
+ *
269
+ * The third credential slot: DATABASE_URL (api), ADMIN_DATABASE_URL (operator),
270
+ * READ_DATABASE_URL (replica). Point it at a physical read replica's endpoint
271
+ * with the SAME least-privilege role as DATABASE_URL — same credential,
272
+ * different host. Returns null when no replica is configured.
273
+ */
274
+ export function getReadDatabaseUrl(): string | null {
275
+ // SST Secret — PascalCase (Resource.ReadDatabaseUrl.value)
276
+ const pascal = tryResource(() => (Resource as any).ReadDatabaseUrl?.value as string);
277
+ if (pascal) return pascal;
278
+ // SST Secret — raw env name (Resource.READ_DATABASE_URL.value)
279
+ const raw = tryResource(() => (Resource as any).READ_DATABASE_URL?.value as string);
280
+ if (raw) return raw;
281
+ // process.env fallback
282
+ if (process.env.READ_DATABASE_URL) return process.env.READ_DATABASE_URL;
283
+ return null;
284
+ }
285
+
286
+ // Lazy singleton read-replica connection (separate from the API connection)
287
+ let readDbInstance: ReturnType<typeof drizzle> | null = null;
288
+ let readSqlClient: ReturnType<typeof postgres> | null = null;
289
+
290
+ /**
291
+ * Read-replica database connection for anonymous read traffic.
292
+ *
293
+ * Connects with READ_DATABASE_URL when configured. When it isn't, returns the
294
+ * primary connection (createDb) with NO warning — unlike the admin split this
295
+ * is a scaling knob, not a security boundary: absent is normal, not a
296
+ * misconfiguration. Apps without a replica keep working with zero config.
297
+ *
298
+ * `withRole` works unchanged against a hot standby: SET LOCAL ROLE and
299
+ * set_config(…, true) are permitted in read-only transactions, and physical
300
+ * replication carries roles, GRANTs, and RLS policies with the catalog. A
301
+ * write through this connection fails loudly on a real replica ("cannot
302
+ * execute … in a read-only transaction") — a routing bug can never silently
303
+ * diverge. See docs/plans/read-replica-routing.md.
304
+ */
305
+ export function createReadDb<T extends Record<string, unknown>>(
306
+ schema: T,
307
+ options?: { maxConnections?: number }
308
+ ): {
309
+ db: ReturnType<typeof drizzle>;
310
+ schema: T;
311
+ } {
312
+ if (readDbInstance) return { db: readDbInstance, schema };
313
+
314
+ const url = getReadDatabaseUrl();
315
+ if (!url) return createDb(schema, options);
316
+
317
+ readSqlClient = postgres(url, {
318
+ max: options?.maxConnections ?? 1,
319
+ idle_timeout: 60,
320
+ connect_timeout: 5,
321
+ max_lifetime: 60 * 5,
322
+ ssl: 'require',
323
+ });
324
+ readDbInstance = drizzle(readSqlClient, { schema });
325
+ return { db: readDbInstance, schema };
326
+ }
327
+
328
+ /**
329
+ * Raw postgres.js client for the read-replica connection.
330
+ * Must be called after createReadDb(). Falls back to the API client when no
331
+ * replica is configured (same fallback as createReadDb).
332
+ */
333
+ export function getReadSql(): ReturnType<typeof postgres> {
334
+ if (readSqlClient) return readSqlClient;
335
+ return getSql();
336
+ }
package/src/plugin.ts CHANGED
@@ -731,46 +731,55 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
731
731
  );
732
732
  };
733
733
 
734
- // --- db:provision — create the role chain + set the login password ---
734
+ // --- db:provision — BOTH sides of the credential split, in the safe order ---
735
735
  // Runs as the operator and creates ONLY roles (no database, no tables, no schema), so
736
736
  // it is safe on an existing database: an app that brings its own DATABASE_URL can run
737
737
  // this to adopt the least-privilege split without re-provisioning anything. The
738
738
  // auto-provisioning construct invokes it at deploy time with a generated password.
739
+ // With adminPassword, also creates the operator login (migrator) and VERIFIES its
740
+ // real connectivity BEFORE the authenticator password is set — the ordering that
741
+ // keeps a BYO-database app from sawing off its own ops plane (see provision.ts).
739
742
  actions['db:provision'] = async (payload, _ctx) => {
740
- const { authPassword, loginRole, appRoles } = (payload || {}) as {
741
- authPassword?: string;
742
- loginRole?: string;
743
- appRoles?: string[];
744
- };
745
743
  const { sql } = await import('drizzle-orm');
746
- const { generateRoleChainSQL } = await import('./role-chain');
744
+ const { runProvision } = await import('./provision');
747
745
 
748
- let roleSql: string;
749
- try {
750
- roleSql = generateRoleChainSQL({ loginRole, appRoles });
751
- } catch (err: any) {
752
- return { error: err?.message || String(err) };
753
- }
754
- await opsDb.execute(sql.raw(roleSql));
755
-
756
- const role = loginRole ?? 'authenticator';
757
- let passwordSet = false;
758
- if (authPassword) {
759
- // Generated via RandomPassword(special:false) — alphanumeric. Reject anything
760
- // else so nothing untrusted is interpolated into the ALTER ROLE literal.
761
- if (!/^[A-Za-z0-9]+$/.test(authPassword)) {
762
- return { error: 'authPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
746
+ // Same host/port/database resolution as db:psql — the verification URL must
747
+ // point at the database this connection is actually provisioning.
748
+ let connection: { host: string; port?: number | string; database: string } | null = null;
749
+ if (options.connectionInfo) {
750
+ const info = await options.connectionInfo();
751
+ if (info?.host && info?.database) {
752
+ connection = { host: info.host, port: info.port, database: info.database };
753
+ }
754
+ } else {
755
+ try {
756
+ const { Resource } = await import('sst');
757
+ const db = (Resource as any).Database;
758
+ if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
759
+ } catch {
760
+ // No linked component and no callback — the admin login lands unverified.
763
761
  }
764
- await opsDb.execute(sql.raw(`ALTER ROLE ${role} WITH LOGIN PASSWORD '${authPassword}';`));
765
- passwordSet = true;
766
762
  }
767
763
 
768
- return {
769
- provisioned: true,
770
- loginRole: role,
771
- appRoles: appRoles ?? ['anon', 'authenticated', 'admin'],
772
- passwordSet,
773
- };
764
+ return await runProvision(
765
+ (payload || {}) as Parameters<typeof runProvision>[0],
766
+ {
767
+ execute: async (statement) => opsDb.execute(sql.raw(statement)),
768
+ connection,
769
+ connectProbe: async (url) => {
770
+ const { default: postgres } = await import('postgres');
771
+ // 'prefer' handles both TLS-required RDS and plain local Postgres.
772
+ const probe = postgres(url, { max: 1, connect_timeout: 5, ssl: 'prefer' });
773
+ try {
774
+ await probe`SELECT 1`;
775
+ await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
776
+ await probe.unsafe('DROP TABLE _everystack_provision_probe');
777
+ } finally {
778
+ await probe.end({ timeout: 1 });
779
+ }
780
+ },
781
+ },
782
+ );
774
783
  };
775
784
 
776
785
  // --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
@@ -0,0 +1,129 @@
1
+ /**
2
+ * runProvision — the db:provision orchestration: both sides of the credential split,
3
+ * in the only safe order.
4
+ *
5
+ * The split has two logins: the least-privilege `authenticator` the API connects as
6
+ * (DATABASE_URL), and the operator `migrator` that owns migrations/DDL
7
+ * (ADMIN_DATABASE_URL). Provisioning only the first half strands the operator path on
8
+ * the linked Postgres master — and for a BYO-database app (no linked component) the
9
+ * operator fallback bottoms out in DATABASE_URL itself, so rewriting it to the
10
+ * authenticator saws off the branch provision sits on: the next ops invoke (including
11
+ * the re-run that would fix it) dies "permission denied".
12
+ *
13
+ * Hence the ordering contract, pinned by __tests__/provision.test.ts:
14
+ * 1. role chain (NOLOGIN tiers + authenticator role, no password)
15
+ * 2. admin role + admin password
16
+ * 3. VERIFY the admin login with a real connection
17
+ * 4. only then the authenticator password — a failed verification aborts the swap.
18
+ *
19
+ * Dependency-injected (execute / connection / connectProbe) so the ordering is testable
20
+ * without a database; plugin.ts wires the real deps.
21
+ */
22
+
23
+ import { generateRoleChainSQL, generateAdminRoleSQL } from './role-chain';
24
+
25
+ export interface ProvisionPayload {
26
+ /** Password for the least-privilege API login. Alphanumeric only. */
27
+ authPassword?: string;
28
+ /** Password for the operator login. Alphanumeric only. Absent = today's half-split. */
29
+ adminPassword?: string;
30
+ loginRole?: string;
31
+ adminRole?: string;
32
+ appRoles?: string[];
33
+ }
34
+
35
+ export interface ProvisionDeps {
36
+ /** Run one SQL string on the (privileged) provisioning connection. */
37
+ execute: (sql: string) => Promise<unknown>;
38
+ /** host/port/database for the verification URL; null when unresolvable. */
39
+ connection?: { host: string; port?: number | string; database: string } | null;
40
+ /** Real login attempt as the admin role — resolves on success, throws on failure. */
41
+ connectProbe?: (url: string) => Promise<void>;
42
+ }
43
+
44
+ export interface ProvisionResult {
45
+ provisioned?: boolean;
46
+ loginRole?: string;
47
+ adminRole?: string;
48
+ appRoles?: string[];
49
+ passwordSet?: boolean;
50
+ adminPasswordSet?: boolean;
51
+ /** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
52
+ adminVerified?: boolean | null;
53
+ error?: string;
54
+ }
55
+
56
+ const ALNUM = /^[A-Za-z0-9]+$/;
57
+
58
+ export async function runProvision(
59
+ payload: ProvisionPayload,
60
+ deps: ProvisionDeps,
61
+ ): Promise<ProvisionResult> {
62
+ const { authPassword, adminPassword, loginRole, adminRole, appRoles } = payload;
63
+
64
+ // Passwords are generated (RandomPassword special:false / hex) — reject anything else
65
+ // BEFORE any DDL runs, so nothing untrusted is interpolated into an ALTER ROLE literal.
66
+ if (authPassword && !ALNUM.test(authPassword)) {
67
+ return { error: 'authPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
68
+ }
69
+ if (adminPassword && !ALNUM.test(adminPassword)) {
70
+ return { error: 'adminPassword must be alphanumeric (generate via RandomPassword special:false or hex)' };
71
+ }
72
+
73
+ let roleSql: string;
74
+ let adminSql: string | null = null;
75
+ try {
76
+ roleSql = generateRoleChainSQL({ loginRole, appRoles });
77
+ if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
78
+ } catch (err: any) {
79
+ return { error: err?.message || String(err) };
80
+ }
81
+
82
+ await deps.execute(roleSql);
83
+
84
+ const login = loginRole ?? 'authenticator';
85
+ const result: ProvisionResult = {
86
+ provisioned: true,
87
+ loginRole: login,
88
+ appRoles: appRoles ?? ['anon', 'authenticated', 'admin'],
89
+ passwordSet: false,
90
+ };
91
+
92
+ // Admin side FIRST — created and verified before the authenticator swap can happen.
93
+ if (adminPassword && adminSql) {
94
+ const admin = adminRole ?? 'migrator';
95
+ await deps.execute(adminSql);
96
+ await deps.execute(`ALTER ROLE ${admin} WITH LOGIN PASSWORD '${adminPassword}';`);
97
+ result.adminRole = admin;
98
+ result.adminPasswordSet = true;
99
+
100
+ if (deps.connection && deps.connectProbe) {
101
+ const { host, port, database } = deps.connection;
102
+ const url = `postgresql://${admin}:${adminPassword}@${host}:${port ?? 5432}/${database}`;
103
+ try {
104
+ await deps.connectProbe(url);
105
+ result.adminVerified = true;
106
+ } catch (err: any) {
107
+ // The whole point of the ordering: a broken admin login must abort the swap,
108
+ // or a BYO-database app loses its only privileged path.
109
+ return {
110
+ error:
111
+ `admin login verification failed for role '${admin}': ${err?.message || String(err)} — ` +
112
+ 'the authenticator password was NOT set and DATABASE_URL must not be rewritten.',
113
+ adminRole: admin,
114
+ adminVerified: false,
115
+ };
116
+ }
117
+ } else {
118
+ result.adminVerified = null; // no way to probe — caller should verify via db:doctor
119
+ }
120
+ }
121
+
122
+ // Authenticator LAST — only reached when the admin side (if requested) held up.
123
+ if (authPassword) {
124
+ await deps.execute(`ALTER ROLE ${login} WITH LOGIN PASSWORD '${authPassword}';`);
125
+ result.passwordSet = true;
126
+ }
127
+
128
+ return result;
129
+ }
package/src/role-chain.ts CHANGED
@@ -73,3 +73,35 @@ export function generateRoleChainSQL(options: RoleChainOptions = {}): string {
73
73
 
74
74
  return lines.join('\n') + '\n';
75
75
  }
76
+
77
+ export interface AdminRoleOptions {
78
+ /**
79
+ * The LOGIN role operator tasks (migrate/seed/DDL/console) connect as — the OTHER half
80
+ * of the credential split, so the master credential stops being the working credential.
81
+ * INHERIT (unlike the authenticator): its power comes from owner-role membership, and it
82
+ * must hold it ambiently to run DDL over owner-created objects. Default 'migrator'.
83
+ */
84
+ adminRole?: string;
85
+ }
86
+
87
+ /**
88
+ * Generate idempotent DDL for the operator (admin) login role. Membership is granted BOTH
89
+ * ways with the CURRENT user (the master/owner running the provision): owner → admin gives
90
+ * the admin ownership rights over every owner-created object (no REASSIGN OWNED), and
91
+ * admin → owner keeps the owner able to manage objects the admin creates later. The grants
92
+ * are guarded so a RE-provision running AS the admin role (post-split, the ops connection
93
+ * is this role) never attempts the self-grant Postgres hard-errors on. Does NOT set a
94
+ * password — that's `db:provision`'s job, from a deploy-generated value.
95
+ */
96
+ export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
97
+ const adminRole = options.adminRole ?? 'migrator';
98
+ assertIdent(adminRole, 'adminRole');
99
+
100
+ const lines: string[] = [
101
+ `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${adminRole}') THEN CREATE ROLE ${adminRole} LOGIN INHERIT NOBYPASSRLS; END IF; END $$;`,
102
+ `ALTER ROLE ${adminRole} WITH LOGIN INHERIT NOBYPASSRLS;`,
103
+ `DO $$ BEGIN IF current_user <> '${adminRole}' THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); EXECUTE format('GRANT ${adminRole} TO %I', current_user); END IF; END $$;`,
104
+ ];
105
+
106
+ return lines.join('\n') + '\n';
107
+ }