feat: add authenticated realtime presence
This commit is contained in:
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user