@everystack/server 0.2.20 → 0.2.23
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/LICENSE +681 -0
- package/README.md +5 -1
- package/jest-preset.js +59 -0
- package/package.json +23 -10
- package/src/db-doctor.ts +229 -0
- package/src/db.ts +100 -10
- package/src/plugin.ts +98 -3
- package/src/role-chain.ts +75 -0
- package/src/ssr.ts +74 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The role chain — generated, idempotent SQL for the least-privilege login role and the
|
|
3
|
+
* application roles it switches between.
|
|
4
|
+
*
|
|
5
|
+
* This is what the `db:provision` action runs (as the operator/owner) at deploy time so a
|
|
6
|
+
* fresh database comes up with the credential split already in place — no hand-written
|
|
7
|
+
* `CREATE ROLE`, no `ALTER ROLE … PASSWORD` in a console. The password is set separately
|
|
8
|
+
* by `db:provision` from a deploy-generated value; this module only shapes the roles and
|
|
9
|
+
* their membership graph. Table grants and RLS policies stay in the app's migrations
|
|
10
|
+
* (they're schema-specific); this is the part that must exist before those grants resolve.
|
|
11
|
+
*
|
|
12
|
+
* See docs/plans/secure-by-default-database.md.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const IDENT = /^[a-z_][a-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
export interface RoleChainOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The LOGIN role the API connects as — least-privilege: LOGIN, NOINHERIT (memberships
|
|
20
|
+
* are SET ROLE-only, never ambient), NOBYPASSRLS, and no grants of its own.
|
|
21
|
+
* Default 'authenticator'.
|
|
22
|
+
*/
|
|
23
|
+
loginRole?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The NOLOGIN application roles the login role may `SET ROLE` to, in order of privilege.
|
|
26
|
+
* Default ['anon', 'authenticated', 'admin'].
|
|
27
|
+
*/
|
|
28
|
+
appRoles?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertIdent(name: string, what: string): void {
|
|
32
|
+
if (!IDENT.test(name)) {
|
|
33
|
+
throw new Error(`Invalid ${what}: ${JSON.stringify(name)} — must match ${IDENT}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate idempotent DDL that creates the application roles and the least-privilege login
|
|
39
|
+
* role, and wires the membership chain so the login role can `SET ROLE` to each. Safe to
|
|
40
|
+
* run on every deploy: each `CREATE ROLE` is `IF NOT EXISTS`-guarded, and the login role's
|
|
41
|
+
* attributes are re-asserted with `ALTER ROLE` so a role created the old way (e.g. without
|
|
42
|
+
* an explicit NOBYPASSRLS) is corrected. Does NOT set passwords — that's `db:provision`'s
|
|
43
|
+
* job, from a deploy-generated value.
|
|
44
|
+
*/
|
|
45
|
+
export function generateRoleChainSQL(options: RoleChainOptions = {}): string {
|
|
46
|
+
const loginRole = options.loginRole ?? 'authenticator';
|
|
47
|
+
const appRoles = options.appRoles ?? ['anon', 'authenticated', 'admin'];
|
|
48
|
+
|
|
49
|
+
assertIdent(loginRole, 'loginRole');
|
|
50
|
+
appRoles.forEach((r) => assertIdent(r, 'app role'));
|
|
51
|
+
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
|
|
54
|
+
// Application roles — NOLOGIN; reached only via SET ROLE.
|
|
55
|
+
for (const role of appRoles) {
|
|
56
|
+
lines.push(
|
|
57
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE ${role} NOLOGIN; END IF; END $$;`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The least-privilege login role. Create if absent, then re-assert attributes so an
|
|
62
|
+
// existing role is brought into compliance (LOGIN, NOINHERIT, NOBYPASSRLS).
|
|
63
|
+
lines.push(
|
|
64
|
+
`DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${loginRole}') THEN CREATE ROLE ${loginRole} LOGIN NOINHERIT NOBYPASSRLS; END IF; END $$;`,
|
|
65
|
+
);
|
|
66
|
+
lines.push(`ALTER ROLE ${loginRole} WITH LOGIN NOINHERIT NOBYPASSRLS;`);
|
|
67
|
+
|
|
68
|
+
// Membership chain — the login role may SET ROLE to each app role. NOINHERIT means it
|
|
69
|
+
// gains their privileges only when it explicitly switches, never ambiently.
|
|
70
|
+
for (const role of appRoles) {
|
|
71
|
+
lines.push(`GRANT ${role} TO ${loginRole};`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return lines.join('\n') + '\n';
|
|
75
|
+
}
|
package/src/ssr.ts
CHANGED
|
@@ -259,6 +259,68 @@ export async function resolveBundleKey(channel: string, storage?: StorageAdapter
|
|
|
259
259
|
return (await resolveWebRelease(channel, storage, db)).bundleKey;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Filesystem-safe, release-unique directory segment derived from the bundle ETag.
|
|
264
|
+
*
|
|
265
|
+
* expo-server loads the server bundle (render.js and every route module) via
|
|
266
|
+
* require()/import(), and Node caches modules by absolute path. If two releases
|
|
267
|
+
* are extracted to the same path, the first release's modules stay resident in
|
|
268
|
+
* Node's module cache and a warm Lambda keeps serving the old HTML forever —
|
|
269
|
+
* even though the new bundle was downloaded and the new release metadata is
|
|
270
|
+
* reported. The ETag changes whenever bundle content changes, so keying the
|
|
271
|
+
* build directory on it gives each distinct release its own path and forces a
|
|
272
|
+
* fresh module load. See __tests__/ssr-build-dir.test.ts.
|
|
273
|
+
*/
|
|
274
|
+
export function releaseDirSegment(etag: string): string {
|
|
275
|
+
return etag.replace(/[^a-zA-Z0-9]+/g, '') || 'default';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Evict CJS module-cache entries whose file lives under `dir`.
|
|
280
|
+
*
|
|
281
|
+
* expo-server loads the server bundle via require(), so a pruned release's
|
|
282
|
+
* modules would otherwise stay resident in Node's module cache (and uncollected)
|
|
283
|
+
* even after its files are deleted — leaking memory across releases on a warm
|
|
284
|
+
* container. Only modules under an already-pruned (inactive) release dir are
|
|
285
|
+
* evicted, so this never touches the live handler. No-op outside CJS (require
|
|
286
|
+
* unavailable) and harmless if expo-server used import() instead — the ESM
|
|
287
|
+
* registry has no eviction API, so those rely on container recycling.
|
|
288
|
+
*/
|
|
289
|
+
export function evictModuleCache(
|
|
290
|
+
dir: string,
|
|
291
|
+
cache: Record<string, unknown> | null =
|
|
292
|
+
(typeof require !== 'undefined' && (require.cache as unknown as Record<string, unknown>)) || null,
|
|
293
|
+
): void {
|
|
294
|
+
if (!cache) return;
|
|
295
|
+
const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep;
|
|
296
|
+
for (const key of Object.keys(cache)) {
|
|
297
|
+
if (key.startsWith(prefix)) delete cache[key];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Remove sibling release directories under a channel base, keeping only `keep`.
|
|
303
|
+
* Bounds /tmp usage (512MB ephemeral) AND evicts each removed release's modules
|
|
304
|
+
* from Node's module cache so they can be garbage-collected — without that, the
|
|
305
|
+
* fix to serve fresh releases would leak module memory across deploys on a
|
|
306
|
+
* long-lived warm container.
|
|
307
|
+
*/
|
|
308
|
+
async function pruneOldReleaseDirs(channelBase: string, keep: string): Promise<void> {
|
|
309
|
+
let entries: string[];
|
|
310
|
+
try {
|
|
311
|
+
entries = await fsp.readdir(channelBase);
|
|
312
|
+
} catch {
|
|
313
|
+
return; // base doesn't exist yet — nothing to prune
|
|
314
|
+
}
|
|
315
|
+
const keepName = path.basename(keep);
|
|
316
|
+
await Promise.all(entries.map(async (name) => {
|
|
317
|
+
if (name === keepName) return;
|
|
318
|
+
const dir = path.join(channelBase, name);
|
|
319
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
320
|
+
evictModuleCache(dir);
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
|
|
262
324
|
/**
|
|
263
325
|
* Returns a Request→Response handler for the current web release,
|
|
264
326
|
* or null if no web release exists yet.
|
|
@@ -275,7 +337,7 @@ export async function getWebHandler(
|
|
|
275
337
|
): Promise<((request: Request) => Promise<Response>) | null> {
|
|
276
338
|
const now = Date.now();
|
|
277
339
|
const channel = options?.channel || process.env.ENVIRONMENT || 'production';
|
|
278
|
-
const
|
|
340
|
+
const channelBase = `${BUILD_DIR_BASE}/${channel}`;
|
|
279
341
|
|
|
280
342
|
const cached = cachedHandlers.get(channel);
|
|
281
343
|
|
|
@@ -308,7 +370,13 @@ export async function getWebHandler(
|
|
|
308
370
|
return wrapSsrHandler(rawHandler, options, cached.metadata);
|
|
309
371
|
}
|
|
310
372
|
|
|
311
|
-
// New or updated release —
|
|
373
|
+
// New or updated release — extract to a RELEASE-UNIQUE directory keyed on the
|
|
374
|
+
// bundle ETag. expo-server require()s the server bundle and Node caches
|
|
375
|
+
// modules by absolute path, so re-extracting over a stable path would leave
|
|
376
|
+
// the previous release's render.js resident in a warm Lambda. A per-release
|
|
377
|
+
// path forces a fresh module load. See releaseDirSegment.
|
|
378
|
+
const buildDir = `${channelBase}/${releaseDirSegment(meta.etag)}`;
|
|
379
|
+
|
|
312
380
|
try {
|
|
313
381
|
await downloadAndExtract(storage, bundleKey, buildDir);
|
|
314
382
|
} catch {
|
|
@@ -316,6 +384,10 @@ export async function getWebHandler(
|
|
|
316
384
|
return null;
|
|
317
385
|
}
|
|
318
386
|
|
|
387
|
+
// Drop sibling release dirs from earlier deploys so /tmp doesn't fill up
|
|
388
|
+
// across many releases on a long-lived warm container.
|
|
389
|
+
await pruneOldReleaseDirs(channelBase, buildDir);
|
|
390
|
+
|
|
319
391
|
if (options?.afterExtract) {
|
|
320
392
|
await options.afterExtract(buildDir);
|
|
321
393
|
}
|