Streams and sockets
A long-lived WebSocket, SSE stream or subscription gets a frozen Connection with per-message checks, an outbound filter and an AbortSignal that ends it when the subject is revoked, expires or loses the permission that opened it.
Every HTTP adapter decides once per request. A WebSocket, a Server-Sent Events stream, a tRPC subscription, an oRPC event iterator or a Nest gateway can stay open for hours, and one decision at the handshake is not enough: a member demoted to viewer would keep receiving updates, a session revoked at the identity provider would keep its sockets, a token that expired mid-stream would stay effective, and every inbound message and outbound item would need a hand-written check. The request-scoped instance is frozen, so a connection cannot keep one instance and hope its grants change. Instead the kernel hands out a Connection that swaps in a fresh instance when the subject changes and ends itself when it must.
Opening a connection
The server kernel and every streaming adapter expose connection(...) next to permdock and protect. It resolves the subject once from the upgrade or stream-opening request and returns a frozen Connection:
const conn = await connection(c, {
permission: permissions.project.read, // checked at open and after every revalidation
data: () => loadProject(c.req.param("id")), // a row, or a loader re-run on every revalidation
revalidate: 5 * 60_000, // optional periodic revalidation, off by default
});| Member | Meaning |
|---|---|
permdock | The current request-scoped instance. After a revalidation it is replaced by a new instance, never mutated. |
signal | An AbortSignal that aborts when the connection must end. signal.reason is a PermDockRevokedError. |
check(permission, data?, { trusted? }) | A per-message decision against the current instance. It never throws and returns the Decision. Data is validated against the resource schema first unless trusted: true marks it as a row the server loaded. |
filter(permission, items) | The outbound filter for items pushed to the subscriber, with the same semantics as permdock.filter. |
close() | Stops the connection's timers and its feed subscription. Adapters call it when the transport closes; a long-running server must not skip it. |
Once the signal has aborted or close() was called, check returns denied with reason no-grant and detail: 'connection-revoked', and filter returns an empty array. If the opening itself failed (building the instance threw, the opening permission was denied, or the data loader returned nothing), the connection starts aborted with denied; reading conn.permdock then throws, so check conn.signal.aborted first.
A denied per-message check does not end the connection. Answer it with a Problem Details frame and keep going; only a revocation, an expiry or a re-denied opening permission closes it.
Why a connection ends
signal.reason.code says why:
| Code | Cause |
|---|---|
session-revoked | A revocation feed published session-revoked for this principal (and this session, when both sides name one). |
expired | The subject's expiresAt (the token's exp) passed, or the feed's subscribe threw. |
denied | The opening permission was denied at open or after a revalidation; the Decision rides on the error as decision. |
subject-changed | A revalidation resolved no principal, a different principal id, or threw. |
PermDockRevokedError is only a connection signal: it is never thrown by can or decide, and it is not a denial reason. toProblemDetails() maps denied to a 403 denied body with the denials, and every other code to a 401 unauthenticated body whose detail is the code. An abort adds nothing to the closed lists on the wire: there is no revoked denial reason and no on('auth') event for it. Per-message checks emit decision events like any other check, so on('decision') and sinks see them.
Revalidation and expiry
- On a
changedevent, the connection resolves the subject again from the original request (bypassing the per-request cache), re-readsmembershipsandcustomRoles, builds a new instance and re-checks the opening permission with freshdata. If the principal is the same and the permission still holds, the new instance replaces the old one and the connection stays open. Revalidations run one at a time. - On
session-revoked, the connection aborts without revalidating: a locally verified JWT would still pass verification, so re-resolving would keep a revoked session alive. - At
subject.expiresAtthe connection aborts withexpired. At the earliestMembership.expiresAtit revalidates, so an expiring membership drops its grants without ending a connection that still qualifies.revalidateadds a periodic revalidation for apps without a feed. Timers are clamped to the platform maximum and cleared on abort and byclose().
Revalidating instead of closing on every change means a role edit that adds a permission does not disconnect every open tab of that user.
The revocation feed
RevocationFeed is the extension interface that tells open connections a subject changed:
interface RevocationFeed {
subscribe(listener: (event: RevocationEvent) => void): () => void;
revoke(event: RevocationEvent): void | Promise<void>;
}
type RevocationEvent = {
principal: string;
session?: string;
tenant?: string;
kind: "session-revoked" | "changed";
};memoryRevocationFeed() is the in-process default exported from permdock and permdock/server. It validates and freezes each event, throws a TypeError for an event without a principal or with an unknown kind, and isolates listener errors so one listener cannot stop the others. A multi-replica app bridges revoke across processes with its own pub/sub (Postgres LISTEN / NOTIFY, Redis, Supabase Realtime). Pass the feed as the revocations option of the kernel or the adapter.
A feed can only end or revalidate a connection; it never grants. A lost event therefore degrades to the expiry timers, and a feed whose subscribe throws aborts the connection with expired. A changed event naming a tenant only reaches connections opened for that tenant (or with no tenant); a session-revoked event naming a session only reaches that session.
Events come from three places:
permdock/ssfwithrevocations: a verifiedsession-revoked(CAEP or Back-Channel Logout) publishessession-revokedfor the subject and session, and the credential, assurance and claims change events publishchangedfor the subject alone. A feed failure never fails the receiver.permdock/scimwithrevocations: every user a write affects gets an event with the handler's tenant, published under the SCIMid,userNameandexternalIdso it reaches whichever id the membership source matched on. Deactivating (active: false) or deleting a user publishessession-revoked; every other write publisheschanged. A throwing feed never fails the identity provider's write.- Your own code, for example an admin screen that edits a role:
revocations.revoke({ principal, tenant, kind: 'changed' }).
Adapter mappings
Each adapter wires the transport so an app does not repeat the plumbing:
| Adapter | Opening | Per message | On abort |
|---|---|---|---|
permdock/server | connection(request, options); Next.js Route Handlers streaming SSE use this directly | conn.check, conn.filter | Your code reads conn.signal |
permdock/hono | connection(c, options) before upgradeWebSocket or streamSSE | conn.check in onMessage; sse(conn, stream, source, { items }) drops items the subscriber cannot read | socket(conn, events) closes the WebSocket with 1008 and the Problem Details type as the reason; sse writes an event: permdock frame with the Problem Details body and resolves, so await it as the last statement of the streamSSE callback |
permdock/elysia | connection(ws, options) in the .ws open handler, one connection per socket | conn.check in message | ws.close(1008, ...) |
permdock/trpc | protect(permission) on a subscription procedure opens the connection | Outbound items pass the filter when protect is given { items: permission }; tracked envelopes are unwrapped before the check | The iterator ends with FORBIDDEN or UNAUTHORIZED, with the Problem Details on cause |
permdock/orpc | The same as tRPC, for event iterators | The same | ORPCError FORBIDDEN or UNAUTHORIZED |
permdock/nest | connection(client, req, options) in handleConnection, one connection per client | PermDockGuard on a @SubscribeMessage handler checks through the client's connection | Emits permdock:error with the Problem Details, then client.disconnect(true), or close(1008, ...) for a raw socket |
Close code 1008 is the RFC 6455 policy-violation code. The close frame and the SSE permdock event carry the reason so a client can tell a revocation from a network drop and does not reconnect in a loop. The SSE frame reuses the existing Problem Details body; there is no new wire format.
Rejected designs
- Re-running
protectper message with the upgrade request. Correct for inbound checks, but it resolves the subject and memberships on every frame, and nothing closes the connection on revocation. - Closing on every
changedevent. Simpler, but it disconnects users whose change added access. - Revocation from
permdock/ssfonly. SSF sees identity-provider revocations, but the common demotion paths are app-side role edits and SCIM, so the feed is a shared channel. - A mutable instance that swaps grants in place. It would break request-scoped immutability and every consumer that captured the instance.
Testing
testHttpAdapter({ streams: true }) in permdock/testing runs stream-revoked, stream-demoted, stream-filter and stream-expired over real servers, and testRevocationFeed checks a feed implementation (extension interfaces). See the server kernel, SSF and SCIM pages for the adapter options.
Last updated on
Snapshots
A snapshot serialises roles, grants and portable conditions so clients evaluate permissions offline; closures stay server-only and invalidation is explicit.
Validation
PermDock validates resource data against its Standard Schema unless the caller marks it as a row the server loaded, synchronously, with a typed error.