actions/connection_closer.ts

Closing live connections — WebSocket and SSE — when the credential behind them is revoked.

Why this exists. A WebSocket and an SSE stream are authorized once, at open. Per-message WebSocket dispatch (actions/perform_action.ts) reloads role grants, never the session or token, and a one-way stream re-reads nothing. So a revocation takes effect on an open connection only when something closes it. ConnectionCloser is that capability, and every revocation handler holds one: the session and token revokes, the revoke-alls, account delete and purge, /logout, /password, and the session and token caps for what they evict. The auth cleanup holds one too (AuthCleanupDeps.connection_closer): its session sweep closes the connections of each expired session it deletes, after that delete commits.

Closing after commit

A revocation runs inside a request transaction, and it must not close a connection until that transaction commits. queue_connection_close defers the close onto the request's post-commit queue (emit_after_commit): it runs after the commit and is dropped on rollback, so a revocation that never happened closes nothing.

Two things rest on that order. A client told "revoked" rechecks its session straight away, and before the commit the row it probes is still there. And connection admission registers a connection and then re-reads its credential (revalidate_resolved_auth): a close that missed the registration must belong to a revocation the re-read can see, which holds only if the close came after the commit.

Why the handlers close directly

The audit listeners (create_ws_auth_guard, create_sse_auth_guard) close connections too, but only once the audit row is written, and that write is pool-routed and fail-open. The handler's own close is the one that runs whatever happens to the audit write, so the closer it holds must reach every transport whose connections outlive their credential. RealtimeCloser is that fan-out, and create_app_backend puts one on AppDeps.

Twin of the Rust spine's SocketRevoker, SocketCloseTarget, queue_socket_close, and RealtimeRevoker.

view source

Declarations
#

7 declarations

close_connections
#

ConnectionCloser
#

actions/connection_closer.ts view source

ConnectionCloser import type {ConnectionCloser} from '@fuzdev/fuz_app/actions/connection_closer.js';

Close live connections by identity, or all of them.

Each method returns how many connections it closed. Closing is idempotent: a connection already gone isn't counted, so a handler's close and an audit listener's close on the same revocation compose. Implementations are synchronous.

One connection that fails to close must not spare the rest: an implementation attempts every matching connection, then throws the first error it met.

BackendWebsocketTransport satisfies this structurally, and create_sse_connection_closer adapts a SubscriberRegistry.

close_sockets_for_session

Close every connection authenticated by the session whose blake3 hash is session_token_hash.

type (session_token_hash: string): number

session_token_hash

type string
returns number

close_sockets_for_token

Close every connection authenticated by the API token api_token_id.

type (api_token_id: string): number

api_token_id

type string
returns number

close_sockets_for_account

Close every connection of account_id, whatever credential opened it — the close for a revocation that invalidates all of an account's credentials.

type (account_id: string & $brand<"Uuid">): number

account_id

type Uuid
returns number

close_all_sockets

Close every connection, whoever opened it — the shutdown close AppServer.close runs. Not a revocation: a WebSocket gets WS_CLOSE_GOING_AWAY, so its client reconnects rather than treating its credential as revoked. The transport is left closing: a registration that arrives afterward is born closed, refused at once (BackendWebsocketTransport and SubscriberRegistry both do).

type (): number

returns number

ConnectionCloseTarget
#

actions/connection_closer.ts view source

ConnectionCloseTarget

type { kind: "session"; session_token_hash: string; } | { kind: "token"; api_token_id: string; } | { kind: "account"; account_id: string & $brand<"Uuid">; }

import type {ConnectionCloseTarget} from '@fuzdev/fuz_app/actions/connection_closer.js';

Which connections a revocation closes — the argument of queue_connection_close, one variant per credential close of ConnectionCloser (close_all_sockets is shutdown, never a revocation).

create_realtime_closer
#

actions/connection_closer.ts view source

(): RealtimeCloser import {create_realtime_closer} from '@fuzdev/fuz_app/actions/connection_closer.js';

Create an empty RealtimeCloser.

create_app_backend creates the one on AppDeps.connection_closer, and create_app_server adds each WebSocket transport it mounts and its audit stream registry, removing them when its assembly fails or the server closes. A consumer that mounts a transport by hand adds it through the connection_closer option of register_ws_endpoint / create_audit_log_sse, or with add.

returns

RealtimeCloser

noop_connection_closer
#

queue_connection_close
#

actions/connection_closer.ts view source

(ctx: EmitAfterCommitContext, closer: ConnectionCloser, target: ConnectionCloseTarget): void import {queue_connection_close} from '@fuzdev/fuz_app/actions/connection_closer.js';

Queue a connection close on ctx's post-commit queue, so it runs only after the request's transaction commits and never when the handler throws.

The one way a revocation handler closes connections — see the module doc for why an in-transaction revocation must not close inline.

In a handler with no wrapping transaction a throw still drops the queued close while an autocommit write stays; close with close_connections straight after the write there.

ctx

the route or action context of the request doing the revoking

closer

the closer to run, deps.connection_closer in a handler

target

the connections to close

returns

void

mutates

  • ctx.post_commit_effects — appends the close

RealtimeCloser
#

actions/connection_closer.ts view source

RealtimeCloser import type {RealtimeCloser} from '@fuzdev/fuz_app/actions/connection_closer.js';

One ConnectionCloser over every live-connection transport of a backend.

Each close fans out to every member and returns the sum of what each closed. A revocation handler holds this rather than one transport, so its close reaches the WebSocket transports and the SSE registries alike, and AppServer.close runs its close_all_sockets to end every live connection of the backend at shutdown.

A member that throws does not stop the fan-out: every member is still called, and the first error is thrown once they all have been — so the caller (the post-commit flush, an audit listener) logs it.

inheritance

add

Add a transport's closer, returning a remover for this addition.

Membership is by reference: a closer added twice — two endpoints sharing one transport — is one member, and every close reaches it once. Each add is counted, and its remover releases only that count: the closer stays a member until every addition is removed, so one owner releasing a shared transport leaves it reachable for the others. Calling a remover again is a no-op.

Members must be the same instances connections are registered on — a closer over a fresh registry closes nothing.

type (closer: ConnectionCloser): () => void

closer

the transport's closer

returns () => void

a remover for this addition, idempotent

member_count

Count of distinct members — introspection for tests and diagnostics.

type (): number

returns number

Depends on
#

Imported by
#