@nfcard/validation 0.21.0 → 0.22.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.js CHANGED
@@ -418,7 +418,7 @@ function elementPrintability(el, ctx) {
418
418
  if (el.sizeMm * el.transform.scale < QR_MIN_SIZE_MM - EPS) {
419
419
  push("qrTooSmall", "The QR code is below the scannable size floor.");
420
420
  }
421
- if (!el.url || !el.url.trim()) {
421
+ if (el.mode !== "redirect" && (!el.url || !el.url.trim())) {
422
422
  push("qrEmptyUrl", "The QR code has no target URL.");
423
423
  }
424
424
  break;
@@ -613,10 +613,11 @@ var baseElementFields = {
613
613
  locked: z.boolean().optional(),
614
614
  colour: z.string().optional()
615
615
  };
616
+ var qrModeSchema = z.enum(["redirect", "raw"]).optional();
616
617
  var cardElementSchema = z.discriminatedUnion("type", [
617
618
  z.object({ ...baseElementFields, type: z.literal("chip"), shape: chipSidesSchema }),
618
619
  z.object({ ...baseElementFields, type: z.literal("text"), text: z.string(), sizeMm: z.number().positive(), weight: z.number(), role: elementRoleSchema, fontKey: z.string().optional(), italic: z.boolean().optional(), underline: z.boolean().optional() }),
619
- z.object({ ...baseElementFields, type: z.literal("qr"), url: z.string().url("invalidUrl"), sizeMm: z.number().positive(), frameRole: elementRoleSchema, moduleRole: elementRoleSchema }),
620
+ z.object({ ...baseElementFields, type: z.literal("qr"), url: z.string().url("invalidUrl").or(z.literal("")).optional(), mode: qrModeSchema, sizeMm: z.number().positive(), frameRole: elementRoleSchema, moduleRole: elementRoleSchema }),
620
621
  z.object({ ...baseElementFields, type: z.literal("image"), assetRef: z.string().min(1), widthMm: z.number().positive(), heightMm: z.number().positive(), role: elementRoleSchema, bgRemoved: z.boolean().optional(), svgOverrides: z.array(z.string().optional()).optional() }),
621
622
  z.object({ ...baseElementFields, type: z.literal("logo"), assetRef: z.string().min(1), widthMm: z.number().positive(), heightMm: z.number().positive(), role: elementRoleSchema })
622
623
  ]);
@@ -650,7 +651,10 @@ var cardDesignSchema = z.object({
650
651
  })
651
652
  )
652
653
  }).optional(),
653
- qr: z.object({ enabled: z.boolean(), url: z.string().url("invalidUrl") }).optional(),
654
+ // NFCARD-561 -- `mode` makes "point to profile" DATA, not a string match; when it is
655
+ // 'redirect' the url is absent and the API substitutes the card's own /r/{code}?src=qr.
656
+ // Optional throughout: prod holds persisted designs with neither field.
657
+ qr: z.object({ enabled: z.boolean(), url: z.string().url("invalidUrl").or(z.literal("")).optional(), mode: qrModeSchema }).optional(),
654
658
  logo: z.object({ svg: z.string().optional(), svgPath: z.string().optional() }).optional(),
655
659
  wordmark: z.boolean().optional(),
656
660
  contentFace: z.enum(["top", "bottom"]).optional(),
@@ -1032,6 +1036,7 @@ export {
1032
1036
  profileUpdateSchema,
1033
1037
  profileUserDataSchema,
1034
1038
  profileVisibilitySchema,
1039
+ qrModeSchema,
1035
1040
  sanitizeHttpUrl,
1036
1041
  sanitizeText,
1037
1042
  signupWithProfileSchema,
package/package.json CHANGED
@@ -1,39 +1,45 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
8
  "exports": {
9
- ".": {
10
- "import": "./dist/index.js",
11
- "types": "./dist/index.d.ts"
12
- }
9
+ ".": "./src/index.ts"
13
10
  },
14
11
  "files": [
15
12
  "dist",
16
13
  "src"
17
14
  ],
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "build": "tsup src/index.ts --format esm --dts --clean",
19
+ "prepublishOnly": "pnpm build"
20
+ },
18
21
  "dependencies": {
19
22
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.15.0"
23
+ "@nfcard/types": "workspace:*"
21
24
  },
