@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.
- package/package.json +1 -1
- package/src/db.ts +78 -0
- package/src/plugin.ts +94 -19
package/package.json
CHANGED
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
|
|
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,
|
|
456
|
+
actions.migrate = async (_payload, _ctx) => {
|
|
382
457
|
const { runMigrations } = await import('./migrate');
|
|
383
|
-
return await runMigrations(
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
644
|
+
result = await evalExpression(opsDb);
|
|
570
645
|
}
|
|
571
646
|
|
|
572
647
|
// Safe-serialize: handle circular refs, Symbols, and complex objects
|