@spfn/core 0.3.0-beta.32 → 0.3.0-beta.33

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.32",
3
+ "version": "0.3.0-beta.33",
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": {
@@ -59,7 +59,9 @@ From `@spfn/core/event/sse`:
59
59
 
60
60
  - `createSSEHandler(router, config?, tokenManager?)` — Hono GET handler
61
61
  - `SSETokenManager` (class), `CacheTokenStore` (class)
62
- - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEMessage`,
62
+ - `closeSSEConnectionsByKey(keyId)` — close the streams a device key opened, on every pod
63
+ (see [Closing the streams of a revoked device key](#closing-the-streams-of-a-revoked-device-key))
64
+ - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEKeyVerifier`, `SSEMessage`,
63
65
  `SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
64
66
  `SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSESubscriptionChange`,
65
67
  `SSEConnectionState`, `SSEUnsubscribe`, `ConnectedPayload`
@@ -269,10 +271,10 @@ can't ride along on the stream request. SPFN uses a **token exchange**: an authe
269
271
  ```
270
272
  Client Server
271
273
  │ POST /events/token (Bearer JWT) │ ← protected by config.middlewares (e.g. authenticate)
272
- │ ───────────────────────────────► │ getSubject(c) → issue one-time token
274
+ │ ───────────────────────────────► │ getSubject(c), getKeyId(c) → issue one-time token
273
275
  │ ◄───────────────────────────── │ { token: "..." } (default TTL 30s)
274
276
  │ GET /events/stream?token=…&events=…
275
- │ ───────────────────────────────► │ verify+consume token (one-time)
277
+ │ ───────────────────────────────► │ consume token (one-time) → verifyKey(keyId) when tagged
276
278
  │ │ → authorize(subject, events)
277
279
  │ │ → resolvePrincipal(subject, request) (once)
278
280
  │ │ → event.canSubscribe(ctx) (once per event)
@@ -295,7 +297,7 @@ export default defineServerConfig()
295
297
  auth: { enabled: true },
296
298
  })
297
299
  .build();
298
- // → POST /events/token (mints token; subject = c.get('auth').userId by default)
300
+ // → POST /events/token (mints token; subject = c.get('auth').userId, keyId = c.get('auth').keyId by default)
299
301
  // → GET /events/stream?token=…&events=…
300
302
  ```
301
303
 
@@ -359,6 +361,54 @@ export default defineServerConfig()
359
361
  ```
360
362
 
361
363
  When `tokenManager` is provided, `store`/`tokenTtl` are ignored (the external manager owns them).