22
25
  "devDependencies": {
23
26
  "tsup": "^8.0.0"
24
27
  },
25
28
  "publishConfig": {
26
29
  "access": "public",
27
- "registry": "https://registry.npmjs.org/"
30
+ "registry": "https://registry.npmjs.org/",
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "import": "./dist/index.js",
36
+ "types": "./dist/index.d.ts"
37
+ }
38
+ }
28
39
  },
29
40
  "repository": {
30
41
  "type": "git",
31
42
  "url": "https://bitbucket.org/fox-hole/nfcard-shared.git",
32
43
  "directory": "validation"
33
- },
34
- "scripts": {
35
- "test": "vitest run",
36
- "test:watch": "vitest",
37
- "build": "tsup src/index.ts --format esm --dts --clean"
38
44
  }
39
- }
45
+ }
@@ -1,85 +1,85 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { bookingSchema, digitalConfigSchema } from './index'
3
-
4
- const baseConfig = {
5
- templateId: 'classic' as const,
6
- accentColor: '#123456',
7
- fontKey: 'inter',
8
- showAvatar: true,
9
- }
10
-
11
- describe('bookingSchema', () => {
12
- it('accepts a valid provider + handle and stores only { provider, handle }', () => {
13
- expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
14
- provider: 'salonic',
15
- handle: 'kiss-fodraszat',
16
- })
17
- })
18
-
19
- it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
20
- expect(
21
- bookingSchema.parse({
22
- provider: 'salonic',
23
- handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
24
- }),
25
- ).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
26
- })
27
-
28
- it('lowercases the handle', () => {
29
- expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
30
- provider: 'reservio',
31
- handle: 'highbrow',
32
- })
33
- })
34
-
35
- it('keeps a sanitized custom label, capped at 40 chars', () => {
36
- const out = bookingSchema.parse({
37
- provider: 'salonic',
38
- handle: 'kiss-fodraszat',
39
- label: '<b>Foglalj</b> ' + 'x'.repeat(60),
40
- })
41
- expect(out?.provider).toBe('salonic')
42
- expect(out?.label).not.toContain('<')
43
- expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
44
- })
45
-
46
- it('drops everything invalid to undefined instead of throwing (never 400)', () => {
47
- expect(bookingSchema.parse(undefined)).toBeUndefined()
48
- expect(bookingSchema.parse(null)).toBeUndefined()
49
- expect(bookingSchema.parse('nonsense')).toBeUndefined()
50
- expect(bookingSchema.parse({})).toBeUndefined()
51
- expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
52
- expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
53
- expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
54
- expect(
55
- bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
56
- ).toBeUndefined()
57
- expect(
58
- bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
59
- ).toBeUndefined()
60
- expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
61
- })
62
- })
63
-
64
- describe('digitalConfigSchema — booking field', () => {
65
- it('round-trips a valid booking', () => {
66
- const parsed = digitalConfigSchema.parse({
67
- ...baseConfig,
68
- booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
69
- })
70
- expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
71
- })
72
-
73
- it('drops an invalid booking without failing the whole config save', () => {
74
- const parsed = digitalConfigSchema.parse({
75
- ...baseConfig,
76
- booking: { provider: 'salonic', handle: 'https://evil.com/x' },
77
- })
78
- expect(parsed.booking).toBeUndefined()
79
- })
80
-
81
- it('is optional — a config without a booking parses fine', () => {
82
- const parsed = digitalConfigSchema.parse(baseConfig)
83
- expect(parsed.booking).toBeUndefined()
84
- })
85
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import { bookingSchema, digitalConfigSchema } from './index'
3
+
4
+ const baseConfig = {
5
+ templateId: 'classic' as const,
6
+ accentColor: '#123456',
7
+ fontKey: 'inter',
8
+ showAvatar: true,
9
+ }
10
+
11
+ describe('bookingSchema', () => {
12
+ it('accepts a valid provider + handle and stores only { provider, handle }', () => {
13
+ expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
14
+ provider: 'salonic',
15
+ handle: 'kiss-fodraszat',
16
+ })
17
+ })
18
+
19
+ it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
20
+ expect(
21
+ bookingSchema.parse({
22
+ provider: 'salonic',
23
+ handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
24
+ }),
25
+ ).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
26
+ })
27
+
28
+ it('lowercases the handle', () => {
29
+ expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
30
+ provider: 'reservio',
31
+ handle: 'highbrow',
32
+ })
33
+ })
34
+
35
+ it('keeps a sanitized custom label, capped at 40 chars', () => {
36
+ const out = bookingSchema.parse({
37
+ provider: 'salonic',
38
+ handle: 'kiss-fodraszat',
39
+ label: '<b>Foglalj</b> ' + 'x'.repeat(60),
40
+ })
41
+ expect(out?.provider).toBe('salonic')
42
+ expect(out?.label).not.toContain('<')
43
+ expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
44
+ })
45
+
46
+ it('drops everything invalid to undefined instead of throwing (never 400)', () => {
47
+ expect(bookingSchema.parse(undefined)).toBeUndefined()
48
+ expect(bookingSchema.parse(null)).toBeUndefined()
49
+ expect(bookingSchema.parse('nonsense')).toBeUndefined()
50
+ expect(bookingSchema.parse({})).toBeUndefined()
51
+ expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
52
+ expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
53
+ expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
54
+ expect(
55
+ bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
56
+ ).toBeUndefined()
57
+ expect(
58
+ bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
59
+ ).toBeUndefined()
60
+ expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
61
+ })
62
+ })
63
+
64
+ describe('digitalConfigSchema — booking field', () => {
65
+ it('round-trips a valid booking', () => {
66
+ const parsed = digitalConfigSchema.parse({
67
+ ...baseConfig,
68
+ booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
69
+ })
70
+ expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
71
+ })
72
+
73
+ it('drops an invalid booking without failing the whole config save', () => {
74
+ const parsed = digitalConfigSchema.parse({
75
+ ...baseConfig,
76
+ booking: { provider: 'salonic', handle: 'https://evil.com/x' },
77
+ })
78
+ expect(parsed.booking).toBeUndefined()
79
+ })
80
+
81
+ it('is optional — a config without a booking parses fine', () => {
82
+ const parsed = digitalConfigSchema.parse(baseConfig)
83
+ expect(parsed.booking).toBeUndefined()
84
+ })
85
+ })
@@ -270,3 +270,79 @@ describe('per-element override fields survive validation (#1  P0 contract)', ()
270
270
  expect(a).toMatchObject({ rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
271
271
  })
272
272
  })
