1
0

feat: add authenticated realtime presence

This commit is contained in:
2026-08-11 20:22:12 -07:00
parent 16dc85a6f8
commit 92ebf8abb5
20 changed files with 3746 additions and 1 deletions
+186
View File
@@ -0,0 +1,186 @@
/** Authenticated HTTP + SSE adapter for the in-memory realtime service. */
import type { FastifyInstance, FastifyReply } from "fastify";
import type { InterestCell } from "../../../src/realtime/types.ts";
import type { ResumeRequest } from "../../../src/realtime/types.ts";
import { validateClientRealtimeMessage } from "../../../src/realtime/protocol.ts";
import type { RealtimeFailureCode } from "../realtime/index.ts";
import type { Services } from "../services.ts";
const BODY_LIMIT = 32 * 1024;
const UNAUTHORIZED = { error: "unauthorized", message: "Realtime requires a signed-in member." };
/** Public, statically bundled demos that exist even when TERA_OFFICES_DIR is empty. */
export const BUNDLED_REALTIME_OFFICE_IDS: ReadonlySet<string> = new Set([
"lumbridge-hq",
"frontier-valley",
"mateo-court",
]);
interface CredentialBody {
sessionId?: unknown;
token?: unknown;
}
interface PoseBody extends CredentialBody {
snapshot?: unknown;
}
function text(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= 512;
}
function failureStatus(code: RealtimeFailureCode): number {
switch (code) {
case "unauthorized":
case "expired": return 401;
case "capacity":
case "rate-limited": return 429;
case "conflict":
case "sequence":
case "interest":
case "ownership":
case "motion": return 409;
case "invalid": return 400;
}
}
function sendFailure(reply: FastifyReply, result: { code: RealtimeFailureCode; message: string }) {
const status = failureStatus(result.code);
if (status === 401) reply.header("www-authenticate", "Bearer");
return reply.code(status).send({ error: result.code, message: result.message });
}
async function interestsExist(interests: readonly InterestCell[], services: Services): Promise<boolean> {
for (const cell of interests) {
if (cell.kind === "california-tile" || cell.kind === "city") continue;
const doc = await services.offices.get(cell.officeId);
if (doc === null) {
// Only the office envelope is public in a client bundle. Without a
// server pack we have no authoritative floor or room catalogue, so those
// finer cells remain unavailable rather than guessed.
if (cell.kind === "office" && BUNDLED_REALTIME_OFFICE_IDS.has(cell.officeId)) continue;
return false;
}
if (cell.kind === "office") continue;
const level = doc.floor.levels.find((candidate) => candidate.id === cell.floorId);
if (!level) return false;
if (
cell.kind === "room" &&
!level.floorplan.rooms.some((room) => room.id === cell.roomId)
) return false;
}
return true;
}
export function registerRealtime(app: FastifyInstance, services: Services): void {
app.post<{ Body: unknown }>(
"/api/v1/realtime/join",
{ bodyLimit: BODY_LIMIT },
async (req, reply) => {
// Authentication precedes interest parsing so anonymous callers cannot
// use office cell errors to learn private identifiers.
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const parsed = validateClientRealtimeMessage(req.body);
if (!parsed.ok) {
return reply.code(400).send({ error: "invalid", message: "Realtime join request is invalid." });
}
const request = parsed.value;
const interests = request.interests;
if (!(await interestsExist(interests, services))) {
return reply.code(400).send({ error: "invalid", message: "Realtime interest is unavailable." });
}
if (request.type === "resume-request") {
const resumed = services.realtime.resume(request, viewer.subject);
if (!resumed.ok) return sendFailure(reply, resumed);
return reply.send(resumed.value);
}
const joined = services.realtime.join({
requestId: request.requestId,
actorId: request.actorId,
subject: viewer.subject,
role: viewer.admin ? "admin" : "member",
interests,
});
if (!joined.ok) return sendFailure(reply, joined);
return reply.code(201).send(joined.value.grant);
},
);
// POST is deliberate: usable session tokens must never enter URLs, reverse-
// proxy logs, referrers, or browser history. Clients consume this SSE body
// through fetch streaming rather than the URL-only EventSource constructor.
app.post<{ Body: unknown }>(
"/api/v1/realtime/events",
{ bodyLimit: 4 * 1024 },
async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated || viewer.subject === null) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const parsed = validateClientRealtimeMessage(req.body);
if (!parsed.ok || parsed.value.type !== "resume-request") {
return reply.code(400).send({ error: "invalid", message: "Realtime resume request is invalid." });
}
const request: ResumeRequest = parsed.value;
if (!(await interestsExist(request.interests, services))) {
return reply.code(400).send({ error: "invalid", message: "Realtime interest is unavailable." });
}
const connection = services.realtime.connectResume(request, viewer.subject, (message) => {
if (!reply.raw.destroyed) reply.raw.write(`event: ${message.type}\ndata: ${JSON.stringify(message)}\n\n`);
});
if (!connection.ok) return sendFailure(reply, connection);
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader("content-type", "text/event-stream; charset=utf-8");
reply.raw.setHeader("cache-control", "private, no-store");
reply.raw.setHeader("connection", "keep-alive");
reply.raw.setHeader("x-accel-buffering", "no");
reply.raw.flushHeaders();
reply.raw.write(`event: resume-grant\ndata: ${JSON.stringify(connection.value.grant)}\n\n`);
const keepalive = setInterval(() => {
if (!reply.raw.destroyed) reply.raw.write(`: keepalive ${Date.now()}\n\n`);
}, 15_000);
keepalive.unref();
// The POST request body finishes immediately; listening on IncomingMessage
// `close` would therefore tear the stream down as soon as credentials
// were parsed. ServerResponse stays open for the lifetime of the SSE feed.
reply.raw.once("close", () => {
clearInterval(keepalive);
connection.value.disconnect();
});
},
);
app.post<{ Body: PoseBody }>(
"/api/v1/realtime/pose",
{ bodyLimit: BODY_LIMIT },
async (req, reply) => {
const body = req.body;
if (!body || !text(body.sessionId) || !text(body.token)) {
return reply.code(400).send({ error: "invalid", message: "Realtime credentials are required." });
}
const submitted = services.realtime.submitPose(body.sessionId, body.token, body.snapshot);
if (!submitted.ok) return sendFailure(reply, submitted);
return reply.code(202).send(submitted.value);
},
);
app.post<{ Body: CredentialBody }>(
"/api/v1/realtime/leave",
{ bodyLimit: 4 * 1024 },
async (req, reply) => {
const body = req.body;
if (!body || !text(body.sessionId) || !text(body.token)) {
return reply.code(400).send({ error: "invalid", message: "Realtime credentials are required." });
}
const left = services.realtime.leave(body.sessionId, body.token);
if (!left.ok) return sendFailure(reply, left);
return reply.code(204).send();
},
);
}