@everystack/server 0.2.13 → 0.2.14

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/db.ts +78 -0
  3. package/src/plugin.ts +93 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/db.ts CHANGED
@@ -42,6 +42,28 @@ export function getDatabaseUrl(): string {
42
42
  throw new Error('DATABASE_URL not set — link an sst.aws.Postgres or sst.Secret, or set process.env.DATABASE_URL');
43
43
  }
44
44
 
45
+ /**
46
+ * Resolve the operator (privileged) database URL, if configured.
47
+ *
48
+ * The API connection (DATABASE_URL) should be least-privilege and RLS-gated.
49
+ * Operator tasks — migrations, seed, console, raw queries — need a role that
50
+ * owns the schema and sees all rows. Keep them on separate credentials so the
51
+ * API Lambda never holds the operator secret.
52
+ *
53
+ * Returns null when no admin URL is configured (single-credential setup).
54
+ */
55
+ export function getAdminDatabaseUrl(): string | null {
56
+ // SST Secret — PascalCase (Resource.AdminDatabaseUrl.value)
57
+ const pascal = tryResource(() => (Resource as any).AdminDatabaseUrl?.value as string);
58
+ if (pascal) return pascal;
59
+ // SST Secret — raw env name (Resource.ADMIN_DATABASE_URL.value)
60
+ const raw = tryResource(() => (Resource as any).ADMIN_DATABASE_URL?.value as string);
61
+ if (raw) return raw;
62
+ // process.env fallback
63
+ if (process.env.ADMIN_DATABASE_URL) return process.env.ADMIN_DATABASE_URL;
64
+ return null;
65
+ }
66
+
45
67
  export function getJwtSecret(): Uint8Array {
46
68
  // PascalCase Resource (Resource.JwtSecret.value)
47
69
  const pascal = tryResource(() => (Resource as any).JwtSecret?.value as string);
@@ -94,3 +116,59 @@ export function getSql(): ReturnType<typeof postgres> {
94
116
  }
95
117
  return sqlClient;
96
118
  }
