DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Video Session Recording Storage Behind Expiring Links for Property Managers

Use two independent capabilities: a short-lived room token for the live call, then a freshly authorized, expiring object URL for the finished recording. Persist an append-only session timeline between them. That timeline lets a property manager reconnect, backfill missed state, and still reach the right recording without turning one bearer URL into permanent access.

Choice Reconnect and backfill Recording access Operational load
Durable session timeline plus private object storage Resume from a monotonic cursor Authorize each request, then issue a short-lived URL Moderate
Process memory plus private object storage No reliable cross-process replay Can issue short-lived URLs Low until restart
One long-lived public URL Unrelated to recovery Link possession becomes durable access Low, with a poor security boundary

Recommendation: keep recordings private, treat the application as the authorization boundary, and store enough ordered events to resume from a client cursor. For a one-person SaaS, this is the smallest design I would ship because one durable log answers the costly support question: what did this browser miss?

Short answer: the Node.js endpoint should verify the signed-in user and property scope, look up the recording by an opaque ID, and return a storage-layer URL with a brief expiry. It should never accept an object key from the browser. The live client should reconnect with its last applied event sequence and backfill before consuming new events.

How should Node.js store a video session recording behind an expiring link?

A WebRTC peer connection carries media, but it is not the system of record for the business workflow. The browser can disappear while the recorder changes from starting to ready, a participant is removed, or a manager adds a note. WebRTC defines connection and ICE state; those state machines do not reconstruct application events that the browser missed.

Property management makes the authorization boundary concrete. A leasing agent may enter a room for property prop_4821; a maintenance contractor may join the same inspection call; only staff with recording permission should later play the archive. Room membership at 10:04 does not imply archive access at 16:30. Store those grants separately.

Give each application event an increasing sequence number within the session. A reconnecting browser sends its last applied sequence, such as 41. The server reads events after 41, applies a bounded page, and then switches the client to the live feed. If events can arrive during that handoff, subscribe first, record the high-water mark, backfill through that mark, and finally drain buffered live events. Otherwise there is a narrow gap where event 42 can vanish.

Do not use timestamps as cursors. Two writes can share a clock value, clocks can move, and pagination around equal values becomes ambiguous. A database-generated integer or another strictly ordered session-local cursor is easier to reason about.

Order matters.

The two boundaries that earn their keep

The first boundary is identity scope. Mint a room token only after checking the authenticated actor, property, session, role, and expiration. Its claims should be narrow enough that theft does not grant entry to another building or call. JWT defines an exp claim, but a token format does not decide policy. Keep token lifetimes aligned with joining the room, and reauthorize when the client asks for a replacement.

The second boundary is recording retrieval. Persist an opaque recording ID, its property and session ownership, storage key, media type, readiness state, and retention deadline. The browser sees the opaque ID. The server owns the storage key.

This split costs an extra lookup. I will take that trade every time: the lookup is undifferentiated work, while leaking a reusable object address across tenants creates a much larger cleanup job. It also keeps deletion and retention policy enforceable after somebody bookmarks an old application route.

The trade-off is explicit. This design is not a fit when authorization must be reevaluated for every byte already in flight, when a regulator requires a complete log of each range request, or when clients cannot receive storage credentials in a URL under any circumstances. In those cases, put an authenticated media proxy in the path and accept its capacity planning, cache behavior, range semantics, and incident surface. At the other end, a disposable internal demo may not need a durable event log at all; reconstructing current state after refresh can be enough. The middle design is useful because ordinary leasing and inspection calls need tenant isolation and predictable recovery, but a solo operator should not volunteer to run a video data plane without a policy reason.

Keep the URL lifetime short enough to limit forwarding, but do not pretend expiry revokes bytes already downloaded. If immediate revocation during playback is required, proxy or segment delivery through an authorization check instead of handing out a direct object URL. That adds bandwidth, latency, and on-call surface. Most small property workflows should first establish whether that stronger control is required.

A small TypeScript implementation

The useful abstraction is not tied to a storage vendor. The object store signs a read operation; the repository enforces ownership; the route composes both after authentication. This example also refuses recordings that are not ready or have passed retention.

type Actor = { userId: string; organizationId: string };

type Recording = {
  id: string;
  organizationId: string;
  propertyId: string;
  objectKey: string;
  state: "processing" | "ready" | "deleted";
  retainUntil: Date;
};

interface RecordingRepository {
  findById(id: string): Promise<Recording | null>;
  canView(userId: string, propertyId: string): Promise<boolean>;
}

