@nfcard/validation 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.17.0"
20
+ "@nfcard/types": "0.19.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
package/src/index.ts CHANGED
Binary file
@@ -0,0 +1,259 @@
1
+ /**
2
+ * NFCARD-656 — write-side guards for `DigitalConfig.layout`.
3
+ *
4
+ * The abuse cases matter more than the happy path here: every style value
5
+ * eventually composes into a CSS rule block, so the assertion of record is
6
+ * "nothing non-primitive survives the schema", with the injection-shaped
7
+ * strings spelled out.
8
+ */
9
+ import { describe, expect, it } from 'vitest'
10
+ import { CANONICAL_OBJECT_ORDER, PROFILE_OBJECT_KINDS, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
11
+ import { MAX_CTA_BUTTONS, digitalConfigSchema } from './index'
12
+ import { STYLE_SCHEMA_FOR_KIND, profileLayoutSchema, safeProfileLayout } from './profileLayout'
13
+
14
+ const okLayout: ProfileLayout = {
15
+ v: 1,
16
+ sectionOrder: ['identity', 'social', 'contact', 'bio', 'actions', 'cta', 'footer'],
17
+ objectOrder: {
18
+ social: ['social-x', 'social-linkedin'],
19
+ actions: ['act-share', 'act-save', 'act-exchange'],
20
+ },
21
+ objects: {
22
+ name: { style: { color: '#112233', sizeStep: 'lg', weight: 'bold' } },
23
+ avatar: { style: { crop: { x: 0.5, y: 0.25, zoom: 1.6 }, borderColor: '#ffffff', borderPx: 3 } },
24
+ 'cta-0': { style: { bg: '#16a34a', color: '#ffffff' } },
25
+ booking: { style: { weight: 'bold' } },
26
+ },
27
+ ownedObjects: [{ id: 'ft-abcd', kind: 'freeText', section: 'bio', text: 'Hello there' }],
28
+ }
29
+
30
+ describe('profileLayoutSchema — accepts real intent', () => {
31
+ it('parses a full document and the result is a ProfileLayout', () => {
32
+ const parsed = profileLayoutSchema.parse(okLayout)
33
+ const typed: ProfileLayout = parsed
34
+ expect(typed.objectOrder?.social).toEqual(['social-x', 'social-linkedin'])
35
+ })
36
+
37
+ it('parses the boring derived document and the empty-intent forms', () => {
38
+ expect(profileLayoutSchema.parse({ v: 1 })).toEqual({ v: 1 })
39
+ expect(profileLayoutSchema.safeParse({ v: 1, objectOrder: {} }).success).toBe(true)
40
+ })
41
+
42
+ it('order entries tolerate well-formed ids this vintage does not know (skew safety)', () => {
43
+ const r = profileLayoutSchema.safeParse({
44
+ v: 1,
45
+ objectOrder: { social: ['social-threads', 'social-x'] },
46
+ })
47
+ expect(r.success).toBe(true)
48
+ })
49
+ })
50
+
51
+ describe('profileLayoutSchema — rejects abuse', () => {
52
+ const bad = (layout: unknown) => profileLayoutSchema.safeParse(layout).success
53
+
54
+ it('version and unknown keys', () => {
55
+ expect(bad({ v: 2 })).toBe(false)
56
+ expect(bad({ v: 1, extra: true })).toBe(false)
57
+ expect(bad({ v: 1, objects: { name: { style: {}, accent: {} } } })).toBe(false)
58
+ })
59
+
60
+ it('sectionOrder: duplicates and unknown sections', () => {
61
+ expect(bad({ v: 1, sectionOrder: ['social', 'social'] })).toBe(false)
62
+ expect(bad({ v: 1, sectionOrder: ['hero'] })).toBe(false)
63
+ })
64
+
65
+ it('objectOrder: malformed ids and oversize lists', () => {
66
+ expect(bad({ v: 1, objectOrder: { social: ['a.b'] } })).toBe(false)
67
+ expect(bad({ v: 1, objectOrder: { social: ['x'.repeat(33)] } })).toBe(false)
68
+ expect(bad({ v: 1, objectOrder: { social: Array.from({ length: 25 }, (_, i) => `id-${i}`) } })).toBe(false)
69
+ expect(bad({ v: 1, objectOrder: { unknownSection: ['social-x'] } })).toBe(false)
70
+ })
71
+
72
+ it('objects: unknown ids, off-vocabulary props, injection-shaped values', () => {
73
+ expect(bad({ v: 1, objects: { 'not-a-thing': { style: {} } } })).toBe(false)
74
+ // contactRow is TEXT-stylable since the any-text wave, but `bg` never
75
+ // joined its vocabulary — a row has no fill of its own.
76
+ expect(bad({ v: 1, objects: { 'contact-email': { style: { bg: '#112233' } } } })).toBe(false)
77
+ expect(bad({ v: 1, objects: { name: { style: { color: 'red;}{' } } } })).toBe(false)
78
+ expect(bad({ v: 1, objects: { name: { style: { color: '#12345' } } } })).toBe(false)
79
+ expect(bad({ v: 1, objects: { avatar: { style: { borderPx: 99 } } } })).toBe(false)
80
+ expect(bad({ v: 1, objects: { avatar: { style: { crop: { x: 0.5, y: 0.5, zoom: 9 } } } } })).toBe(false)
81
+ expect(bad({ v: 1, objects: { 'cta-0': { style: { bg: 'url(javascript:1)' } } } })).toBe(false)
82
+ expect(bad({ v: 1, objects: { name: { style: { fontKey: '../evil' } } } })).toBe(false)
83
+ })
84
+
85
+ it('objects: the override cap and the distinct-font cap', () => {
86
+ const many = Object.fromEntries(
87
+ Array.from({ length: 65 }, (_, i) => [`ft-over${String(i).padStart(4, '0')}`, { style: {} }]),
88
+ )
89
+ expect(bad({ v: 1, objects: many })).toBe(false)
90
+
91
+ const fonts = Object.fromEntries(
92
+ ['name', 'role', 'bio', 'ft-aaaa', 'ft-bbbb'].map((id, i) => [
93
+ id,
94
+ { style: { fontKey: `font-${i}` } },
95
+ ]),
96
+ )
97
+ expect(bad({ v: 1, objects: fonts })).toBe(false)
98
+ })
99
+
100
+ it('ownedObjects: caps, duplicate ids, non-hosting sections, dissolving text', () => {
101
+ const ft = (id: string, section = 'bio', text = 'ok') => ({ id, kind: 'freeText', section, text })
102
+ expect(bad({ v: 1, ownedObjects: Array.from({ length: 6 }, (_, i) => ft(`ft-num${i}x`)) })).toBe(false)
103
+ expect(bad({ v: 1, ownedObjects: [ft('ft-abcd'), ft('ft-abcd')] })).toBe(false)
104
+ expect(bad({ v: 1, ownedObjects: [ft('ft-abcd', 'contact')] })).toBe(false)
105
+ expect(bad({ v: 1, ownedObjects: [ft('ft-abcd', 'bio', '<b></b>')] })).toBe(false)
106
+ })
107
+
108
+ it('freeText text is sanitised, not just bounded', () => {
109
+ const r = profileLayoutSchema.parse({
110
+ v: 1,
111
+ ownedObjects: [
112
+ { id: 'ft-abcd', kind: 'freeText', section: 'bio', text: ' <b>Szia</b> világ ' },
113
+ ],
114
+ })
115
+ expect(r.ownedObjects?.[0].text).toBe('Szia világ')
116
+ })
117
+ })
118
+
119
+ describe('the any-text wave: per-kind style vocabulary (kind × prop matrix)', () => {
120
+ const style = (id: string, s: Record<string, unknown>) =>
121
+ profileLayoutSchema.safeParse({ v: 1, objects: { [id]: { style: s } } }).success
122
+
123
+ it('rows (contactRow / socialLink) take the text vocabulary minus align/sizeStep', () => {
124
+ const full = {
125
+ color: '#112233',
126
+ weight: 'bold',
127
+ italic: true,
128
+ underline: true,
129
+ fontKey: 'inter',
130
+ }
131
+ expect(style('contact-email', full)).toBe(true)
132
+ expect(style('social-instagram', full)).toBe(true)
133
+ // Row alignment is the template's grid — align stays refused. And a row
134
+ // is a compound (label/value spans with own px sizes), so sizeStep has
135
+ // nothing honest to render — refused rather than stored dead.
136
+ expect(style('contact-email', { align: 'center' })).toBe(false)
137
+ expect(style('social-instagram', { align: 'start' })).toBe(false)
138
+ expect(style('contact-email', { sizeStep: 'sm' })).toBe(false)
139
+ expect(style('social-instagram', { sizeStep: 'lg' })).toBe(false)
140
+ })
141
+
142
+ it('buttons take label styling; geometry props stay out', () => {
143
+ const label = { color: '#112233', weight: 'bold', italic: true, fontKey: 'inter' }
144
+ expect(style('cta-0', { ...label, bg: '#16a34a' })).toBe(true)
145
+ expect(style('booking', { ...label, bg: '#16a34a' })).toBe(true)
146
+ // No sizeStep (button geometry is the template's), no underline, no align.
147
+ expect(style('cta-0', { sizeStep: 'lg' })).toBe(false)
148
+ expect(style('cta-0', { underline: true })).toBe(false)
149
+ expect(style('booking', { align: 'center' })).toBe(false)
150
+ })
151
+
152
+ it('🔴 variant is GONE for every button kind — no surface could ever write it', () => {
153
+ expect(style('act-share', { color: '#112233', bg: '#16a34a', weight: 'bold' })).toBe(true)
154
+ for (const id of ['cta-0', 'booking', 'act-share']) {
155
+ expect(style(id, { variant: 'solid' })).toBe(false)
156
+ }
157
+ })
158
+
159
+ it('🔴 the vocabulary is TOTAL: every kind dispatches to a schema', () => {
160
+ // Driven off PROFILE_OBJECT_KINDS, not a hardcoded list — a NINTH kind
161
+ // added to types without a row here fails THIS line, not a production
162
+ // render. (tsc would catch it too, but tsc does not run on the publish
163
+ // path — validation 0.24.0 shipped while tsc was red.)
164
+ for (const kind of PROFILE_OBJECT_KINDS) {
165
+ expect(STYLE_SCHEMA_FOR_KIND[kind], `no style schema for kind '${kind}'`).toBeDefined()
166
+ }
167
+ // And through the real dispatch: one id per kind; `{}` is the least
168
+ // style. (The empty-style accept is also the additive guarantee: old
169
+ // documents keep parsing as vocabularies widen.)
170
+ const representative: Record<string, string> = {
171
+ text: 'name',
172
+ freeText: 'ft-abcd',
173
+ contactRow: 'contact-email',
174
+ socialLink: 'social-instagram',
175
+ image: 'avatar',
176
+ ctaButton: 'cta-0',
177
+ linkOut: 'booking',
178
+ action: 'act-share',
179
+ }
180
+ for (const kind of PROFILE_OBJECT_KINDS) {
181
+ const id = representative[kind]
182
+ expect(id, `no representative id for kind '${kind}' — extend this test`).toBeDefined()
183
+ expect(style(id, {})).toBe(true)
184
+ }
185
+ })
186
+
187
+ it('🔴 a kind the table misses is a REFUSAL, never a throw', () => {
188
+ // The cross-package skew case: a types vintage that knows a kind this
189
+ // validation build does not. The guard must add an issue — an exception
190
+ // in superRefine ESCAPES safeParse (zod 3.25), and safeProfileLayout's
191
+ // "the public page never 500s over decoration" contract dies with it.
192
+ const stolen = STYLE_SCHEMA_FOR_KIND.action
193
+ delete (STYLE_SCHEMA_FOR_KIND as Partial<typeof STYLE_SCHEMA_FOR_KIND>).action
194
+ try {
195
+ const r = profileLayoutSchema.safeParse({
196
+ v: 1,
197
+ objects: { 'act-share': { style: { weight: 'bold' } } },
198
+ })
199
+ expect(r.success).toBe(false)
200
+ // A style-LESS override on the unknown kind keeps the old accept.
201
+ expect(
202
+ profileLayoutSchema.safeParse({ v: 1, objects: { 'act-share': {} } }).success,
203
+ ).toBe(true)
204
+ } finally {
205
+ STYLE_SCHEMA_FOR_KIND.action = stolen
206
+ }
207
+ })
208
+
209
+ it('the distinct-font cap counts row and button fontKeys too', () => {
210
+ const fonts = Object.fromEntries(
211
+ ['name', 'contact-email', 'social-instagram', 'cta-0', 'booking'].map((id, i) => [
212
+ id,
213
+ { style: { fontKey: `font-${i}` } },
214
+ ]),
215
+ )
216
+ expect(profileLayoutSchema.safeParse({ v: 1, objects: fonts }).success).toBe(false)
217
+ })
218
+ })
219
+
220
+ describe('digitalConfigSchema integration', () => {
221
+ const baseConfig = {
222
+ templateId: 'classic',
223
+ accentColor: '#16a34a',
224
+ fontKey: 'inter',
225
+ showAvatar: true,
226
+ }
227
+
228
+ it('validates existing profiles unchanged (no layout key)', () => {
229
+ expect(digitalConfigSchema.safeParse(baseConfig).success).toBe(true)
230
+ })
231
+
232
+ it('accepts and KEEPS a valid layout — the strip-mode guard the web store needs', () => {
233
+ const r = digitalConfigSchema.parse({ ...baseConfig, layout: okLayout })
234
+ expect(r.layout?.objectOrder?.social).toEqual(['social-x', 'social-linkedin'])
235
+ })
236
+
237
+ it('rejects a config whose layout is malformed rather than silently dropping it', () => {
238
+ expect(digitalConfigSchema.safeParse({ ...baseConfig, layout: { v: 7 } }).success).toBe(false)
239
+ })
240
+ })
241
+
242
+ describe('safeProfileLayout (render-side gate)', () => {
243
+ it('null/undefined/garbage → null; valid → data', () => {
244
+ expect(safeProfileLayout(undefined)).toBeNull()
245
+ expect(safeProfileLayout(null)).toBeNull()
246
+ expect(safeProfileLayout('layout')).toBeNull()
247
+ expect(safeProfileLayout({ v: 2 })).toBeNull()
248
+ expect(safeProfileLayout({ v: 1 })).toEqual({ v: 1 })
249
+ })
250
+ })
251
+
252
+ describe('cross-package constants stay in lockstep', () => {
253
+ it('CANONICAL_OBJECT_ORDER.cta carries exactly MAX_CTA_BUTTONS cta slots', () => {
254
+ // types cannot import validation, so the canonical array hard-codes five
255
+ // slots; this is the test that keeps the two declarations honest.
256
+ const ctaSlots = CANONICAL_OBJECT_ORDER.cta.filter((id) => ctaObjectIndex(id) !== null)
257
+ expect(ctaSlots.length).toBe(MAX_CTA_BUTTONS)
258
+ })
259
+ })
@@ -0,0 +1,297 @@
1
+ /**
2
+ * NFCARD-656 — the write-side schema for `DigitalConfig.layout`, the stored
3
+ * object/section document. The vocabulary (section ids, object ids, kinds,
4
+ * caps) lives in @nfcard/types; this file only decides what a WRITE may
5
+ * contain.
6
+ *
7
+ * Posture, and why it differs between the two order surfaces:
8
+ * · `objects` (style overrides) is STRICT — every key must name an object
9
+ * this schema's vintage knows, because styles are rendered into CSS and
10
+ * an un-dispatchable style cannot be validated per kind. Version skew is
11
+ * covered by the deploy order (api ships before web, so the accepting
12
+ * schema is never older than the writing client).
13
+ * · `sectionOrder` / `objectOrder` entries are validated by SHAPE only
14
+ * (charset + length). An order entry naming nothing is harmless by
15
+ * construction — `resolveObjectOrder` filters to what is present at
16
+ * render — and rejecting it would turn "the user once ordered a thing
17
+ * that later disappeared" into a 400 on an unrelated save.
18
+ *
19
+ * ⚠ NOT the XSS boundary. FreeText is rendered through Nunjucks with
20
+ * autoescape ON (FOXHOLE-587); the hygiene here (tag/control strip, caps)
21
+ * is defense-in-depth, same standing as the rest of @nfcard/validation.
22
+ */
23
+ import { z } from 'zod'
24
+ import {
25
+ MAX_DISTINCT_OBJECT_FONTS,
26
+ MAX_FREE_TEXT_LENGTH,
27
+ MAX_LAYOUT_OBJECT_OVERRIDES,
28
+ MAX_OBJECT_ORDER_LENGTH,
29
+ MAX_OWNED_OBJECTS,
30
+ FREE_TEXT_ID_RE,
31
+ PROFILE_SECTIONS,
32
+ PROFILE_SECTION_IDS,
33
+ objectKindOf,
34
+ type ProfileLayout,
35
+ type ProfileObjectKind,
36
+ type ProfileSectionId,
37
+ } from '@nfcard/types'
38
+ // ⚠ Deliberate import cycle (index.ts imports this file for
39
+ // `digitalConfigSchema.layout`). It is init-safe: `sanitizeText` is a
40
+ // hoisted function DECLARATION referenced only inside parse-time closures,
41
+ // never called while modules initialise. Importing it beats re-implementing
42
+ // it — a second, weaker copy of a sanitizer is the recurring 624/626/627
43
+ // failure mode.
44
+ import { sanitizeText } from './index'
45
+
46
+ const HEX_RE = /^#[0-9A-Fa-f]{6}$/
47
+
48
+ /** Order entries and override keys share the token grammar's id budget. */
49
+ const OBJECT_ID_SHAPE_RE = /^[A-Za-z0-9_-]{1,32}$/
50
+
51
+ export const textObjectStyleSchema = z
52
+ .object({
53
+ color: z.string().regex(HEX_RE).optional(),
54
+ sizeStep: z.enum(['sm', 'md', 'lg']).optional(),
55
+ align: z.enum(['start', 'center']).optional(),
56
+ weight: z.enum(['normal', 'bold']).optional(),
57
+ italic: z.boolean().optional(),
58
+ underline: z.boolean().optional(),
59
+ // Resolved through the api's font map at render; an unknown key falls
60
+ // back to the template pairing, so membership is not validated here.
61
+ fontKey: z
62
+ .string()
63
+ .min(1)
64
+ .max(64)
65
+ .regex(/^[A-Za-z0-9-]+$/)
66
+ .optional(),
67
+ })
68
+ .strict()
69
+
70
+ export const imageObjectStyleSchema = z
71
+ .object({
72
+ crop: z
73
+ .object({
74
+ x: z.number().min(0).max(1),
75
+ y: z.number().min(0).max(1),
76
+ zoom: z.number().min(1).max(3),
77
+ })
78
+ .strict()
79
+ .optional(),
80
+ borderColor: z.string().regex(HEX_RE).optional(),
81
+ borderPx: z.number().int().min(0).max(8).optional(),
82
+ shape: z.enum(['circle', 'rounded', 'square']).optional(),
83
+ })
84
+ .strict()
85
+
86
+ /**
87
+ * Contact rows and social chips: text minus `align` and `sizeStep`. Row
88
+ * alignment is the template's grid; and a row is a COMPOUND (label/value
89
+ * spans with their own px sizes), so no wrapper-level em can scale it
90
+ * without destroying the internal hierarchy — either would be a
91
+ * stored-but-dead field.
92
+ */
93
+ export const rowTextStyleSchema = textObjectStyleSchema.omit({
94
+ align: true,
95
+ sizeStep: true,
96
+ })
97
+
98
+ /** Label styling (the text vocabulary's `color`/`weight`/`italic`/`fontKey`,
99
+ * DERIVED so the validators stay in lockstep) + `bg` fill. No `sizeStep`
100
+ * in v1 — button geometry is the template's. `variant` is GONE (0.24.0
101
+ * accepted it and the render did style it): NO SURFACE COULD EVER WRITE IT
102
+ * — the toolbar exposes no variant control and none is planned — and a
103
+ * stored-but-unwritable field is dead weight on every future vintage. No
104
+ * deployed tag ever accepted one, so nothing stored carries it. */
105
+ export const buttonObjectStyleSchema = textObjectStyleSchema
106
+ .pick({ color: true, weight: true, italic: true, fontKey: true })
107
+ .extend({ bg: z.string().regex(HEX_RE).optional() })
108
+ .strict()
109
+
110
+ /**
111
+ * Which style vocabulary each kind accepts — TOTAL over the kind vocabulary
112
+ * since the any-text wave, so "unknown kind" can only mean an id this
113
+ * schema's vintage cannot dispatch (already refused by `objectKindOf`).
114
+ *
115
+ * EXPORTED for the web toolbar: `toolbarControlsFor` derives its control
116
+ * matrix from these shapes instead of hand-mirroring them, so a vocabulary
117
+ * change here reshapes the UI by construction.
118
+ */
119
+ export const STYLE_SCHEMA_FOR_KIND: Record<
120
+ ProfileObjectKind,
121
+ z.ZodObject<z.ZodRawShape>
122
+ > = {
123
+ text: textObjectStyleSchema,
124
+ freeText: textObjectStyleSchema,
125
+ contactRow: rowTextStyleSchema,
126
+ socialLink: rowTextStyleSchema,
127
+ image: imageObjectStyleSchema,
128
+ ctaButton: buttonObjectStyleSchema,
129
+ linkOut: buttonObjectStyleSchema,
130
+ action: buttonObjectStyleSchema,
131
+ }
132
+
133
+ const orderListSchema = z
134
+ .array(z.string().regex(OBJECT_ID_SHAPE_RE))
135
+ .max(MAX_OBJECT_ORDER_LENGTH)
136
+
137
+ const freeTextObjectSchema = z
138
+ .object({
139
+ id: z.string().regex(FREE_TEXT_ID_RE),
140
+ kind: z.literal('freeText'),
141
+ section: z.enum(PROFILE_SECTION_IDS),
142
+ text: z
143
+ .string()
144
+ .max(2000) // raw guard; the transform caps to the real bound below
145
+ .transform((v) => sanitizeText(v, MAX_FREE_TEXT_LENGTH)),
146
+ })
147
+ .strict()
148
+
149
+ export const profileLayoutSchema = z
150
+ .object({
151
+ v: z.literal(1),
152
+ sectionOrder: z
153
+ .array(z.enum(PROFILE_SECTION_IDS))
154
+ .max(PROFILE_SECTION_IDS.length)
155
+ .optional(),
156
+ objectOrder: z
157
+ .object(
158
+ Object.fromEntries(
159
+ PROFILE_SECTION_IDS.map((id) => [id, orderListSchema.optional()]),
160
+ ) as Record<ProfileSectionId, z.ZodOptional<typeof orderListSchema>>,
161
+ )
162
+ .strict()
163
+ .partial()
164
+ .optional(),
165
+ objects: z.record(z.string().regex(OBJECT_ID_SHAPE_RE), z.unknown()).optional(),
166
+ ownedObjects: z.array(freeTextObjectSchema).max(MAX_OWNED_OBJECTS).optional(),
167
+ })
168
+ .strict()
169
+ .superRefine((layout, ctx) => {
170
+ // A duplicated section is a client bug — loud beats silently reordered.
171
+ if (layout.sectionOrder) {
172
+ const seen = new Set(layout.sectionOrder)
173
+ if (seen.size !== layout.sectionOrder.length) {
174
+ ctx.addIssue({
175
+ code: z.ZodIssueCode.custom,
176
+ path: ['sectionOrder'],
177
+ message: 'duplicate section id',
178
+ })
179
+ }
180
+ }
181
+
182
+ if (layout.objects) {
183
+ const keys = Object.keys(layout.objects)
184
+ if (keys.length > MAX_LAYOUT_OBJECT_OVERRIDES) {
185
+ ctx.addIssue({
186
+ code: z.ZodIssueCode.custom,
187
+ path: ['objects'],
188
+ message: `more than ${MAX_LAYOUT_OBJECT_OVERRIDES} overrides`,
189
+ })
190
+ return
191
+ }
192
+ const fonts = new Set<string>()
193
+ for (const key of keys) {
194
+ const kind = objectKindOf(key)
195
+ if (!kind) {
196
+ ctx.addIssue({
197
+ code: z.ZodIssueCode.custom,
198
+ path: ['objects', key],
199
+ message: 'unknown object id',
200
+ })
201
+ continue
202
+ }
203
+ const styleSchema = STYLE_SCHEMA_FOR_KIND[kind]
204
+ const override = layout.objects[key]
205
+ const overrideParsed = z
206
+ .object({ style: z.unknown().optional() })
207
+ .strict()
208
+ .safeParse(override)
209
+ if (!overrideParsed.success) {
210
+ ctx.addIssue({
211
+ code: z.ZodIssueCode.custom,
212
+ path: ['objects', key],
213
+ message: 'override must be { style? }',
214
+ })
215
+ continue
216
+ }
217
+ const style = overrideParsed.data.style
218
+ if (style === undefined) continue
219
+ // The table is TOTAL by type, but tsc does not run on the publish
220
+ // path and `objectKindOf` lives in @nfcard/types behind a ^range —
221
+ // a types vintage that knows a NINTH kind can resolve under a
222
+ // validation build whose table stops at eight. Without this guard
223
+ // that skew is a TypeError that ESCAPES safeParse (verified against
224
+ // zod 3.25), turning `safeProfileLayout`'s promised null into a 500
225
+ // on the public render. Refusal, never a throw.
226
+ if (!styleSchema) {
227
+ ctx.addIssue({
228
+ code: z.ZodIssueCode.custom,
229
+ path: ['objects', key, 'style'],
230
+ message: `kind '${kind}' has no style vocabulary in this build`,
231
+ })
232
+ continue
233
+ }
234
+ const parsed = styleSchema.safeParse(style)
235
+ if (!parsed.success) {
236
+ for (const issue of parsed.error.issues) {
237
+ ctx.addIssue({
238
+ code: z.ZodIssueCode.custom,
239
+ path: ['objects', key, 'style', ...issue.path],
240
+ message: issue.message,
241
+ })
242
+ }
243
+ continue
244
+ }
245
+ const fontKey = (parsed.data as { fontKey?: string }).fontKey
246
+ if (fontKey) fonts.add(fontKey)
247
+ }
248
+ if (fonts.size > MAX_DISTINCT_OBJECT_FONTS) {
249
+ ctx.addIssue({
250
+ code: z.ZodIssueCode.custom,
251
+ path: ['objects'],
252
+ message: `more than ${MAX_DISTINCT_OBJECT_FONTS} distinct fonts`,
253
+ })
254
+ }
255
+ }
256
+
257
+ if (layout.ownedObjects) {
258
+ const ids = new Set<string>()
259
+ layout.ownedObjects.forEach((obj, i) => {
260
+ if (ids.has(obj.id)) {
261
+ ctx.addIssue({
262
+ code: z.ZodIssueCode.custom,
263
+ path: ['ownedObjects', i, 'id'],
264
+ message: 'duplicate freeText id',
265
+ })
266
+ }
267
+ ids.add(obj.id)
268
+ if (!PROFILE_SECTIONS[obj.section].hosts.includes('freeText')) {
269
+ ctx.addIssue({
270
+ code: z.ZodIssueCode.custom,
271
+ path: ['ownedObjects', i, 'section'],
272
+ message: `section '${obj.section}' does not host freeText`,
273
+ })
274
+ }
275
+ if (obj.text.length === 0) {
276
+ // Post-sanitise emptiness: a tags-only submission dissolves here.
277
+ ctx.addIssue({
278
+ code: z.ZodIssueCode.custom,
279
+ path: ['ownedObjects', i, 'text'],
280
+ message: 'empty after sanitisation',
281
+ })
282
+ }
283
+ })
284
+ }
285
+ })
286
+
287
+ /**
288
+ * Render-side gate: a stored layout that no longer parses (a vintage this
289
+ * build predates, a hand-corrupted blob) renders as CANONICAL rather than
290
+ * failing the whole page. Mirrors the lenient posture of
291
+ * `resolveRenderBackground` — the public page never 500s over decoration.
292
+ */
293
+ export function safeProfileLayout(value: unknown): ProfileLayout | null {
294
+ if (value === undefined || value === null) return null
295
+ const parsed = profileLayoutSchema.safeParse(value)
296
+ return parsed.success ? (parsed.data as ProfileLayout) : null
297
+ }