import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types"; import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key"; import { parseRefTarget } from "../parse-ref-target"; import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types"; export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]); // Field-Namen die typischerweise PII enthalten. Ohne `pii: true` / // `userOwned` / `tenantOwned` / `allowPlaintext`-Marker → Boot-Warning. // Lower-case compare für case-insensitive Match (displayName vs displayname). // // Bewusst NICHT in der Liste: // - `name` allein — zu viele Geschäfts-Kontexte (product.name, // tenant.name, role.name) sind kein PII. Personen-Namen werden // ueber displayName / firstName / lastName / fullName erfasst. // // Quelle: docs/plans/datenschutz/crypto-shredding.md Boot-Validation-Sektion. export const PII_DIRECT_NAME_HINTS: ReadonlySet = new Set([ "email", "phone", "phonenumber", "mobile", "address", "street", "postalcode", "zipcode", "zip", "city", "displayname", "firstname", "lastname", "fullname", "birthday", "birthdate", "dateofbirth", "dob", "ssn", "taxid", "vatid", "passport", "iban", "bic", ]); // Field-Namen die typischerweise User-Generated-Content enthalten — // User-Forget muss diese mit Author-Subject-Key encrypten. export const PII_USER_OWNED_NAME_HINTS: ReadonlySet = new Set([ "body", "text", "content", "message", "comment", "description", "note", "notes", ]); // Field names that typically hold a FK into the `user` entity — carry no // content of their own, but the subject-data obligation attaches to the // link, not to annotated content. Without this hint, an entity with no // userOwned-content field stays invisible to the GDPR-hook-coverage guard // even though `authorId` is still personal data. export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet = new Set([ "authorid", "assigneeid", "assigneeuserid", "ownerid", "createdby", "createdbyid", "createdbyuserid", "updatedby", "updatedbyid", "updatedbyuserid", "invitedby", "approvedby", "reviewedby", "uploadedby", "assignedto", "reportedby", "memberid", ]); // --- Extension preSave wiring validation --- /** Extensions with preSave must target an entity that has mapped write handlers. */ export function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void { const extensionsWithPreSave = new Set(); for (const f of features) { for (const [extName, def] of Object.entries(f.registrarExtensions ?? {})) { if (def.hooks?.preSave) extensionsWithPreSave.add(extName); } } // skip: no extensions declare preSave hooks — nothing to validate if (extensionsWithPreSave.size === 0) return; const entitiesWithMappedWrites = new Set(); for (const f of features) { for (const [handlerName, entityName] of Object.entries(f.handlerEntityMappings ?? {})) { if (handlerName in (f.writeHandlers ?? {})) { entitiesWithMappedWrites.add(entityName); } } } for (const f of features) { for (const usage of f.extensionUsages) { if (!extensionsWithPreSave.has(usage.extensionName)) continue; if (!entitiesWithMappedWrites.has(usage.entityName)) { throw new Error( `Feature "${f.name}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` + `but no write handler is entity-mapped to "${usage.entityName}". ` + `Use create/update/delete on a matching entity or name the handler "entity:verb".`, ); } } } } // --- Handler access validation --- // Rate-limit modes that bucket per user.id. Anonymous endpoints would put // every unauthenticated caller into a single shared bucket (id="anonymous"), // turning the rate-limit into a global tap any caller can drain. Boot-fail // before the misconfiguration ships. const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet = new Set(["user", "user+handler"]); // Every handler must declare access. Missing access is treated as default-deny // at runtime, but we fail at boot to turn an easy-to-miss security regression // into a loud configuration error. export function validateHandlerAccess(feature: FeatureDefinition): void { const kinds = [ { kind: "write" as const, label: "Write", handlers: feature.writeHandlers }, { kind: "query" as const, label: "Query", handlers: feature.queryHandlers }, { kind: "stream" as const, label: "Stream", handlers: feature.streamHandlers }, ]; for (const { kind, label, handlers } of kinds) { for (const [name, handler] of Object.entries(handlers)) { if (!handler.access) { throw new Error( `${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` + `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`, ); } validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit); } } } export function validateAnonymousRateLimit( featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"], ): void { // skip: handler doesn't opt into rate-limit, no user-bucket risk if (!rateLimit) return; // skip: openToAll handlers don't allow anonymous (hasAccess rejects), so // the user-bucket footgun doesn't apply if (!("roles" in access)) return; // skip: handler doesn't list anonymous, regular role-rate-limit is fine if (!access.roles.includes("anonymous")) return; // skip: rate-limit is already keyed on something safe (ip / tenant) if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return; throw new Error( `${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` + `rateLimit.per="${rateLimit.per}" — every anonymous request shares user.id="anonymous", ` + `so this bucket would be a single global tap any caller could drain. ` + `Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`, ); } // --- MultiStreamProjection delivery-invariant --- // // `delivery: "per-instance"` mit einer `table` ist eine semantische Falle: // N Dispatcher-Instanzen würden parallel die gleichen INSERT/UPDATE-Zeilen // schreiben (Race / Duplicates), und ein Rebuild würde nur eine Zeile in // kumiko_event_consumers anfassen (die SHARED_INSTANCE_SENTINEL-Zeile), // während Live-Cursor in per-instance-Zeilen liegen → Cursor-Divergenz. // // Die Invariante ist: per-instance-Consumer sind rein side-effect (SSE, // in-memory cache invalidation). Wer eine Tabelle materialisiert, braucht // shared delivery — das ist exactly-once globally und gibt dem Rebuild // einen einzigen Cursor zum zurücksetzen. export function validateMultiStreamProjections(feature: FeatureDefinition): void { for (const [name, msp] of Object.entries(feature.multiStreamProjections)) { if (msp.delivery === "per-instance" && msp.table !== undefined) { throw new Error( `[Feature ${feature.name}] MultiStreamProjection "${name}" has delivery="per-instance" AND a backing table — ` + `that combination would make every dispatcher-instance write the same rows (duplicate INSERTs), and rebuild would reset only the shared cursor while live cursors live per-instance (cursor divergence). ` + `Use delivery="shared" (default) for table-materializing projections, or drop the table for side-effect-only consumers (SSE, in-memory caches).`, ); } } } // --- Located-Timestamp validation --- // // Wenn ein Feld `type: "timestamp"` einen `locatedBy`-Marker trägt, muss das // referenzierte Feld in derselben Entity existieren UND vom Typ `tz` sein. // Sonst weiß weder DB-Wrapper noch JSON-Serializer welche TZ zur Wall-Clock // gehört → silent data loss bei Reads in anderer Server-TZ. // // Die häufigste Quelle von Konflikten ist Hand-Konstruktion: // { foo: { type: "timestamp", locatedBy: "fooTz" } } // ohne das `fooTz`-Feld zu deklarieren. `createLocatedTimestampField()` // erzeugt stattdessen EIN Feld vom Typ "locatedTimestamp" — wer den nutzt, // fliegt nicht durch diesen Validator. export function validateLocatedTimestamps(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { const fields = entity.fields; for (const [fieldName, field] of Object.entries(fields)) { if (field.type !== "timestamp" || field.locatedBy === undefined) continue; const referenced = fields[field.locatedBy]; if (!referenced) { throw new Error( `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` + `locatedBy: "${field.locatedBy}" but no field with that name exists in the entity. ` + `Either declare the tz-field, or use createLocatedTimestampField() ` + `to create a single located-timestamp field instead.`, ); } if (referenced.type !== "tz") { throw new Error( `Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` + `locatedBy: "${field.locatedBy}" but that field is type "${referenced.type}", ` + `expected "tz". The locatedBy marker must point to a tz-field (IANA-zone slot).`, ); } } } } // --- Entity-Index validation --- // // entity.indexes deklariert Composite-/Unique-Indices über mehrere Feld- // Spalten. Häufige Fehler: Tippfehler im Feld-Namen, leere column-Liste, // Index auf einem Field das die DB-Spalte gar nicht existiert (file/image // in der multi-Variante). Catched at boot, lange bevor drizzle-kit beim // generate-Run zickt. // // `tenantId` als einzige Spalte ist redundant — buildEntityTable legt // den Index sowieso automatisch an. Wir lassen die Composite-Form erlaubt // (`["tenantId", "key"]` ist sinnvoll), nur die rein-tenantId-Single- // column-Form blockieren wir. export function validateEntityIndexes(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { if (!entity.indexes) continue; const fieldNames = new Set(Object.keys(entity.fields)); for (const [idx, def] of entity.indexes.entries()) { const where = `Feature "${feature.name}", entity "${entityName}", indexes[${idx}]`; if (def.columns.length === 0) { throw new Error(`${where}: empty columns list. An index needs at least one column.`); } for (const col of def.columns) { if (col === "tenantId" || col === "id" || col === "version") continue; // base columns if (!fieldNames.has(col)) { throw new Error( `${where}: column "${col}" does not match any field in the entity. ` + `Available fields: ${[...fieldNames].join(", ")}.`, ); } const field = entity.fields[col]; if ( field && (field.type === "files" || field.type === "images" || (field.type === "reference" && field.multiple === true)) ) { throw new Error( `${where}: column "${col}" is a multi-value field (${field.type}) — ` + `these have no DB column to index on. Use a single-value field or remove from the index.`, ); } if (field && field.type === "longText") { // longText ist semantisch "potentially-megabytes content" — ein // BTREE-Index auf einer 1-MB-Spalte ist Performance-Disaster // (PG würde in TOAST-pages dereferenzieren müssen für jeden // Index-Lookup). Konsistent mit der type-level-decision dass // longText kein sortable/searchable/filterable hat. Wer // wirklich indexieren will, nimmt `text` mit den // entsprechenden Skalierungs-Trade-offs. throw new Error( `${where}: column "${col}" is a longText field — these cannot be indexed. ` + `Use \`text\` if you need indexing, or rely on the SearchAdapter (Meilisearch) for full-text search on long content.`, ); } } // UNIQUE-constraint auf tenantId ist semantisch (1:1 tenant→entity) // und NICHT redundant — buildEntityTable's auto-Index ist nur ein // Performance-Hint, kein constraint. Nur die rein-tenantId-Single- // column-non-unique-Form blockieren. if (def.columns.length === 1 && def.columns[0] === "tenantId" && !def.unique) { throw new Error( `${where}: single-column index on "tenantId" is redundant — ` + `buildEntityTable always creates one automatically. Remove this entry.`, ); } // AES-GCM's random-IV ciphertext is never equal to itself across two // writes of the same plaintext, so a unique index on an encrypted // column can never detect a real duplicate. if (def.unique) { for (const col of def.columns) { const field = entity.fields[col]; if (field && (field.type === "text" || field.type === "longText") && field.encrypted) { throw new Error( `${where}: column "${col}" is encrypted — a unique index on it can never detect ` + `duplicates (non-deterministic ciphertext). Remove \`unique\` or \`encrypted\`.`, ); } } } } } } // --- Encrypted field validation --- export function validateEncryptedFields(feature: FeatureDefinition): boolean { let found = false; for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { for (const [fieldName, field] of Object.entries(entity.fields)) { // Beide string-typed fields können encrypted sein. Die // searchable/sortable-Konflikt-Checks gelten nur für `text` // (longText hat diese flags type-level nicht). if (field.type !== "text" && field.type !== "longText") continue; if (!field.encrypted) continue; found = true; if (field.type === "text") { if (field.searchable) { throw new Error( `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable`, ); } if (field.sortable) { throw new Error( `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable`, ); } // AES-GCM draws a fresh random IV per call, so the ciphertext is // non-deterministic — an equality filter on it never matches, even // against the identical plaintext. if (field.filterable) { throw new Error( `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and filterable ` + `— non-deterministic ciphertext never matches an equality filter.`, ); } } } } return found; } // --- File field detection --- export function validateFileFields(feature: FeatureDefinition): boolean { for (const entity of Object.values(feature.entities ?? {})) { for (const field of Object.values(entity.fields)) { if (FILE_FIELD_TYPES.has(field.type)) return true; } } return false; } // --- Embedded field validation --- const VALID_EMBEDDED_SUB_TYPES = new Set([ "text", "number", "boolean", "date", "money", "decimal", "select", "reference", "timestamp", ]); const NUMERIC_EMBEDDED_SUB_TYPES = new Set(["number", "money", "decimal"]); // 15 is where 10^scale exhausts a double's integer range — beyond it the // scale check in the write schema could no longer hold. function isValidEmbeddedDecimalScale(scale: number): boolean { return Number.isInteger(scale) && scale >= 0 && scale <= 15; } // Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation, shared by // top-level reference fields and reference sub-fields of an embedded field // (only the field-path in error messages differs, e.g. "accountId" vs // "lines.accountId", so a failure is locatable either way). // 1) referenced entity exists (same-feature OR cross-feature qualified via // ":"). Same-feature is the default; cross-feature // requires an explicit ":" prefix. // 2) labelField (if set) exists on the referenced entity. // 3) Query handler `:query::list` is registered — the // renderer fires it on Combobox open, so a missing handler crashes the // Combobox at runtime. function validateReferenceTarget( entityName: string, fieldPath: string, refString: string, labelField: string | undefined, feature: FeatureDefinition, featureMap: ReadonlyMap, ): void { const target = parseRefTarget(refString, feature.name); const targetFeature = featureMap.get(target.featureName); if (!targetFeature) { const knownFeatures = [...featureMap.keys()].sort().join(", "); throw new Error( `[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` + `targets unknown feature "${target.featureName}" via "${refString}". ` + `Known features: ${knownFeatures}.`, ); } const targetEntity = targetFeature.entities?.[target.entityName]; if (!targetEntity) { const known = Object.keys(targetFeature.entities ?? {}) .sort() .join(", ") || "(none)"; const where = target.featureName === feature.name ? `in this feature` : `in feature "${target.featureName}"`; throw new Error( `[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` + `targets unknown entity "${target.entityName}" ${where}. ` + `Known entities: ${known}.`, ); } if (labelField !== undefined) { const knownFields = Object.keys(targetEntity.fields); // "id" always exists, even without an explicit field definition (PK). if (labelField !== "id" && !knownFields.includes(labelField)) { throw new Error( `[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` + `references labelField "${labelField}" which does not exist on entity ` + `"${target.entityName}". Known fields: ${[...knownFields, "id"].sort().join(", ")}.`, ); } } // Pins query-handler existence. The renderer fires // `:query::list` on Combobox open // (use-reference-lookup, ReferenceInput); without a handler that's a 404 // on first click. defaultEntityQueryHandler names are stored short as // ":list" in feature.queryHandlers. const expectedHandlerShortName = `${target.entityName}:list`; if (targetFeature.queryHandlers[expectedHandlerShortName] === undefined) { throw new Error( `[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` + `targets entity "${target.entityName}" but no list-query-handler is registered ` + `there. Add r.queryHandler(defineEntityListHandler("${target.entityName}", ` + `${target.entityName}Entity)) to feature "${target.featureName}", or pick a ` + `different label/entity.`, ); } } // Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation for top-level // reference fields (self-reference, entity → entity, is allowed). The actual // checks run in validateReferenceTarget — shared with the reference // sub-fields of an embedded field (validateEmbeddedFields). export function validateReferenceFields( feature: FeatureDefinition, featureMap: ReadonlyMap, ): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { for (const [fieldName, field] of Object.entries(entity.fields)) { if (field.type !== "reference") continue; validateReferenceTarget( entityName, fieldName, field.entity, field.labelField, feature, featureMap, ); } } } export function validateEmbeddedFields( feature: FeatureDefinition, featureMap: ReadonlyMap, ): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { for (const [fieldName, field] of Object.entries(entity.fields)) { if (field.type !== "embedded") continue; if (!field.schema || Object.keys(field.schema).length === 0) { throw new Error( `Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema`, ); } for (const [subName, subField] of Object.entries(field.schema)) { if (!VALID_EMBEDDED_SUB_TYPES.has(subField.type)) { throw new Error( `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid type "${subField.type}". Allowed: ${[...VALID_EMBEDDED_SUB_TYPES].join(", ")}`, ); } if (subField.type === "decimal" && !isValidEmbeddedDecimalScale(subField.scale)) { throw new Error( `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid scale ${subField.scale}. Must be an integer between 0 and 15.`, ); } if (subField.type === "select" && subField.options.length === 0) { throw new Error( `Embedded field "${fieldName}.${subName}" on entity "${entityName}" has empty options`, ); } // Reference sub-fields get the same target/labelField/query-handler // checks as a top-level reference field — same failure mode // (crashing Combobox at runtime) if skipped, so it can't stay a // second-class citizen just because it's nested. if (subField.type === "reference") { validateReferenceTarget( entityName, `${fieldName}.${subName}`, subField.entity, subField.labelField, feature, featureMap, ); } } validateEmbeddedListMetadata(fieldName, entityName, field, entity); } } } function validateEmbeddedListBounds( fieldName: string, entityName: string, field: EmbeddedFieldDef, ): void { if (field.minItems !== undefined && field.minItems < 0) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has invalid minItems ${field.minItems}. Must be >= 0.`, ); } if (field.maxItems !== undefined && field.maxItems < 1) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has invalid maxItems ${field.maxItems}. Must be >= 1.`, ); } if ( field.minItems !== undefined && field.maxItems !== undefined && field.minItems > field.maxItems ) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}.`, ); } // required:true means "at least one row" (schema-builder.ts falls back to // minItems:1 for that); an explicit minItems:0 would silently win over // that and let a required list submit empty — reject the contradiction // instead of picking one side of it for the caller. if (field.required === true && field.minItems === 0) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" sets required:true and minItems:0 — these contradict each other. Drop minItems (defaults to 1) or set required:false.`, ); } } function validateEmbeddedDerivedCells( fieldName: string, entityName: string, field: EmbeddedFieldDef, ): void { // skip: no derived cells declared — nothing to validate if (field.derived === undefined) return; for (const [derivedName, derivedDef] of Object.entries(field.derived)) { if (!(derivedName in field.schema)) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema.`, ); } for (const sourceName of derivedDef.from) { if (!(sourceName in field.schema)) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`, ); } } } } function validateEmbeddedTotalsColumns( fieldName: string, entityName: string, field: EmbeddedFieldDef, ): void { // skip: no totals columns declared — nothing to validate if (field.totals === undefined) return; for (const totalName of field.totals) { const totalSubField = field.schema[totalName]; if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field.`, ); } } } function validateEmbeddedTotalsMatch( fieldName: string, entityName: string, field: EmbeddedFieldDef, entity: EntityDefinition, ): void { // skip: no totalsMatch declared — nothing to validate if (field.totalsMatch === undefined) return; for (const [subFieldName, siblingFieldName] of Object.entries(field.totalsMatch)) { const subField = field.schema[subFieldName]; if (subField?.type !== "money") { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry for "${subFieldName}", which is not a money sub-field in its schema.`, ); } const siblingField = entity.fields[siblingFieldName]; if (siblingField?.type !== "money") { throw new Error( `Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry mapping "${subFieldName}" to sibling field "${siblingFieldName}", which is not a money field on entity "${entityName}".`, ); } } } function validateEmbeddedDerivedAndTotals( fieldName: string, entityName: string, field: EmbeddedFieldDef, entity: EntityDefinition, ): void { validateEmbeddedDerivedCells(fieldName, entityName, field); validateEmbeddedTotalsColumns(fieldName, entityName, field); validateEmbeddedTotalsMatch(fieldName, entityName, field, entity); } function validateEmbeddedListMetadata( fieldName: string, entityName: string, field: EmbeddedFieldDef, entity: EntityDefinition, ): void { validateEmbeddedListBounds(fieldName, entityName, field); if ( field.multiple !== true && (field.minItems !== undefined || field.maxItems !== undefined || field.derived !== undefined || field.totals !== undefined || field.totalsMatch !== undefined) ) { throw new Error( `Embedded field "${fieldName}" on entity "${entityName}" sets minItems/maxItems/derived/totals/totalsMatch, which is only valid on an embedded LIST field (multiple: true).`, ); } validateEmbeddedDerivedAndTotals(fieldName, entityName, field, entity); } // --- MultiSelect field validation --- // // options muss non-empty sein (sonst wäre das Feld nicht benutzbar) und // default — wenn gesetzt — ist eine Teilmenge der options. Beides würde // auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur // die früheste Stelle für klare Fehlermeldungen. export function validateMultiSelectFields(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { for (const [fieldName, field] of Object.entries(entity.fields)) { if (field.type !== "multiSelect") continue; if (field.options.length === 0) { throw new Error( `MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options`, ); } if (field.default !== undefined) { const validOptions = new Set(field.options); for (const value of field.default) { if (!validOptions.has(value)) { throw new Error( `MultiSelect default "${value}" on "${entityName}.${fieldName}" is not a valid option. Valid: ${field.options.join(", ")}`, ); } } } } } } // --- Image variant validation --- function isPositiveInt(value: number): boolean { return Number.isInteger(value) && value > 0; } // Renderers cap dimensions in practice (sharp refuses output above 0x1000000 // pixels); 8192 per edge is generously above any real thumbnail/preview use // and keeps a boot-declared spec from becoming a memory-exhaustion vector. const MAX_VARIANT_EDGE_PX = 8192; // sharp's blur() accepts a sigma of 0.3..1000; anything above that throws at // render time, months after boot, on the first request for that variant. const MAX_BLUR_SIGMA = 1000; function isValidBlurRegion(region: BlurRegion): boolean { const { x, y, width, height } = region; return ( Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(width) && Number.isFinite(height) && x >= 0 && y >= 0 && width >= 0 && height >= 0 && x + width <= 1 && y + height <= 1 ); } // kumiko-lint-ignore complexity-budget blur/size bounds added for boot-time DoS guard function assertValidVariantSpec(name: string, spec: VariantSpec, where: string): void { if (!VARIANT_NAME_PATTERN.test(name)) { throw new Error( `Image variant "${name}" ${where} must match ${VARIANT_NAME_PATTERN.source} — the name becomes part of a storage key and a URL segment.`, ); } if (spec.size !== undefined && spec.maxEdge !== undefined) { throw new Error(`Image variant "${name}" ${where} sets both "size" and "maxEdge" — pick one.`); } if ( spec.size !== undefined && !( isPositiveInt(spec.size.width) && isPositiveInt(spec.size.height) && spec.size.width <= MAX_VARIANT_EDGE_PX && spec.size.height <= MAX_VARIANT_EDGE_PX ) ) { throw new Error( `Image variant "${name}" ${where} has an invalid "size" (${spec.size.width}x${spec.size.height}) — must be a positive integer up to ${MAX_VARIANT_EDGE_PX}px per edge.`, ); } if ( spec.maxEdge !== undefined && !(isPositiveInt(spec.maxEdge) && spec.maxEdge <= MAX_VARIANT_EDGE_PX) ) { throw new Error( `Image variant "${name}" ${where} has an invalid "maxEdge" (${spec.maxEdge}) — must be a positive integer up to ${MAX_VARIANT_EDGE_PX}px.`, ); } if (spec.quality !== undefined && !(isPositiveInt(spec.quality) && spec.quality <= 100)) { throw new Error( `Image variant "${name}" ${where} has "quality" ${spec.quality} — must be an integer in 1..100.`, ); } if ( spec.blur !== undefined && !(Number.isFinite(spec.blur) && spec.blur > 0 && spec.blur <= MAX_BLUR_SIGMA) ) { throw new Error( `Image variant "${name}" ${where} has "blur" ${spec.blur} — must be a finite number in (0, ${MAX_BLUR_SIGMA}].`, ); } if (spec.blurRegions !== undefined) { for (const region of spec.blurRegions) { if (!isValidBlurRegion(region)) { throw new Error( `Image variant "${name}" ${where} has an invalid "blurRegions" entry ${JSON.stringify(region)} — x/y/width/height must be within 0..1 and x+width/y+height must not exceed 1.`, ); } } } } // A bad spec is only visible when someone finally requests that variant — // possibly months later, in production. Catch it at boot instead. export function validateImageVariants(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { for (const [fieldName, field] of Object.entries(entity.fields)) { if (field.type !== "image" && field.type !== "images") continue; if (field.variants === undefined) continue; const where = `on "${entityName}.${fieldName}" in feature "${feature.name}"`; for (const [name, spec] of Object.entries(field.variants)) { assertValidVariantSpec(name, spec, where); } } } } // --- Transition validation --- export function validateTransitions(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { if (!entity.transitions) continue; for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) { const field = entity.fields[fieldName]; if (!field) { throw new Error( `Transitions defined for unknown field "${fieldName}" on entity "${entityName}" in feature "${feature.name}"`, ); } if (field.type !== "select") { throw new Error( `Transitions defined for field "${fieldName}" on entity "${entityName}" but field type is "${field.type}" (must be "select")`, ); } const validOptions = new Set(field.options); // Check all states in the transition map for (const [from, targets] of Object.entries(transitionMap)) { if (!validOptions.has(from)) { throw new Error( `Transition state "${from}" on "${entityName}.${fieldName}" is not a valid option. Valid: ${[...validOptions].join(", ")}`, ); } for (const to of targets) { if (!validOptions.has(to)) { throw new Error( `Transition target "${to}" (from "${from}") on "${entityName}.${fieldName}" is not a valid option. Valid: ${[...validOptions].join(", ")}`, ); } } } } } } // --- extendSchema column collision detection --- export function validateExtendSchemaCollisions(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { const existingFields = new Set(Object.keys(entity.fields)); // Check if any registered extension would collide with existing fields for (const ext of Object.values(feature.registrarExtensions)) { if (!ext.extendSchema) continue; const extraFields = ext.extendSchema(entityName); for (const fieldName of Object.keys(extraFields)) { if (existingFields.has(fieldName)) { throw new Error( `extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}"`, ); } } } } } // --- derivedFields / fields collision detection --- // // augmentDerivedFields (entity-handlers.ts) does `{...row}` then // `out[fieldName] = def.derive(row, ctx)` — a derivedFields key that matches a // stored field name silently overwrites the real, persisted value with the // derived one on every read, without a boot or runtime error. Reject it here, // analogous to the extendSchema collision check above. export function validateDerivedFieldCollisions(feature: FeatureDefinition): void { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { if (!entity.derivedFields) continue; const fieldNames = new Set(Object.keys(entity.fields)); for (const derivedName of Object.keys(entity.derivedFields)) { if (fieldNames.has(derivedName)) { throw new Error( `Entity "${entityName}": derivedFields key "${derivedName}" conflicts with a stored ` + `field of the same name — the derived value would silently overwrite the real one on every read.`, ); } } } }