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
+27
View File
@@ -31,6 +31,10 @@ without breaking the typecheck, which is the wrong order to find out.
| `GET /api/v1/markers` | — | `MarkersBody` | **private; 401 unless signed in** — public empty body when `TERA_MARKERS_SOURCE=none` |
| `GET /api/v1/offices/:id` | — | `OfficeDoc` | public offices only |
| `GET /api/v1/offices/:id/presence` | — | `PresenceBody` | **private; 401 unless signed in** — never publicly cached |
| `POST /api/v1/realtime/join` | — | strict `JoinRequest` or `ResumeRequest` | **private; never cached** |
| `POST /api/v1/realtime/events` | — | fetch-streamed SSE, opened with a strict `ResumeRequest` | **private; never cached** |
| `POST /api/v1/realtime/pose` | — | one owned `EntityPoseSnapshot` | **private; never cached** |
| `POST /api/v1/realtime/leave` | — | session id + opaque resume token | **private; never cached** |
Every body is declared once, in `src/server/wire.ts` in the **root** package —
type-only, so it compiles to nothing and both the browser build and this service
@@ -48,6 +52,29 @@ reply before any route runs, and a route opts in explicitly. A request that
arrived with an `Authorization` header or a cookie never gets a public policy,
whatever the route asked for.
### Realtime sessions
Hosted presence is an optional, in-memory adapter. It stores no pose history on
disk and disappears cleanly on restart; the static city and office continue to
work when it is absent. Joining requires the deployment's existing auth, and a
session owns exactly one opaque actor plus a bounded list of interest cells.
The server validates entity ownership, monotonic sequence/time, cell membership,
speed, climb and turn rate before broadcasting a pose only to the same cell.
The event stream is deliberately a **POST consumed with `fetch()`**, not an
`EventSource` URL. Session/resume credentials stay in the request body and
therefore out of browser history, referrers, Caddy access logs and copied URLs.
Tokens are random, stored only as SHA-256 hashes, rotated on resume, and bounded
by absolute TTL and disconnect grace. Reconnect, room capacity and pose rate
are capped. Authentication subjects remain server-only; peers see page-scoped
opaque entity ids and no profile name, face texture, email, or media locator.
California, city, office, floor and room cells are distinct coordinate and
authorization boundaries. Disk-backed office packs are checked before a floor
or room may be joined. The three public demo offices bundled into this repo may
use their office envelope without a duplicate server pack; that exception does
not invent floor/room access and does not apply to arbitrary tenant ids.
## Regions
**A caller's coordinate is never forwarded upstream. It only selects among the
+3 -1
View File
@@ -20,6 +20,7 @@ import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerPresence } from "./routes/presence.ts";
import { registerRealtime } from "./routes/realtime.ts";
import { registerSatellites } from "./routes/satellites.ts";
import { registerSession } from "./routes/session.ts";
import { registerWeather } from "./routes/weather.ts";
@@ -51,6 +52,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerMarkers(app, services);
registerOffices(app, services);
registerPresence(app, services);
registerRealtime(app, services);
registerSession(app, services);
app.setNotFoundHandler(async (_req, reply) => {
@@ -86,7 +88,7 @@ function registerCors(app: FastifyInstance, allowed: string[]): void {
reply.header("access-control-allow-origin", origin);
reply.header("access-control-allow-headers", "authorization, content-type");
reply.header("access-control-allow-methods", "GET, OPTIONS");
reply.header("access-control-allow-methods", "GET, POST, OPTIONS");
// Whatever the response ends up being, it depended on the Origin header.
reply.header("vary", "Origin");
+13
View File
@@ -0,0 +1,13 @@
export {
createRealtimeService,
type RealtimeConnection,
type RealtimeFailureCode,
type RealtimeJoinInput,
type RealtimeJoinResult,
type RealtimeListener,
type RealtimeResult,
type RealtimeResumeConnection,
type RealtimeService,
type RealtimeServiceOptions,
type RealtimeSessionView,
} from "./service.ts";
+664
View File
@@ -0,0 +1,664 @@
/**
* Dependency-free, in-memory authoritative realtime sessions.
*
* The service owns credentials, interest filtering, sequence allocation and
* movement validation. HTTP/SSE routes are adapters only. No poses survive a
* process restart and usable bearer tokens are never retained in memory.
*/
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
isEntityPoseSnapshot,
isInterestCell,
validateMotion,
} from "../../../src/realtime/protocol.ts";
import type {
EntityPoseSnapshot,
InterestCell,
JoinGrant,
MotionLimits,
PoseDelta,
RealtimeMemberRole,
ResumeGrant,
ResumeRequest,
ServerRealtimeMessage,
} from "../../../src/realtime/types.ts";
const UINT32_MAX = 4_294_967_295;
export interface RealtimeServiceOptions {
now?: () => number;
sessionTtlMs?: number;
disconnectGraceMs?: number;
maximumSessions?: number;
maximumSessionsPerCell?: number;
maximumInterestsPerSession?: number;
maximumPoseUpdatesPerWindow?: number;
rateWindowMs?: number;
actorMotionLimits?: MotionLimits;
vehicleMotionLimits?: MotionLimits;
}
export interface RealtimeJoinInput {
requestId: string;
actorId: string;
subject: string;
role: RealtimeMemberRole;
interests: readonly InterestCell[];
}
export interface RealtimeJoinResult {
sessionId: string;
token: string;
grant: JoinGrant;
}
export type RealtimeFailureCode =
| "invalid"
| "unauthorized"
| "expired"
| "capacity"
| "conflict"
| "rate-limited"
| "sequence"
| "interest"
| "ownership"
| "motion";
export type RealtimeResult<T> =
| { ok: true; value: T }
| { ok: false; code: RealtimeFailureCode; message: string };
export type RealtimeListener = (message: ServerRealtimeMessage) => void;
interface Session {
id: string;
tokenHash: Buffer;
previousTokenHash: Buffer | null;
previousTokenExpiresAt: number;
subject: string;
actorId: string;
role: RealtimeMemberRole;
interests: readonly InterestCell[];
interestKeys: ReadonlySet<string>;
createdAt: number;
expiresAt: number;
disconnectedAt: number | null;
lastClientSequence: number;
rateWindowStartedAt: number;
rateCount: number;
proposedPoses: Map<string, EntityPoseSnapshot>;
poses: Map<string, EntityPoseSnapshot>;
listeners: Set<RealtimeListener>;
}
interface ResolvedOptions {
now: () => number;
sessionTtlMs: number;
disconnectGraceMs: number;
maximumSessions: number;
maximumSessionsPerCell: number;
maximumInterestsPerSession: number;
maximumPoseUpdatesPerWindow: number;
rateWindowMs: number;
actorMotionLimits: MotionLimits;
vehicleMotionLimits: MotionLimits;
}
export interface RealtimeService {
join(input: RealtimeJoinInput): RealtimeResult<RealtimeJoinResult>;
authenticate(sessionId: string, token: string): RealtimeResult<RealtimeSessionView>;
connect(sessionId: string, token: string, listener: RealtimeListener): RealtimeResult<RealtimeConnection>;
resume(request: ResumeRequest, subject: string): RealtimeResult<ResumeGrant>;
connectResume(
request: ResumeRequest,
subject: string,
listener: RealtimeListener,
): RealtimeResult<RealtimeResumeConnection>;
submitPose(
sessionId: string,
token: string,
snapshot: unknown,
): RealtimeResult<PoseDelta>;
leave(sessionId: string, token: string): RealtimeResult<null>;
revokeSubject(subject: string): number;
cleanup(): number;
sessionCount(): number;
}
export interface RealtimeSessionView {
sessionId: string;
actorId: string;
role: RealtimeMemberRole;
interests: readonly InterestCell[];
expiresAt: number;
snapshot: readonly EntityPoseSnapshot[];
}
export interface RealtimeConnection {
view: RealtimeSessionView;
disconnect(): void;
}
export interface RealtimeResumeConnection extends RealtimeConnection {
grant: ResumeGrant;
}
function finitePositive(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function finiteCount(value: number | undefined, fallback: number): number {
return Math.max(1, Math.floor(finitePositive(value, fallback)));
}
function resolveOptions(value: RealtimeServiceOptions): ResolvedOptions {
return {
now: value.now ?? Date.now,
sessionTtlMs: finitePositive(value.sessionTtlMs, 5 * 60_000),
disconnectGraceMs: finitePositive(value.disconnectGraceMs, 20_000),
maximumSessions: finiteCount(value.maximumSessions, 1_024),
maximumSessionsPerCell: finiteCount(value.maximumSessionsPerCell, 96),
maximumInterestsPerSession: finiteCount(value.maximumInterestsPerSession, 16),
maximumPoseUpdatesPerWindow: finiteCount(value.maximumPoseUpdatesPerWindow, 120),
rateWindowMs: finitePositive(value.rateWindowMs, 10_000),
actorMotionLimits: value.actorMotionLimits ?? {
maximumHorizontalSpeedMps: 24,
maximumVerticalSpeedMps: 18,
maximumTurnRateDegPerSec: 540,
positionSlackM: 1.5,
maximumDeltaSeconds: 2,
},
vehicleMotionLimits: value.vehicleMotionLimits ?? {
maximumHorizontalSpeedMps: 105,
maximumVerticalSpeedMps: 45,
maximumTurnRateDegPerSec: 240,
positionSlackM: 4,
maximumDeltaSeconds: 2,
},
};
}
function hashToken(token: string): Buffer {
return createHash("sha256").update(token).digest();
}
function tokenMatches(expected: Buffer, token: string): boolean {
if (typeof token !== "string" || token.length < 16 || token.length > 512) return false;
const found = hashToken(token);
return found.length === expected.length && timingSafeEqual(found, expected);
}
function interestKey(cell: InterestCell): string {
switch (cell.kind) {
case "california-tile": return `tile:${cell.level}:${cell.x}:${cell.y}`;
case "city": return `city:${cell.cityId}`;
case "office": return `office:${cell.officeId}`;
case "floor": return `floor:${cell.officeId}:${cell.floorId}`;
case "room": return `room:${cell.officeId}:${cell.floorId}:${cell.roomId}`;
}
}
function cloneCell(cell: InterestCell): InterestCell {
return { ...cell };
}
function clonePose(pose: EntityPoseSnapshot): EntityPoseSnapshot {
return JSON.parse(JSON.stringify(pose)) as EntityPoseSnapshot;
}
function entityKey(pose: EntityPoseSnapshot): string {
return pose.entity === "actor" ? `actor:${pose.actorId}` : `vehicle:${pose.vehicleId}`;
}
function poseInterestKey(pose: EntityPoseSnapshot): string | null {
if (pose.pose.space === "local") return interestKey(pose.pose.cell);
// Geographic coordinates are interpreted through the session's authored
// outdoor interest: either one exact California tile or one exact city.
return null;
}
function validIdentity(value: string): boolean {
return typeof value === "string" && value.length > 0 && value.length <= 256;
}
export function createRealtimeService(options: RealtimeServiceOptions = {}): RealtimeService {
const settings = resolveOptions(options);
const sessions = new Map<string, Session>();
const epoch = randomBytes(16).toString("base64url");
let serverSequence = 0;
function nextSequence(): number {
serverSequence = serverSequence >= UINT32_MAX ? 0 : serverSequence + 1;
return serverSequence;
}
function geographicInterestKeys(session: Session): string[] {
return [...session.interestKeys].filter(
(key) => key.startsWith("tile:") || key.startsWith("city:"),
);
}
function poseBroadcastKeys(session: Session, pose: EntityPoseSnapshot): string[] {
const local = poseInterestKey(pose);
return local === null ? geographicInterestKeys(session) : [local];
}
function broadcast(delta: PoseDelta, keys: readonly string[], excluding?: Session): void {
if (keys.length === 0) return;
for (const peer of sessions.values()) {
if (peer === excluding || !keys.some((key) => peer.interestKeys.has(key))) continue;
for (const listener of peer.listeners) listener(delta);
}
}
function removalDelta(session: Session): PoseDelta | null {
const removedEntityIds = [...session.poses.keys()];
if (removedEntityIds.length === 0) return null;
return {
type: "pose-delta",
protocolVersion: 1,
serverEpoch: epoch,
sequence: nextSequence(),
timestampMs: settings.now(),
updates: [],
removedEntityIds,
};
}
function broadcastRemoval(session: Session, keys = [...session.interestKeys]): void {
const delta = removalDelta(session);
if (delta) broadcast(delta, keys, session);
}
function remove(session: Session, announce = true): void {
if (announce) broadcastRemoval(session);
sessions.delete(session.id);
session.listeners.clear();
session.proposedPoses.clear();
session.poses.clear();
session.tokenHash.fill(0);
session.previousTokenHash?.fill(0);
}
function cleanup(): number {
const now = settings.now();
let removed = 0;
for (const session of sessions.values()) {
const graceExpired =
session.disconnectedAt !== null && now - session.disconnectedAt > settings.disconnectGraceMs;
if (now >= session.expiresAt || graceExpired) {
remove(session);
removed += 1;
}
}
return removed;
}
function authenticate(
sessionId: string,
token: string,
allowPrevious = false,
): RealtimeResult<Session> {
cleanup();
if (!validIdentity(sessionId)) {
return { ok: false, code: "unauthorized", message: "Realtime session is invalid." };
}
const session = sessions.get(sessionId);
const matchesCurrent = session ? tokenMatches(session.tokenHash, token) : false;
const matchesPrevious = allowPrevious && session?.previousTokenHash !== null &&
settings.now() <= (session?.previousTokenExpiresAt ?? 0) &&
tokenMatches(session?.previousTokenHash ?? Buffer.alloc(0), token);
if (!session || (!matchesCurrent && !matchesPrevious)) {
return { ok: false, code: "unauthorized", message: "Realtime session is invalid." };
}
if (settings.now() >= session.expiresAt) {
remove(session);
return { ok: false, code: "expired", message: "Realtime session expired." };
}
return { ok: true, value: session };
}
function view(session: Session): RealtimeSessionView {
const snapshot: EntityPoseSnapshot[] = [];
for (const peer of sessions.values()) {
for (const pose of peer.poses.values()) {
const visible = poseBroadcastKeys(peer, pose).some((key) => session.interestKeys.has(key));
if (visible) snapshot.push(clonePose(pose));
}
}
return {
sessionId: session.id,
actorId: session.actorId,
role: session.role,
interests: session.interests.map(cloneCell),
expiresAt: session.expiresAt,
snapshot,
};
}
function join(input: RealtimeJoinInput): RealtimeResult<RealtimeJoinResult> {
cleanup();
if (
!validIdentity(input.requestId) || !validIdentity(input.actorId) ||
!validIdentity(input.subject) ||
(input.role !== "member" && input.role !== "admin") ||
!Array.isArray(input.interests) || input.interests.length === 0 ||
input.interests.length > settings.maximumInterestsPerSession ||
!input.interests.every(isInterestCell)
) return { ok: false, code: "invalid", message: "Realtime join request is invalid." };
if (sessions.size >= settings.maximumSessions) {
return { ok: false, code: "capacity", message: "Realtime service is at capacity." };
}
if ([...sessions.values()].some((session) => session.actorId === input.actorId)) {
return { ok: false, code: "conflict", message: "Realtime actor id is already active." };
}
const unique = new Map(input.interests.map((cell) => [interestKey(cell), cloneCell(cell)]));
for (const key of unique.keys()) {
let count = 0;
for (const session of sessions.values()) if (session.interestKeys.has(key)) count += 1;
if (count >= settings.maximumSessionsPerCell) {
return { ok: false, code: "capacity", message: "Realtime interest cell is at capacity." };
}
}
const now = settings.now();
const id = randomBytes(18).toString("base64url");
const token = randomBytes(32).toString("base64url");
const interests = [...unique.values()];
const session: Session = {
id,
tokenHash: hashToken(token),
previousTokenHash: null,
previousTokenExpiresAt: 0,
subject: input.subject,
actorId: input.actorId,
role: input.role,
interests,
interestKeys: new Set(unique.keys()),
createdAt: now,
expiresAt: now + settings.sessionTtlMs,
disconnectedAt: null,
lastClientSequence: -1,
rateWindowStartedAt: now,
rateCount: 0,
proposedPoses: new Map(),
poses: new Map(),
listeners: new Set(),
};
sessions.set(id, session);
const grant: JoinGrant = {
type: "join-grant",
protocolVersion: 1,
requestId: input.requestId,
sessionId: id,
actorId: input.actorId,
role: input.role,
serverEpoch: epoch,
serverTimeMs: now,
nextSequence: serverSequence >= UINT32_MAX ? 0 : serverSequence + 1,
resumeToken: token,
interests: interests.map(cloneCell),
initial: view(session).snapshot,
};
return { ok: true, value: { sessionId: id, token, grant } };
}
function connect(
sessionId: string,
token: string,
listener: RealtimeListener,
): RealtimeResult<RealtimeConnection> {
if (typeof listener !== "function") {
return { ok: false, code: "invalid", message: "Realtime listener is invalid." };
}
const found = authenticate(sessionId, token);
if (!found.ok) return found;
const session = found.value;
session.disconnectedAt = null;
session.listeners.add(listener);
let connected = true;
return {
ok: true,
value: {
view: view(session),
disconnect() {
if (!connected) return;
connected = false;
session.listeners.delete(listener);
if (session.listeners.size === 0) session.disconnectedAt = settings.now();
},
},
};
}
function updateInterests(
session: Session,
requested: readonly InterestCell[],
): RealtimeResult<readonly InterestCell[]> {
if (
!Array.isArray(requested) || requested.length === 0 ||
requested.length > settings.maximumInterestsPerSession || !requested.every(isInterestCell)
) return { ok: false, code: "invalid", message: "Realtime interests are invalid." };
const unique = new Map(requested.map((cell) => [interestKey(cell), cloneCell(cell)]));
for (const key of unique.keys()) {
let count = 0;
for (const peer of sessions.values()) {
if (peer.id !== session.id && peer.interestKeys.has(key)) count += 1;
}
if (count >= settings.maximumSessionsPerCell) {
return { ok: false, code: "capacity", message: "Realtime interest cell is at capacity." };
}
}
const changed = unique.size !== session.interestKeys.size ||
[...unique.keys()].some((key) => !session.interestKeys.has(key));
const previousKeys = [...session.interestKeys];
if (changed) broadcastRemoval(session, previousKeys);
session.interests = [...unique.values()];
session.interestKeys = new Set(unique.keys());
if (changed) {
// A pose from the old cell cannot become the first authoritative point in
// a new cell's speed check or leak through its next snapshot.
session.poses.clear();
session.proposedPoses.clear();
}
return { ok: true, value: session.interests };
}
function rotateToken(session: Session): string {
const token = randomBytes(32).toString("base64url");
session.previousTokenHash?.fill(0);
session.previousTokenHash = session.tokenHash;
session.previousTokenExpiresAt = settings.now() + 10_000;
session.tokenHash = hashToken(token);
return token;
}
function resume(
request: ResumeRequest,
subject: string,
): RealtimeResult<ResumeGrant> {
// The immediately previous token is accepted only for resume. This small
// overlap lets a client retry when the rotated grant was lost in transit,
// without letting an old token continue publishing or leaving a session.
const found = authenticate(request.sessionId, request.resumeToken, true);
if (!found.ok) return found;
const session = found.value;
if (session.subject !== subject) {
return { ok: false, code: "unauthorized", message: "Realtime session is invalid." };
}
if (request.serverEpoch !== epoch || request.lastReceivedSequence > serverSequence) {
return { ok: false, code: "sequence", message: "Realtime resume point is invalid." };
}
const updated = updateInterests(session, request.interests);
if (!updated.ok) return updated;
const continuous = request.lastReceivedSequence === serverSequence;
const token = rotateToken(session);
return {
ok: true,
value: {
type: "resume-grant",
protocolVersion: 1,
requestId: request.requestId,
sessionId: session.id,
serverEpoch: epoch,
serverTimeMs: settings.now(),
nextSequence: serverSequence >= UINT32_MAX ? 0 : serverSequence + 1,
resumeToken: token,
continuous,
snapshot: view(session).snapshot,
},
};
}
function connectResume(
request: ResumeRequest,
subject: string,
listener: RealtimeListener,
): RealtimeResult<RealtimeResumeConnection> {
if (typeof listener !== "function") {
return { ok: false, code: "invalid", message: "Realtime listener is invalid." };
}
const resumed = resume(request, subject);
if (!resumed.ok) return resumed;
const session = sessions.get(request.sessionId);
if (!session) return { ok: false, code: "expired", message: "Realtime session expired." };
session.disconnectedAt = null;
session.listeners.add(listener);
let connected = true;
return {
ok: true,
value: {
grant: resumed.value,
view: view(session),
disconnect() {
if (!connected) return;
connected = false;
session.listeners.delete(listener);
if (session.listeners.size === 0) session.disconnectedAt = settings.now();
},
},
};
}
function submitPose(
sessionId: string,
token: string,
input: unknown,
): RealtimeResult<PoseDelta> {
const found = authenticate(sessionId, token);
if (!found.ok) return found;
const session = found.value;
const now = settings.now();
if (now - session.rateWindowStartedAt >= settings.rateWindowMs) {
session.rateWindowStartedAt = now;
session.rateCount = 0;
}
session.rateCount += 1;
if (session.rateCount > settings.maximumPoseUpdatesPerWindow) {
return { ok: false, code: "rate-limited", message: "Realtime pose rate exceeded." };
}
if (!isEntityPoseSnapshot(input)) {
return { ok: false, code: "invalid", message: "Realtime pose is invalid." };
}
const snapshot = input;
if (snapshot.sequence <= session.lastClientSequence) {
return { ok: false, code: "sequence", message: "Realtime pose sequence is stale." };
}
if (snapshot.timestampMs > now + 5_000 || snapshot.timestampMs < now - 10_000) {
return { ok: false, code: "sequence", message: "Realtime pose timestamp is stale." };
}
if (
(snapshot.entity === "actor" && snapshot.actorId !== session.actorId) ||
(snapshot.entity === "vehicle" && snapshot.driverActorId !== session.actorId)
) return { ok: false, code: "ownership", message: "Realtime entity is not owned by this session." };
const localKey = poseInterestKey(snapshot);
if (localKey !== null && !session.interestKeys.has(localKey)) {
return { ok: false, code: "interest", message: "Realtime pose is outside the joined interest." };
}
if (localKey === null && geographicInterestKeys(session).length !== 1) {
return {
ok: false,
code: "interest",
message: "Geographic pose requires exactly one outdoor interest.",
};
}
const key = entityKey(snapshot);
const previous = session.proposedPoses.get(key);
if (previous) {
const motion = validateMotion(
previous,
snapshot,
snapshot.entity === "vehicle" ? settings.vehicleMotionLimits : settings.actorMotionLimits,
);
if (!motion.ok) {
return { ok: false, code: "motion", message: `Realtime motion rejected: ${motion.reason}.` };
}
}
session.lastClientSequence = snapshot.sequence;
const authoritative = clonePose({ ...snapshot, sequence: nextSequence(), timestampMs: now });
const removedEntityIds = [...session.poses.keys()].filter((existing) => existing !== key);
for (const removed of removedEntityIds) {
session.poses.delete(removed);
session.proposedPoses.delete(removed);
}
session.proposedPoses.set(key, clonePose(snapshot));
session.poses.set(key, authoritative);
const delta: PoseDelta = {
type: "pose-delta",
protocolVersion: 1,
serverEpoch: epoch,
sequence: authoritative.sequence,
timestampMs: now,
updates: [clonePose(authoritative)],
removedEntityIds,
};
broadcast(delta, poseBroadcastKeys(session, snapshot));
return { ok: true, value: delta };
}
function leave(sessionId: string, token: string): RealtimeResult<null> {
const found = authenticate(sessionId, token);
if (!found.ok) return found;
remove(found.value);
return { ok: true, value: null };
}
function revokeSubject(subject: string): number {
let count = 0;
for (const session of sessions.values()) {
if (session.subject !== subject) continue;
const message: ServerRealtimeMessage = {
type: "membership-revoked",
protocolVersion: 1,
serverEpoch: epoch,
sequence: nextSequence(),
timestampMs: settings.now(),
sessionId: session.id,
actorId: session.actorId,
reason: "membership-revoked",
reconnectAllowed: false,
};
for (const listener of session.listeners) listener(message);
broadcastRemoval(session);
remove(session, false);
count += 1;
}
return count;
}
return {
join,
authenticate(sessionId, token) {
const found = authenticate(sessionId, token);
return found.ok ? { ok: true, value: view(found.value) } : found;
},
connect,
resume,
connectResume,
submitPose,
leave,
revokeSubject,
cleanup,
sessionCount: () => sessions.size,
};
}
+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();
},
);
}
+3
View File
@@ -12,6 +12,7 @@ import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
import { createRealtimeService, type RealtimeService } from "./realtime/index.ts";
import { createSatellitesService, type SatellitesService } from "./satellites/index.ts";
import { createWeatherService, type WeatherService } from "./weather/index.ts";
import type { Config } from "./config.ts";
@@ -24,6 +25,7 @@ export interface Services {
markers: MarkerStore;
offices: OfficeStore;
presence: PresenceStore;
realtime: RealtimeService;
auth: AuthService;
/** Epoch milliseconds, for `uptimeSeconds` on the health body. */
startedAt: number;
@@ -43,6 +45,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
markers: createMarkerStore(config, log),
offices: createOfficeStore(config.offices.dir),
presence: createPresenceStore(config.presence.dir),
realtime: createRealtimeService(),
auth: createAuth(config.auth),
startedAt: Date.now(),
};
+663
View File
@@ -0,0 +1,663 @@
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { after, describe, it } from "node:test";
import type {
ActorPoseSnapshot,
FloorInterest,
InterestCell,
PoseDelta,
VehiclePoseSnapshot,
} from "../../../src/realtime/types.ts";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { createRealtimeService } from "../realtime/index.ts";
import { BUNDLED_REALTIME_OFFICE_IDS } from "../routes/realtime.ts";
const FLOOR: FloorInterest = {
kind: "floor",
officeId: "lumbridge-hq",
floorId: "level-1",
};
const OTHER_FLOOR: FloorInterest = {
kind: "floor",
officeId: "mateo-court",
floorId: "level-1",
};
function actorPose(
actorId: string,
sequence: number,
timestampMs: number,
xM: number,
cell: InterestCell = FLOOR,
): ActorPoseSnapshot {
if (cell.kind === "california-tile") throw new Error("fixture needs local interest");
return {
entity: "actor",
actorId,
kind: "humanoid",
sequence,
timestampMs,
pose: {
space: "local",
cell,
xM,
yM: 0,
zM: 0,
headingDeg: 0,
pitchDeg: 0,
},
velocity: { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 },
};
}
function geographicActor(
actorId: string,
sequence: number,
timestampMs: number,
lat = 37.7,
): ActorPoseSnapshot {
return {
entity: "actor",
actorId,
kind: "humanoid",
sequence,
timestampMs,
pose: {
space: "geographic",
lat,
lng: -122.4,
altitudeM: 10,
headingDeg: 0,
pitchDeg: 0,
},
velocity: { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 },
};
}
function vehiclePose(
driverActorId: string,
sequence: number,
timestampMs: number,
): VehiclePoseSnapshot {
return {
entity: "vehicle",
vehicleId: `vehicle-${driverActorId}`,
kind: "model-x",
driverActorId,
sequence,
timestampMs,
pose: {
space: "local",
cell: FLOOR,
xM: 0,
yM: 0,
zM: 0,
headingDeg: 0,
pitchDeg: 0,
},
velocity: { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 },
steering: 0,
wheelRadians: 0,
};
}
function joined(
service: ReturnType<typeof createRealtimeService>,
actorId: string,
interests: readonly InterestCell[] = [FLOOR],
subject = `auth-subject-for-${actorId}`,
) {
const result = service.join({
requestId: `request-${actorId}`,
actorId,
subject,
role: "member",
interests,
});
assert.equal(result.ok, true);
if (!result.ok) throw new Error("realtime fixture could not join");
return result.value;
}
describe("authoritative realtime service", () => {
it("stores only hashed credentials and authenticates opaque sessions", () => {
const service = createRealtimeService();
const session = joined(service, "member-a");
assert.notEqual(session.sessionId, session.token);
assert.equal(service.authenticate(session.sessionId, session.token).ok, true);
assert.equal(service.authenticate(session.sessionId, `${session.token}x`).ok, false);
assert.equal(session.grant.resumeToken, session.token);
assert.deepEqual(session.grant.initial, []);
});
it("keeps auth subjects private while reserving each opaque actor id", () => {
const service = createRealtimeService();
const first = service.join({
requestId: "request",
actorId: "page-f4f9445d",
subject: "private-auth-subject",
role: "member",
interests: [FLOOR],
});
assert.equal(first.ok, true);
if (!first.ok) return;
assert.equal(JSON.stringify(first.value.grant).includes("private-auth-subject"), false);
const view = service.authenticate(first.value.sessionId, first.value.token);
assert.equal(view.ok, true);
if (view.ok) assert.equal(JSON.stringify(view.value).includes("private-auth-subject"), false);
const collision = service.join({
requestId: "request-2",
actorId: "page-f4f9445d",
subject: "another-private-subject",
role: "member",
interests: [FLOOR],
});
assert.deepEqual(collision, {
ok: false,
code: "conflict",
message: "Realtime actor id is already active.",
});
});
it("broadcasts poses only to listeners sharing the exact interest cell", () => {
let now = 10_000;
const service = createRealtimeService({ now: () => now });
const a = joined(service, "member-a");
const b = joined(service, "member-b");
const c = joined(service, "member-c", [OTHER_FLOOR]);
const receivedA: unknown[] = [];
const receivedB: unknown[] = [];
const receivedC: unknown[] = [];
service.connect(a.sessionId, a.token, (message) => receivedA.push(message));
service.connect(b.sessionId, b.token, (message) => receivedB.push(message));
service.connect(c.sessionId, c.token, (message) => receivedC.push(message));
const submitted = service.submitPose(
a.sessionId,
a.token,
actorPose("member-a", 1, now, 0),
);
assert.equal(submitted.ok, true);
assert.equal(receivedA.length, 1);
assert.equal(receivedB.length, 1);
assert.equal(receivedC.length, 0);
now += 1_000;
const second = service.submitPose(a.sessionId, a.token, actorPose("member-a", 2, now, 5));
assert.equal(second.ok, true);
const late = joined(service, "member-late");
assert.equal(late.grant.initial.length, 1, "a late join receives the current cell snapshot");
});
it("allows geographic city poses and scopes them to the exact detailed city", () => {
const now = 15_000;
const service = createRealtimeService({ now: () => now });
const bay = { kind: "city" as const, cityId: "bay-area" as const };
const socal = { kind: "city" as const, cityId: "socal" as const };
const source = joined(service, "page-bay-source", [bay]);
const bayPeer = joined(service, "page-bay-peer", [bay]);
const socalPeer = joined(service, "page-socal-peer", [socal]);
const bayMessages: unknown[] = [];
const socalMessages: unknown[] = [];
service.connect(bayPeer.sessionId, bayPeer.token, (message) => bayMessages.push(message));
service.connect(socalPeer.sessionId, socalPeer.token, (message) => socalMessages.push(message));
const sent = service.submitPose(
source.sessionId,
source.token,
geographicActor("page-bay-source", 1, now),
);
assert.equal(sent.ok, true);
assert.equal(bayMessages.length, 1);
assert.equal(socalMessages.length, 0);
});
it("removes the previous entity when one session switches actor to vehicle", () => {
let now = 17_000;
const service = createRealtimeService({ now: () => now });
const source = joined(service, "page-switch");
const peer = joined(service, "page-peer");
const received: PoseDelta[] = [];
service.connect(peer.sessionId, peer.token, (message) => {
if (message.type === "pose-delta") received.push(message);
});
service.submitPose(source.sessionId, source.token, actorPose("page-switch", 1, now, 0));
now += 100;
const switched = service.submitPose(
source.sessionId,
source.token,
vehiclePose("page-switch", 2, now),
);
assert.equal(switched.ok, true);
if (!switched.ok) return;
assert.deepEqual(switched.value.removedEntityIds, ["actor:page-switch"]);
assert.equal(switched.value.updates[0]?.entity, "vehicle");
assert.ok(switched.value.sequence > (received[0]?.sequence ?? -1));
assert.deepEqual(received.at(-1)?.removedEntityIds, ["actor:page-switch"]);
});
it("broadcasts removals to old-cell peers on interest move and leave", () => {
let now = 18_000;
const service = createRealtimeService({ now: () => now });
const source = joined(service, "page-moving");
const oldPeer = joined(service, "page-old-peer");
const received: PoseDelta[] = [];
service.connect(oldPeer.sessionId, oldPeer.token, (message) => {
if (message.type === "pose-delta") received.push(message);
});
service.submitPose(source.sessionId, source.token, actorPose("page-moving", 1, now, 0));
const moved = service.resume({
type: "resume-request",
protocolVersion: 1,
requestId: "move-request",
sessionId: source.sessionId,
resumeToken: source.token,
serverEpoch: source.grant.serverEpoch,
lastReceivedSequence: received.at(-1)?.sequence ?? 0,
interests: [OTHER_FLOOR],
}, "auth-subject-for-page-moving");
assert.equal(moved.ok, true);
assert.deepEqual(received.at(-1)?.removedEntityIds, ["actor:page-moving"]);
const leaving = joined(service, "page-leaving");
now += 100;
service.submitPose(leaving.sessionId, leaving.token, actorPose("page-leaving", 1, now, 0));
const beforeLeave = received.at(-1)?.sequence ?? -1;
assert.equal(service.leave(leaving.sessionId, leaving.token).ok, true);
assert.deepEqual(received.at(-1)?.removedEntityIds, ["actor:page-leaving"]);
assert.ok((received.at(-1)?.sequence ?? -1) > beforeLeave);
});
it("rejects stale, unowned, off-interest and impossible motion", () => {
let now = 20_000;
const service = createRealtimeService({ now: () => now });
const session = joined(service, "member-a");
assert.equal(service.submitPose(
session.sessionId,
session.token,
actorPose("member-b", 1, now, 0),
).ok, false);
assert.equal(service.submitPose(
session.sessionId,
session.token,
actorPose("member-a", 1, now, 0, OTHER_FLOOR),
).ok, false);
assert.equal(service.submitPose(
session.sessionId,
session.token,
actorPose("member-a", 1, now, 0),
).ok, true);
assert.equal(service.submitPose(
session.sessionId,
session.token,
actorPose("member-a", 1, now, 1),
).ok, false);
now += 100;
const hacked = service.submitPose(
session.sessionId,
session.token,
actorPose("member-a", 2, now, 1_000),
);
assert.equal(hacked.ok, false);
if (!hacked.ok) assert.equal(hacked.code, "motion");
});
it("caps session/cell occupancy and pose rate", () => {
let now = 30_000;
const service = createRealtimeService({
now: () => now,
maximumSessionsPerCell: 1,
maximumPoseUpdatesPerWindow: 1,
});
const session = joined(service, "member-a");
const refused = service.join({
requestId: "request-b",
actorId: "member-b",
subject: "member-b",
role: "member",
interests: [FLOOR],
});
assert.equal(refused.ok, false);
assert.equal(service.submitPose(
session.sessionId,
session.token,
actorPose("member-a", 1, now, 0),
).ok, true);
now += 50;
const limited = service.submitPose(
session.sessionId,
session.token,
actorPose("member-a", 2, now, 0),
);
assert.equal(limited.ok, false);
if (!limited.ok) assert.equal(limited.code, "rate-limited");
});
it("honours disconnect grace, absolute TTL, leave and membership revocation", () => {
let now = 40_000;
const service = createRealtimeService({
now: () => now,
disconnectGraceMs: 500,
sessionTtlMs: 2_000,
});
const session = joined(service, "member-a");
const messages: unknown[] = [];
const connected = service.connect(session.sessionId, session.token, (message) => messages.push(message));
assert.equal(connected.ok, true);
if (!connected.ok) return;
connected.value.disconnect();
now += 400;
assert.equal(service.cleanup(), 0);
assert.equal(service.authenticate(session.sessionId, session.token).ok, true);
now += 600;
assert.equal(service.cleanup(), 1);
const revoked = joined(service, "member-b");
service.connect(revoked.sessionId, revoked.token, (message) => messages.push(message));
assert.equal(service.revokeSubject("auth-subject-for-member-b"), 1);
assert.equal(messages.length, 1);
assert.equal(service.authenticate(revoked.sessionId, revoked.token).ok, false);
const left = joined(service, "member-c");
assert.equal(service.leave(left.sessionId, left.token).ok, true);
assert.equal(service.sessionCount(), 0);
});
it("strictly resumes, changes interest, and rotates the usable token", () => {
const service = createRealtimeService({ now: () => 50_000 });
const session = joined(service, "member-a");
const resumed = service.resume({
type: "resume-request",
protocolVersion: 1,
requestId: "resume-1",
sessionId: session.sessionId,
resumeToken: session.token,
serverEpoch: session.grant.serverEpoch,
lastReceivedSequence: session.grant.nextSequence === 0 ? 0 : session.grant.nextSequence - 1,
interests: [OTHER_FLOOR],
}, "auth-subject-for-member-a");
assert.equal(resumed.ok, true);
if (!resumed.ok) return;
assert.equal(resumed.value.type, "resume-grant");
assert.equal(resumed.value.requestId, "resume-1");
assert.notEqual(resumed.value.resumeToken, session.token);
assert.equal(service.authenticate(session.sessionId, session.token).ok, false);
const current = service.authenticate(session.sessionId, resumed.value.resumeToken);
assert.equal(current.ok, true);
if (current.ok) assert.deepEqual(current.value.interests, [OTHER_FLOOR]);
});
});
const SECRET = "realtime-test-secret-that-is-long-enough";
function jwt(subject: string): string {
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
sub: subject,
exp: Math.floor(Date.now() / 1_000) + 3_600,
})}`;
return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`;
}
function app() {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET });
config.logLevel = "silent";
return buildApp(config);
}
function joinBody(actorId: string, interests: readonly InterestCell[]) {
return {
type: "join-request",
protocolVersion: 1,
requestId: `request-${actorId}`,
actorId,
resumeToken: null,
lastReceivedSequence: null,
interests,
};
}
describe("realtime HTTP routes", () => {
it("declares exactly the three public bundled office fallbacks", () => {
assert.deepEqual([...BUNDLED_REALTIME_OFFICE_IDS].sort(), [
"frontier-valley",
"lumbridge-hq",
"mateo-court",
]);
});
it("never accepts stream credentials in a URL", async () => {
const server = app();
after(() => server.close());
const response = await server.inject({
method: "GET",
url: "/api/v1/realtime/events?sessionId=leak&token=must-not-be-logged",
});
assert.equal(response.statusCode, 404);
});
it("authenticates before joining and rejects opaque actor-id collisions", async () => {
const server = app();
after(() => server.close());
const anonymous = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
payload: joinBody("member-a", [{ kind: "city", cityId: "bay-area" }]),
});
assert.equal(anonymous.statusCode, 401);
const opaque = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization: `Bearer ${jwt("member-a")}` },
payload: joinBody("page-random-opaque", [{ kind: "city", cityId: "bay-area" }]),
});
assert.equal(opaque.statusCode, 201);
assert.equal(JSON.stringify(opaque.json()).includes("member-a"), false);
const collision = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization: `Bearer ${jwt("member-b")}` },
payload: joinBody("page-random-opaque", [{ kind: "city", cityId: "bay-area" }]),
});
assert.equal(collision.statusCode, 409);
const absentOffice = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization: `Bearer ${jwt("member-a")}` },
payload: joinBody("member-a", [{ kind: "office", officeId: "invented-private-office" }]),
});
assert.equal(absentOffice.statusCode, 400);
assert.deepEqual(absentOffice.json(), {
error: "invalid",
message: "Realtime interest is unavailable.",
});
});
it("allows bundled demo office envelopes without disk packs but not invented floors", async () => {
const server = app();
after(() => server.close());
const authorization = `Bearer ${jwt("member-office")}`;
const office = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization },
payload: joinBody("member-office", [{ kind: "office", officeId: "lumbridge-hq" }]),
});
assert.equal(office.statusCode, 201);
const floor = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization },
payload: joinBody("member-office", [{
kind: "floor",
officeId: "lumbridge-hq",
floorId: "invented-floor",
}]),
});
assert.equal(floor.statusCode, 400);
});
it("joins, accepts an owned pose, and leaves without persistence", async () => {
const server = app();
after(() => server.close());
const joinedResponse = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization: `Bearer ${jwt("member-a")}` },
payload: joinBody("member-a", [{ kind: "california-tile", level: 6, x: 1, y: 2 }]),
});
assert.equal(joinedResponse.statusCode, 201);
const grant = joinedResponse.json<{ sessionId: string; resumeToken: string }>();
const timestampMs = Date.now();
const pose = await server.inject({
method: "POST",
url: "/api/v1/realtime/pose",
payload: {
sessionId: grant.sessionId,
token: grant.resumeToken,
snapshot: {
entity: "actor",
actorId: "member-a",
kind: "humanoid",
sequence: 1,
timestampMs,
pose: {
space: "geographic",
lat: 37.7,
lng: -122.4,
altitudeM: 10,
headingDeg: 0,
pitchDeg: 0,
},
velocity: { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 },
},
},
});
assert.equal(pose.statusCode, 202);
assert.equal(pose.json<{ type: string }>().type, "pose-delta");
const left = await server.inject({
method: "POST",
url: "/api/v1/realtime/leave",
payload: { sessionId: grant.sessionId, token: grant.resumeToken },
});
assert.equal(left.statusCode, 204);
const gone = await server.inject({
method: "POST",
url: "/api/v1/realtime/pose",
payload: {
sessionId: grant.sessionId,
token: grant.resumeToken,
snapshot: {
entity: "actor",
actorId: "member-a",
kind: "humanoid",
sequence: 2,
timestampMs: timestampMs + 100,
pose: {
space: "geographic",
lat: 37.7,
lng: -122.4,
altitudeM: 10,
headingDeg: 0,
pitchDeg: 0,
},
velocity: { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 },
},
},
});
assert.equal(gone.statusCode, 401);
});
it("streams the immediate snapshot from a credential-body POST", async () => {
const server = app();
const address = await server.listen({ host: "127.0.0.1", port: 0 });
const controller = new AbortController();
try {
const joinResponse = await fetch(`${address}/api/v1/realtime/join`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt("member-stream")}`,
"content-type": "application/json",
},
body: JSON.stringify(joinBody("member-stream", [{ kind: "city", cityId: "bay-area" }])),
});
assert.equal(joinResponse.status, 201);
const grant = await joinResponse.json() as {
sessionId: string;
resumeToken: string;
serverEpoch: string;
nextSequence: number;
};
const events = await fetch(`${address}/api/v1/realtime/events`, {
method: "POST",
headers: {
authorization: `Bearer ${jwt("member-stream")}`,
"content-type": "application/json",
},
body: JSON.stringify({
type: "resume-request",
protocolVersion: 1,
requestId: "stream-resume",
sessionId: grant.sessionId,
resumeToken: grant.resumeToken,
serverEpoch: grant.serverEpoch,
lastReceivedSequence: grant.nextSequence === 0 ? 0 : grant.nextSequence - 1,
interests: [{ kind: "city", cityId: "bay-area" }],
}),
signal: controller.signal,
});
assert.equal(events.status, 200);
assert.match(events.headers.get("content-type") ?? "", /^text\/event-stream/);
const reader = events.body?.getReader();
assert.ok(reader);
const first = await reader.read();
assert.equal(first.done, false);
const event = new TextDecoder().decode(first.value);
assert.match(event, /event: resume-grant/);
assert.match(event, /"type":"resume-grant"/);
controller.abort();
await reader.cancel().catch(() => undefined);
} finally {
controller.abort();
await server.close();
}
});
it("accepts the root client's exact move-interest resume shape on join", async () => {
const server = app();
after(() => server.close());
const authorization = `Bearer ${jwt("member-client")}`;
const first = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization },
payload: joinBody("member-client", [{ kind: "city", cityId: "bay-area" }]),
});
assert.equal(first.statusCode, 201);
const grant = first.json<{
sessionId: string;
resumeToken: string;
serverEpoch: string;
nextSequence: number;
}>();
const moved = await server.inject({
method: "POST",
url: "/api/v1/realtime/join",
headers: { authorization },
payload: {
type: "resume-request",
protocolVersion: 1,
requestId: "tera-client-2",
sessionId: grant.sessionId,
resumeToken: grant.resumeToken,
serverEpoch: grant.serverEpoch,
lastReceivedSequence: grant.nextSequence === 0 ? 0 : grant.nextSequence - 1,
interests: [{ kind: "city", cityId: "socal" }],
},
});
assert.equal(moved.statusCode, 200);
const resume = moved.json<{ type: string; requestId: string; resumeToken: string }>();
assert.equal(resume.type, "resume-grant");
assert.equal(resume.requestId, "tera-client-2");
assert.notEqual(resume.resumeToken, grant.resumeToken);
});
});