@nfcard/validation 0.25.0 → 0.26.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _nfcard_types from '@nfcard/types';
2
- import { CardAsset, CardElement, ElementTransform, ProfileObjectKind, ProfileLayout } from '@nfcard/types';
2
+ import { CardAsset, ElementTransform, CardElement, ProfileObjectKind, ProfileLayout } from '@nfcard/types';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -47,6 +47,21 @@ interface Pt {
47
47
  declare function isFiniteTransform(t: ElementTransform): boolean;
48
48
  /** The 4 corners of a `wMm × hMm` box (centre-origin local) under a transform. */
49
49
  declare function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[];
50
+ /** Is a DISC of radius `rMm` centred at `t` inside the ID-1 rim minus the safety margin?
51
+ *
52
+ * NFCARD-841 — the chip is physically a DISC, but {@link footprintOf} gives it the 27.5 mm
53
+ * bounding SQUARE, so running it through {@link cornersOnCard} + {@link obbCorners} made its
54
+ * on-card verdict ROTATION-SENSITIVE. A circle covers the same area at every angle, so that is
55
+ * wrong in principle; it was also wrong in practice, because past scale ≈1.31 the square's
56
+ * half-diagonal (13.75·s·√2) exceeds the 25.5 mm rim and a 45°-rotated chip was rejected at
57
+ * EVERY position on the card — unorderable with no placement that could save it.
58
+ *
59
+ * ⚠ At rotation 0 this is EXACTLY the old bound (the square's half-height IS the radius), so no
60
+ * previously-legal design changes verdict and no previously-illegal one is let through. The only
61
+ * behaviour that moves is the rotation artifact.
62
+ *
63
+ * Rotation-invariant by construction — it takes no angle. Mirrors cardgen `disc_on_card`. */
64
+ declare function discOnCard(t: ElementTransform, rMm: number, margin?: number): boolean;
50
65
  /** Are all corners inside the ID-1 rim minus the safety margin? */
51
66
  declare function cornersOnCard(corners: Pt[], margin?: number): boolean;
52
67
  type ChipAxis = 0 | 1 | 2;
@@ -22722,5 +22737,63 @@ declare function validateUserData(data: unknown): ValidationResult<z.infer<typeo
22722
22737
  declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer<typeof physicalConfigSchema>>;
22723
22738
  declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
22724
22739
  declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
22740
+ /** What physical thing carries the code. Fulfilment dispatch, never routing. */
22741
+ declare const productTypeSchema: z.ZodEnum<["card", "qr_sticker", "keychain", "board"]>;
22742
+ /** Where a tap goes. */
22743
+ declare const linkDestinationTypeSchema: z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>;
22744
+ /** The lifecycle of the code itself, independent of where it points. */
22745
+ declare const linkStatusSchema: z.ZodEnum<["minted", "unclaimed", "claimed", "active", "void"]>;
22746
+ /** Which touchpoint of one physical object a row is. */
22747
+ declare const linkRoleSchema: z.ZodEnum<["chip", "qr"]>;
22748
+ /**
22749
+ * The three destination columns, as a request body carries them.
22750
+ *
22751
+ * SHAPE ONLY. Which types a given caller may actually SET is a tier question,
22752
+ * answered server-side against a tier the server derives itself — putting it
22753
+ * here would mean a client could read the gate off the schema, and a schema
22754
+ * cannot see who is asking.
22755
+ *
22756
+ * `destUrl` is capped but not URL-validated on purpose: the single gate is the
22757
+ * server's external-URL parser, and a second, looser check here would only
22758
+ * teach a client that a URL it will later refuse was accepted.
22759
+ */
22760
+ declare const MAX_LINK_DEST_URL_LENGTH = 2048;
22761
+ declare const linkDestinationInputSchema: z.ZodObject<{
22762
+ destinationType: z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>;
22763
+ destProfileId: z.ZodOptional<z.ZodString>;
22764
+ destUrl: z.ZodOptional<z.ZodString>;
22765
+ }, "strip", z.ZodTypeAny, {
22766
+ destinationType: "auto" | "board" | "profile" | "hub_chooser" | "external";
22767
+ destProfileId?: string | undefined;
22768
+ destUrl?: string | undefined;
22769
+ }, {
22770
+ destinationType: "auto" | "board" | "profile" | "hub_chooser" | "external";
22771
+ destProfileId?: string | undefined;
22772
+ destUrl?: string | undefined;
22773
+ }>;
22774
+ type LinkDestinationInput = z.infer<typeof linkDestinationInputSchema>;
22775
+ /**
22776
+ * A destination the buyer chose BEFORE the object existed.
22777
+ *
22778
+ * Every field optional, including the discriminator, because "the buyer never
22779
+ * opened the picker" is the overwhelmingly common state and has to be storable
22780
+ * as silence rather than as an explicit default. Consumed once at fulfilment,
22781
+ * where it may only ever SOFTEN — a routing preference must not fail a paid
22782
+ * order.
22783
+ */
22784
+ declare const linkDestinationIntentSchema: z.ZodObject<{
22785
+ destinationType: z.ZodOptional<z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>>;
22786
+ destProfileId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
22787
+ destUrl: z.ZodOptional<z.ZodOptional<z.ZodString>>;
22788
+ }, "strip", z.ZodTypeAny, {
22789
+ destinationType?: "auto" | "board" | "profile" | "hub_chooser" | "external" | undefined;
22790
+ destProfileId?: string | undefined;
22791
+ destUrl?: string | undefined;
22792
+ }, {
22793
+ destinationType?: "auto" | "board" | "profile" | "hub_chooser" | "external" | undefined;
22794
+ destProfileId?: string | undefined;
22795
+ destUrl?: string | undefined;
22796
+ }>;
22797
+ type LinkDestinationIntent = z.infer<typeof linkDestinationIntentSchema>;
22725
22798
 
22726
- export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
22799
+ export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, type LinkDestinationInput, type LinkDestinationIntent, MAX_CTA_BUTTONS, MAX_LINK_DEST_URL_LENGTH, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, discOnCard, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, linkDestinationInputSchema, linkDestinationIntentSchema, linkDestinationTypeSchema, linkRoleSchema, linkStatusSchema, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, productTypeSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
package/dist/index.js CHANGED
@@ -4,7 +4,11 @@ import {
4
4
  isTipProviderId,
5
5
  normalizeTipHandle,
6
6
  isBookingProviderId,
7
- normalizeBookingHandle
7
+ normalizeBookingHandle,
8
+ PRODUCT_TYPES,
9
+ LINK_DESTINATION_TYPES,
10
+ LINK_STATUSES,
11
+ LINK_ROLES
8
12
  } from "@nfcard/types";
9
13
 
10
14
  // src/printability.ts
@@ -41,6 +45,9 @@ function obbCorners(t, wMm, hMm) {
41
45
  ];
42
46
  return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }));
