@spfn/core 0.3.0-beta.23 → 0.3.0-beta.24

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.
@@ -1,5 +1,5 @@
1
1
  import { Context } from 'hono';
2
- import { E as EventRouterDef, I as InferEventNames, g as InferEventPayload } from './router-CysBmgv3.js';
2
+ import { E as EventRouterDef, I as InferEventNames, h as InferEventPayload, j as SubscribeRequest } from './router-ZDV1ISTo.js';
3
3
  import { c as SSETokenStore, S as SSETokenManager } from './token-manager-Ckjt2-qq.js';
4
4
 
5
5
  /**
@@ -79,6 +79,17 @@ interface SSEHandlerAuthConfig {
79
79
  * Return false to skip sending the event to this user.
80
80
  */
81
81
  filter?: Record<string, (subject: string, payload: unknown) => boolean>;
82
+ /**
83
+ * Resolve the principal every `.canSubscribe()` of a connection receives
84
+ * (called once per connection, after the subject is known).
85
+ *
86
+ * The stream request carries no `AuthContext`: the subject comes from the
87
+ * one-time token, and the stream is mounted without the app's middleware.
88
+ * So an app that decides on roles or memberships loads them here, once,
89
+ * instead of in every predicate. Absent → predicates see
90
+ * `principal: undefined`. A throw refuses the connection with 403.
91
+ */
92
+ resolvePrincipal?: (subject: string, request: SubscribeRequest) => unknown | Promise<unknown>;
82
93
  }
83
94
  /**
84
95
  * SSE auth configuration (user-facing, generic)
@@ -114,6 +125,7 @@ interface SSEAuthConfig<TRouter extends EventRouterDef<any>> {
114
125
  filter?: {
115
126
  [K in InferEventNames<TRouter>]?: (subject: string, payload: InferEventPayload<TRouter, K>) => boolean;
116
127
  };
128
+ resolvePrincipal?: (subject: string, request: SubscribeRequest) => unknown | Promise<unknown>;
117
129
  }
118
130
  /**
119
131
  * SSE Handler configuration
@@ -270,6 +282,19 @@ interface SSESubscribeOptions<TRouter extends EventRouterDef<any>> {
270
282
  * Called when reconnecting
271
283
  */
272
284
  onReconnect?: (attempt: number) => void;
285
+ /**
286
+ * Called when the server grants or revokes one event on the open connection
287
+ * (`subscription.granted` / `subscription.revoked` frames). Only events in
288
+ * `events` can change: an event never requested cannot be granted.
289
+ */
290
+ onSubscriptionChange?: (change: SSESubscriptionChange<TRouter>) => void;
291
+ }
292
+ /**
293
+ * A `grant` / `revoke` the server applied to the open connection.
294
+ */
295
+ interface SSESubscriptionChange<TRouter extends EventRouterDef<any>> {
296
+ event: InferEventNames<TRouter>;
297
+ status: 'granted' | 'revoked';
273
298
  }
274
299
  /**
275
300
  * SSE connection state
@@ -280,4 +305,4 @@ type SSEConnectionState = 'connecting' | 'open' | 'closed' | 'error';
280
305
  */
281
306
  type SSEUnsubscribe = () => void;
282
307
 
283
- export type { SSEClientConfig as S, SSESubscribeOptions as a, SSEUnsubscribe as b, SSEConnectionState as c, SSEHandlerConfig as d, SSEAuthConfig as e, SSEEventHandler as f, SSEEventHandlers as g, SSEHandlerAuthConfig as h, SSEMessage as i };
308
+ export type { SSEClientConfig as S, SSESubscribeOptions as a, SSEUnsubscribe as b, SSEConnectionState as c, SSEHandlerConfig as d, SSEAuthConfig as e, SSEEventHandler as f, SSEEventHandlers as g, SSEHandlerAuthConfig as h, SSEMessage as i, SSESubscriptionChange as j };
@@ -1,5 +1,5 @@
1
1
  import { Context } from 'hono';
2
- import { a as EventDef, i as EventRouterBase, I as InferEventNames, g as InferEventPayload } from './router-CysBmgv3.js';
2
+ import { a as EventDef, l as EventRouterBase, I as InferEventNames, h as InferEventPayload } from './router-ZDV1ISTo.js';
3
3
  import { c as SSETokenStore, S as SSETokenManager } from './token-manager-Ckjt2-qq.js';
4
4
 
5
5
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.23",
3
+ "version": "0.3.0-beta.24",
4
4
  "description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "exports": {
@@ -235,7 +235,8 @@ throws instead of one declaration silently winning. A router `streamPath` that d
235
235
  a client must exchange a token first;