119
+
120
+ // Lazy singleton operator DB connection (separate from the API connection)
121
+ let adminDbInstance: ReturnType<typeof drizzle> | null = null;
122
+ let adminSqlClient: ReturnType<typeof postgres> | null = null;
123
+ let adminFallbackWarned = false;
124
+
125
+ /**
126
+ * Operator database connection for privileged tasks (migrate, seed, console).
127
+ *
128
+ * Connects with ADMIN_DATABASE_URL when configured. When it isn't, falls back
129
+ * to the regular DATABASE_URL connection with a one-time warning — existing
130
+ * single-credential apps keep working, but RLS is only fail-closed once the
131
+ * credentials are split. See docs/plans/admin-database-url.md.
132
+ */
133
+ export function createAdminDb<T extends Record<string, unknown>>(
134
+ schema: T,
135
+ options?: { maxConnections?: number }
136
+ ): {
137
+ db: ReturnType<typeof drizzle>;
138
+ schema: T;
139
+ } {
140
+ if (adminDbInstance) return { db: adminDbInstance, schema };
141
+
142
+ const adminUrl = getAdminDatabaseUrl();
143
+ if (!adminUrl) {
144
+ if (!adminFallbackWarned) {
145
+ adminFallbackWarned = true;
146
+ console.warn(
147
+ '[everystack/db] ADMIN_DATABASE_URL not set — operator tasks are using the '
148
+ + 'API connection (DATABASE_URL). If that role has BYPASSRLS, your RLS policies '
149
+ + 'are not enforced. Split the credentials: docs/plans/admin-database-url.md'
150
+ );
151
+ }
152
+ return createDb(schema, options);
153
+ }
154
+
155
+ adminSqlClient = postgres(adminUrl, {
156
+ max: options?.maxConnections ?? 1,
157
+ idle_timeout: 60,
158
+ connect_timeout: 5,
159
+ max_lifetime: 60 * 5,
160
+ ssl: 'require',
161
+ });
162
+ adminDbInstance = drizzle(adminSqlClient, { schema });
163
+ return { db: adminDbInstance, schema };
164
+ }
165
+
166
+ /**
167
+ * Raw postgres.js client for the operator connection.
168
+ * Must be called after createAdminDb(). Falls back to the API client when
169
+ * the credentials aren't split (same fallback as createAdminDb).
170
+ */
171
+ export function getAdminSql(): ReturnType<typeof postgres> {
172
+ if (adminSqlClient) return adminSqlClient;
173
+ return getSql();
174
+ }
package/src/plugin.ts CHANGED
@@ -27,6 +27,14 @@ import {
27
27
  export interface PluginContext {
28
28
  /** Drizzle database connection */
29
29
  db: any;
30
+ /**
31
+ * Operator (privileged) connection for migrate/seed/console — see
32
+ * createAdminDb(). When present, dbPlugin runs ALL operator actions on it
33
+ * instead of `db`, so `db` can be a least-privilege, RLS-gated role.
34
+ * In a dedicated ops Lambda, set both `db` and `adminDb` to the admin
35
+ * connection.
36
+ */
37
+ adminDb?: any;
30
38
  /** App schema (all tables) */
31
39
  schema: Record<string, any>;
32
40
  /** Shared JWT verification — one function, used by all plugins */
@@ -262,6 +270,74 @@ export function createPluginLambdaHandler(
262
270
  };
263
271
  }
264
272
 
273
+ // --- Ops Lambda Handler (actions only) ---
274
+
275
+ export interface OpsLambdaHandlerOptions {
276
+ /** Creates the shared context. Use createAdminDb() here — this Lambda is
277
+ * the operator entrypoint and should hold the privileged credential. */
278
+ context: () => Promise<PluginContext>;
279
+ /** Plugins to collect actions from. Routes/handlers they contribute are ignored. */
280
+ plugins: Plugin[];
281
+ /** App-level actions merged with plugin actions (app wins on collision). */
282
+ actions?: Record<string, ActionHandler>;
283
+ }
284
+
285
+ /**
286
+ * Dedicated operator entrypoint — handles `_action` invokes only.
287
+ *
288
+ * Deploy as a separate Lambda (server/ops.ts) and link ADMIN_DATABASE_URL to
289
+ * it instead of the API function. IAM (lambda:InvokeFunction) is the auth
290
+ * layer, same as the API _action path. HTTP events are rejected: this
291
+ * function must never be wired to a Function URL or the CDN.
292
+ */
293
+ export function createOpsLambdaHandler(
294
+ options: OpsLambdaHandlerOptions
295
+ ): (event: Record<string, unknown>) => Promise<unknown> {
296
+ let cached: { ctx: PluginContext; actions: Record<string, ActionHandler> } | null = null;
297
+
298
+ async function ensureInitialized() {
299
+ if (cached) return cached;
300
+ const ctx = await options.context();
301
+ const actions: Record<string, ActionHandler> = {};
302
+ for (const plugin of options.plugins) {
303
+ const contribution = await plugin(ctx);
304
+ if (contribution.actions) Object.assign(actions, contribution.actions);
305
+ }
306
+ Object.assign(actions, options.actions ?? {});
307
+ cached = { ctx, actions };
308
+ log('info', 'Ops handlers initialized', { actions: Object.keys(actions).length });
309
+ return cached;
310
+ }
311
+
312
+ return async (event: Record<string, unknown>): Promise<unknown> => {
313
+ if (!('_action' in event) || typeof event._action !== 'string') {
314
+ // Not an operator invoke. Reject HTTP-shaped events explicitly.
315
+ if ('requestContext' in event || 'rawPath' in event) {
316
+ return {
317
+ statusCode: 403,
318
+ headers: { 'Content-Type': 'application/json', 'cache-control': 'no-store' },
319
+ body: JSON.stringify({ error: 'Forbidden' }),
320
+ };
321
+ }
322
+ return { error: 'Missing _action' };
323
+ }
324
+
325
+ const { ctx, actions } = await ensureInitialized();
326
+ const actionName = event._action as string;
327
+ const actionHandler = actions[actionName];
328
+ if (!actionHandler) {
329
+ return { error: `Unknown action: ${actionName}` };
330
+ }
331
+ log('info', 'Ops action invoked', { action: actionName });
332
+ try {
333
+ return await actionHandler(event._payload, ctx);
334
+ } catch (error) {
335
+ log('error', 'Ops action error', { action: actionName, error: String(error) });
336
+ return { error: String(error) };
337
+ }
338
+ };
339
+ }
340
+
265
341
  // --- SSR Fallback Plugin ---
266
342
 
267
343
  import type { PostProcessResult, WebHandlerOptions } from './ssr';
@@ -377,10 +453,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
377
453
  return async (ctx: PluginContext): Promise<PluginContribution> => {
378
454
  const actions: Record<string, ActionHandler> = {};
379
455
 
456
+ // Operator connection: all actions below run privileged tasks (DDL,
457
+ // unrestricted reads). Prefer the dedicated admin connection so the API
458
+ // connection can be least-privilege and RLS-gated.
459
+ const opsDb = ctx.adminDb ?? ctx.db;
460
+
380
461
  // --- migrate ---
381
- actions.migrate = async (_payload, ctx) => {
462
+ actions.migrate = async (_payload, _ctx) => {
382
463
  const { runMigrations } = await import('./migrate');
383
- return await runMigrations(ctx.db, options.migrationsFolder);
464
+ return await runMigrations(opsDb, options.migrationsFolder);
384
465
  };
385
466
 
386
467
  // --- seed (only when app provides a seed function) ---
@@ -390,7 +471,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
390
471
  if (ctx.environment !== 'dev') {
391
472
  return { error: 'Seed is only available in dev environment' };
392
473
  }
393
- return await seedFn(ctx.db, ctx.schema);
474
+ return await seedFn(opsDb, ctx.schema);
394
475
  };
395
476
  }
396
477
 
@@ -404,14 +485,14 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
404
485
  const dropStatements = schemas
405
486
  .map(s => `DROP SCHEMA IF EXISTS ${s} CASCADE;`)
406
487
  .join('\n ');
407
- await ctx.db.execute(sql.raw(`
488
+ await opsDb.execute(sql.raw(`
408
489
  ${dropStatements}
409
490
  CREATE SCHEMA public;
410
491
  GRANT ALL ON SCHEMA public TO postgres;
411
492
  GRANT ALL ON SCHEMA public TO public;
412
493
  `));
413
494
  const { runMigrations } = await import('./migrate');
414
- const result = await runMigrations(ctx.db, options.migrationsFolder);
495
+ const result = await runMigrations(opsDb, options.migrationsFolder);
415
496
  return { reset: true, ...result };
416
497
  };
417
498
 
@@ -435,7 +516,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
435
516
  };
436
517
 
437
518
  // --- db:query ---
438
- actions['db:query'] = async (payload, ctx) => {
519
+ actions['db:query'] = async (payload, _ctx) => {
439
520
  const { sql: querySql } = payload as { sql: string };
440
521
  if (!querySql) {
441
522
  return { error: 'SQL query is required' };
@@ -451,7 +532,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
451
532
  }
452
533
  try {
453
534
  const { sql } = await import('drizzle-orm');
454
- let queryDb = ctx.db;
535
+ let queryDb = opsDb;
455
536
  const readUrl = options.readDatabaseUrl ?? process.env.READ_DATABASE_URL;
456
537
  if (readUrl && readUrl !== process.env.DATABASE_URL) {
457
538
  const { drizzle } = await import('drizzle-orm/postgres-js');
@@ -489,7 +570,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
489
570
  // Auth signal — helpers write to this during eval, we read it after
490
571
  const authSignal = { user: null as Record<string, unknown> | null, changed: false };
491
572
  const authHelpers = createAuthHelpers({
492
- db: ctx.db,
573
+ db: opsDb,
493
574
  schema: ctx.schema,
494
575
  signal: authSignal,
495
576
  ...options.consoleAuth,
@@ -542,7 +623,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
542
623
 
543
624
  if (sandbox) {
544
625
  try {
545
- await ctx.db.transaction(async (tx: any) => {
626
+ await opsDb.transaction(async (tx: any) => {
546
627
  await applySettings(tx);
547
628
  result = await evalExpression(tx);
548
629
  throw SANDBOX_ROLLBACK;
@@ -551,14 +632,14 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
551
632
  if (err !== SANDBOX_ROLLBACK) throw err;
552
633
  }
553
634
  } else {
554
- result = await ctx.db.transaction(async (tx: any) => {
635
+ result = await opsDb.transaction(async (tx: any) => {
555
636
  await applySettings(tx);
556
637
  return evalExpression(tx);
557
638
  });
558
639
  }
559
640
  } else if (sandbox) {
560
641
  try {
561
- await ctx.db.transaction(async (tx: any) => {
642
+ await opsDb.transaction(async (tx: any) => {
562
643
  result = await evalExpression(tx);
563
644
  throw SANDBOX_ROLLBACK;
564
645
  });
@@ -566,7 +647,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
566
647
  if (err !== SANDBOX_ROLLBACK) throw err;
567
648
  }
568
649
  } else {
569
- result = await evalExpression(ctx.db);
650
+ result = await evalExpression(opsDb);
570
651
  }
571
652
 
572
653
  // Safe-serialize: handle circular refs, Symbols, and complex objects