364
+ `@spfn/auth`'s manager also checks the device key at consume (`verifyKey`, below), so sharing it
365
+ is what makes a token minted before a sign-out useless after it.
366
+
367
+ ### Closing the streams of a revoked device key
368
+
369
+ A stream outlives the token that opened it: the token is checked once, at open, and pings keep
370
+ the stream alive. So the token carries the **device key** it was minted for, and revoking that
371
+ key ends what it opened.
372
+
373
+ - **The token is tagged.** The token endpoint passes `getKeyId(c)` (default
374
+ `c.get('auth')?.keyId`; an app without `auth` is fine) to `issue(subject, { keyId })`. An
375
+ empty, `null` or `undefined` key leaves the token untagged — such a stream is never closed by
376
+ key. A non-device value (`'service'`) is stored as given; nothing asks to close it.
377
+ - **A token minted before the revocation is refused at open.** `SSETokenManagerConfig.verifyKey`
378
+ runs when a tagged token is consumed; `false` or a throw refuses it (`401`). The token is
379
+ consumed first, so a refused token is never retried. The throw is warned without its text.
380
+ - **`closeSSEConnectionsByKey(keyId)` ends the open ones.** It closes, on this process, every
381
+ stream whose token carried `keyId` — every `createSSEHandler` of the process, not one — and a
382
+ connect of that key still in admission ends at open, with no `connected` frame and no
383
+ subscription. With a cache (resolved lazily from `getCache()`, so a pod serving no events
384
+ publishes too) it then publishes `{ kind: 'close', keyId }` on the same control channel as
385
+ `grant` / `revoke`, `<channelPrefix>$control`
386
+ ([Multi-instance broadcast](#multi-instance-broadcast-cross-pod-fan-out)), so every other pod
387
+ whose events are wired through the cache closes its own — whatever events it serves; a pod
388
+ skips its own echo, and a close without a string `keyId` is malformed and ignored. A failed publish is warned and swallowed: the promise
389
+ always resolves. A close this pod cannot publish while the transport's breaker is open, or
390
+ whose publish fails, closes this pod's streams at once and is re-sent when the breaker
391
+ cooldown ends and whenever the next publish succeeds; while it is pending, the key's streams on
392
+ other pods stay open. A held close is lost only when the Redis outage outlasts this pod (its
393
+ shutdown disposes held closes) or when more than 1000 closes are held (the oldest are dropped);
394
+ those streams then stay open until they disconnect, and their next token mint is refused. At most 1000 closes wait; past
395
+ that the oldest is dropped, with a warning that gives the count. The log says how many closed and why — never the key id.
396
+
397
+ ```typescript
398
+ import { closeSSEConnectionsByKey } from '@spfn/core/event/sse';
399
+ import { onAfterCommit } from '@spfn/core/db';
400
+
401
+ // after your own revocation statement
402
+ onAfterCommit(() => closeSSEConnectionsByKey(revokedKeyId));
403
+ ```
404
+
405
+ `@spfn/auth` does this for every key revocation (sign out one device, sign out everywhere,
406
+ logout, password change and reset, account deletion, rotation, renewal, re-login): an app on
407
+ `tokenManager: () => getOneTimeTokenManager()` gets it with no configuration change. The client
408
+ whose stream closed reconnects within `reconnectDelay`; its token mint then answers `401` and
409
+ it stops (`onClose('unauthorized')`, see the [client](#createauthsseclientconfig--with-auth-recommended)).
410
+ A device whose key was *replaced* (rotation, renewal) reconnects with the new key, which the
411
+ response installed meanwhile.
362
412
 
363
413
  ### Authorization hooks
364
414
 
@@ -553,6 +603,7 @@ client.subscribe({
553
603
  | `store` | `SSETokenStore` | auto (Cache → InMemory) | token storage backend |
554
604
  | `tokenManager` | `SSETokenManager \| () => SSETokenManager` | — | reuse an external manager (lazy resolver recommended); overrides `store`/`tokenTtl` |
555
605
  | `getSubject` | `(c: Context) => string \| null` | `c.get('auth')?.userId ?? null` | extract subject on the token endpoint |
606
+ | `getKeyId` | `(c: Context) => string \| null \| undefined` | `c.get('auth')?.keyId` | the device key the token is tagged with; empty → untagged ([revoked keys](#closing-the-streams-of-a-revoked-device-key)) |
556
607
  | `authorize` | `(subject, events[]) => events[] \| Promise<…>` | — | subscription authorization (once on connect) |
557
608
  | `filter` | `{ [event]?: (subject, payload) => boolean }` | — | per-event payload filter |
558
609
  | `resolvePrincipal` | `(subject, request) => unknown \| Promise<unknown>` | — | principal for every `canSubscribe` (once per connection); a throw ⇒ `403` |
@@ -659,6 +710,13 @@ export const { GET, POST } = createRpcProxy({
659
710
  });
660
711
  ```
661
712
 
713
+ **A `401` from the token mint is final.** When `acquireToken` rejects with an error whose
714
+ `statusCode` (a registered class such as `UnauthorizedError`) or `status` (the api client's
715
+ `ApiError`, and `createAuthSSEClient`'s own error) is `401`, the client closes the
716
+ subscription and calls `onClose('unauthorized')` once, instead of reconnecting — the session
717
+ is gone (its device key was revoked), and retrying would mint and fail every `reconnectDelay`
718
+ forever. Any other rejection reconnects as before. `onClose` stays assignable from `() => void`.
719
+
662
720
  ### `subscribeToEvents(events, handlers, config?)` — one-shot helper
663
721
 
664
722
  ```typescript
@@ -742,7 +800,8 @@ payload can't serialize, the event is **still delivered to this pod's own subscr
742
800
  asymmetric case where the publish succeeds but **this pod's subscriber socket is down** (its
743
801
  echo — the only path to same-pod streams — wouldn't arrive). A sustained publish outage trips
744
802
  a short circuit breaker so emits fast-path to local instead of each paying the publish timeout.
745
- A publish never crashes `emit` or kills a stream. ioredis auto-reconnects and re-subscribes
803
+ A publish never crashes `emit` or kills a stream. Events are not re-sent; a key close is (see
804
+ `closeSSEConnectionsByKey`). ioredis auto-reconnects and re-subscribes
746
805
  (autoResubscribe, default true). A per-event SUBSCRIBE failure at
747
806
  startup degrades that event to in-process (logged) rather than aborting boot. Same-pod
748
807
  delivery costs one Redis round-trip — for a single-replica deploy with a cache, prefer
@@ -848,7 +907,8 @@ fully decoupled.
848
907
  this: on error with `acquireToken` set, it closes and reconnects through its own path to
849
908
  mint a fresh token. Don't disable `reconnect` expecting native retry to work with auth.
850
909
  - **Stream-time errors aren't subscription errors.** `authorize` returning `[]` ⇒ `403` at
851
- connect; invalid event names ⇒ `400`; missing/expired token ⇒ `401`. `onError` on the
910
+ connect; invalid event names ⇒ `400`; missing/expired token, or one whose key `verifyKey`
911
+ refuses ⇒ `401`. `onError` on the
852
912
  client fires for transport-level drops, not for these HTTP rejections — check the network
853
913
  response when a connection never opens.
854
914
  - **Subject default.** Without a custom `getSubject`, the token endpoint reads
@@ -608,7 +608,7 @@ export function Shell({ initial, workspaceId }: { initial: StateClientInitial<ty
608
608
  | `current(id, key?)` | Synchronous: `{ at, value, data?, status }`, `{ status: 'revoked' }` for a state revoked before any value arrived, or `undefined`. The same frozen object until a change is applied |
609
609
  | `connection()` | Synchronous: the connection status ([Connection status](#connection-status)), a frozen object, the same reference until it changes |
610
610
  | `subscribeConnection(listener)` | Registers `listener(status)`, called with the new status on every change, after `connection()` returns it; returns its unsubscribe (idempotent). The same function subscribed twice is two subscriptions |
611
- | `retry()` | When `'failed'` or `'reconnecting'`: reads the first snapshot now (its retry count reset), or, once the manifest is known, reopens the stream now. A no-op when `'connected'`, while the first snapshot is in flight, and with nothing subscribed — after `close()` too, until a new `subscribe` |
611
+ | `retry()` | When `'failed'`, `'reconnecting'` or `'closed'` (unauthorized — after the user signed in again): reads the first snapshot now (its retry count reset), or, once the manifest is known, reopens the stream now. A no-op when `'connected'`, while the first snapshot is in flight, and with nothing subscribed — after `close()` too, until a new `subscribe` |
612
612
  | `close()` | Closes the stream, drops every listener — connection listeners too — and stops listening to `online`; the status is back to `'reconnecting'` 0. Idempotent. A `subscribe` after it starts the client again, without `online`: `retry()` restarts it as before |
613
613
 
614
614
  `onError` receives a failure of something the client ran on its own — the first snapshot, the
@@ -695,6 +695,7 @@ first snapshot — so it says where they stand. `connection()` is one of:
695
695
  | `{ status: 'connected' }` | a stream is open (the SSE `onOpen`) |
696
696
  | `{ status: 'reconnecting', attempt }` | no stream open — not yet, or nothing subscribed — or the SSE client retrying a dropped one: `attempt` is its count (`onReconnect`), reset when a stream opens; `0` before the first stream opens, while the first snapshot is read or retried, and after `retry()` |
697
697
  | `{ status: 'failed' }` | the retries are exhausted: the SSE client's `maxReconnectAttempts` (its `onClose`, the attempts spent), or `maxRetries` for the first snapshot. Nothing more is tried until a restart (below) or, with the manifest unknown, a `get`, `write` or `snapshot` that brings it |
698
+ | `{ status: 'closed', reason: 'unauthorized' }` | the stream's token mint answered `401` (the SSE client's `onClose('unauthorized')`): the session is gone — its device key was revoked, e.g. "sign out this device" from another one. No retry is set, and neither `online` nor a `subscribe` reopens it; only the app's own `retry()`, once the user signed in again, does |
698
699
 
699
700
  - **Start value** `{ status: 'reconnecting', attempt: 0 }`, a client built with `initial` too:
700
701
  its entries are ready, its stream is not.
@@ -715,10 +716,17 @@ first snapshot — so it says where they stand. `connection()` is one of:
715
716
  - **`close()`** sets `'reconnecting'` 0, drops the connection listeners and removes the
716
717
  `online` listener; its own teardown is not `'failed'`. A `subscribe` after it starts the
717
718
  client again, and `retry()` restarts it; `online` is not listened to again.
719
+ - **`'closed'` / `'unauthorized'`** is not a network failure, so nothing restarts it on its
720
+ own: not `online`, not a `subscribe`. `retry()` reopens the stream (`'reconnecting'` 0); the
721
+ last unsubscribe resets the client to the start value, as from any status.
718
722
 
719
723
  ```tsx
720
724
  const connection = useConnectionStatus(state);
721
725
 
726
+ if (connection.status === 'closed')
727
+ {
728
+ return <Banner>You were signed out on this device.</Banner>; // connection.reason === 'unauthorized'
729
+ }
722
730
  if (connection.status === 'failed')
723
731
  {
724
732
  return <Banner action={() => state.retry()}>The server has a problem.</Banner>;