@everystack/events 0.4.3 → 0.4.5
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 +10 -0
- package/package.json +4 -3
- package/src/listener.ts +36 -1
- package/src/types.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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.5] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
_Internal changes only._
|
|
10
|
+
|
|
11
|
+
## [0.4.4] - 2026-10-30
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- createListener takes a role and switches to it once per session
|
|
16
|
+
|
|
7
17
|
## [0.4.3] - 2026-10-23
|
|
8
18
|
|
|
9
19
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/events",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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.
|
|
67
|
+
"@everystack/model": "0.4.27"
|
|
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.
|
|
112
|
+
"@everystack/auth": "0.4.11"
|
|
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) ---
|