@everystack/server 0.4.21 → 0.4.25

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.4.21",
3
+ "version": "0.4.25",
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>",
@@ -24,76 +24,99 @@
24
24
  ],
25
25
  "exports": {
26
26
  ".": {
27
+ "source": "./src/index.ts",
27
28
  "types": "./src/index.ts",
28
29
  "default": "./src/index.ts"
29
30
  },
30
31
  "./db": {
32
+ "source": "./src/db.ts",
31
33
  "types": "./src/db.ts",
32
34
  "default": "./src/db.ts"
33
35
  },
34
36
  "./ssr": {
37
+ "source": "./src/ssr.ts",
35
38
  "types": "./src/ssr.ts",
36
39
  "default": "./src/ssr.ts"
37
40
  },
38
41
  "./image": {
42
+ "source": "./src/image.ts",
39
43
  "types": "./src/image.ts",
40
44
  "default": "./src/image.ts"
41
45
  },
42
46
  "./migrate": {
47
+ "source": "./src/migrate.ts",
43
48
  "types": "./src/migrate.ts",
44
49
  "default": "./src/migrate.ts"
45
50
  },
46
51
  "./db-doctor": {
52
+ "source": "./src/db-doctor.ts",
47
53
  "types": "./src/db-doctor.ts",
48
54
  "default": "./src/db-doctor.ts"
49
55
  },
50
56
  "./worker": {
57
+ "source": "./src/worker.ts",
51
58
  "types": "./src/worker.ts",
52
59
  "default": "./src/worker.ts"
53
60
  },
54
61
  "./stubs": {
62
+ "source": "./src/stubs.ts",
55
63
  "types": "./src/stubs.ts",
56
64
  "default": "./src/stubs.ts"
57
65
  },
58
66
  "./testing": {
67
+ "source": "./src/testing/index.ts",
59
68
  "types": "./src/testing/index.ts",
60
69
  "default": "./src/testing/index.ts"
61
70
  },
62
71
  "./jest-preset": "./jest-preset.js",
63
72
  "./jest-preset.js": "./jest-preset.js",
64
73
  "./plugin": {
74
+ "source": "./src/plugin.ts",
65
75
  "types": "./src/plugin.ts",
66
76
  "default": "./src/plugin.ts"
67
77
  },
68
78
  "./provision": {
79
+ "source": "./src/provision.ts",
69
80
  "types": "./src/provision.ts",
70
81
  "default": "./src/provision.ts"
71
82
  },
83
+ "./rotate": {
84
+ "source": "./src/rotate.ts",
85
+ "types": "./src/rotate.ts",
86
+ "default": "./src/rotate.ts"
87
+ },
72
88
  "./cdn": {
89
+ "source": "./src/cdn/index.ts",
73
90
  "types": "./src/cdn/index.ts",
74
91
  "default": "./src/cdn/index.ts"
75
92
  },
76
93
  "./cdn/transforms": {
94
+ "source": "./src/cdn/transforms.ts",
77
95
  "types": "./src/cdn/transforms.ts",
78
96
  "default": "./src/cdn/transforms.ts"
79
97
  },
80
98
  "./kvs": {
99
+ "source": "./src/kvs.ts",
81
100
  "types": "./src/kvs.ts",
82
101
  "default": "./src/kvs.ts"
83
102
  },
84
103
  "./media": {
104
+ "source": "./src/media.ts",
85
105
  "types": "./src/media.ts",
86
106
  "default": "./src/media.ts"
87
107
  },
88
108
  "./pipeline": {
109
+ "source": "./src/pipeline.ts",
89
110
  "types": "./src/pipeline.ts",
90
111
  "default": "./src/pipeline.ts"
91
112
  },
92
113
  "./backup": {
114
+ "source": "./src/backup.ts",
93
115
  "types": "./src/backup.ts",
94
116
  "default": "./src/backup.ts"
95
117
  },
96
118
  "./backup-run": {
119
+ "source": "./src/backup-run.ts",
97
120
  "types": "./src/backup-run.ts",
98
121
  "default": "./src/backup-run.ts"
99
122
  }
@@ -164,7 +187,7 @@
164
187
  "ts-jest": "29.4.9",
165
188
  "typescript": "5.9.3",
166
189
  "@everystack/auth": "0.4.3",
167
- "@everystack/cli": "0.4.60"
190
+ "@everystack/cli": "0.4.64"
168
191
  },
