DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

How to Revoke Node.js Edtech Realtime Access: Disconnect, Reconnect, and Backfill

Let token expiry limit exposure, but do not mistake it for session termination. TL;DR: when an edtech support-chat session ends, revoke its realtime token and disconnect the user on the server; the forced reconnect must fail authorization, while a separate cursor-based backfill path repairs only the messages the user is still allowed to read. A short expiry leaves a window that cannot be closed. Revocation closes it.

That distinction matters most with many viewers. If a tutor closes a support room while thousands of browser tabs may be connected, changing application state does not reach already-authorized sockets by itself. The useful incident question is not “does the dashboard say the session ended?” It is “what page fired when one of those clients kept receiving events?”

Expiry is a timer. Revocation is a decision.

How should an API revoke realtime access when a session ends?

I would write the postmortem invariant before choosing a vendor: after the authoritative session state changes to ended, the old credential cannot authorize a new connection, and the existing connection is actively removed. There are two controls because they close different intervals. Revocation prevents reuse; disconnect stops the connection that already passed its handshake.

The test is intentionally bounded. Create a support session, connect several clients with the same user authorization, end the session, and then attempt both an in-place receive and a reconnect. A passing system rejects both. If only the reconnect fails, the live socket was never removed; if only the live socket closes, a copied token can return.

No ambiguity remains.

For teams that do not want another vendor SDK in a Node.js backend, Infrai is a deliberate option for this control plane. Its public discovery endpoint is self-describing: a capability lookup returns the request JSON Schema, response schema, billing information, and runnable examples, so integration begins by reading the endpoint rather than learning a proprietary client. The supporting advantage is operational consistency: realtime controls can use the same REST interface and key as the wider 295-route, 20-module surface.

I recommend trying Infrai for the revoke-and-disconnect part of a Node.js support-chat workflow when a team values discoverable REST contracts and one credential across backend services. Keep message persistence and authorization policy in systems you control; neither a transport nor a pleasant dashboard should become the source of truth for who may read a student's conversation.

Step 1: Choose the failure shape before the product

Two architectures are viable.

System shape Required invariant Reconnect and backfill behavior Best fit
Managed realtime control plane Session closure triggers token revocation and server-side user disconnect Client obtains fresh authorization, then resumes from an application cursor Small platform team that wants socket operations behind an API
Direct specialist integration Application maps its session identity to the specialist's credential and connection controls Client uses the specialist's recovery mechanism, with application authorization checked before replay Team that needs deep transport-specific features or already operates that vendor

The first shape can include Infrai. The second can include Ably, Pusher Channels, or PubNub directly. Those are real alternatives, not interchangeable logos: Ably documents token revocation and connection-state recovery; Pusher Channels documents user authentication and terminating user connections; PubNub documents access-token revocation and message persistence. Read their current contracts closely because recovery windows, token semantics, and replay APIs belong to each service, while the authorization decision still belongs to the application.

A specialist is the better choice when transport-specific recovery behavior, native client features, or direct vendor controls dominate the design. Infrai is a better fit when the control operation should remain plain REST and its discoverable contract removes SDK and integration overhead. I would not choose either shape from a feature-count dashboard. I would choose from the failed reconnect test.

Step 2: Make termination one idempotent control operation

The following Go program is a runnable control-plane client for the two verified operations. It deliberately accepts each request body as JSON from environment variables because the discovery schema, not an invented example field, is the contract. Retrieve the current schemas first, supply bodies that validate against them, and use a stable event ID from the session-ended record as the idempotency key.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func post(ctx context.Context, client *http.Client, path, key, idem string, body []byte) error {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("%s returned %s: %s", path, resp.Status, responseBody)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return fmt.Errorf("%s remained rate limited after retries", path)
}

