@everystack/server 0.2.13 → 0.2.15

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 +94 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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 */
@@ -103,6 +111,14 @@ export interface PluginLambdaHandlerOptions {
103
111
  actions?: Record<string, ActionHandler>;
104
112
  /** Override default Cache-Control headers */
105
113
  cache?: ServerCacheConfig;
114
+ /**
115
+ * Serve HTTP. Default true. Set false for an operator Lambda (server/ops.ts):
116
+ * routes are not built and HTTP-shaped events are rejected with 403 — the
117
+ * function answers `_action` invokes only. IAM (lambda:InvokeFunction) is the
118
+ * auth layer. Use with createAdminDb() so the privileged credential lives
119
+ * here, not in the HTTP-facing API function.
120
+ */
121
+ http?: boolean;
106
122
  }
107
123
 
108
124
  // --- Plugin Lambda Handler Implementation ---
@@ -110,9 +126,10 @@ export interface PluginLambdaHandlerOptions {
110
126
  export function createPluginLambdaHandler(
111
127
  options: PluginLambdaHandlerOptions
112
128
  ): (event: APIGatewayProxyEventV2 | Record<string, unknown>) => Promise<APIGatewayProxyStructuredResultV2 | unknown> {
129
+ const httpEnabled = options.http !== false;
113
130
  let cached: {
114
131
  ctx: PluginContext;
115
- router: (path: string, method: string) => Handler;
132
+ router?: (path: string, method: string) => Handler;
116
133
  actions: Record<string, ActionHandler>;
117
134
  logSink?: LogSink;
118
135
  } | null = null;
@@ -128,11 +145,6 @@ export function createPluginLambdaHandler(
128
145
  contributions.push(await plugin(ctx));
129
146
  }
130
147
 
131
- // Collect routes (plugin order = priority)
132
- const pluginRoutes = contributions.flatMap(c => c.routes ?? []);
133
- const appRoutes = options.routes?.(ctx) ?? [];
134
- const allRoutes = [...pluginRoutes, ...appRoutes];
135
-
136
148
  // Collect actions (app-level takes precedence on collision)
137
149
  const pluginActions: Record<string, ActionHandler> = {};
138
150
  for (const contribution of contributions) {
@@ -142,6 +154,20 @@ export function createPluginLambdaHandler(
142
154
  }
143
155
  const allActions = { ...pluginActions, ...(options.actions ?? {}) };
144
156
 
157
+ // Operator mode (http: false) — actions only. Skip routing entirely;
158
+ // HTTP-shaped events are rejected below. Routes contributed by plugins
159
+ // are intentionally ignored.
160
+ if (!httpEnabled) {
161
+ cached = { ctx, actions: allActions };
162
+ log('info', 'Plugin handlers initialized (actions only)', { plugins: options.plugins.length });
163
+ return cached;
164
+ }
165
+
166
+ // Collect routes (plugin order = priority)
167
+ const pluginRoutes = contributions.flatMap(c => c.routes ?? []);
168
+ const appRoutes = options.routes?.(ctx) ?? [];
169
+ const allRoutes = [...pluginRoutes, ...appRoutes];
170
+
145
171
  // Collect log sink (first one wins)
146
172
  const logSink = contributions.find(c => c.logSink)?.logSink;
147
173
 
@@ -174,6 +200,20 @@ export function createPluginLambdaHandler(
174
200
  }
175
201
  }
176
202
 
203
+ // Operator mode — reject anything that isn't an `_action` invoke. A caller
204
+ // with IAM invoke rights can hand the function any event shape; refuse to
205
+ // route HTTP on the privileged connection.
206
+ if (!httpEnabled) {
207
+ if ('requestContext' in event || 'rawPath' in event) {
208
+ return {
209
+ statusCode: 403,
210
+ headers: { 'Content-Type': 'application/json', 'cache-control': 'no-store' },
211
+ body: JSON.stringify({ error: 'Forbidden' }),
212
+ };
213
+ }
214
+ return { error: 'Missing _action' };
215
+ }
216
+
177
217
  // HTTP event — Function URL or API Gateway
178
218
  const httpEvent = event as APIGatewayProxyEventV2;
179
219
  const requestId =
@@ -186,7 +226,7 @@ export function createPluginLambdaHandler(
186
226
  const request = eventToRequest(httpEvent);
187
227
  const path = httpEvent.rawPath;
188
228
  const method = httpEvent.requestContext.http.method;
189
- const handlerFn = router(path, method);
229
+ const handlerFn = router!(path, method);
190
230
  const response = await handlerFn(request);
191
231
  const result = await responseToResult(response);
192
232
 
@@ -262,6 +302,36 @@ export function createPluginLambdaHandler(
262
302
  };
263
303
  }
264
304
 
305
+ // --- Ops Lambda Handler (actions only) ---
306
+
307
+ export interface OpsLambdaHandlerOptions {
308
+ /** Creates the shared context. Use createAdminDb() here — this Lambda is
309
+ * the operator entrypoint and should hold the privileged credential. */
310
+ context: () => Promise<PluginContext>;
311
+ /** Plugins to collect actions from. Routes/handlers they contribute are ignored. */
312
+ plugins: Plugin[];
313
+ /** App-level actions merged with plugin actions (app wins on collision). */
314
+ actions?: Record<string, ActionHandler>;
315
+ }
316
+
317
+ /**
318
+ * Dedicated operator entrypoint — handles `_action` invokes only.
319
+ *
320
+ * Thin alias for `createPluginLambdaHandler({ ...options, http: false })`.
321
+ * Deploy as a separate Lambda (server/ops.ts) and link ADMIN_DATABASE_URL to
322
+ * it instead of the API function. IAM (lambda:InvokeFunction) is the auth
323
+ * layer; HTTP-shaped events are rejected.
324
+ *
325
+ * @deprecated Prefer `createPluginLambdaHandler({ http: false })` directly.
326
+ */
327
+ export function createOpsLambdaHandler(
328
+ options: OpsLambdaHandlerOptions
329
+ ): (event: Record<string, unknown>) => Promise<unknown> {
330
+ return createPluginLambdaHandler({ ...options, http: false }) as (
331
+ event: Record<string, unknown>
332
+ ) => Promise<unknown>;
333
+ }
334
+
265
335
  // --- SSR Fallback Plugin ---
266
336
 
267
337
  import type { PostProcessResult, WebHandlerOptions } from './ssr';
@@ -377,10 +447,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
377
447
  return async (ctx: PluginContext): Promise<PluginContribution> => {
378
448
  const actions: Record<string, ActionHandler> = {};
379
449
 
450
+ // Operator connection: all actions below run privileged tasks (DDL,
451
+ // unrestricted reads). Prefer the dedicated admin connection so the API
452
+ // connection can be least-privilege and RLS-gated.
453
+ const opsDb = ctx.adminDb ?? ctx.db;
454
+
380
455
  // --- migrate ---
381
- actions.migrate = async (_payload, ctx) => {
456
+ actions.migrate = async (_payload, _ctx) => {
382
457
  const { runMigrations } = await import('./migrate');
383
- return await runMigrations(ctx.db, options.migrationsFolder);
458
+ return await runMigrations(opsDb, options.migrationsFolder);
384
459
  };
385
460
 
386
461
  // --- seed (only when app provides a seed function) ---
@@ -390,7 +465,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
390
465
  if (ctx.environment !== 'dev') {
391
466
  return { error: 'Seed is only available in dev environment' };
392
467
  }
393
- return await seedFn(ctx.db, ctx.schema);
468
+ return await seedFn(opsDb, ctx.schema);
394
469
  };
395
470
  }
396
471
 
@@ -404,14 +479,14 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
404
479
  const dropStatements = schemas
405
480
  .map(s => `DROP SCHEMA IF EXISTS ${s} CASCADE;`)
406
481
  .join('\n ');
407
- await ctx.db.execute(sql.raw(`
482
+ await opsDb.execute(sql.raw(`
408
483
  ${dropStatements}
409
484
  CREATE SCHEMA public;
410
485
  GRANT ALL ON SCHEMA public TO postgres;
411
486
  GRANT ALL ON SCHEMA public TO public;
412
487
  `));
413
488
  const { runMigrations } = await import('./migrate');
414
- const result = await runMigrations(ctx.db, options.migrationsFolder);
489
+ const result = await runMigrations(opsDb, options.migrationsFolder);
415
490
  return { reset: true, ...result };
416
491
  };
417
492
 
@@ -435,7 +510,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
435
510
  };
436
511
 
437
512
  // --- db:query ---
438
- actions['db:query'] = async (payload, ctx) => {
513
+ actions['db:query'] = async (payload, _ctx) => {
439
514
  const { sql: querySql } = payload as { sql: string };
440
515
  if (!querySql) {
441
516
  return { error: 'SQL query is required' };
@@ -451,7 +526,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
451
526
  }
452
527
  try {
453
528
  const { sql } = await import('drizzle-orm');
454
- let queryDb = ctx.db;
529
+ let queryDb = opsDb;
455
530
  const readUrl = options.readDatabaseUrl ?? process.env.READ_DATABASE_URL;
456
531
  if (readUrl && readUrl !== process.env.DATABASE_URL) {
457
532
  const { drizzle } = await import('drizzle-orm/postgres-js');
@@ -489,7 +564,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
489
564
  // Auth signal — helpers write to this during eval, we read it after
490
565
  const authSignal = { user: null as Record<string, unknown> | null, changed: false };
491
566
  const authHelpers = createAuthHelpers({
492
- db: ctx.db,
567
+ db: opsDb,
493
568
  schema: ctx.schema,
494
569
  signal: authSignal,
495
570
  ...options.consoleAuth,
@@ -542,7 +617,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
542
617
 
543
618
  if (sandbox) {
544
619
  try {
545
- await ctx.db.transaction(async (tx: any) => {
620
+ await opsDb.transaction(async (tx: any) => {
546
621
  await applySettings(tx);
547
622
  result = await evalExpression(tx);
548
623
  throw SANDBOX_ROLLBACK;
@@ -551,14 +626,14 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
551
626
  if (err !== SANDBOX_ROLLBACK) throw err;
552
627
  }
553
628
  } else {
554
- result = await ctx.db.transaction(async (tx: any) => {
629
+ result = await opsDb.transaction(async (tx: any) => {
555
630
  await applySettings(tx);
556
631
  return evalExpression(tx);
557
632
  });
558
633
  }
559
634
  } else if (sandbox) {
560
635
  try {
561
- await ctx.db.transaction(async (tx: any) => {
636
+ await opsDb.transaction(async (tx: any) => {
562
637
  result = await evalExpression(tx);
563
638
  throw SANDBOX_ROLLBACK;
564
639
  });
@@ -566,7 +641,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
566
641
  if (err !== SANDBOX_ROLLBACK) throw err;
567
642
  }
568
643
  } else {
569
- result = await evalExpression(ctx.db);
644
+ result = await evalExpression(opsDb);
570
645
  }
571
646
 
572
647
  // Safe-serialize: handle circular refs, Symbols, and complex objects