169
192
  "scripts": {
170
193
  "test": "jest",
package/src/db.ts CHANGED
@@ -24,6 +24,25 @@ function sstLink<T = Record<string, any>>(name: string): T | undefined {
24
24
  return loadSstResources()[name] as T | undefined;
25
25
  }
26
26
 
27
+ // ---------------------------------------------------------------------------
28
+ // Runtime credential override (Phase B: credential rotation)
29
+ // ---------------------------------------------------------------------------
30
+ //
31
+ // The rotate command delivers rotated credentials via Lambda env vars
32
+ // (EVERYSTACK_CRED_*), set during the same UpdateFunctionConfiguration call
33
+ // that bumps EVERYSTACK_GEN. The getters check these before the blob.
34
+ // No S3/SSM reads, no new permissions, credential split preserved.
35
+ //
36
+ // Lifecycle: rotate sets env + updates SST blob → deploy bakes new blob,
37
+ // SST resets env → blob is correct. Until next deploy, env wins over blob.
38
+
39
+ const CRED_PREFIX = 'EVERYSTACK_CRED_';
40
+
41
+ /** Read a rotated credential from env, or undefined if not set. */
42
+ function rotatedCred(name: string): string | undefined {
43
+ return process.env[`${CRED_PREFIX}${name}`] || undefined;
44
+ }
45
+
27
46
  /**
28
47
  * Read ANY SST-linked resource by name, without importing `sst` — the
29
48
  * bundler-safe replacement for `Resource.<Name>` (e.g. `sstResource('Updates')?.name`
@@ -35,11 +54,9 @@ export function sstResource<T = Record<string, any>>(name: string): T | undefine
35
54
  }
36
55
 
37
56
  export function getDatabaseUrl(): string {
38
- // An EXPLICIT DATABASE_URL wins over the component-derived master. This is what
39
- // lets the API connection be a least-privilege role (`authenticator`) while the SST
40
- // Postgres component still exists to back the owner/ops credential. If the master
41
- // resolved first, setting DATABASE_URL to a least-privilege role would be silently
42
- // ignored. See docs/plans/credential-split-rls-everywhere.md.
57
+ // Rotated credential wins (set by db:provision --rotate via env var)
58
+ const rotated = rotatedCred('DatabaseUrl');
59
+ if (rotated) return rotated;
43
60
 
44
61
  // SST Secret — PascalCase (SST_RESOURCE_DatabaseUrl → { value })
45
62
  const pascal = sstLink('DatabaseUrl')?.value as string | undefined;
@@ -99,6 +116,10 @@ export function getMasterDatabaseUrl(): string | null {
99
116
  * Returns null when no admin URL is configured (single-credential setup).
100
117
  */
101
118
  export function getAdminDatabaseUrl(): string | null {
119
+ // Rotated credential wins (set by db:provision --rotate via env var)
120
+ const rotated = rotatedCred('AdminDatabaseUrl');
121
+ if (rotated) return rotated;
122
+
102
123
  // SST Secret — PascalCase (SST_RESOURCE_AdminDatabaseUrl → { value })
103
124
  const pascal = sstLink('AdminDatabaseUrl')?.value as string | undefined;
104
125
  if (pascal) return pascal;
@@ -360,6 +381,10 @@ export function getAdminSql(): ReturnType<typeof postgres> {
360
381
  * different host. Returns null when no replica is configured.
361
382
  */
362
383
  export function getReadDatabaseUrl(): string | null {
384
+ // Rotated credential wins (set by db:provision --rotate via env var)
385
+ const rotated = rotatedCred('ReadDatabaseUrl');
386
+ if (rotated) return rotated;
387
+
363
388
  // SST Secret — PascalCase (SST_RESOURCE_ReadDatabaseUrl → { value })
364
389
  const pascal = sstLink('ReadDatabaseUrl')?.value as string | undefined;
365
390
  if (pascal) return pascal;
package/src/provision.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * without a database; plugin.ts wires the real deps.
21
21
  */
22
22
 
23
- import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL } from './role-chain';
23
+ import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL } from './role-chain';
24
24
 
25
25
  export interface ProvisionPayload {
26
26
  /** Password for the least-privilege API login. Alphanumeric only. */
@@ -37,6 +37,8 @@ export interface ProvisionPayload {
37
37
  * empty = all-public app, no path set (byte-identical to the pre-search-path-ownership behavior).
38
38
  */
39
39
  searchPath?: string[];
40
+ /** Create A/B rotation pairs for credential rotation without downtime. */
41
+ rotationPairs?: boolean;
40
42
  }
41
43
 
42
44
  export interface ProvisionDeps {
@@ -57,6 +59,7 @@ export interface ProvisionResult {
57
59
  adminPasswordSet?: boolean;
58
60
  /** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
59
61
  adminVerified?: boolean | null;
62
+ rotationPairsCreated?: boolean;
60
63
  error?: string;
61
64
  }
62
65
 
@@ -136,6 +139,42 @@ export async function runProvision(
136
139
  }
137
140
  }
138
141
 
142
+ // A/B rotation pairs — create after base roles exist so the membership grants resolve.
143
+ if (payload.rotationPairs) {
144
+ const roles = appRoles ?? ['anon', 'authenticated', 'admin'];
145
+ const authPairSql = generateRotationPairSQL(login, 'NOINHERIT NOBYPASSRLS', roles);
146
+ await deps.execute(authPairSql);
147
+
148
+ // Set search_path on authenticator pair roles (F5 fix)
149
+ if (searchPath) {
150
+ for (const suffix of ['_a', '_b']) {
151
+ const sp = generateSearchPathSQL(`${login}${suffix}`, searchPath);
152
+ if (sp) await deps.execute(sp);
153
+ }
154
+ }
155
+
156
+ if (adminSql) {
157
+ const admin = adminRole ?? 'migrator';
158
+ // Admin pairs need owner membership to run DDL (F4 fix)
159
+ const adminPairSql = generateRotationPairSQL(admin, 'INHERIT NOBYPASSRLS');
160
+ await deps.execute(adminPairSql);
161
+ // Grant owner → pair roles (same as the base admin role gets)
162
+ for (const suffix of ['_a', '_b']) {
163
+ await deps.execute(
164
+ `DO $$ BEGIN IF current_user <> '${admin}${suffix}' AND NOT pg_has_role('${admin}${suffix}', current_user, 'MEMBER') AND NOT pg_has_role(current_user, '${admin}${suffix}', 'MEMBER') THEN EXECUTE format('GRANT %I TO ${admin}${suffix}', current_user); END IF; END $$;`,
165
+ );
166
+ }
167
+ // Set search_path on admin pair roles (F5 fix)
168
+ if (searchPath) {
169
+ for (const suffix of ['_a', '_b']) {
170
+ const sp = generateSearchPathSQL(`${admin}${suffix}`, searchPath);
171
+ if (sp) await deps.execute(sp);
172
+ }
173
+ }
174
+ }
175
+ result.rotationPairsCreated = true;
176
+ }
177
+
139
178
  // Authenticator LAST — only reached when the admin side (if requested) held up.
140
179
  if (authPassword) {
141
180
  await deps.execute(`ALTER ROLE ${login} WITH LOGIN PASSWORD '${authPassword}';`);
package/src/role-chain.ts CHANGED
@@ -95,6 +95,36 @@ export function generateSearchPathSQL(role: string, searchPath: string[]): strin
95
95
  return `ALTER ROLE ${role} SET search_path = ${searchPath.join(', ')};\n`;
96
96
  }
97
97
 
98
+ /**
99
+ * Generate idempotent DDL for an A/B login-role pair used by credential rotation.
100
+ * Each pair has the same membership as the base role. One starts with LOGIN (active),
101
+ * the other with NOLOGIN (inactive). `db:provision --rotate` swaps them.
102
+ */
103
+ export function generateRotationPairSQL(
104
+ baseRole: string,
105
+ attributes: string,
106
+ appRoles?: string[],
107
+ ): string {
108
+ assertIdent(baseRole, 'baseRole');
109
+ const a = `${baseRole}_a`;
110
+ const b = `${baseRole}_b`;
111
+
112
+ const lines: string[] = [];
113
+ for (const [role, login] of [[a, 'LOGIN'], [b, 'NOLOGIN']] as const) {
114
+ lines.push(
115
+ `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE ${role} ${login} ${attributes}; END IF; END $$;`,
116
+ );
117
+ }
118
+ if (appRoles) {
119
+ for (const appRole of appRoles) {
120
+ assertIdent(appRole, 'app role');
121
+ lines.push(`GRANT ${appRole} TO ${a};`);
122
+ lines.push(`GRANT ${appRole} TO ${b};`);
123
+ }
124
+ }
125
+ return lines.join('\n') + '\n';
126
+ }
127
+
98
128
  export interface AdminRoleOptions {
99
129
  /**
100
130
  * The LOGIN role operator tasks (migrate/seed/DDL/console) connect as — the OTHER half
package/src/rotate.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Credential rotation — the database-side logic for A/B role swaps.
3
+ *
4
+ * Pure functions + dependency-injected IO so the rotation order is testable
5
+ * without a database. The CLI orchestrates the AWS side (SSM writes, rolling
6
+ * restart, origin verification).
7
+ */
8
+
9
+ const IDENT = /^[a-z_][a-z0-9_]*$/;
10
+
11
+ function assertIdent(name: string, what: string): void {
12
+ if (!IDENT.test(name)) {
13
+ throw new Error(`Invalid ${what}: ${JSON.stringify(name)} — must match ${IDENT}`);
14
+ }
15
+ }
16
+
17
+ export interface RotationSlot {
18
+ baseRole: string;
19
+ activeRole: string;
20
+ inactiveRole: string;
21
+ }
22
+
23
+ export interface RotationDeps {
24
+ query: (sql: string) => Promise<any[]>;
25
+ execute: (sql: string) => Promise<unknown>;
26
+ }
27
+
28
+ /**
29
+ * Detect which _a/_b role has LOGIN (the active one).
30
+ * Returns null if neither exists (rotation pairs not provisioned).
31
+ */
32
+ export async function detectActiveRole(
33
+ baseRole: string,
34
+ deps: RotationDeps,
35
+ ): Promise<RotationSlot | null> {
36
+ assertIdent(baseRole, 'baseRole');
37
+ const a = `${baseRole}_a`;
38
+ const b = `${baseRole}_b`;
39
+
40
+ const rows = await deps.query(
41
+ `SELECT rolname, rolcanlogin FROM pg_roles WHERE rolname IN ('${a}', '${b}')`,
42
+ );
43
+
44
+ const aRow = rows.find((r: any) => r.rolname === a);
45
+ const bRow = rows.find((r: any) => r.rolname === b);
46
+
47
+ if (!aRow && !bRow) return null;
48
+ if (!aRow || !bRow) {
49
+ throw new Error(
50
+ `Only one rotation role exists (${aRow ? a : b}). Run db:provision with rotationPairs first.`,
51
+ );
52
+ }
53
+
54
+ if (aRow.rolcanlogin && !bRow.rolcanlogin) {
55
+ return { baseRole, activeRole: a, inactiveRole: b };
56
+ }
57
+ if (bRow.rolcanlogin && !aRow.rolcanlogin) {
58
+ return { baseRole, activeRole: b, inactiveRole: a };
59
+ }
60
+ if (aRow.rolcanlogin && bRow.rolcanlogin) {
61
+ return { baseRole, activeRole: a, inactiveRole: b };
62
+ }
63
+ throw new Error(`Neither ${a} nor ${b} has LOGIN. Run db:provision to fix the role chain.`);
64
+ }
65
+
66
+ /**
67
+ * Provision the inactive role: set password, grant LOGIN.
68
+ */
69
+ const ALNUM = /^[A-Za-z0-9]+$/;
70
+
71
+ export async function provisionInactiveRole(
72
+ slot: RotationSlot,
73
+ password: string,
74
+ deps: RotationDeps,
75
+ ): Promise<void> {
76
+ if (!ALNUM.test(password)) {
77
+ throw new Error('password must be alphanumeric (generate via randomBytes hex)');
78
+ }
79
+ const { inactiveRole } = slot;
80
+ assertIdent(inactiveRole, 'inactiveRole');
81
+ await deps.execute(`ALTER ROLE ${inactiveRole} WITH LOGIN PASSWORD '${password}';`);
82
+ }
83
+
84
+ /**
85
+ * Retire the old active role: REVOKE LOGIN so no new connections succeed.
86
+ */
87
+ export async function retireRole(
88
+ roleName: string,
89
+ deps: RotationDeps,
90
+ ): Promise<void> {
91
+ assertIdent(roleName, 'role');
92
+ await deps.execute(`ALTER ROLE ${roleName} WITH NOLOGIN;`);
93
+ }
94
+
95
+ /**
96
+ * Count active sessions for a role.
97
+ */
98
+ export async function countRoleSessions(
99
+ roleName: string,
100
+ deps: RotationDeps,
101
+ ): Promise<number> {
102
+ assertIdent(roleName, 'role');
103
+ const rows = await deps.query(
104
+ `SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = '${roleName}'`,
105
+ );
106
+ return rows[0]?.n ?? 0;
107
+ }
108
+
109
+ /**
110
+ * Terminate all sessions for a role.
111
+ */
112
+ export async function terminateRoleSessions(
113
+ roleName: string,
114
+ deps: RotationDeps,
115
+ ): Promise<number> {
116
+ assertIdent(roleName, 'role');
117
+ const rows = await deps.query(
118
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = '${roleName}'`,
119
+ );
120
+ return rows.length;
121
+ }
122
+
123
+ /**
124
+ * Drain old role sessions: poll, then terminate after grace period.
125
+ * Returns when session count reaches zero.
126
+ */
127
+ export async function drainRole(
128
+ roleName: string,
129
+ graceMs: number,
130
+ deps: RotationDeps,
131
+ ): Promise<{ drained: boolean; terminated: number }> {
132
+ const deadline = Date.now() + graceMs;
133
+ const POLL_MS = 2_000;
134
+
135
+ while (Date.now() < deadline) {
136
+ const count = await countRoleSessions(roleName, deps);
137
+ if (count === 0) return { drained: true, terminated: 0 };
138
+ await new Promise((r) => setTimeout(r, POLL_MS));
139
+ }
140
+
141
+ const remaining = await countRoleSessions(roleName, deps);
142
+ if (remaining === 0) return { drained: true, terminated: 0 };
143
+
144
+ const terminated = await terminateRoleSessions(roleName, deps);
145
+ return { drained: true, terminated };
146
+ }
package/src/ssr.ts CHANGED
@@ -161,7 +161,7 @@ export function deharvestLoaderData(html: string): string {
161
161
  *
162
162
  * @param innerHandler - The expo-server request handler
163
163
  * @param options - SSR processing options
164
- * @param metadata - Release metadata (createdAt used for X-Server-Debug header)
164
+ * @param metadata - Release metadata emitted as x-everystack-{release,runtime,created} headers
165
165
  */
166
166
  export function wrapSsrHandler(
167
167
  innerHandler: (request: Request) => Promise<Response>,
@@ -193,9 +193,11 @@ export function wrapSsrHandler(
193
193
  // Delegate to inner handler
194
194
  const response = await innerHandler(cleanRequest);
195
195
 
196
- // Set debug fingerprint — release timestamp identifies the serving bundle
196
+ // Release identity headers — observable by probes, cache:purge, and update --verify
197
197
  const headers = new Headers(response.headers);
198
- if (metadata?.createdAt) headers.set('X-Server-Debug', metadata.createdAt);
198
+ if (metadata?.updateId) headers.set('x-everystack-release', metadata.updateId);
199
+ if (metadata?.runtimeVersion) headers.set('x-everystack-runtime', metadata.runtimeVersion);
200
+ if (metadata?.createdAt) headers.set('x-everystack-created', metadata.createdAt);
199
201
 
200
202
  // Non-HTML responses pass through untouched
201
203
  const ct = headers.get('content-type') || '';
@@ -460,6 +462,16 @@ export async function getWebHandler(
460
462
  // across many releases on a long-lived warm container.
461
463
  await pruneOldReleaseDirs(channelBase, buildDir);
462
464
 
465
+ // Clear Metro's in-memory module registry so the new bundle's modules load
466
+ // fresh. Metro stores registered modules in a globalThis Map keyed by numeric
467
+ // ID; IDs can collide across exports (e.g. drizzle-orm appears in both
468
+ // render.js and loaders/index.js with the same ID). Without a clear, the old
469
+ // bundle's factories stay resident and the new bundle's __d silently skips
470
+ // any ID it has already seen, serving stale or incompatible code.
471
+ if (typeof (globalThis as any).__c === 'function') {
472
+ (globalThis as any).__c();
473
+ }
474
+
463
475
  if (options?.afterExtract) {
464
476
  await options.afterExtract(buildDir);
465
477
  }