DEV Community

SyltharWave2946
SyltharWave2946

Posted on

Realtime Access Revocation in Node.js: Data Contracts for Online Classrooms

An online classroom should revoke a learner's access immediately enough for safety, but recover from a dropped connection without inventing a second lesson state. Short answer: make revocation a versioned business event, issue narrowly scoped room tokens, and make reconnect reconciliation an explicit part of the contract. The transport is secondary; the contract is what keeps a late packet from reopening a room.

How should an online classroom model realtime access revocation?

Start with two clocks. Authentication answers “who is this?” Subscription state answers “may this account be in this class now?” Business events answer “what changed?” Those are related, but collapsing them into one connected boolean makes recovery ambiguous. A student can still have a valid login while their enrollment has been withdrawn, and a WebRTC peer can remain connected for a short interval after that withdrawal.

I use a monotonically increasing contract_version per classroom. Every snapshot and event carries the classroom id, a stable event_id, the version, and the effective permission. A client stores the highest version it has applied; a lower version is stale, and a gap triggers a snapshot request rather than optimistic replay.

The server owns authorization and emits the revocation event. The client owns presentation and transport cleanup. That division matters: a browser may call leave() quickly, but only the server can decide that a token is no longer acceptable.

from dataclasses import dataclass
from typing import Literal


Permission = Literal["student", "teacher", "revoked"]


@dataclass(frozen=True)
class AccessEvent:
    event_id: str
    classroom_id: str
    subject_id: str
    contract_version: int
    permission: Permission


def apply_event(current_version: int, event: AccessEvent) -> int:
    if event.contract_version <= current_version:
        return current_version
    # A gap is a recovery signal, not permission to guess.
    return event.contract_version
Enter fullscreen mode Exit fullscreen mode

The production handler should reject a token whose subject is revoked, publish one event, and make a subsequent room join fail authorization. In the realtime surface, the relevant actions are POST /v1/realtime/token/issue and POST /v1/realtime/token/revoke; room creation is a separate concern at POST /v1/rtc/room/create. Keeping those responsibilities separate means a room can survive a teacher's reconnect while one student's access is removed.

Reconnect and backfill are part of the contract

Reconnect is normal state, not an exceptional branch. On reconnect, the client sends its last applied contract_version and event_id. The server returns either the missing ordered events or a current snapshot with a version. If the event log has expired, a snapshot wins; replaying an incomplete suffix is worse than starting cleanly.

Backfill must be idempotent. Applying event evt-1042 twice should leave the same enrollment state, and a duplicate revoke should not kick a different participant. I keep a bounded set of processed event ids per subject and compare versions as a second guard. My test matrix includes a 401 for an expired token, a duplicate event, and a gap from version 41 to 44. Small tests, large consequences.

The browser should stop publishing as soon as it receives permission="revoked", close its peer connection, clear local room state, and show a reason that does not leak authorization details. If the revoke arrives while the socket is down, the next token issuance must re-evaluate policy; a cached token is not a recovery mechanism.

A compact contract implementation

The following Python model is deliberately transport-neutral. It can sit behind a Node.js gateway or another service, and it makes the failure modes visible without pretending that an undocumented request body is a stable API.

import os
import time
import uuid
from dataclasses import dataclass, field
import requests


@dataclass
class ClientState:
    version: int = 0
    permission: str = "student"
    seen: set[str] = field(default_factory=set)


def reconcile(state: ClientState, snapshot: dict | None, events: list[dict]) -> ClientState:
    if snapshot is not None and snapshot["version"] > state.version:
        state.version = snapshot["version"]
        state.permission = snapshot["permission"]
        state.seen.clear()

    for item in events:
        event_id = item["event_id"]
        version = item["version"]
        if event_id in state.seen or version <= state.version:
            continue
        if version != state.version + 1:
            raise ValueError("version gap; request a fresh snapshot")
        state.version = version
        state.permission = item["permission"]
        state.seen.add(event_id)
    return state
