import { Schema } from "effect"; export const MAX_SYNC_MESSAGE_BYTES = 16 * 1024; export const MAX_PRESENTATION_HASH_LENGTH = 2_048; export const JOIN_CODE_LENGTH = 6; const MAX_MESSAGE_ID_LENGTH = 128; export type PresentationRole = "presenter" | "audience"; export type PresentationSnapshot = { readonly hash: string; readonly revision: number; }; export type PresentationPresence = { readonly presenters: number; readonly audience: number; }; export type CreateSessionRequest = { readonly role: PresentationRole; readonly initialHash: string; }; export type JoinSessionRequest = { readonly role: PresentationRole; readonly code: string; }; export type SessionGrant = { readonly sessionId: string; readonly code: string; readonly connectionToken: string; readonly websocketPath: "/api/presentation-sync/ws"; readonly snapshot: PresentationSnapshot; }; export type ClientSyncMessage = | { readonly type: "location.publish"; readonly hash: string; readonly baseRevision: number; readonly messageId: string; } | { readonly type: "session.end" } | { readonly type: "ping"; readonly nonce: string }; export type ServerSyncMessage = | { readonly type: "location.snapshot"; readonly snapshot: PresentationSnapshot; readonly originatingMessageId: string | null; } | { readonly type: "presence.snapshot"; readonly presence: PresentationPresence; } | { readonly type: "location.rejected"; readonly reason: "stale_revision"; readonly current: PresentationSnapshot; readonly messageId: string; } | { readonly type: "session.ended"; readonly reason: "presenter_ended" | "expired"; } | { readonly type: "protocol.error"; readonly code: "invalid_message" | "message_too_large" | "forbidden"; readonly message: string; }; export type DecodeResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: "invalid_json" | "invalid_message" | "message_too_large"; }; export const isCanonicalPresentationHash = (value: string): boolean => value.length > 0 && value.length <= MAX_PRESENTATION_HASH_LENGTH && (value.startsWith("#scene/") || value.startsWith("#discuss/")); export const normalizeJoinCode = (value: string): string => value.replace(/[\s-]/g, "").toUpperCase(); const PresentationRoleSchema = Schema.Literal("presenter", "audience"); const NonNegativeIntegerSchema = Schema.Number.pipe( Schema.int(), Schema.between(0, Number.MAX_SAFE_INTEGER), ); const CanonicalHashSchema = Schema.String.pipe( Schema.filter(isCanonicalPresentationHash), ); const BoundedMessageIdSchema = Schema.String.pipe( Schema.maxLength(MAX_MESSAGE_ID_LENGTH), ); const SnapshotSchema = Schema.Struct({ hash: CanonicalHashSchema, revision: NonNegativeIntegerSchema, }); const PresenceSchema = Schema.Struct({ presenters: NonNegativeIntegerSchema, audience: NonNegativeIntegerSchema, }); const ClientSyncMessageSchema = Schema.Union( Schema.Struct({ type: Schema.Literal("location.publish"), hash: CanonicalHashSchema, baseRevision: NonNegativeIntegerSchema, messageId: BoundedMessageIdSchema, }), Schema.Struct({ type: Schema.Literal("session.end") }), Schema.Struct({ type: Schema.Literal("ping"), nonce: BoundedMessageIdSchema, }), ); const ServerSyncMessageSchema = Schema.Union( Schema.Struct({ type: Schema.Literal("location.snapshot"), snapshot: SnapshotSchema, originatingMessageId: Schema.NullOr(BoundedMessageIdSchema), }), Schema.Struct({ type: Schema.Literal("presence.snapshot"), presence: PresenceSchema, }), Schema.Struct({ type: Schema.Literal("location.rejected"), reason: Schema.Literal("stale_revision"), current: SnapshotSchema, messageId: BoundedMessageIdSchema, }), Schema.Struct({ type: Schema.Literal("session.ended"), reason: Schema.Literal("presenter_ended", "expired"), }), Schema.Struct({ type: Schema.Literal("protocol.error"), code: Schema.Literal("invalid_message", "message_too_large", "forbidden"), message: Schema.String, }), ); const CreateSessionRequestSchema = Schema.Struct({ role: PresentationRoleSchema, initialHash: CanonicalHashSchema, }); const JoinSessionRequestSchema = Schema.Struct({ role: PresentationRoleSchema, code: Schema.String.pipe( Schema.filter((value) => value.length === JOIN_CODE_LENGTH), ), }); const SessionCredentialSchema = Schema.String.pipe( Schema.minLength(1), Schema.maxLength(MAX_MESSAGE_ID_LENGTH), ); const SessionCodeSchema = Schema.String.pipe( Schema.filter((value) => value.length === JOIN_CODE_LENGTH), ); const SessionGrantSchema = Schema.Struct({ sessionId: SessionCredentialSchema, code: SessionCodeSchema, connectionToken: SessionCredentialSchema, websocketPath: Schema.Literal("/api/presentation-sync/ws"), snapshot: SnapshotSchema, }); const parseJson = (input: string): DecodeResult => { // Measure UTF-8 bytes before parsing so websocket limits match transport size. if (new TextEncoder().encode(input).byteLength > MAX_SYNC_MESSAGE_BYTES) { return { ok: false, error: "message_too_large" }; } try { return { ok: true, value: JSON.parse(input) as unknown }; } catch { return { ok: false, error: "invalid_json" }; } }; const decodeSchema = ( input: string, schema: Schema.Schema, ): DecodeResult => { const parsed = parseJson(input); if (!parsed.ok) return parsed; try { return { ok: true, value: Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })( parsed.value, ), }; } catch { return { ok: false, error: "invalid_message" }; } }; export const decodeClientSyncMessage = ( input: string, ): DecodeResult => decodeSchema(input, ClientSyncMessageSchema); export const decodeServerSyncMessage = ( input: string, ): DecodeResult => decodeSchema(input, ServerSyncMessageSchema); export const decodeCreateSessionRequest = ( input: string, ): DecodeResult => decodeSchema(input, CreateSessionRequestSchema); export const decodeJoinSessionRequest = ( input: string, ): DecodeResult => { const parsed = parseJson(input); if (!parsed.ok) return parsed; const normalized = // Normalize before schema decoding so the exact length applies to user input // after harmless separators have been removed. typeof parsed.value === "object" && parsed.value !== null && !Array.isArray(parsed.value) && "code" in parsed.value && typeof parsed.value.code === "string" ? { ...parsed.value, code: normalizeJoinCode(parsed.value.code) } : parsed.value; try { return { ok: true, value: Schema.decodeUnknownSync(JoinSessionRequestSchema, { onExcessProperty: "error", })(normalized), }; } catch { return { ok: false, error: "invalid_message" }; } }; export const decodeSessionGrant = ( input: string, ): DecodeResult => decodeSchema(input, SessionGrantSchema);