236
236
  - a contracted event without a payload schema (`defineEvent(name)`) — its frame carries no `data`;
237
237
  declare `Type.Object({})` and emit `{}` instead;
238
- - a contracted event under the key `connected` or `ping` — those are the stream's own frames;
238
+ - a contracted event under the key `connected`, `ping`, `subscription.granted` or
239
+ `subscription.revoked` — those are the stream's own frames;
239
240
  - a contracted event with no `since`.
240
241
 
241
242
  **What a reader of the document needs.**
@@ -254,8 +255,9 @@ throws instead of one declaration silently winning. A router `streamPath` that d
254
255
  `EventDef` registered under two keys is two items.
255
256
  - A frame's `data` is the JSON `{ "event": name, "data": payload }`, with `payload` matching the
256
257
  item's schema.
257
- - `connected` (once, when the stream opens) and `ping` (keep-alive) frames are the stream's own and
258
- are not items.
258
+ - `connected` (once, when the stream opens), `ping` (keep-alive), `subscription.granted` and
259
+ `subscription.revoked` (an event activated or deactivated on the open connection) frames are the
260
+ stream's own and are not items.
259
261
  - With `auth: "tokenExchange"` the client first posts to `tokenPath`, then opens
260
262
  `streamPath?events=…&token=…`. `tokenPath` is derived from `streamPath` exactly as the server
261
263
  derives it.
@@ -45,8 +45,12 @@ From `@spfn/core/event`:
45
45
  - `.contract({ since, deprecatedIn?, removedIn? })` on an event and `.contract({ auth, streamPath? })`