273
+
274
+ // ── NFCARD-561: qr.mode makes "point to profile" data, not a string match ──
275
+ //
276
+ // The API used to detect it with a QR_PROFILE_TARGETS sentinel set
277
+ // ('' / https://nfcard.hu / http://nfcard.hu). Any near-miss — a www. host, a
278
+ // trailing path, a stale buyer-typed value — printed verbatim, and a printed
279
+ // card cannot be corrected afterwards.
280
+
281
+ describe('qr.mode (NFCARD-561)', () => {
282
+ it("accepts mode 'redirect' with NO url — the target is resolved at card creation", () => {
283
+ const res = cardDesignSchema.safeParse(
284
+ withElements([chipEl(), { ...qrEl(), url: undefined, mode: 'redirect' }]),
285
+ )
286
+ expect(res.success).toBe(true)
287
+ })
288
+
289
+ it("accepts mode 'redirect' with an empty-string url (the pre-561 free-layout spelling)", () => {
290
+ const res = cardDesignSchema.safeParse(
291
+ withElements([chipEl(), { ...qrEl(), url: '', mode: 'redirect' }]),
292
+ )
293
+ expect(res.success).toBe(true)
294
+ })
295
+
296
+ it("accepts mode 'raw' alongside a buyer-typed url", () => {
297
+ const res = cardDesignSchema.safeParse(
298
+ withElements([chipEl(), { ...qrEl(), url: 'https://acme.example/team', mode: 'raw' }]),
299
+ )
300
+ expect(res.success).toBe(true)
301
+ })
302
+
303
+ it('rejects an unknown mode', () => {
304
+ const res = cardDesignSchema.safeParse(
305
+ withElements([chipEl(), { ...qrEl(), mode: 'profile' }]),
306
+ )
307
+ expect(res.success).toBe(false)
308
+ })
309
+
310
+ it('still rejects a malformed url whatever the mode', () => {
311
+ const res = cardDesignSchema.safeParse(
312
+ withElements([chipEl(), { ...qrEl(), url: 'not-a-url', mode: 'raw' }]),
313
+ )
314
+ expect(codes(res)).toContain('invalidUrl')
315
+ })
316
+
317
+ it('a mode-less QR element still validates (pre-561 designs are untouched)', () => {
318
+ expect(cardDesignSchema.safeParse(withElements([chipEl(), qrEl()])).success).toBe(true)
319
+ })
320
+
321
+ it('carries mode through the legacy qr channel too', () => {
322
+ const parsed = cardDesignSchema.parse({
323
+ ...legacyDesign,
324
+ qr: { enabled: true, mode: 'redirect' },
325
+ }) as { qr: Record<string, unknown> }
326
+ expect(parsed.qr).toMatchObject({ enabled: true, mode: 'redirect' })
327
+ })
328
+ })
329
+
330
+ describe('printability — a redirect QR needs no url (NFCARD-561)', () => {
331
+ const at = (over = {}) =>
332
+ ({ ...qrEl(), transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1, z: 2 }, ...over }) as never
333
+
334
+ it("does not raise qrEmptyUrl for mode 'redirect' with no url", () => {
335
+ const codes = elementPrintability(at({ url: undefined, mode: 'redirect' })).map((v) => v.code)
336
+ expect(codes).not.toContain('qrEmptyUrl')
337
+ })
338
+
339
+ it("still raises qrEmptyUrl for mode 'raw' with no url", () => {
340
+ const codes = elementPrintability(at({ url: '', mode: 'raw' })).map((v) => v.code)
341
+ expect(codes).toContain('qrEmptyUrl')
342
+ })
343
+
344
+ it('still raises qrEmptyUrl for a mode-less QR with no url (legacy behaviour)', () => {
345
+ const codes = elementPrintability(at({ url: '' })).map((v) => v.code)
346
+ expect(codes).toContain('qrEmptyUrl')
347
+ })
348
+ })
package/src/index.ts CHANGED
Binary file
@@ -294,7 +294,11 @@ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Viol
294
294
  if (el.sizeMm * el.transform.scale < QR_MIN_SIZE_MM - EPS) {
295
295
  push('qrTooSmall', 'The QR code is below the scannable size floor.')
296
296
  }
