@everystack/events 0.4.3 → 0.4.4

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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to `@everystack/events` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.4] - 2026-10-30
8
+
9
+ ### Added
10
+
11
+ - createListener takes a role and switches to it once per session
12
+
7
13
  ## [0.4.3] - 2026-10-23
8
14
 
9
15
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/events",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "PostgreSQL LISTEN/NOTIFY bridge for serverless — WebSocket fan-out + Lambda invocation",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -64,7 +64,7 @@
64
64
  }
65
65
  },
66
66
  "dependencies": {
67
- "@everystack/model": "0.4.23"
67
+ "@everystack/model": "0.4.25"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@aws-sdk/client-apigatewaymanagementapi": "3.1053.0",
@@ -105,10 +105,11 @@
105
105
  "@types/react": "19.2.14",
106
106
  "drizzle-orm": "0.41.0",
107
107
  "jest": "29.7.0",
108
+ "postgres": "3.4.9",
108
109
  "react": "19.2.0",
109
110
  "ts-jest": "29.4.9",
110
111
  "typescript": "5.9.3",
111
- "@everystack/auth": "0.4.9"
112
+ "@everystack/auth": "0.4.10"
112
113
  },
113
114
  "scripts": {
114
115
  "test": "jest",
package/src/listener.ts CHANGED
@@ -276,6 +276,26 @@ async function reconcile(
276
276
  await loadEventActions(pgClient, actionCache);
277
277
  }
278
278
 
279
+ // --- Session role ---
280
+
281
+ const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
282
+
283
+ /** The role is spliced into SET ROLE, so it must be a plain identifier; never the lifecycle role. */
284
+ function assertListenerRole(role: string): void {
285
+ if (!ROLE_NAME.test(role)) throw new Error(`Invalid role name: ${JSON.stringify(role)}`);
286
+ if (role === 'everystack_lifecycle') throw new Error('the listener must not run as everystack_lifecycle: pass an application role (e.g. admin)');
287
+ }
288
+
289
+ /**
290
+ * Switch the session to `role` and prove it took. Session-level, not SET LOCAL: LISTEN is
291
+ * session-scoped and the listener holds one session for its whole run, outside any transaction.
292
+ */
293
+ async function switchSessionRole(pgClient: InternalListenerOptions['pgClient'], role: string): Promise<void> {
294
+ await pgClient.query(`SET ROLE ${role}`);
295
+ const { rows } = await pgClient.query('SELECT current_user AS role');
296
+ if (rows[0]?.role !== role) throw new Error(`role ${role} was not set: current_user is ${String(rows[0]?.role)}`);
297
+ }
298
+
279
299
  // --- Internal (testable) listener factory ---
280
300
 
281
301
  interface InternalListenerOptions {
@@ -294,6 +314,8 @@ interface InternalListenerOptions {
294
314
  heartbeatInterval?: number;
295
315
  eventActions?: EventActionsOptions;
296
316
  kvsClient?: KvsClient | null;
317
+ /** See ListenerOptions.role. */
318
+ role?: string;
297
319
  }
298
320
 
299
321
  interface HandlerEvent {
@@ -311,11 +333,22 @@ export function createListenerInternal(options: InternalListenerOptions) {
311
333
  heartbeatInterval = 30000,
312
334
  eventActions,
313
335
  kvsClient = null,
336
+ role,
314
337
  } = options;
338
+ if (role !== undefined) assertListenerRole(role);
315
339
 
316
340
  return async function handler(event: HandlerEvent = {}): Promise<{ status: string; reason?: string }> {
317
- // 1. Connect
341
+ // 1. Connect, then switch role before anything else runs in this session. This is the only
342
+ // connect: the session is never re-opened, so the switch covers everything after it.
318
343
  await pgClient.connect();
344
+ if (role !== undefined) {
345
+ try {
346
+ await switchSessionRole(pgClient, role);
347
+ } catch (err) {
348
+ await pgClient.end().catch(() => {});
349
+ throw err;
350
+ }
351
+ }
319
352
 
320
353
  // 2. Acquire advisory lock
321
354
  const { rows } = await pgClient.query(
@@ -393,6 +426,7 @@ export function createListenerInternal(options: InternalListenerOptions) {
393
426
  // --- Public API ---
394
427
 
395
428
  export function createListener(options: ListenerOptions) {
429
+ if (options.role !== undefined) assertListenerRole(options.role);
396
430
  return async function handler(_event?: unknown): Promise<{ status: string; reason?: string }> {
397
431
  // Dynamic imports to avoid bundling AWS SDK when not needed
398
432
  const { default: pg } = await import('pg' as string);
@@ -449,6 +483,7 @@ export function createListener(options: ListenerOptions) {
449
483
  heartbeatInterval: options.heartbeatInterval,
450
484
  eventActions: options.eventActions,
451
485
  kvsClient,
486
+ role: options.role,
452
487
  });
453
488
 
454
489
  return internal({});
package/src/types.ts CHANGED
@@ -50,6 +50,12 @@ export interface ListenerOptions {
50
50
  reconcile?: (db: unknown) => Promise<void>;
51
51
  heartbeatInterval?: number;
52
52
  eventActions?: EventActionsOptions;
53
+ /**
54
+ * An application role to `SET ROLE` to right after connect, checked with `current_user`.
55
+ * Lets the listener connect as a least-privilege login (NOINHERIT, granted the role) instead
56
+ * of the owner. Each invocation opens a new session and switches it. Omit to stay as the login.
57
+ */
58
+ role?: string;
53
59
  }
54
60
 
55
61
  // --- Subscription (internal NOTIFY payloads) ---