@everystack/server 0.3.7 → 0.4.1

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.7",
3
+ "version": "0.4.1",
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>",
@@ -143,8 +143,8 @@
143
143
  "sst": "4.13.1",
144
144
  "ts-jest": "29.4.9",
145
145
  "typescript": "5.9.3",
146
- "@everystack/auth": "0.4.0",
147
- "@everystack/cli": "0.4.5"
146
+ "@everystack/cli": "0.4.7",
147
+ "@everystack/auth": "0.4.0"
148
148
  },
149
149
  "scripts": {
150
150
  "test": "jest",
package/src/db-doctor.ts CHANGED
@@ -324,8 +324,13 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
324
324
  remediation:
325
325
  'Run `everystack db:provision --stage <stage>` — it creates the least-privilege role chain on this '
326
326
  + 'database (idempotent, creates no database) AND writes the DATABASE_URL secret for you, without ever '
327
- + 'printing the credential. Then in sst.config.ts declare `new sst.Secret("DatabaseUrl")`, link it to the '
328
- + 'API function, and drop the raw Postgres component from the API link (keep it on ops/worker); redeploy. '
327
+ + 'printing the credential. PRECONDITION before flipping the API: the handler must escalate per request '
328
+ + '(pgSettings + SET ROLE / withRole) — the authenticator holds no privileges of its own (NOINHERIT), so a '
329
+ + 'handler that never escalates cannot even see the schema and every endpoint fails "relation does not exist". '
330
+ + 'Then flip in TWO deploys: (1) declare `new sst.Secret("DatabaseUrl")` and link it to the API function while '
331
+ + 'KEEPING the raw Postgres component linked (an explicit DatabaseUrl already outranks it, and the component is '
332
+ + 'your instant rollback — unlink the secret to revert); verify with real requests and `everystack db:doctor`; '
333
+ + '(2) then drop the raw Postgres component from the API link (keep it on ops/worker) and redeploy. '
329
334
  + 'New apps get all of this automatically from the `everystack.Postgres` construct.',
330
335
  });
331
336
  } else {
@@ -359,7 +364,7 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
359
364
  status: accessible ? 'fail' : 'pass',
360
365
  detail: accessible
361
366
  ? `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.`
362
- : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior`,
367
+ : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior. NOTE the runtime requirement this creates: the handler MUST escalate per request (pgSettings + SET ROLE / withRole); a handler that never escalates fails every endpoint with "relation ... does not exist".`,
363
368
  remediation: accessible
364
369
  ? `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().`
365
370
  : undefined,
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/role-chain.ts CHANGED
@@ -85,13 +85,18 @@ export interface AdminRoleOptions {
85
85
  }
86
86
 
87
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.
88
+ * Generate idempotent DDL for the operator (admin) login role. Membership is granted ONE
89
+ * way — owner → admin — giving the admin ownership rights over every owner-created object
90
+ * (no REASSIGN OWNED). Never the reverse: Postgres forbids circular role membership, and
91
+ * the 0.4.0 reciprocal grant broke the SECOND stage on a shared RDS instance (roles are
92
+ * INSTANCE-level; the first stage's `migrator ∈ owner` membership made the reciprocal a
93
+ * cycle — 'role "migrator" is a member of role "postgres"').
94
+ *
95
+ * Adoption over creation: the grant is skipped when the membership already exists (a
96
+ * prior stage or a manual setup made it) and when granting would FORM a cycle (the owner
97
+ * is already a member of the admin role in some prior topology). A re-provision running
98
+ * AS the admin role (post-split, the ops connection is this role) skips the self-grant
99
+ * Postgres hard-errors on. Does NOT set a password — that's `db:provision`'s job.
95
100
  */
96
101
  export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
97
102
  const adminRole = options.adminRole ?? 'migrator';
@@ -100,7 +105,7 @@ export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
100
105
  const lines: string[] = [
101
106
  `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${adminRole}') THEN CREATE ROLE ${adminRole} LOGIN INHERIT NOBYPASSRLS; END IF; END $$;`,
102
107
  `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 $$;`,
108
+ `DO $$ BEGIN IF current_user <> '${adminRole}' AND NOT pg_has_role('${adminRole}', current_user, 'MEMBER') AND NOT pg_has_role(current_user, '${adminRole}', 'MEMBER') THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); END IF; END $$;`,
104
109
  ];
105
110
 
106
111
  return lines.join('\n') + '\n';