@nfcard/validation 0.22.2 → 0.24.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/dist/index.d.ts +2229 -30
- package/dist/index.js +426 -226
- package/package.json +2 -2
- package/src/index.ts +0 -0
- package/src/profileBackground.test.ts +116 -0
- package/src/profileLayout.test.ts +158 -0
- package/src/profileLayout.ts +265 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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.
|
|
20
|
+
"@nfcard/types": "0.18.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"tsup": "^8.0.0"
|
package/src/index.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NFCARD-641 (564d/S4) Phase 1 — the profile background write gate.
|
|
3
|
+
*
|
|
4
|
+
* Lenient by design, like tipJar/booking/ctaButtons: a malformed background is
|
|
5
|
+
* dropped to `undefined` rather than 400-ing the whole profile save. The tests
|
|
6
|
+
* that matter are therefore the ones proving it drops rather than *mangles* —
|
|
7
|
+
* a half-applied gradient would render as a surprise nobody asked for.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import { digitalConfigSchema, profileBackgroundSchema } from './index'
|
|
11
|
+
|
|
12
|
+
const base = {
|
|
13
|
+
templateId: 'classic' as const,
|
|
14
|
+
accentColor: '#F47B20',
|
|
15
|
+
fontKey: 'inter',
|
|
16
|
+
showAvatar: true,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('profileBackgroundSchema', () => {
|
|
20
|
+
it('accepts a solid background', () => {
|
|
21
|
+
expect(profileBackgroundSchema.parse({ kind: 'solid' })).toEqual({ kind: 'solid' })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('accepts a two-stop gradient and keeps both stops', () => {
|
|
25
|
+
expect(
|
|
26
|
+
profileBackgroundSchema.parse({ kind: 'gradient', from: '#101820', to: '#F47B20' }),
|
|
27
|
+
).toEqual({ kind: 'gradient', from: '#101820', to: '#F47B20' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('🔴 drops a HALF-valid gradient rather than inventing the missing stop', () => {
|
|
31
|
+
// Storing `{from}` with no `to` would render as a gradient to… something.
|
|
32
|
+
// Both stops or nothing.
|
|
33
|
+
expect(profileBackgroundSchema.parse({ kind: 'gradient', from: '#101820' })).toBeUndefined()
|
|
34
|
+
expect(profileBackgroundSchema.parse({ kind: 'gradient', to: '#101820' })).toBeUndefined()
|
|
35
|
+
expect(
|
|
36
|
+
profileBackgroundSchema.parse({ kind: 'gradient', from: '#101820', to: 'red' }),
|
|
37
|
+
).toBeUndefined()
|
|
38
|
+
expect(
|
|
39
|
+
profileBackgroundSchema.parse({ kind: 'gradient', from: 'nothex', to: '#101820' }),
|
|
40
|
+
).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('🔴 rejects any non-hex stop — nothing user-typed reaches the CSS', () => {
|
|
44
|
+
for (const hostile of [
|
|
45
|
+
'red',
|
|
46
|
+
'#fff',
|
|
47
|
+
'#1018200',
|
|
48
|
+
'var(--brand)',
|
|
49
|
+
'url(javascript:alert(1))',
|
|
50
|
+
'#101820; background: url(x)',
|
|
51
|
+
'rgb(0,0,0)',
|
|
52
|
+
]) {
|
|
53
|
+
expect(
|
|
54
|
+
profileBackgroundSchema.parse({ kind: 'gradient', from: hostile, to: '#101820' }),
|
|
55
|
+
hostile,
|
|
56
|
+
).toBeUndefined()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('normalises the angle into [0,360) so the render never defends against it', () => {
|
|
61
|
+
const at = (angleDeg: unknown) =>
|
|
62
|
+
profileBackgroundSchema.parse({ kind: 'gradient', from: '#101820', to: '#F47B20', angleDeg })
|
|
63
|
+
expect(at(160)).toMatchObject({ angleDeg: 160 })
|
|
64
|
+
expect(at(0)).toMatchObject({ angleDeg: 0 })
|
|
65
|
+
expect(at(-40)).toMatchObject({ angleDeg: 320 })
|
|
66
|
+
expect(at(400)).toMatchObject({ angleDeg: 40 })
|
|
67
|
+
expect(at(160.4)).toMatchObject({ angleDeg: 160 })
|
|
68
|
+
// Not a usable number → omit the key entirely and let the render default.
|
|
69
|
+
for (const bad of ['160', NaN, Infinity, null, {}]) {
|
|
70
|
+
expect(at(bad), String(bad)).not.toHaveProperty('angleDeg')
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('drops an unknown kind, and anything that is not an object', () => {
|
|
75
|
+
expect(profileBackgroundSchema.parse({ kind: 'image', url: 'https://x/y.png' })).toBeUndefined()
|
|
76
|
+
expect(profileBackgroundSchema.parse({ kind: 'gradient' })).toBeUndefined()
|
|
77
|
+
for (const v of [null, undefined, 'gradient', 42, []]) {
|
|
78
|
+
expect(profileBackgroundSchema.parse(v), String(v)).toBeUndefined()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not yet accept an image — that is NFCARD-645', () => {
|
|
83
|
+
// Pinned deliberately: shipping the type without the sanitiser would be an
|
|
84
|
+
// unguarded URL on the render path.
|
|
85
|
+
expect(
|
|
86
|
+
profileBackgroundSchema.parse({ kind: 'image', url: 'https://cdn.example.com/a.jpg' }),
|
|
87
|
+
).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('digitalConfigSchema × background', () => {
|
|
92
|
+
it('every profile that exists today still validates — background is optional', () => {
|
|
93
|
+
const parsed = digitalConfigSchema.parse(base)
|
|
94
|
+
expect(parsed.background).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('carries a valid gradient through', () => {
|
|
98
|
+
const parsed = digitalConfigSchema.parse({
|
|
99
|
+
...base,
|
|
100
|
+
background: { kind: 'gradient', from: '#101820', to: '#F47B20', angleDeg: 160 },
|
|
101
|
+
})
|
|
102
|
+
expect(parsed.background).toEqual({
|
|
103
|
+
kind: 'gradient',
|
|
104
|
+
from: '#101820',
|
|
105
|
+
to: '#F47B20',
|
|
106
|
+
angleDeg: 160,
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a malformed background does not fail the whole save', () => {
|
|
111
|
+
// The lenient contract: the profile still saves, minus the background.
|
|
112
|
+
const parsed = digitalConfigSchema.parse({ ...base, background: { kind: 'gradient', from: 'x' } })
|
|
113
|
+
expect(parsed.background).toBeUndefined()
|
|
114
|
+
expect(parsed.templateId).toBe('classic')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
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, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
|
|
11
|
+
import { MAX_CTA_BUTTONS, digitalConfigSchema } from './index'
|
|
12
|
+
import { 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', variant: 'solid' } },
|
|
25
|
+
booking: { style: { variant: 'outline' } },
|
|
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, non-stylable kinds, injection-shaped values', () => {
|
|
73
|
+
expect(bad({ v: 1, objects: { 'not-a-thing': { style: {} } } })).toBe(false)
|
|
74
|
+
// contactRow is arrangeable but not stylable in v1 — storing a style the
|
|
75
|
+
// render never reads is the defect, not a feature.
|
|
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('digitalConfigSchema integration', () => {
|
|
120
|
+
const baseConfig = {
|
|
121
|
+
templateId: 'classic',
|
|
122
|
+
accentColor: '#16a34a',
|
|
123
|
+
fontKey: 'inter',
|
|
124
|
+
showAvatar: true,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it('validates existing profiles unchanged (no layout key)', () => {
|
|
128
|
+
expect(digitalConfigSchema.safeParse(baseConfig).success).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('accepts and KEEPS a valid layout — the strip-mode guard the web store needs', () => {
|
|
132
|
+
const r = digitalConfigSchema.parse({ ...baseConfig, layout: okLayout })
|
|
133
|
+
expect(r.layout?.objectOrder?.social).toEqual(['social-x', 'social-linkedin'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('rejects a config whose layout is malformed rather than silently dropping it', () => {
|
|
137
|
+
expect(digitalConfigSchema.safeParse({ ...baseConfig, layout: { v: 7 } }).success).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('safeProfileLayout (render-side gate)', () => {
|
|
142
|
+
it('null/undefined/garbage → null; valid → data', () => {
|
|
143
|
+
expect(safeProfileLayout(undefined)).toBeNull()
|
|
144
|
+
expect(safeProfileLayout(null)).toBeNull()
|
|
145
|
+
expect(safeProfileLayout('layout')).toBeNull()
|
|
146
|
+
expect(safeProfileLayout({ v: 2 })).toBeNull()
|
|
147
|
+
expect(safeProfileLayout({ v: 1 })).toEqual({ v: 1 })
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('cross-package constants stay in lockstep', () => {
|
|
152
|
+
it('CANONICAL_OBJECT_ORDER.cta carries exactly MAX_CTA_BUTTONS cta slots', () => {
|
|
153
|
+
// types cannot import validation, so the canonical array hard-codes five
|
|
154
|
+
// slots; this is the test that keeps the two declarations honest.
|
|
155
|
+
const ctaSlots = CANONICAL_OBJECT_ORDER.cta.filter((id) => ctaObjectIndex(id) !== null)
|
|
156
|
+
expect(ctaSlots.length).toBe(MAX_CTA_BUTTONS)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
export const buttonObjectStyleSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
bg: z.string().regex(HEX_RE).optional(),
|
|
89
|
+
variant: z.enum(['solid', 'soft', 'outline']).optional(),
|
|
90
|
+
})
|
|
91
|
+
.strict()
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which style vocabulary each kind accepts. `contactRow` and `socialLink`
|
|
95
|
+
* are deliberately ABSENT in v1: no template renders a style for them yet,
|
|
96
|
+
* and a schema that stores what the server never reads is the EditTextSheet
|
|
97
|
+
* lesson in write form. Additive to extend.
|
|
98
|
+
*/
|
|
99
|
+
const STYLE_SCHEMA_FOR_KIND: Partial<Record<ProfileObjectKind, z.ZodTypeAny>> = {
|
|
100
|
+
text: textObjectStyleSchema,
|
|
101
|
+
freeText: textObjectStyleSchema,
|
|
102
|
+
image: imageObjectStyleSchema,
|
|
103
|
+
ctaButton: buttonObjectStyleSchema,
|
|
104
|
+
linkOut: buttonObjectStyleSchema,
|
|
105
|
+
action: buttonObjectStyleSchema,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const orderListSchema = z
|
|
109
|
+
.array(z.string().regex(OBJECT_ID_SHAPE_RE))
|
|
110
|
+
.max(MAX_OBJECT_ORDER_LENGTH)
|
|
111
|
+
|
|
112
|
+
const freeTextObjectSchema = z
|
|
113
|
+
.object({
|
|
114
|
+
id: z.string().regex(FREE_TEXT_ID_RE),
|
|
115
|
+
kind: z.literal('freeText'),
|
|
116
|
+
section: z.enum(PROFILE_SECTION_IDS),
|
|
117
|
+
text: z
|
|
118
|
+
.string()
|
|
119
|
+
.max(2000) // raw guard; the transform caps to the real bound below
|
|
120
|
+
.transform((v) => sanitizeText(v, MAX_FREE_TEXT_LENGTH)),
|
|
121
|
+
})
|
|
122
|
+
.strict()
|
|
123
|
+
|
|
124
|
+
export const profileLayoutSchema = z
|
|
125
|
+
.object({
|
|
126
|
+
v: z.literal(1),
|
|
127
|
+
sectionOrder: z
|
|
128
|
+
.array(z.enum(PROFILE_SECTION_IDS))
|
|
129
|
+
.max(PROFILE_SECTION_IDS.length)
|
|
130
|
+
.optional(),
|
|
131
|
+
objectOrder: z
|
|
132
|
+
.object(
|
|
133
|
+
Object.fromEntries(
|
|
134
|
+
PROFILE_SECTION_IDS.map((id) => [id, orderListSchema.optional()]),
|
|
135
|
+
) as Record<ProfileSectionId, z.ZodOptional<typeof orderListSchema>>,
|
|
136
|
+
)
|
|
137
|
+
.strict()
|
|
138
|
+
.partial()
|
|
139
|
+
.optional(),
|
|
140
|
+
objects: z.record(z.string().regex(OBJECT_ID_SHAPE_RE), z.unknown()).optional(),
|
|
141
|
+
ownedObjects: z.array(freeTextObjectSchema).max(MAX_OWNED_OBJECTS).optional(),
|
|
142
|
+
})
|
|
143
|
+
.strict()
|
|
144
|
+
.superRefine((layout, ctx) => {
|
|
145
|
+
// A duplicated section is a client bug — loud beats silently reordered.
|
|
146
|
+
if (layout.sectionOrder) {
|
|
147
|
+
const seen = new Set(layout.sectionOrder)
|
|
148
|
+
if (seen.size !== layout.sectionOrder.length) {
|
|
149
|
+
ctx.addIssue({
|
|
150
|
+
code: z.ZodIssueCode.custom,
|
|
151
|
+
path: ['sectionOrder'],
|
|
152
|
+
message: 'duplicate section id',
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (layout.objects) {
|
|
158
|
+
const keys = Object.keys(layout.objects)
|
|
159
|
+
if (keys.length > MAX_LAYOUT_OBJECT_OVERRIDES) {
|
|
160
|
+
ctx.addIssue({
|
|
161
|
+
code: z.ZodIssueCode.custom,
|
|
162
|
+
path: ['objects'],
|
|
163
|
+
message: `more than ${MAX_LAYOUT_OBJECT_OVERRIDES} overrides`,
|
|
164
|
+
})
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const fonts = new Set<string>()
|
|
168
|
+
for (const key of keys) {
|
|
169
|
+
const kind = objectKindOf(key)
|
|
170
|
+
if (!kind) {
|
|
171
|
+
ctx.addIssue({
|
|
172
|
+
code: z.ZodIssueCode.custom,
|
|
173
|
+
path: ['objects', key],
|
|
174
|
+
message: 'unknown object id',
|
|
175
|
+
})
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const styleSchema = STYLE_SCHEMA_FOR_KIND[kind]
|
|
179
|
+
const override = layout.objects[key]
|
|
180
|
+
const overrideParsed = z
|
|
181
|
+
.object({ style: z.unknown().optional() })
|
|
182
|
+
.strict()
|
|
183
|
+
.safeParse(override)
|
|
184
|
+
if (!overrideParsed.success) {
|
|
185
|
+
ctx.addIssue({
|
|
186
|
+
code: z.ZodIssueCode.custom,
|
|
187
|
+
path: ['objects', key],
|
|
188
|
+
message: 'override must be { style? }',
|
|
189
|
+
})
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const style = overrideParsed.data.style
|
|
193
|
+
if (style === undefined) continue
|
|
194
|
+
if (!styleSchema) {
|
|
195
|
+
ctx.addIssue({
|
|
196
|
+
code: z.ZodIssueCode.custom,
|
|
197
|
+
path: ['objects', key, 'style'],
|
|
198
|
+
message: `kind '${kind}' is not stylable`,
|
|
199
|
+
})
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const parsed = styleSchema.safeParse(style)
|
|
203
|
+
if (!parsed.success) {
|
|
204
|
+
for (const issue of parsed.error.issues) {
|
|
205
|
+
ctx.addIssue({
|
|
206
|
+
code: z.ZodIssueCode.custom,
|
|
207
|
+
path: ['objects', key, 'style', ...issue.path],
|
|
208
|
+
message: issue.message,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const fontKey = (parsed.data as { fontKey?: string }).fontKey
|
|
214
|
+
if (fontKey) fonts.add(fontKey)
|
|
215
|
+
}
|
|
216
|
+
if (fonts.size > MAX_DISTINCT_OBJECT_FONTS) {
|
|
217
|
+
ctx.addIssue({
|
|
218
|
+
code: z.ZodIssueCode.custom,
|
|
219
|
+
path: ['objects'],
|
|
220
|
+
message: `more than ${MAX_DISTINCT_OBJECT_FONTS} distinct fonts`,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (layout.ownedObjects) {
|
|
226
|
+
const ids = new Set<string>()
|
|
227
|
+
layout.ownedObjects.forEach((obj, i) => {
|
|
228
|
+
if (ids.has(obj.id)) {
|
|
229
|
+
ctx.addIssue({
|
|
230
|
+
code: z.ZodIssueCode.custom,
|
|
231
|
+
path: ['ownedObjects', i, 'id'],
|
|
232
|
+
message: 'duplicate freeText id',
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
ids.add(obj.id)
|
|
236
|
+
if (!PROFILE_SECTIONS[obj.section].hosts.includes('freeText')) {
|
|
237
|
+
ctx.addIssue({
|
|
238
|
+
code: z.ZodIssueCode.custom,
|
|
239
|
+
path: ['ownedObjects', i, 'section'],
|
|
240
|
+
message: `section '${obj.section}' does not host freeText`,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
if (obj.text.length === 0) {
|
|
244
|
+
// Post-sanitise emptiness: a tags-only submission dissolves here.
|
|
245
|
+
ctx.addIssue({
|
|
246
|
+
code: z.ZodIssueCode.custom,
|
|
247
|
+
path: ['ownedObjects', i, 'text'],
|
|
248
|
+
message: 'empty after sanitisation',
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Render-side gate: a stored layout that no longer parses (a vintage this
|
|
257
|
+
* build predates, a hand-corrupted blob) renders as CANONICAL rather than
|
|
258
|
+
* failing the whole page. Mirrors the lenient posture of
|
|
259
|
+
* `resolveRenderBackground` — the public page never 500s over decoration.
|
|
260
|
+
*/
|
|
261
|
+
export function safeProfileLayout(value: unknown): ProfileLayout | null {
|
|
262
|
+
if (value === undefined || value === null) return null
|
|
263
|
+
const parsed = profileLayoutSchema.safeParse(value)
|
|
264
|
+
return parsed.success ? (parsed.data as ProfileLayout) : null
|
|
265
|
+
}
|