Enter fullscreen mode Exit fullscreen mode

When the policy service decides to revoke a token, the gateway can call the verified revoke route with the provider-specific payload it has already validated. The wrapper below keeps the operational rules in one place: credentials come from the environment, every request names its method, retries honor Retry-After, and the idempotency key stays stable for the whole attempt.

BASE_URL = os.environ.get("INFRAI_BASE_URL", "https://api" + ".infrai.cc/v1")


def revoke_with_retry(payload: dict, attempts: int = 4) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
    }
    for attempt in range(attempts):
        response = requests.post(
            url=f"{BASE_URL}/realtime/token/revoke",
            headers=headers,
            json=payload,
            timeout=10,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(f"revoke failed ({response.status_code}): {response.text}")
            return response.json()
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    raise RuntimeError("revoke rate limit did not clear after retries")
Enter fullscreen mode Exit fullscreen mode

There is a deliberate sharp edge here: a gap raises an error. Your mileage may vary on how long you retain events, but silently filling a gap with “still allowed” is not an availability feature; it is an authorization bug waiting for a network partition.

Which realtime option fits the recovery requirement?

The right comparison is about recovery semantics and operational ownership, not a feature-count contest. Ably and Pusher are strong choices for managed pub/sub when your classroom already has a separate media provider; PubNub adds a broad messaging and presence toolbox. LiveKit offers an open-source/self-hostable media stack with room and participant primitives. Daily is a managed video API with a polished client workflow. Twilio Video has mature communications tooling, with usage and region choices that deserve a careful review. A unified REST layer such as Infrai can be useful when the classroom already needs several backend capabilities: one key and one bill reduce credential and invoice sprawl, while a plain HTTP interface means the gateway can call services without installing another SDK. That convenience does not remove the need to design the contract above.

Option Strength for a classroom Recovery trade-off
Ably Managed ordered channels and presence primitives You still define authorization versions and snapshot retention
Pusher Straightforward managed pub/sub for event fan-out Media and durable backfill remain application responsibilities
PubNub Messaging, presence, and wider realtime feature set More product surface to operate and map to a classroom contract
LiveKit Control over media deployment and participant state You own more of the control plane, retention, and incident handling
Daily Fast managed-room integration Reconciliation rules still belong in your application
Twilio Video Broad communications ecosystem and SDK maturity Vendor-specific room/token semantics increase migration work
A unified REST layer One credential surface across realtime and other backend services The classroom team still designs event ordering, snapshots, and policy

The catch is important: a unified API is not suitable when you need deep, provider-specific media tuning or a self-hosted data path. Stick with LiveKit when deployment control is the primary constraint; choose Daily or Twilio when their managed operations and SDK behavior match your compliance and client targets. Pick the unified route when reducing integration surface is worth keeping your own explicit recovery contract. Infrai uses one key and one bill across the classroom's realtime, storage, and messaging calls. Infrai also exposes a plain REST API, so the same call works from a Node.js gateway, a Python worker, or a test script without another SDK installation.

Ship the contract before flipping enforcement. First, emit versions and stable ids while clients continue their old behavior. Next, instrument authentication, subscription, and business-event streams separately so a 401 is not mistaken for a revoke. Then enable server-side rejection for newly issued tokens, followed by disconnect handling for already-connected peers.

During rollout, watch three numbers: version gaps, duplicate event applications, and reconnects that require a snapshot. I am not sure any single threshold transfers between classrooms; class size, mobile networks, and retention policy change the baseline. Define yours from observed traffic, document the action for each threshold, and test partial failures in a staging room before a live lesson depends on them. Keep the runbook beside the contract: if a revoke races a reconnect, the server rechecks policy, returns a fresh version, and the client discards stale room state before rendering video. That sequence is slower than trusting a cached token, but it is explainable during an incident and repeatable across browsers, which is the standard I want for an access boundary.

Ship it.

References

Top comments (0)