import * as z from "zod/mini"; import { type IdentityTypeValue, isIdentityTypeValue, } from "../identity-type.js"; import { createBoundedProtocolJsonObjectSchema } from "../json-value.js"; const maxCategoryLength = 255; const maxCommentLength = 64 * 1024; const maxGraphKeyLength = 128; const maxReasonCount = 20; const maxReasonLength = 128; const maxReferenceCount = 20; const maxRelatedSubjectCount = 50; const maxTargetIdLength = 4096; const maxValueLength = 255; const maxFeedbackInputNodes = 25_000; const maxFeedbackInputBytes = 64 * 1024; const graphKeyPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const referenceKeyPattern = /^[a-z][a-z0-9]*(?:[_.-][a-z0-9]+)*$/u; const externalIdentityTypePattern = /^externalId\.users\.none\.[a-z0-9][a-z0-9_.-]*$/u; const feedbackJsonBounds = { maxCollectionSize: 100, maxDepth: 8, maxKeyLength: 255, maxStringLength: 16 * 1024, maxTotalNodes: 1000, } as const; const feedbackJsonObjectSchema = createBoundedProtocolJsonObjectSchema(feedbackJsonBounds); const isNonBlank = (value: unknown) => typeof value === "string" && value.trim().length > 0; const hasNoSurroundingWhitespace = (value: unknown) => typeof value === "string" && value === value.trim(); const nonBlankStringCheck = z.refine(isNonBlank, { message: "Expected a non-blank string.", }); const noSurroundingWhitespaceCheck = z.refine(hasNoSurroundingWhitespace, { message: "Expected no surrounding whitespace.", }); const fitsFeedbackInputObjectGraphBounds = (value: unknown): boolean => { const pending: unknown[] = [value]; const visited = new WeakSet(); let visitedNodes = 0; while (pending.length > 0) { visitedNodes += 1; if (visitedNodes > maxFeedbackInputNodes) { return false; } const current = pending.pop(); if (current === null || typeof current !== "object") { continue; } if (visited.has(current)) { continue; } visited.add(current); if (Object.hasOwn(current, "__proto__")) { return false; } if (Array.isArray(current)) { if ( current.length > feedbackJsonBounds.maxCollectionSize || visitedNodes + pending.length + current.length > maxFeedbackInputNodes ) { return false; } for (const child of current) { pending.push(child); } continue; } const keys = Object.keys(current); if ( keys.length > feedbackJsonBounds.maxCollectionSize || visitedNodes + pending.length + keys.length > maxFeedbackInputNodes ) { return false; } for (const key of keys) { pending.push((current as Record)[key]); } } return true; }; const fitsFeedbackInputByteBounds = (value: unknown): boolean => { try { const serialized = JSON.stringify(value); return ( typeof serialized === "string" && new TextEncoder().encode(serialized).byteLength <= maxFeedbackInputBytes ); } catch { return false; } }; const feedbackInputObjectGraphSchema = z.unknown().check( z.refine(fitsFeedbackInputObjectGraphBounds, { message: "Feedback input contains an unsafe or oversized object graph.", }), z.refine(fitsFeedbackInputByteBounds, { message: "Feedback input exceeds the maximum serialized size.", }), ); const finiteNumberSchema = z.number().check( z.refine(Number.isFinite, { message: "Expected a finite number.", }), ); export const instanceFeedbackCategorySchema = z .string() .check( nonBlankStringCheck, noSurroundingWhitespaceCheck, z.maxLength(maxCategoryLength), ); const commentSchema = z .string() .check(nonBlankStringCheck, z.maxLength(maxCommentLength)); const graphKeySchema = z.string().check( z.minLength(1), z.maxLength(maxGraphKeyLength), z.regex(graphKeyPattern, { message: "Expected a lowercase kebab-case graph key.", }), ); export const instanceFeedbackReferenceKeySchema = z.string().check( z.minLength(1), z.maxLength(maxGraphKeyLength), z.regex(referenceKeyPattern, { message: "Expected a lowercase reference key using letters, numbers, dots, underscores, or hyphens.", }), ); const valueSchema = z.union([ z.boolean(), finiteNumberSchema, z.string().check(nonBlankStringCheck, z.maxLength(maxValueLength)), ]); const reasonSchema = z .string() .check( nonBlankStringCheck, noSurroundingWhitespaceCheck, z.maxLength(maxReasonLength), ); const reasonsSchema = z .tuple([reasonSchema], reasonSchema) .check(z.maxLength(maxReasonCount)); const graphSubjectRefShape = { collection: graphKeySchema, id: z .string() .check( nonBlankStringCheck, noSurroundingWhitespaceCheck, z.maxLength(maxTargetIdLength), ), }; export const instanceFeedbackSubjectRefSchema = z.pipe( feedbackInputObjectGraphSchema, z.strictObject(graphSubjectRefShape), ); export const instanceFeedbackTargetSchema = z.pipe( feedbackInputObjectGraphSchema, z.strictObject({ ...graphSubjectRefShape, properties: z.optional(feedbackJsonObjectSchema), references: z.optional( z .record(instanceFeedbackReferenceKeySchema, feedbackJsonObjectSchema) .check( z.refine((value) => Object.keys(value).length <= maxReferenceCount, { message: `Expected at most ${maxReferenceCount} references.`, }), ), ), }), ); const contextShape = { properties: z.optional(feedbackJsonObjectSchema), related: z.optional( z .array( z.strictObject({ role: graphKeySchema, subject: instanceFeedbackSubjectRefSchema, }), ) .check(z.maxLength(maxRelatedSubjectCount)), ), target: z.optional(instanceFeedbackTargetSchema), }; const categorizedInputSchema = z.strictObject({ ...contextShape, category: instanceFeedbackCategorySchema, comment: z.optional(commentSchema), reasons: z.optional(reasonsSchema), value: z.optional(valueSchema), }); const commentOnlyInputSchema = z.strictObject({ ...contextShape, category: z.optional(z.never()), comment: commentSchema, reasons: z.optional(z.never()), value: z.optional(z.never()), }); export const instanceFeedbackInputSchema = z.pipe( feedbackInputObjectGraphSchema, z.union([categorizedInputSchema, commentOnlyInputSchema]), ); type DeclaredIdentityType = | IdentityTypeValue | `externalId.users.none.${string}`; const declaredIdentityTypeSchema = z.pipe( z.string().check( nonBlankStringCheck, noSurroundingWhitespaceCheck, z.maxLength(255), z.refine( (value) => typeof value === "string" && (isIdentityTypeValue(value) || externalIdentityTypePattern.test(value)), { message: "Expected a supported identity type." }, ), ), z.transform((value): DeclaredIdentityType => value as DeclaredIdentityType), ); export const instanceFeedbackDeclaredIdentitySchema = z.strictObject({ type: declaredIdentityTypeSchema, value: z .string() .check( nonBlankStringCheck, noSurroundingWhitespaceCheck, z.maxLength(4096), ), }); export const instanceFeedbackCaptureTransportSchema = z.strictObject({ context: z.strictObject({ identities: z .array(instanceFeedbackDeclaredIdentitySchema) .check(z.maxLength(32)), }), feedback: instanceFeedbackInputSchema, }); export type InstanceFeedbackInput = z.output< typeof instanceFeedbackInputSchema >; export type InstanceFeedbackCaptureTransport = z.output< typeof instanceFeedbackCaptureTransportSchema >;