297
- if (!el.url || !el.url.trim()) {
297
+ // NFCARD-561 an absent url is CORRECT for a "point to profile" QR: the
298
+ // card's own /r/{shortCode}?src=qr is substituted at card creation, since
299
+ // the short code does not exist at design time. Only a `raw` (or legacy,
300
+ // mode-less) QR actually needs a buyer-supplied target.
301
+ if (el.mode !== 'redirect' && (!el.url || !el.url.trim())) {
298
302
  push('qrEmptyUrl', 'The QR code has no target URL.')
299
303
  }
300
304
  break
@@ -1,77 +1,77 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { tipJarSchema, digitalConfigSchema } from './index'
3
-
4
- const baseConfig = {
5
- templateId: 'classic' as const,
6
- accentColor: '#123456',
7
- fontKey: 'inter',
8
- showAvatar: true,
9
- }
10
-
11
- describe('tipJarSchema', () => {
12
- it('accepts a valid provider + handle and stores only { provider, handle }', () => {
13
- expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
14
- provider: 'kofi',
15
- handle: 'ricsi',
16
- })
17
- })
18
-
19
- it('normalizes a pasted full URL down to the handle', () => {
20
- expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
21
- { provider: 'kofi', handle: 'ricsi' },
22
- )
23
- })
24
-
25
- it('lowercases revolut handles', () => {
26
- expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
27
- provider: 'revolut',
28
- handle: 'ricsi',
29
- })
30
- })
31
-
32
- it('keeps a sanitized custom label, capped at 40 chars', () => {
33
- const out = tipJarSchema.parse({
34
- provider: 'kofi',
35
- handle: 'ricsi',
36
- label: '<b>Támogass</b> ' + 'x'.repeat(60),
37
- })
38
- expect(out?.provider).toBe('kofi')
39
- expect(out?.label).not.toContain('<')
40
- expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
41
- })
42
-
43
- it('drops everything invalid to undefined instead of throwing (never 400)', () => {
44
- expect(tipJarSchema.parse(undefined)).toBeUndefined()
45
- expect(tipJarSchema.parse(null)).toBeUndefined()
46
- expect(tipJarSchema.parse('nonsense')).toBeUndefined()
47
- expect(tipJarSchema.parse({})).toBeUndefined()
48
- expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
49
- expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
50
- expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
51
- expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
52
- expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
53
- })
54
- })
55
-
56
- describe('digitalConfigSchema — tipJar field', () => {
57
- it('round-trips a valid tipJar', () => {
58
- const parsed = digitalConfigSchema.parse({
59
- ...baseConfig,
60
- tipJar: { provider: 'revolut', handle: 'ricsi' },
61
- })
62
- expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
63
- })
64
-
65
- it('drops an invalid tipJar without failing the whole config save', () => {
66
- const parsed = digitalConfigSchema.parse({
67
- ...baseConfig,
68
- tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
69
- })
70
- expect(parsed.tipJar).toBeUndefined()
71
- })
72
-
73
- it('is optional — a config without a tipJar parses fine', () => {
74
- const parsed = digitalConfigSchema.parse(baseConfig)
75
- expect(parsed.tipJar).toBeUndefined()
76
- })
77
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import { tipJarSchema, digitalConfigSchema } from './index'
3
+
4
+ const baseConfig = {
5
+ templateId: 'classic' as const,
6
+ accentColor: '#123456',
7
+ fontKey: 'inter',
8
+ showAvatar: true,
9
+ }
10
+
11
+ describe('tipJarSchema', () => {
12
+ it('accepts a valid provider + handle and stores only { provider, handle }', () => {
13
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
14
+ provider: 'kofi',
15
+ handle: 'ricsi',
16
+ })
17
+ })
18
+
19
+ it('normalizes a pasted full URL down to the handle', () => {
20
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
21
+ { provider: 'kofi', handle: 'ricsi' },
22
+ )
23
+ })
24
+
25
+ it('lowercases revolut handles', () => {
26
+ expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
27
+ provider: 'revolut',
28
+ handle: 'ricsi',
29
+ })
30
+ })
31
+
32
+ it('keeps a sanitized custom label, capped at 40 chars', () => {
33
+ const out = tipJarSchema.parse({
34
+ provider: 'kofi',
35
+ handle: 'ricsi',
36
+ label: '<b>Támogass</b> ' + 'x'.repeat(60),
37
+ })
38
+ expect(out?.provider).toBe('kofi')
39
+ expect(out?.label).not.toContain('<')
40
+ expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
41
+ })
42
+
43
+ it('drops everything invalid to undefined instead of throwing (never 400)', () => {
44
+ expect(tipJarSchema.parse(undefined)).toBeUndefined()
45
+ expect(tipJarSchema.parse(null)).toBeUndefined()
46
+ expect(tipJarSchema.parse('nonsense')).toBeUndefined()
47
+ expect(tipJarSchema.parse({})).toBeUndefined()
48
+ expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
49
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
50
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
51
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
52
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe('digitalConfigSchema — tipJar field', () => {
57
+ it('round-trips a valid tipJar', () => {
58
+ const parsed = digitalConfigSchema.parse({
59
+ ...baseConfig,
60
+ tipJar: { provider: 'revolut', handle: 'ricsi' },
61
+ })
62
+ expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
63
+ })
64
+
65
+ it('drops an invalid tipJar without failing the whole config save', () => {
66
+ const parsed = digitalConfigSchema.parse({
67
+ ...baseConfig,
68
+ tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
69
+ })
70
+ expect(parsed.tipJar).toBeUndefined()
71
+ })
72
+
73
+ it('is optional — a config without a tipJar parses fine', () => {
74
+ const parsed = digitalConfigSchema.parse(baseConfig)
75
+ expect(parsed.tipJar).toBeUndefined()
76
+ })
77
+ })