import type { ActorCredential, NewPersonalChannel, PersonalInboxWindow, PersonalMentions } from "./gateway.ts"; import { canonicalChannelName } from "../channels.ts"; import type { AttachmentAlreadyKept } from "../outgoing-attachment.ts"; export class MalformedRequest extends Error { constructor(message: string) { super(message); this.name = "MalformedRequest"; } } function missing(field: string): MalformedRequest { return new MalformedRequest(`missing required field ${field}`); } export type PersonRequest = { actor: ActorCredential; conversationID?: string; messageID?: string; body?: string; parentID?: string; emoji?: string; before?: string; since?: string; until?: string; heldIDs: string[]; query?: string; name?: string; externalID?: string; largestBytes?: number; arrivalsURL?: string; typingURL?: string; withdrawalsURL?: string; readAt?: string; mediaURL?: string; range?: string; sourceURL?: string; contentType?: string; counterpartExternalIDs: string[]; attachments: AttachmentAlreadyKept[]; mentions?: PersonalMentions; timeZone?: string; }; export function parseNewChannel(value: unknown): NewPersonalChannel { const record = asRecord(value); const visibility = record.visibility; if (visibility !== "open" && visibility !== "private") { throw new MalformedRequest("visibility must be open or private"); } const name = requireText(record, "name"); if (!canonicalChannelName(name)) throw new MalformedRequest("a channel name needs more than a # prefix"); return { name, description: optionalText(record, "description"), visibility, memberExternalIDs: parseMemberExternalIDs(record), }; } export function parseMemberExternalIDs(value: unknown): string[] { const members = asRecord(value).memberExternalIDs ?? []; if (!Array.isArray(members) || members.some((entry) => typeof entry !== "string")) { throw new MalformedRequest("memberExternalIDs must be a list of ids"); } return members as string[]; } const legacyBuzzSecretField = "userSecretHex"; const legacyCounterpartField = "counterpartPubkeyHex"; export function parsePersonRequest(value: unknown): PersonRequest { const record = asRecord(value); return { actor: parseActor(record), conversationID: optionalText(record, "conversationID"), messageID: optionalText(record, "messageID"), body: optionalText(record, "body"), parentID: optionalText(record, "parentID"), emoji: optionalText(record, "emoji"), before: optionalText(record, "before"), since: optionalText(record, "since"), until: optionalText(record, "until"), heldIDs: parseHeldIDs(record), query: optionalText(record, "query"), name: optionalText(record, "name"), externalID: optionalText(record, "externalID"), largestBytes: optionalCount(record, "largestBytes"), arrivalsURL: optionalText(record, "arrivalsURL"), typingURL: optionalText(record, "typingURL"), withdrawalsURL: optionalText(record, "withdrawalsURL"), readAt: optionalText(record, "readAt"), mediaURL: optionalText(record, "mediaURL"), range: optionalText(record, "range"), sourceURL: optionalText(record, "sourceURL"), contentType: optionalText(record, "contentType"), counterpartExternalIDs: parseCounterparts(record), attachments: parseAttachments(record), mentions: parseMentions(record), timeZone: parseTimeZone(record), }; } function parseTimeZone(record: Record): string | undefined { const timeZone = optionalText(record, "timeZone"); if (timeZone === undefined) return undefined; try { return new Intl.DateTimeFormat("en", { timeZone }).resolvedOptions().timeZone; } catch { throw new MalformedRequest(`timeZone ${JSON.stringify(timeZone)} is not an IANA time zone`); } } function parseHeldIDs(record: Record): string[] { const given = record.heldIDs ?? []; if (!Array.isArray(given) || given.some((entry) => typeof entry !== "string")) { throw new MalformedRequest("heldIDs must be a list of message ids"); } return given as string[]; } function parseMentions(record: Record): PersonalMentions | undefined { const given = record.mentions; if (given === undefined) return undefined; const mentions = asRecord(given); const externalIDs = mentions.externalIDs ?? []; if (!Array.isArray(externalIDs) || externalIDs.some((entry) => typeof entry !== "string")) { throw new MalformedRequest("mentions.externalIDs must be a list of ids"); } return { externalIDs: externalIDs as string[], isEveryone: mentions.isEveryone === true }; } function parseAttachments(record: Record): AttachmentAlreadyKept[] { const given = record.attachments; if (given === undefined) return []; if (!Array.isArray(given)) throw new MalformedRequest("attachments must be a list"); return given.map((entry) => parseKeptAttachment(asRecord(entry))); } export function parseKeptAttachment(attachment: Record): AttachmentAlreadyKept { return { filename: requiredText(attachment, "filename"), contentType: requiredText(attachment, "contentType"), address: requiredText(attachment, "address"), sizeBytes: requiredCount(attachment, "sizeBytes"), digest: requiredText(attachment, "digest"), }; } function requiredCount(record: Record, field: string): number { const value = optionalCount(record, field); if (value === undefined) throw missing(field); return value; } function requiredText(record: Record, field: string): string { const value = record[field]; if (typeof value !== "string" || value.trim() === "") throw missing(field); return value; } export function parseCredentialAnswers(value: unknown): Record { const given = asRecord(value).answers; if (given === undefined) throw missing("answers"); const answers: Record = {}; for (const [field, answer] of Object.entries(asRecord(given))) { if (typeof answer === "string") answers[field] = answer; } return answers; } export function parseActor(record: Record): ActorCredential { const given = record.actor; if (given !== undefined) { const actor = asRecord(given); return { kind: requireText(actor, "kind"), secret: requireText(actor, "secret") }; } const legacySecret = optionalText(record, legacyBuzzSecretField); if (legacySecret) return { kind: "buzz-secret", secret: legacySecret }; throw missing("actor"); } export function requireConversation(request: PersonRequest): string { if (!request.conversationID) throw missing("conversationID"); return request.conversationID; } export function requireReadAt(request: PersonRequest): Date { if (!request.readAt) throw missing("readAt"); const readAt = new Date(request.readAt); if (Number.isNaN(readAt.getTime())) throw new MalformedRequest("readAt must be an ISO 8601 time"); return readAt; } export function inboxWindowOf(request: PersonRequest): PersonalInboxWindow { const since = optionalTime(request.since, "since"); const until = optionalTime(request.until, "until"); return { ...(since && { since }), ...(until && { until }), heldIDs: request.heldIDs }; } function optionalTime(value: string | undefined, field: string): Date | undefined { if (!value) return undefined; const time = new Date(value); if (Number.isNaN(time.getTime())) throw new MalformedRequest(`${field} must be an ISO 8601 time`); return time; } export function requireQuery(request: PersonRequest): string { if (!request.query?.trim()) throw missing("query"); return request.query; } export function requireMessage(request: PersonRequest): string { if (!request.messageID) throw missing("messageID"); return request.messageID; } export function requireName(request: PersonRequest): string { if (!request.name) throw missing("name"); return request.name; } export function requireExternalID(request: PersonRequest): string { if (!request.externalID) throw missing("externalID"); return request.externalID; } export function requireMediaURL(request: PersonRequest): string { if (!request.mediaURL) throw missing("mediaURL"); return request.mediaURL; } const singleRangePattern = /^bytes=\d+-\d+$/; export function requireMediaRange(request: PersonRequest): string { if (!request.range) throw missing("range"); if (!singleRangePattern.test(request.range)) throw new MalformedRequest("range must be one bytes=START-END range"); return request.range; } export function requireMediaSource(request: PersonRequest): { url: string; contentType: string } { if (!request.sourceURL) throw missing("sourceURL"); return { url: request.sourceURL, contentType: request.contentType ?? "application/octet-stream" }; } export function requireLargestBytes(request: PersonRequest): number { if (!request.largestBytes) throw missing("largestBytes"); return request.largestBytes; } const loopbackHostnames = new Set(["127.0.0.1", "localhost"]); export function requireLoopbackArrivalsURL(request: PersonRequest): string { if (!request.arrivalsURL) throw missing("arrivalsURL"); return loopbackURL("arrivalsURL", request.arrivalsURL); } export function optionalLoopbackTypingURL(request: PersonRequest): string | undefined { if (!request.typingURL) return undefined; return loopbackURL("typingURL", request.typingURL); } export function optionalLoopbackWithdrawalsURL(request: PersonRequest): string | undefined { if (!request.withdrawalsURL) return undefined; return loopbackURL("withdrawalsURL", request.withdrawalsURL); } function loopbackURL(field: string, offered: string): string { const parsed = URL.parse(offered); if (!parsed || parsed.protocol !== "http:" || !loopbackHostnames.has(parsed.hostname)) { throw new MalformedRequest(`${field} must be an http address on this machine's loopback`); } return parsed.toString(); } function parseCounterparts(record: Record): string[] { const given = record.counterpartExternalIDs; if (Array.isArray(given)) return given.filter((entry): entry is string => typeof entry === "string"); const legacy = optionalText(record, legacyCounterpartField); return legacy ? [legacy] : []; } function asRecord(value: unknown): Record { if (typeof value !== "object" || value === null) { throw new MalformedRequest("expected a JSON object"); } return value as Record; } function requireText(record: Record, field: string): string { const value = record[field]; if (typeof value !== "string" || value.trim().length === 0) { throw missing(field); } return value; } function optionalText(record: Record, field: string): string | undefined { const value = record[field]; return typeof value === "string" && value.length > 0 ? value : undefined; } function optionalCount(record: Record, field: string): number | undefined { const value = record[field]; return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; }