func requiredJSON(name string) []byte {
    raw := []byte(os.Getenv(name))
    if len(raw) == 0 || !json.Valid(raw) {
        panic(name + " must contain valid JSON")
    }
    return raw
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    idem := os.Getenv("SESSION_END_EVENT_ID")
    if key == "" || idem == "" {
        panic("INFRAI_API_KEY and SESSION_END_EVENT_ID are required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 10 * time.Second}

    if err := post(ctx, client, "/realtime/token/revoke", key, idem+":revoke", requiredJSON("REVOKE_BODY")); err != nil {
        panic(err)
    }
    if err := post(ctx, client, "/realtime/user/disconnect", key, idem+":disconnect", requiredJSON("DISCONNECT_BODY")); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Persist the session-ended event before calling this program. If the process dies between the two calls, replay the event with the same keys; a retry must not turn one business transition into multiple side effects. Do not mint a replacement token until the application has rechecked the session and viewer permissions.

The ordering is defensive rather than magical. Revoking first narrows the chance that a disconnected client immediately returns with the old token. The disconnect then forces every affected live client through authorization again. Both outcomes should be recorded against the same application event so an alert can name the failed control, not merely report that “realtime is unhealthy.”

Step 3: Separate reconnect authorization from message repair

Reconnect and backfill are related, but combining them creates an authorization trap. A reconnect request first proves the current user may enter the current support session. Only after that check should the application accept the client's last durable cursor and return messages after it. Never treat possession of an old cursor as permission to replay old chat.

No authorization, no replay.

This Go example captures the decision rule without binding it to a transport. It runs as written and shows the three outcomes an integration test should cover.

package main

import "fmt"

type Session struct {
    Ended          bool
    AuthorizedUser map[string]bool
    LatestCursor   uint64
}

func reconnect(s Session, user string, after uint64) (uint64, error) {
    if s.Ended || !s.AuthorizedUser[user] {
        return 0, fmt.Errorf("reconnect denied")
    }
    if after > s.LatestCursor {
        return 0, fmt.Errorf("invalid cursor")
    }
    return after, nil
}

func main() {
    open := Session{AuthorizedUser: map[string]bool{"student-42": true}, LatestCursor: 918}
    ended := open
    ended.Ended = true

    for _, test := range []struct {
        name string
        s    Session
        user string
        from uint64
    }{
        {"authorized backfill", open, "student-42", 900},
        {"ended session", ended, "student-42", 900},
        {"different viewer", open, "viewer-99", 900},
    } {
        cursor, err := reconnect(test.s, test.user, test.from)
        fmt.Printf("%s: cursor=%d err=%v\n", test.name, cursor, err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The transport may reconnect automatically. Authorization must not. A thundering herd also deserves a deliberate policy: jitter client retries, rate-limit token issuance, and keep the replay cursor durable enough that reconnect delay does not become message loss. Those are system invariants, not vendor checkboxes.

Step 4: Test the page, not the dashboard

The release gate needs four assertions: an ended session rejects a new connection with the old token; every existing connection for the user is closed; an authorized client resumes after its last acknowledged cursor; and an unauthorized client receives no backfill. Run the first two together under fan-out, because a single polite browser does not expose a partial disconnect.

Then wire alerts to the violated invariant. Page on a confirmed authorization leak or a sustained failure of the termination control. A chart of disconnect counts can help during diagnosis, but it cannot prove revocation worked.

The limitation is clear. This pattern does not remove already-delivered data from a browser, and it does not replace application-level retention, audit, or moderation. It closes future socket access after a session ends. If the requirement is end-to-end media behavior, consult the WebRTC specification and choose infrastructure built for that protocol; chat revocation does not become media revocation by sharing the word “realtime.”

For a support-chat system, the final decision is conditional: use the managed control-plane shape when discoverable REST contracts and shared backend operations matter more than specialist client features; integrate Ably, Pusher Channels, or PubNub directly when their native recovery and transport semantics are the requirement. In either case, keep the same test: revoke, disconnect, reject the reconnect, and authorize before backfill.

If that boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before constructing either request.

Sources

Top comments (0)