/** * Runtime validation for the shared group-files contracts * (packages/core/src/shared-files.ts — imported as @tactical-media-broadcast-hub/core). * * The shared file is TYPES ONLY; the task contract says "future HTTP/DB * adapters must validate these shapes at runtime". This module is that * adapter boundary: every value entering or leaving the DB/HTTP seam passes * through one of these guards, so a stray row can never render an * invalid GroupFile. * * Style follows the api's existing house convention: `unknown` in, typed * value out, BadRequestException on bad shape (http/validation.ts), never * `any`, and DB CHECK constraints (029) are coarse backstops only. */ import { BadRequestException } from "@nestjs/common"; import { MIME_CLASSES } from "@tactical-media-broadcast-hub/core"; import type { FileRetention, GroupFileMapping, MatrixAttachmentCandidate, MimeClass, } from "@tactical-media-broadcast-hub/core"; import type { GroupFile } from "./group-files.types.js"; // FileRetention (packages/core/src/shared-files.ts): mode is keep|until and // legalHold is an orthogonal boolean flag — NOT a third mode. export const RETENTION_MODES = ["keep", "until"] as const; export type RetentionMode = (typeof RETENTION_MODES)[number]; export const ORIGIN_KINDS = ["matrix", "bridge", "upload", "pad-export"] as const; export type OriginKind = (typeof ORIGIN_KINDS)[number]; export { MIME_CLASSES, type MimeClass }; export const FILE_STATUSES = ["available", "revoked", "expired"] as const; export type FileStatus = (typeof FILE_STATUSES)[number]; const SHA256_HEX = /^[0-9a-f]{64}$/; const MXID = /^@[^@\s]+:[^@\s]+$/; const MXC_URI = /^mxc:\/\/[^/\s]+\/[^/\s?#]+$/; const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function requireField(container: Record, key: string): unknown { if (!(key in container)) throw new BadRequestException(`${key} is required`); return container[key]; } function stringField(container: Record, key: string, field = key): string { const value = requireField(container, key); if (typeof value !== "string" || value === "") throw new BadRequestException(`${field} must be a non-empty string`); return value; } function isoField(container: Record, key: string, field = key): string { const value = stringField(container, key, field); if (!ISO_TIMESTAMP.test(value)) throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`); return value; } function enumField(container: Record, key: string, allowed: readonly T[], field = key): T { const value = requireField(container, key); if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) { throw new BadRequestException(`${field} must be one of: ${allowed.join(", ")}`); } return value as T; } function integerField(container: Record, key: string, field = key): number { const value = requireField(container, key); const n = typeof value === "number" ? value : NaN; if (!Number.isSafeInteger(n) || n < 0) throw new BadRequestException(`${field} must be a non-negative safe integer`); return n; } /** sha256 lowercase hex, per the contract ("verified payload bytes ... hex"). */ export function isSha256Hex(value: unknown): value is string { return typeof value === "string" && SHA256_HEX.test(value); } export function requireSha256(value: unknown, field = "sha256"): string { if (!isSha256Hex(value)) throw new BadRequestException(`${field} must be lowercase sha256 hex (64 chars)`); return value; } export function requireMxid(value: unknown, field = "senderMxid"): string { if (typeof value !== "string" || !MXID.test(value)) throw new BadRequestException(`${field} must be a Matrix user id (@user:host)`); return value; } export function requireMxcUri(value: unknown, field = "mxcUri"): string { if (typeof value !== "string" || !MXC_URI.test(value)) throw new BadRequestException(`${field} must be an mxc:// URI`); return value; } export function requireRoomId(value: unknown, field = "roomId"): string { if (typeof value !== "string" || !/^![^:\s]+:[^:\s]+$/.test(value)) { throw new BadRequestException(`${field} must be an immutable Matrix room id (!localpart:host) — never an alias`); } return value; } /** Retention is decoupled from link expiry; legalHold blocks deletion. */ export function parseRetention(value: unknown, field = "retention"): FileRetention { if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`); const mode = enumField(value, "mode", RETENTION_MODES, `${field}.mode`); const legalHold = requireField(value, "legalHold"); if (typeof legalHold !== "boolean") throw new BadRequestException(`${field}.legalHold must be a boolean`); if (mode === "until") return { mode, legalHold, expiresAt: isoField(value, "expiresAt", `${field}.expiresAt`) }; if (value.expiresAt !== undefined && value.expiresAt !== null) { throw new BadRequestException(`${field}.expiresAt must not be set when mode is "keep"`); } return { mode, legalHold }; } /** POST /files/:id/retention body {mode, until?, legalHold?} → FileRetention. */ export function parseRetentionInput(value: unknown): FileRetention { if (!isRecord(value)) throw new BadRequestException("body must be an object"); const mode = enumField(value, "mode", RETENTION_MODES, "mode"); const legalHold = value.legalHold === undefined ? false : value.legalHold; if (typeof legalHold !== "boolean") throw new BadRequestException("legalHold must be a boolean"); const rawUntil = value.until; if (mode === "until") { if (rawUntil === undefined || rawUntil === null) throw new BadRequestException("until is required when mode is 'until'"); return { mode, legalHold, expiresAt: requireIsoTimestamp(rawUntil, "until") }; } if (rawUntil !== undefined && rawUntil !== null) { throw new BadRequestException("until must not be set when mode is 'keep'"); } return { mode, legalHold }; } export function requireIsoTimestamp(value: unknown, field: string): string { if (typeof value !== "string" || !ISO_TIMESTAMP.test(value) || !Number.isFinite(Date.parse(value))) { throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`); } return value; } /** The four SharedFileOrigin variants, discriminated by `kind`. */ export function parseSharedFileOrigin(value: unknown, field = "origin"): GroupFile["origin"] { if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`); const kind = enumField(value, "kind", ORIGIN_KINDS, `${field}.kind`); if (kind === "matrix") return { kind, mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`) }; if (kind === "bridge") { return { kind, mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`), providerId: stringField(value, "providerId", `${field}.providerId`), ...(value.remoteEventId === undefined || value.remoteEventId === null ? {} : { remoteEventId: stringField(value, "remoteEventId", `${field}.remoteEventId`) }), }; } if (kind === "upload") { if (value.sharedFrom !== undefined && value.sharedFrom !== "personal") throw new BadRequestException(`${field}.sharedFrom must be personal`); return { kind, ...(value.sharedFrom === "personal" ? { sharedFrom: "personal" as const } : {}) }; } return { kind: "pad-export", padId: stringField(value, "padId", `${field}.padId`), revision: stringField(value, "revision", `${field}.revision`) }; } /** Matrix event eligibility input for the hub-archive seam. Unknown state fails closed (core helper). */ export function parseMatrixAttachmentCandidate(value: unknown, field = "event"): MatrixAttachmentCandidate { if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`); const encryption = enumField(value, "roomEncryption", ["unencrypted", "encrypted", "unknown"] as const, `${field}.roomEncryption`); const encryptedFile = requireField(value, "encryptedFile"); const redacted = requireField(value, "redacted"); if (typeof encryptedFile !== "boolean") throw new BadRequestException(`${field}.encryptedFile must be a boolean`); if (typeof redacted !== "boolean") throw new BadRequestException(`${field}.redacted must be a boolean`); return { roomId: requireRoomId(value.roomId, `${field}.roomId`), eventId: stringField(value, "eventId", `${field}.eventId`), senderId: requireMxid(value.senderId, `${field}.senderId`), eventType: stringField(value, "eventType", `${field}.eventType`), msgtype: stringField(value, "msgtype", `${field}.msgtype`), roomEncryption: encryption, encryptedFile, redacted, mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`), }; } export function parseGroupFileMapping(value: unknown, field = "mapping"): GroupFileMapping { if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`); const policy = enumField(value, "archivePolicy", ["disabled", "unencrypted-archive"] as const, `${field}.archivePolicy`); return { workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`), groupId: stringField(value, "groupId", `${field}.groupId`), roomId: requireRoomId(value.roomId, `${field}.roomId`), spaceId: stringField(value, "spaceId", `${field}.spaceId`), folderId: stringField(value, "folderId", `${field}.folderId`), archivePolicy: policy, retention: parseRetention(value.retention, `${field}.retention`), }; } /** The served GroupFile wire shape, guarded on the way out of the DB. */ export function parseGroupFile(value: unknown, field = "item"): GroupFile { if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`); const origin = parseSharedFileOrigin(value.origin, `${field}.origin`); const eventId = value.eventId; if (eventId !== undefined && eventId !== null && typeof eventId !== "string") { throw new BadRequestException(`${field}.eventId must be a string or null`); } const file = value.file; if (!isRecord(file)) throw new BadRequestException(`${field}.file must be an object`); const shareLink = value.shareLink; if (!isRecord(shareLink)) throw new BadRequestException(`${field}.shareLink must be an object`); const access = enumField(shareLink, "access", ["members", "public"] as const, `${field}.shareLink.access`); const shareExpires = shareLink.expiresAt; if (shareExpires !== undefined && shareExpires !== null && (typeof shareExpires !== "string" || !ISO_TIMESTAMP.test(shareExpires))) { throw new BadRequestException(`${field}.shareLink.expiresAt must be a UTC ISO-8601 timestamp or null`); } const checksum = value.checksum; if (!isRecord(checksum)) throw new BadRequestException(`${field}.checksum must be an object`); return { id: stringField(value, "id", `${field}.id`), workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`), groupId: value.groupId === null ? null : stringField(value, "groupId", `${field}.groupId`), ownerId: nullableUuid(value.ownerId, `${field}.ownerId`), folderId: nullableUuid(value.folderId, `${field}.folderId`), createdBy: nullableUuid(value.createdBy, `${field}.createdBy`), shared: value.shared === undefined ? false : booleanField(value.shared, `${field}.shared`), roomId: value.roomId === undefined || value.roomId === null ? null : requireRoomId(value.roomId, `${field}.roomId`), senderId: requireMxid(value.senderId, `${field}.senderId`), eventId: eventId === undefined || eventId === null ? null : eventId, origin, name: stringField(value, "name", `${field}.name`), mimeType: stringField(value, "mimeType", `${field}.mimeType`), mimeClass: enumField(value, "mimeClass", MIME_CLASSES, `${field}.mimeClass`), sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`), checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) }, createdAt: isoField(value, "createdAt", `${field}.createdAt`), indexedAt: isoField(value, "indexedAt", `${field}.indexedAt`), file: { spaceId: file.spaceId === null ? null : stringField(file, "spaceId", `${field}.file.spaceId`), resourceId: stringField(file, "resourceId", `${field}.file.resourceId`), version: stringField(file, "version", `${field}.file.version`), ...(file.storageKey === undefined ? {} : { storageKey: stringField(file, "storageKey", `${field}.file.storageKey`) }), }, shareLink: { url: stringField(shareLink, "url", `${field}.shareLink.url`), access, expiresAt: shareExpires === undefined || shareExpires === null ? null : shareExpires, }, retention: parseRetention(value.retention, `${field}.retention`), status: enumField(value, "status", FILE_STATUSES, `${field}.status`), }; } function nullableUuid(value: unknown, field: string): string | null { if (value === undefined || value === null) return null; if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { throw new BadRequestException(`${field} must be a UUID or null`); } return value; } function booleanField(value: unknown, field: string): boolean { if (typeof value !== "boolean") throw new BadRequestException(`${field} must be a boolean`); return value; }