// Registry request-body schemas and world-segment validation. import { SINGLE_WORLD, type Takeover } from "./keys.js"; import { arrayOf, boolean, finiteNumber, literal, nonEmpty, object, optional, recordOf, string, } from "./schema.js"; export interface RegisterBody { clientId: string; secret: string; namespaces: string[]; advertiseUrl: string; headroom: number; draining: boolean; challenge: string; challengeValue: string; hostname?: string | undefined; env?: string | undefined; envs?: Record | undefined; takeover?: Takeover | undefined; epoch?: number | undefined; instances?: Record | undefined; prevSessionId?: string | undefined; } const epochSchema = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0; export const registerSchema = object({ clientId: nonEmpty(string), secret: nonEmpty(string), namespaces: nonEmpty(arrayOf(string)), advertiseUrl: string, headroom: finiteNumber, draining: boolean, challenge: nonEmpty(string), challengeValue: nonEmpty(string), hostname: optional(nonEmpty(string)), env: optional(nonEmpty(string)), envs: optional(recordOf(nonEmpty(string))), takeover: optional(literal("wind_down", "deny")), epoch: optional(epochSchema), instances: optional(recordOf(arrayOf(nonEmpty(string)))), prevSessionId: optional(nonEmpty(string)), }); // One path segment in placement keys and "\n"-separated overwritten fields. const WORLD_SEGMENT = /^[^\s/:]+$/; /** Validates and lowercases a registered hostname or env; "*" is reserved * for single-hostname mode. */ export const validateWorldSegment = ( kind: "hostname" | "env", raw: string, ): string => { const value = raw.toLowerCase(); if (value === SINGLE_WORLD || !WORLD_SEGMENT.test(value)) { throw new Error(`invalid ${kind}: ${JSON.stringify(raw)}`); } return value; }; export interface HeartbeatBody { sessionId: string; headroom: number; draining: boolean; challenge: string; challengeValue: string; acks?: string[] | undefined; } export const heartbeatSchema = object({ sessionId: nonEmpty(string), headroom: finiteNumber, draining: boolean, challenge: nonEmpty(string), challengeValue: nonEmpty(string), acks: optional(arrayOf(nonEmpty(string))), }); const placementEpochSchema = (value: unknown): value is string => typeof value === "string" && /^\d+$/.test(value); export const releaseItemSchema = object<{ sessionId: string; namespace: string; id: string; epoch?: string | undefined; }>({ sessionId: nonEmpty(string), namespace: string, id: string, epoch: optional(placementEpochSchema), }); export const deregisterSchema = object<{ sessionId: string }>({ sessionId: nonEmpty(string), }); const deadlineSchema = (value: unknown): value is number | null => value === null || finiteNumber(value); export const windDownSchema = object<{ namespace: string; id?: string | undefined; deadline: number | null; env?: string | undefined; }>({ namespace: nonEmpty(string), id: optional(nonEmpty(string)), deadline: deadlineSchema, env: optional(nonEmpty(string)), }); export const drainSchema = object<{ clientId: string; deadline: number | null; }>({ clientId: nonEmpty(string), deadline: deadlineSchema, }); export const undrainSchema = object<{ clientId: string }>({ clientId: nonEmpty(string), });