46
46
  on an event router — publish the event in `contracts/current.json` (see
47
47
  [Contracted events](#contracted-events-for-separately-compiled-clients))
48
+ - `.canSubscribe(predicate)`, `.filter(predicate)`, `.grant(subject)`, `.revoke(subject)` on an
49
+ event — who may subscribe over SSE, which frames a connection receives, and changing an open
50
+ connection (see [Per-event permission](#per-event-permission-cansubscribe-filter-grant--revoke))
48
51
  - Types: `EventDef`, `EventHandler`, `InferEventPayload`, `PubSubCache`, `JobQueueSender`,
49
- `EventContract`, `EventRouterContract`,
52
+ `EventContract`, `EventRouterContract`, `SubscribePredicate`, `SubscribeContext`,
53
+ `SubscribeRequest`, `FramePredicate`, `FrameContext`,
50
54
  `EventRouterDef`, `InferEventNames`, `InferEventPayloads`,
51
55
  `InferRouterEventPayload` (the two-arg router variant — see note)
52
56
 
@@ -56,8 +60,8 @@ From `@spfn/core/event/sse`:
56
60
  - `SSETokenManager` (class), `CacheTokenStore` (class)
57
61
  - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEMessage`,
58
62
  `SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
59
- `SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSEConnectionState`,
60
- `SSEUnsubscribe`
63
+ `SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSESubscriptionChange`,
64
+ `SSEConnectionState`, `SSEUnsubscribe`
61
65
 
62
66
  From `@spfn/core/event/sse/client`:
63
67
 
@@ -133,10 +137,16 @@ Promise<void>` otherwise. Handlers run via `Promise.allSettled` — see
133
137
  | `emit(payload?)` | `Promise<void>` | fan-out to handlers (or cache) + job queues |
134
138
  | `useCache(cache)` | `(PubSubCache) => Promise<EventDef>` | enable cross-instance broadcast (must `await`) |
135
139
  | `contract(contract)` | `(EventContract) => EventDef` | publish in the contract; returns the same event; a second call throws |
140
+ | `canSubscribe(predicate)` | `<P>(SubscribePredicate<P>) => EventDef` | who may subscribe over SSE (once per connection); a second call throws |
141
+ | `filter(predicate)` | `(FramePredicate<TPayload>) => EventDef` | which frames a connection receives (sync, per frame); a second call throws |
142
+ | `grant(subject)` | `Promise<void>` | activate the event on the subject's open SSE connections where it is denied |
143
+ | `revoke(subject)` | `Promise<void>` | deactivate the event on the subject's open SSE connections where it is allowed |
136
144
 
137
- > `_registerJobQueue` and `_payload` exist on the interface but are **internal** —
138
- > `_registerJobQueue` is called by `@spfn/core/job` when a job does `.on(event)`; `_payload`
139
- > is a type-inference carrier (always `undefined` at runtime). Don't call them.
145
+ > `_registerJobQueue`, `_registerSubscriptionControl` and `_payload` exist on the interface but
146
+ > are **internal** — `_registerJobQueue` is called by `@spfn/core/job` when a job does
147
+ > `.on(event)`; `_registerSubscriptionControl` by `createSSEHandler` so `grant` / `revoke` reach
148
+ > its connections; `_payload` is a type-inference carrier (always `undefined` at runtime). Don't
149
+ > call them.
140
150
 
141
151
  ---
142
152
 
@@ -262,8 +272,10 @@ Client Server
262
272
  │ GET /events/stream?token=…&events=…
263
273
  │ ───────────────────────────────► │ verify+consume token (one-time)
264
274
  │ │ → authorize(subject, events)
265
- │ ◄═══════════════════════════════ │ SSE stream opens
266
- │ event: <name> / data: {…} │ ← filter(subject, payload) per emission
275
+ │ │ → resolvePrincipal(subject, request) (once)
276
+ │ │ → event.canSubscribe(ctx) (once per event)
277
+ │ ◄═══════════════════════════════ │ SSE stream opens (connected: subscribed + denied)
278
+ │ event: <name> / data: {…} │ ← filter(subject, payload) + event.filter(payload, ctx) per emission
267
279
  ```
268
280
 
269
281
  Enable it with `auth: { enabled: true }`:
@@ -381,6 +393,101 @@ Return `false` to skip. Payload is typed per event.
381
393
  })
382
394
  ```
383
395
 
396
+ ### Per-event permission (`canSubscribe`, `filter`, `grant` / `revoke`)
397
+
398
+ The global hooks above are one function for the whole router. When the rule belongs to one event,
399
+ declare it where the event is defined:
400
+
401
+ ```typescript
402
+ export const auditLogged = defineEvent('audit.logged', payload)
403
+ .canSubscribe<AuthPrincipal>(({ subject, principal, request }) => principal?.role === 'admin');
404
+
405
+ export const runtimeChanged = defineEvent('runtime.changed', payload)
406
+ .filter((payload, { subject, request }) => payload.workspaceId === request.query.workspaceId);
407
+ ```
408
+
409
+ - **`canSubscribe(ctx)`** — once per connection per requested event, only when the connection has a
410
+ subject. `true` allows; `false` or a throw denies (a throw is logged at warn with the event name).
411
+ A denial never refuses the connection: an event denied here is listed in the `connected` frame's
412
+ `deniedEvents`, and a connection whose every event is denied stays open with zero subscriptions,
413
+ so a later `grant` can activate one. May be async.
414
+ - **`filter(payload, ctx)`** — per frame, with or without a subject; it matches the payload against
415
+ the connect request. It runs beside the global `filter` map — both must return `true`. Sync. A
416
+ throw drops that frame (warn) and leaves the connection alive.
417
+ - **`request`** in both is a snapshot taken once at connect: `{ query, headers }` with lowercased
418
+ header names — never the live Hono context. It is the connect request as sent: it holds the
419
+ `cookie` / `authorization` headers and the consumed one-time `token` in `query`, so the app must
420
+ never log the snapshot.
421
+ - Both are set once per event: an `EventDef` is a shared singleton, so a second call throws.
422
+
423
+ **`resolvePrincipal` — why the stream has no `AuthContext`.** The subject comes from the one-time
424
+ token, and the stream is mounted without the app's middleware, so there is no request auth object
425
+ to hand a predicate. `auth.resolvePrincipal(subject, request)` loads it instead, **once per
426
+ connection**, after the subject is known and before any `canSubscribe`; every predicate of that
427
+ connection receives the same value as `principal` (type it with `.canSubscribe<P>()`). Absent →
428
+ `principal: undefined`. A throw refuses the connection with `403 { error: 'Principal could not be
429
+ resolved' }`.
430
+
431
+ ```typescript
432
+ .events(eventRouter, {
433
+ auth: {
434
+ enabled: true,
435
+ resolvePrincipal: async (subject) => principalsRepository.findBySubject(subject),
436
+ },
437
+ })
438
+ ```
439
+
440
+ **Order.** `authorize` (an empty result is still `403`) → `canSubscribe` of each event `authorize`
441
+ kept → per frame, the global `filter` and the event's `filter`. An event `authorize` dropped is
442
+ never asked and counts as denied too.
443
+
444
+ **Cost.** At connect: one predicate call per requested event (plus one `resolvePrincipal`). Per
445
+ frame: sync filters only — never storage. The handler sets no timeout on them: a hanging
446
+ `canSubscribe` or `resolvePrincipal` holds the connect open, so the app owns the timeout. A
447
+ client that leaves meanwhile is forgotten at once; the late verdict opens nothing.
448
+
449
+ **`grant` / `revoke` — changing an open connection.**
450
+
451
+ ```typescript
452
+ await workspaceRuntime.revoke(userId); // membership ended
453
+ await workspaceRuntime.grant(userId); // membership restored
454
+ ```
455
+
456
+ - `revoke(subject)` — on every open connection of the subject where the event is allowed: drop the
457
+ subscription and send a `subscription.revoked` frame `{ event }` (the router key). `grant(subject)`
458
+ — where it is denied: subscribe (the same code path as at connect, with the stored request
459
+ snapshot) and send `subscription.granted`. Anything else is a no-op, and no connection resolves
460
+ without error.
461
+ - With the cache transport attached (`CACHE_URL`), the call applies on this pod and is published
462
+ once on the control channel `<channelPrefix>$control`; every other pod applies it to its own
463
+ connections. Without a cache it is local only.
464
+ - Three rules:
465
+ 1. **Open connections only.** Neither re-runs `canSubscribe` or `authorize` — they are an
466
+ operator's statement about connections that exist now.
467
+ 2. **Permission lives in app data.** The next connect asks `canSubscribe` afresh, so a missed
468
+ call delays until the client reconnects; it never produces a wrong answer after. A call that
469
+ lands while a connect is still in `authorize` / `resolvePrincipal` / `canSubscribe` is not
470
+ missed: it is recorded and applied over the predicates' verdict when that connection opens
471
+ (the last call wins; no frame — the `connected` frame already lists the result).
472
+ 3. **An event never requested cannot be granted.** The connection only knows the events in its
473
+ `?events=` list — request every event the page may need, and let `canSubscribe` deny.
474
+ - A frame already enqueued before a `revoke` may still be written; none after it is.
475
+ - WebSocket routers are not covered.
476
+
477
+ `subscription.granted` and `subscription.revoked` are stream-owned frames like `connected` and
478
+ `ping`: not in the contract, and reserved as router keys of contracted events. On the client:
479
+
480
+ ```typescript
481
+ client.subscribe({
482
+ events: ['runtimeChanged'],
483
+ handlers: { runtimeChanged: (p) => update(p) },
484
+ onSubscriptionChange: ({ event, status }) =>
485
+ {
486
+ // event: 'runtimeChanged' (typed); status: 'granted' | 'revoked'
487
+ },
488
+ });
489
+ ```
490
+
384
491
  ### SSE auth config (`SSEAuthConfig<TRouter>`)
385
492
 
386
493
  | Option | Type | Default | Description |
@@ -392,6 +499,7 @@ Return `false` to skip. Payload is typed per event.
392
499
  | `getSubject` | `(c: Context) => string \| null` | `c.get('auth')?.userId ?? null` | extract subject on the token endpoint |
393
500
  | `authorize` | `(subject, events[]) => events[] \| Promise<…>` | — | subscription authorization (once on connect) |
394
501
  | `filter` | `{ [event]?: (subject, payload) => boolean }` | — | per-event payload filter |
502
+ | `resolvePrincipal` | `(subject, request) => unknown \| Promise<unknown>` | — | principal for every `canSubscribe` (once per connection); a throw ⇒ `403` |
395
503
 
396
504
  ---
397
505
 
@@ -742,8 +850,20 @@ interface EventDef<TPayload = void> {
742
850
  useCache: (cache: PubSubCache) => Promise<EventDef<TPayload>>;
743
851
  readonly _contract?: EventContract;
744
852
  contract: (contract: EventContract) => EventDef<TPayload>;
853
+ readonly _canSubscribe?: SubscribePredicate<any>;
854
+ canSubscribe: <P = unknown>(predicate: SubscribePredicate<P>) => EventDef<TPayload>;
855
+ readonly _filter?: FramePredicate<TPayload>;
856
+ filter: (predicate: FramePredicate<TPayload>) => EventDef<TPayload>;
857
+ grant: (subject: string) => Promise<void>;
858
+ revoke: (subject: string) => Promise<void>;
745
859
  }
746
860
 
861
+ interface SubscribeRequest { query: Record<string, string>; headers: Record<string, string>; }
862
+ interface SubscribeContext<P = unknown> { subject: string; principal: P | undefined; request: SubscribeRequest; }
863
+ type SubscribePredicate<P = unknown> = (ctx: SubscribeContext<P>) => boolean | Promise<boolean>;
864
+ interface FrameContext { subject: string | undefined; request: SubscribeRequest; }
865
+ type FramePredicate<TPayload> = (payload: TPayload, ctx: FrameContext) => boolean;
866
+
747
867
  interface EventContract { since: string; deprecatedIn?: string; removedIn?: string; }
748
868
 
749
869
  type EventHandler<TPayload> = (payload: TPayload) => void | Promise<void>;