@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 +3 -3
- package/src/db-doctor.ts +8 -3
- package/src/db.ts +72 -0
- package/src/role-chain.ts +13 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
147
|
-
"@everystack/
|
|
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.
|
|
328
|
-
+ '
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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}'
|
|
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';
|