136 lines
4.7 KiB
TypeScript
136 lines
4.7 KiB
TypeScript
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<T>()` 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<string, unknown> {
|
|
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<T extends string>(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<string, unknown> | 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<Task[]> | 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<Mission[]> | 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<Project[]> | 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<Project> | null {
|
|
if (!isProject(value)) return null;
|
|
const project = value as Project;
|
|
return { data: project, workspace: project.userId };
|
|
}
|