43
47
  }
48
+ function discOnCard(t, rMm, margin = EDGE_MARGIN_MM) {
49
+ return Math.abs(t.xMm) + rMm <= CARD_W_MM / 2 - margin + EPS && Math.abs(t.yMm) + rMm <= CARD_H_MM / 2 - margin + EPS;
50
+ }
44
51
  function cornersOnCard(corners, margin = EDGE_MARGIN_MM) {
45
52
  const maxX = CARD_W_MM / 2 - margin;
46
53
  const maxY = CARD_H_MM / 2 - margin;
@@ -441,8 +448,8 @@ function elementPrintability(el, ctx) {
441
448
  break;
442
449
  }
443
450
  }
444
- const fp = footprintOf(el);
445
- if (!cornersOnCard(obbCorners(el.transform, fp.w, fp.h))) {
451
+ const onCard = el.type === "chip" ? discOnCard(el.transform, CHIP_RADIUS_MM * el.transform.scale) : cornersOnCard(obbCorners(el.transform, footprintOf(el).w, footprintOf(el).h));
452
+ if (!onCard) {
446
453
  push("elementOffCard", "The element extends past the printable card area.");
447
454
  }
448
455
  return out;
@@ -643,7 +650,7 @@ function isWebAddress(value) {
643
650
  }
644
651
  var webAddressSchema = z2.string().refine(isWebAddress, "invalidUrl").optional().or(z2.literal(""));
645
652
  function stripUnsafeChars(s) {
646
- return s.replace(/[-<>"'`\\]/g, "").trim();
653
+ return s.replace(/[\x00-\x1f\x7f<>"'`\\]/g, "").trim();
647
654
  }
648
655
  function sanitizeHttpUrl(raw) {
649
656
  if (typeof raw !== "string") return "";
@@ -663,7 +670,7 @@ function sanitizeHttpUrl(raw) {
663
670
  }
664
671
  function sanitizeText(raw, max = 50) {
665
672
  if (typeof raw !== "string") return "";
666
- const noTags = raw.replace(/<[^>]*>/g, "").replace(/[-]/g, "");
673
+ const noTags = raw.replace(/<[^>]*>/g, "").replace(/[\x00-\x1f\x7f]/g, "");
667
674
  return noTags.trim().slice(0, max);
668
675
  }
669
676
  var secureUrl = z2.string().max(2048).optional().or(z2.literal("")).transform((v) => sanitizeHttpUrl(v));
@@ -1173,6 +1180,17 @@ function validateConfiguration(data) {
1173
1180
  if (result.success) return { success: true, data: result.data };
1174
1181
  return { success: false, errors: formatZodErrors(result.error) };
1175
1182
  }
1183
+ var productTypeSchema = z2.enum(PRODUCT_TYPES);
1184
+ var linkDestinationTypeSchema = z2.enum(LINK_DESTINATION_TYPES);
1185
+ var linkStatusSchema = z2.enum(LINK_STATUSES);
1186
+ var linkRoleSchema = z2.enum(LINK_ROLES);
1187
+ var MAX_LINK_DEST_URL_LENGTH = 2048;
1188
+ var linkDestinationInputSchema = z2.object({
1189
+ destinationType: linkDestinationTypeSchema,
1190
+ destProfileId: z2.string().trim().min(1).max(64).optional(),
1191
+ destUrl: z2.string().trim().max(MAX_LINK_DEST_URL_LENGTH).optional()
1192
+ });
1193
+ var linkDestinationIntentSchema = linkDestinationInputSchema.partial();
1176
1194
  export {
1177
1195
  ADVANCE_FALLBACK_EM,
1178
1196
  CARD_H_MM,
@@ -1183,6 +1201,7 @@ export {
1183
1201
  EMPTY_TEXT_FLOOR_EM,
1184
1202
  ITALIC_OVERHANG_EM,
1185
1203
  MAX_CTA_BUTTONS,
1204
+ MAX_LINK_DEST_URL_LENGTH,
1186
1205
  MIN_WALL_MM,
1187
1206
  NFC_COIL_DIAMETER_MM,
1188
1207
  NFC_RADIAL_CLEARANCE_MM,
@@ -1216,6 +1235,7 @@ export {
1216
1235
  ctaButtonsSchema,
1217
1236
  digitalConfigSchema,
1218
1237
  digitalVisibilitySchema,
1238
+ discOnCard,
1219
1239
  elementPrintability,
1220
1240
  elementTransformSchema,
1221
1241
  entrySourceSchema,
@@ -1224,12 +1244,18 @@ export {
1224
1244
  hubProfilesUpdateSchema,
1225
1245
  imageObjectStyleSchema,
1226
1246
  isFiniteTransform,
1247
+ linkDestinationInputSchema,
1248
+ linkDestinationIntentSchema,
1249
+ linkDestinationTypeSchema,
1250
+ linkRoleSchema,
1251
+ linkStatusSchema,
1227
1252
  materialSchema,
1228
1253
  mmToNearestChipAnchor,
1229
1254
  obbCorners,
1230
1255
  patternFamilySchema,
1231
1256
  physicalConfigSchema,
1232
1257
  physicalContentOverridesSchema,
1258
+ productTypeSchema,
1233
1259
  profileAccessTokenCreateSchema,
1234
1260
  profileBackgroundSchema,
1235
1261
  profileCreateSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.25.0",
3
+ "version": "0.26.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.19.0"
20
+ "@nfcard/types": "0.24.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
@@ -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
+ })
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { sanitizeHttpUrl, sanitizeText } from './index';
6
+
7
+ /**
8
+ * The character classes in `stripUnsafeChars` and `sanitizeText` used to be
9
+ * written with LITERAL 0x00 / 0x1f / 0x7f bytes in the source.
10
+ *
11
+ * That was not a style problem. It made this file read as BINARY to ripgrep
12
+ * ("found \0 byte around offset 2873"), so `grep`/`Grep` silently skipped it
13
+ * and anyone searching for `sanitizeHttpUrl` concluded it did not exist. Worse,
14
+ * a Prettier run, an editor "strip control characters" save, or a
15
+ * `.gitattributes` filter would rewrite those classes without anyone noticing —
16
+ * and `sanitizeHttpUrl` is the SECOND-OPINION gate that `parseExternalUrl` in
17
+ * nfcard-api cross-checks itself against. The two disagreeing turns into a
18
+ * blanket refusal of every external URL: an outage, from a formatter.
19
+ *
20
+ * The bytes are now `\x00` / `\x1f` / `\x7f` escapes — same semantics, plain
21
+ * ASCII, greppable, formatter-proof. These tests keep it that way.
22
+ */
23
+
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+ const SOURCE = fs.readFileSync(path.join(HERE, 'index.ts'));
26
+
27
+ describe('the source file stays plain ASCII', () => {
28
+ it('contains NO literal control bytes — that is what made it read as binary', () => {
29
+ const offenders: string[] = [];
30
+ for (let i = 0; i < SOURCE.length; i++) {
31
+ const byte = SOURCE[i];
32
+ const isControl = byte === 0 || byte < 9 || (byte > 13 && byte < 32) || byte === 127;
33
+ if (isControl) offenders.push(`0x${byte.toString(16)} @ ${i}`);
34
+ }
35
+ expect(offenders).toEqual([]);
36
+ });
37
+
38
+ it('still declares the classes it always did, as escapes', () => {
39
+ const text = SOURCE.toString('utf8');
40
+ // ⚠️ Not a cosmetic assertion. `[ -<...]` — which is what these look like
41
+ // once the control bytes are eaten — is a RANGE over U+0020–U+003C that
42
+ // swallows `.` `/` `:` and every digit. That exact bug is on the record in
43
+ // this codebase; this pins the intended form so it cannot come back.
44
+ expect(text).toContain('/[\\x00-\\x1f\\x7f<>"\'`\\\\]/g');
45
+ expect(text).toContain('/[\\x00-\\x1f\\x7f]/g');
46
+ });
47
+ });
48
+
49
+ describe('the classes behave exactly as before the escape rewrite', () => {
50
+ const CONTROLS = ['\x00', '\x01', '\x1f', '\x7f'];
51
+
52
+ it.each(CONTROLS)('sanitizeHttpUrl strips %j out of a host', (ch) => {
53
+ expect(sanitizeHttpUrl(`https://exam${ch}ple.com/x`)).toBe('https://example.com/x');
54
+ });
55
+
56
+ it.each(CONTROLS)('sanitizeText strips %j', (ch) => {
57
+ expect(sanitizeText(`Ri${ch}csi`)).toBe('Ricsi');
58
+ });
59
+
60
+ it('still strips the HTML-ish characters beside them', () => {
61
+ expect(sanitizeHttpUrl('https://example.com/<script>')).toBe('https://example.com/script');
62
+ });
63
+
64
+ it('does NOT strip the printable characters a URL needs', () => {
65
+ // The guard against re-introducing the U+0020–U+003C range bug: every one
66
+ // of these sits inside that range and must survive.
67
+ expect(sanitizeHttpUrl('https://example.com/a.b/c-1?d=2&e=3')).toBe(
68
+ 'https://example.com/a.b/c-1?d=2&e=3',
69
+ );
70
+ });
71
+ });
package/src/index.ts CHANGED
Binary file
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { LINK_DESTINATION_TYPES, PRODUCT_TYPES } from '@nfcard/types'
3
+ import {
4
+ MAX_LINK_DEST_URL_LENGTH,
5
+ linkDestinationIntentSchema,
6
+ linkDestinationTypeSchema,
7
+ linkRoleSchema,
8
+ linkStatusSchema,
9
+ productTypeSchema,
10
+ } from './index'
11
+
12
+ /**
13
+ * NFCARD-330 — the Zod half of the Link contracts.
14
+ *
15
+ * The assertion that matters most is the DERIVATION one: these schemas are
16
+ * built from the `@nfcard/types` const arrays rather than re-spelling the
17
+ * strings, which is a deliberate departure from this package's house style and
18
+ * the whole reason the ticket exists.
19
+ */
20
+
21
+ describe('the enum schemas', () => {
22
+ it('accept exactly the vocabulary and nothing else', () => {
23
+ for (const type of PRODUCT_TYPES) {
24
+ expect(productTypeSchema.safeParse(type).success).toBe(true)
25
+ }
26
+ expect(productTypeSchema.safeParse('poster').success).toBe(false)
27
+
28
+ for (const type of LINK_DESTINATION_TYPES) {
29
+ expect(linkDestinationTypeSchema.safeParse(type).success).toBe(true)
30
+ }
31
+ expect(linkDestinationTypeSchema.safeParse('hub').success).toBe(false)
32
+
33
+ expect(linkStatusSchema.safeParse('minted').success).toBe(true)
34
+ expect(linkStatusSchema.safeParse('dormant').success).toBe(false)
35
+ expect(linkRoleSchema.safeParse('qr').success).toBe(true)
36
+ expect(linkRoleSchema.safeParse('sticker').success).toBe(false)
37
+ })
38
+
39
+ // ⭐ THE DERIVATION. If someone "helpfully" rewrites these as literal
40
+ // z.enum(['card', …]) lists, the schema and the type can drift — which is the
41
+ // exact failure NFCARD-330 was filed to end. This asserts the two are the
42
+ // same set, so a one-sided edit fails here.
43
+ it('stay derived from the types package, not hand-copied', () => {
44
+ expect(productTypeSchema.options).toEqual([...PRODUCT_TYPES])
45
+ expect(linkDestinationTypeSchema.options).toEqual([...LINK_DESTINATION_TYPES])
46
+ })
47
+ })
48
+
49
+ describe('linkDestinationInputSchema / intent', () => {
50
+ it('accepts a destination with its matching target', () => {
51
+ expect(
52
+ linkDestinationIntentSchema.safeParse({ destinationType: 'profile', destProfileId: 'p1' })
53
+ .success,
54
+ ).toBe(true)
55
+ expect(
56
+ linkDestinationIntentSchema.safeParse({
57
+ destinationType: 'external',
58
+ destUrl: 'https://barberbros.hu/foglalas',
59
+ }).success,
60
+ ).toBe(true)
61
+ })
62
+
63
+ // "The buyer never opened the picker" is the overwhelmingly common state and
64
+ // has to be storable as silence, not as an explicit default.
65
+ it('accepts an entirely empty intent', () => {
66
+ expect(linkDestinationIntentSchema.safeParse({}).success).toBe(true)
67
+ })
68
+
69
+ it('refuses a destination type that does not exist', () => {
70
+ expect(linkDestinationIntentSchema.safeParse({ destinationType: 'hub' }).success).toBe(false)
71
+ })
72
+
73
+ // Capped, but NOT URL-validated: the one gate is the server's external-URL
74
+ // parser, and a second looser check here would teach a client that a URL the
75
+ // server will refuse was accepted.
76
+ it('caps the URL length without judging the URL', () => {
77
+ expect(
78
+ linkDestinationIntentSchema.safeParse({ destinationType: 'external', destUrl: 'not-a-url' })
79
+ .success,
80
+ ).toBe(true)
81
+ expect(
82
+ linkDestinationIntentSchema.safeParse({
83
+ destinationType: 'external',
84
+ destUrl: 'h'.repeat(MAX_LINK_DEST_URL_LENGTH + 1),
85
+ }).success,
86
+ ).toBe(false)
87
+ })
88
+
89
+ it('trims the targets so a padded value is stored clean', () => {
90
+ const parsed = linkDestinationIntentSchema.parse({
91
+ destinationType: 'profile',
92
+ destProfileId: ' p1 ',
93
+ })
94
+ expect(parsed.destProfileId).toBe('p1')
95
+ })
96
+ })
@@ -26,14 +26,33 @@ import {
26
26
  * Regenerate (both sides together):
27
27
  * node ../../nfcard-cardgen/tests/parity/gen_printability_parity.mjs
28
28
  *
29
- * Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI).
29
+ * Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI) — but says so
30
+ * out loud. A silent skip is the worst outcome here: the guard reads as green while the two
31
+ * implementations drift. Set `NFCARD_CARDGEN_DIR` when the sibling layout does not apply (a
32
+ * git worktree sits two levels deeper, so the relative guess misses).
30
33
  */
31
- const GOLDEN = fileURLToPath(
32
- new URL('../../../nfcard-cardgen/tests/parity/printability_parity.json', import.meta.url),
33
- )
34
- const present = existsSync(GOLDEN)
34
+ function resolveGolden(): string | null {
35
+ const rel = 'tests/parity/printability_parity.json'
36
+ const env = process.env.NFCARD_CARDGEN_DIR
37
+ const candidates = env
38
+ ? [`${env.replace(/[\\/]+$/, '')}/${rel}`]
39
+ : [
40
+ fileURLToPath(new URL(`../../../nfcard-cardgen/${rel}`, import.meta.url)),
41
+ fileURLToPath(new URL(`../../../../../nfcard-cardgen/${rel}`, import.meta.url)),
42
+ ]
43
+ return candidates.find((p) => existsSync(p)) ?? null
44
+ }
45
+
46
+ const GOLDEN = resolveGolden()
47
+ const present = GOLDEN !== null
48
+ if (!present) {
49
+ console.warn(
50
+ '[parity] SKIPPED — nfcard-cardgen/tests/parity/printability_parity.json not found. ' +
51
+ 'Set NFCARD_CARDGEN_DIR to run the cross-language guard.',
52
+ )
53
+ }
35
54
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
- const golden: any = present ? JSON.parse(readFileSync(GOLDEN, 'utf-8')) : { elementCases: [] }
55
+ const golden: any = GOLDEN ? JSON.parse(readFileSync(GOLDEN, 'utf-8')) : { elementCases: [] }
37
56
  const TOL = 1e-9
38
57
 
39
58
  // Mirror cardgen `validate_elements`: per-element printability + the graph-level
@@ -5,6 +5,9 @@ import {
5
5
  ITALIC_OVERHANG_EM,
6
6
  TEXT_LINE_FACTOR_EM,
7
7
  elementPrintability,
8
+ discOnCard,
9
+ CHIP_RADIUS_MM,
10
+ chipPlacementMaxXY,
8
11
  footprintOf,
9
12
  textAdvanceEm,
10
13
  CARD_W_MM,
@@ -105,3 +108,47 @@ describe('NFCARD-530 — elementOffCard verdict with the accurate estimate', ()
105
108
  expect(elementPrintability(el, ctx).map((v) => v.code)).toContain('elementOffCard')
106
109
  })
107
110
  })
111
+
112
+ // ── NFCARD-841 — the chip is a DISC, so its on-card verdict is rotation-invariant ──
113
+ describe('chip on-card check (NFCARD-841)', () => {
114
+ const chip = (xMm: number, yMm: number, rotationDeg = 0, scale = 1): CardElement =>
115
+ ({ id: 'c1', type: 'chip', shape: 0, transform: { xMm, yMm, rotationDeg, scale, z: 0 } }) as unknown as CardElement
116
+ const codes = (el: CardElement) => elementPrintability(el, { assetsById: new Map() }).map((v) => v.code)
117
+
118
+ it('rotation NEVER changes a chip verdict — it used to, at every angle past scale 1.31', () => {
119
+ for (const scale of [1, 1.2, 1.4, 1.6]) {
120
+ for (const [x, y] of [[0, 0], [10, 2], [-18, -2], [26.55, 2.5]] as const) {
121
+ const at0 = codes(chip(x, y, 0, scale))
122
+ for (const rot of [15, 30, 45, 60, 90, 180, 275]) {
123
+ expect(codes(chip(x, y, rot, scale)), `x${x} y${y} s${scale} rot${rot}`).toEqual(at0)
124
+ }
125
+ }
126
+ }
127
+ })
128
+
129
+ it('the case that had NO legal position: 45° at scale 1.6, anywhere on the card', () => {
130
+ // Before the fix the square's half-diagonal (13.75·1.6·√2 = 31.1 mm) exceeded the 25.5 mm
131
+ // rim, so elementOffCard fired at EVERY centre — a design no placement could rescue.
132
+ expect(codes(chip(0, 0, 45, 1.6))).toEqual([])
133
+ expect(codes(chip(0, 0, 0, 1.6))).toEqual([]) // …and it always agreed with the unrotated twin
134
+ })
135
+
136
+ it('at rotation 0 the new bound is EXACTLY the old one — no design changes verdict', () => {
137
+ // The square's half-height IS the disc radius, so this is a pure no-op where it used to be
138
+ // correct. Walk the y axis across the boundary and check the flip lands on the same value.
139
+ const rimMaxY = 54 / 2 - 1.5 - CHIP_RADIUS_MM // the old square bound at scale 1 = 11.75
140
+ expect(discOnCard(chip(0, rimMaxY).transform, CHIP_RADIUS_MM)).toBe(true)
141
+ expect(discOnCard(chip(0, rimMaxY + 0.01).transform, CHIP_RADIUS_MM)).toBe(false)
142
+ })
143
+
144
+ it('the ENVELOPE still binds first, so this did not loosen the pocket rule', () => {
145
+ // chipPlacementMaxXY is the disc bound minus the 1 mm pocket buffer, at every scale — so a
146
+ // chip that clears the envelope can never trip elementOffCard, and NFCARD-421 stays fixed.
147
+ for (const scale of [1, 1.3, 1.6]) {
148
+ const env = chipPlacementMaxXY(scale)
149
+ expect(env.maxY).toBeCloseTo(54 / 2 - 1.5 - CHIP_RADIUS_MM * scale - 1, 6)
150
+ expect(codes(chip(0, env.maxY, 45, scale))).toEqual([]) // on the envelope edge: legal
151
+ expect(codes(chip(0, env.maxY + 0.02, 45, scale))).toEqual(['chipOutsideEnvelope']) // just past: ONLY the envelope
152
+ }
153
+ })
154
+ })
@@ -86,6 +86,27 @@ export function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[]
86
86
  return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }))
87
87
  }
88
88
 
89
+ /** Is a DISC of radius `rMm` centred at `t` inside the ID-1 rim minus the safety margin?
90
+ *
91
+ * NFCARD-841 — the chip is physically a DISC, but {@link footprintOf} gives it the 27.5 mm
92
+ * bounding SQUARE, so running it through {@link cornersOnCard} + {@link obbCorners} made its
93
+ * on-card verdict ROTATION-SENSITIVE. A circle covers the same area at every angle, so that is
94
+ * wrong in principle; it was also wrong in practice, because past scale ≈1.31 the square's
95
+ * half-diagonal (13.75·s·√2) exceeds the 25.5 mm rim and a 45°-rotated chip was rejected at
96
+ * EVERY position on the card — unorderable with no placement that could save it.
97
+ *
98
+ * ⚠ At rotation 0 this is EXACTLY the old bound (the square's half-height IS the radius), so no
99
+ * previously-legal design changes verdict and no previously-illegal one is let through. The only
100
+ * behaviour that moves is the rotation artifact.
101
+ *
102
+ * Rotation-invariant by construction — it takes no angle. Mirrors cardgen `disc_on_card`. */
103
+ export function discOnCard(t: ElementTransform, rMm: number, margin = EDGE_MARGIN_MM): boolean {
104
+ return (
105
+ Math.abs(t.xMm) + rMm <= CARD_W_MM / 2 - margin + EPS &&
106
+ Math.abs(t.yMm) + rMm <= CARD_H_MM / 2 - margin + EPS
107
+ )
108
+ }
109
+
89
110
  /** Are all corners inside the ID-1 rim minus the safety margin? */
90
111
  export function cornersOnCard(corners: Pt[], margin = EDGE_MARGIN_MM): boolean {
91
112
  const maxX = CARD_W_MM / 2 - margin
@@ -323,8 +344,14 @@ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Viol
323
344
  }
324
345
  }
325
346
 
326
- const fp = footprintOf(el)
327
- if (!cornersOnCard(obbCorners(el.transform, fp.w, fp.h))) {
347
+ // NFCARD-841 the CHIP is judged as the disc it is, not as its bounding square, so the
348
+ // verdict cannot depend on an angle a circle does not have. Every other element keeps the OBB
349
+ // check: their footprints really are rectangles and rotation really does move their corners.
350
+ const onCard =
351
+ el.type === 'chip'
352
+ ? discOnCard(el.transform, CHIP_RADIUS_MM * el.transform.scale)
353
+ : cornersOnCard(obbCorners(el.transform, footprintOf(el).w, footprintOf(el).h))
354
+ if (!onCard) {
328
355
  push('elementOffCard', 'The element extends past the printable card area.')
329
356
  }
330
357
  return out
@@ -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
+ })