interface PrivateObjectStore {
  signRead(input: {
    key: string;
    expiresInSeconds: number;
    responseContentType: string;
  }): Promise<string>;
}

export function recordingRoutes(
  app: import("express").Express,
  recordings: RecordingRepository,
  objects: PrivateObjectStore,
) {
  app.post("/recordings/:recordingId/playback", async (req, res) => {
    const actor = res.locals.actor as Actor | undefined;
    if (!actor) return res.sendStatus(401);

    const recording = await recordings.findById(req.params.recordingId);
    if (!recording || recording.organizationId !== actor.organizationId) {
      return res.sendStatus(404);
    }

    const allowed = await recordings.canView(actor.userId, recording.propertyId);
    if (!allowed) return res.sendStatus(404);
    if (recording.state !== "ready") return res.sendStatus(409);
    if (recording.retainUntil.getTime() <= Date.now()) return res.sendStatus(410);

    const url = await objects.signRead({
      key: recording.objectKey,
      expiresInSeconds: 300,
      responseContentType: "video/mp4",
    });

    res.set("Cache-Control", "no-store");
    return res.status(200).json({ url, expiresInSeconds: 300 });
  });
}
Enter fullscreen mode Exit fullscreen mode

Return 404 for both a missing recording and a cross-tenant denial so the route does not confirm that another organization owns the ID. Use 409 while processing because the resource exists but cannot yet supply playback. The 410 response makes retention expiry explicit. Storage errors should be mapped separately and logged with an internal correlation ID, never with the signed URL.

The five-minute value is an implementation choice, not a universal optimum. Make it configuration, test expiry with a fake clock, and issue a new URL only after repeating authorization. Do not refresh merely because a caller presents the old URL.

Five minutes is not magic.

For reconnect, keep the contract equally small:

type SessionEvent = {
  sessionId: string;
  sequence: number;
  type: "participant.joined" | "participant.left" | "recording.ready";
  occurredAt: string;
  data: Record<string, unknown>;
};

interface SessionEventStore {
  after(sessionId: string, sequence: number, limit: number): Promise<SessionEvent[]>;
}

async function backfill(
  store: SessionEventStore,
  sessionId: string,
  afterSequence: number,
): Promise<SessionEvent[]> {
  if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
    throw new RangeError("Invalid event cursor");
  }
  return store.after(sessionId, afterSequence, 200);
}
Enter fullscreen mode Exit fullscreen mode

Apply events idempotently by (sessionId, sequence). A retry may deliver the same row twice. That is fine. Missing one is not.

This is the trap: a green reconnect indicator proves transport recovery, not state recovery.

Testing the handoff, not just the happy path

Unit tests around token parsing are necessary but weak evidence for recovery. The valuable test pauses a client at sequence 41, writes 42 through 47, reconnects it, and asserts that each sequence is applied once in order. Repeat while writing 48 during the backfill-to-live handoff. Then restart the application process and run the same case against the durable store.

Recording tests need two organizations and two properties. An authenticated user from organization A must not distinguish organization B's recording ID from a nonexistent ID. A permitted user should receive a URL that works before expiry and fails after it. After permission removal, requesting a new URL must fail even if a previously downloaded file cannot be recalled.

Watch four signals in production: reconnect attempts, backfill event count, time from call end to recording.ready, and playback authorization denials. Tag metrics with bounded values such as outcome and client version, not user IDs, URLs, or session IDs. Signed query strings are credentials; access logs and error trackers should redact them.

Ship weekly, but stage this path in slices. First make the event cursor durable. Then make recording metadata and retention explicit. Finally add direct signed delivery. Each slice has a failure mode you can observe without requiring a second engineer to untangle three new systems at once.

When is the simpler runner-up enough?

Process memory can be reasonable for a prototype where every participant can refresh the entire current state, no audit trail is promised, and a restart losing transient events has no business consequence. It is also useful in local development. The moment support needs to answer why one leasing agent saw processing while another saw ready, memory has stopped being the cheaper option.

Proxying every recording byte is the opposite runner-up: more machinery, but stronger control during delivery. Choose it when policy demands authorization throughout playback, when every range request must be audited, or when storage URLs cannot be exposed to clients. Budget for byte-range handling, cache rules, throughput, and partial-request testing.

There is no free branch here.

The decision rule stays plain. Use durable cursors when missed state matters. Keep archives private and authorize every new playback grant. Outsource blob durability and URL signing behind a narrow interface; keep tenant policy and event ordering in the application, because those are the parts that define the product.

References

Top comments (0)