import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types'; import type { FreshPayload } from './model'; /** * Runtime schema validators for gateway collections (RI-5-001). * * `api()` returns untrusted JSON cast to `T`; these validators are the * seam where a malformed response becomes an explicit schema mismatch * instead of flowing into the render path as if it were healthy data. */ const taskStatuses: readonly TaskStatus[] = [ 'not-started', 'in-progress', 'blocked', 'done', 'cancelled', ]; const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low']; const missionStatuses: readonly MissionStatus[] = [ 'planning', 'active', 'paused', 'completed', 'failed', ]; const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived']; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isString(value: unknown): value is string { return typeof value === 'string'; } function isNullableString(value: unknown): value is string | null { return value === null || typeof value === 'string'; } function isOneOf(value: unknown, allowed: readonly T[]): value is T { return typeof value === 'string' && (allowed as readonly string[]).includes(value); } function isNullableRecord(value: unknown): value is Record | null { return value === null || isRecord(value); } function isNullableStringArray(value: unknown): value is string[] | null { if (value === null) return true; if (!Array.isArray(value)) return false; return value.every((item) => typeof item === 'string'); } function isIsoLike(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } function isTask(value: unknown): value is Task { if (!isRecord(value)) return false; return ( isString(value['id']) && isString(value['title']) && isOneOf(value['status'], taskStatuses) && isOneOf(value['priority'], taskPriorities) && isNullableString(value['projectId']) && isNullableString(value['missionId']) && isNullableString(value['assignee']) && isNullableStringArray(value['tags']) && isNullableRecord(value['metadata']) && isNullableString(value['dueDate']) && isIsoLike(value['createdAt']) && isIsoLike(value['updatedAt']) ); } /** Tasks carry no workspace identity; scope falls back to the policy. */ export function validateTaskCollection(value: unknown): FreshPayload | null { if (!Array.isArray(value) || !value.every(isTask)) return null; return { data: value as Task[], workspace: null }; } function isMission(value: unknown): value is Mission { if (!isRecord(value)) return false; return ( isString(value['id']) && isString(value['name']) && isOneOf(value['status'], missionStatuses) && isNullableString(value['projectId']) && isNullableString(value['description']) && isNullableRecord(value['metadata']) && isIsoLike(value['createdAt']) && isIsoLike(value['updatedAt']) ); } /** Missions carry no workspace identity; scope falls back to the policy. */ export function validateMissionCollection(value: unknown): FreshPayload | null { if (!Array.isArray(value) || !value.every(isMission)) return null; return { data: value as Mission[], workspace: null }; } function isProject(value: unknown): value is Project { if (!isRecord(value)) return false; return ( isString(value['id']) && isString(value['name']) && isOneOf(value['status'], projectStatuses) && isString(value['userId']) && isNullableString(value['description']) && isNullableRecord(value['metadata']) && isIsoLike(value['createdAt']) && isIsoLike(value['updatedAt']) ); } /** * Projects are workspace-scoped: every item must carry the same `userId`. * A collection mixing identities (cross-workspace leak) is a schema * mismatch; the uniform `userId` becomes the snapshot workspace. */ export function validateProjectCollection(value: unknown): FreshPayload | null { if (!Array.isArray(value) || !value.every(isProject)) return null; const projects = value as Project[]; const workspaces = new Set(projects.map((project) => project.userId)); if (workspaces.size > 1) return null; return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null }; } /** Single project entity (project detail primary collection). */ export function validateProjectEntity(value: unknown): FreshPayload | null { if (!isProject(value)) return null; const project = value as Project; return { data: project, workspace: project.userId }; }