@stamprally/core 0.9.0 → 0.10.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.cjs CHANGED
@@ -12,34 +12,34 @@ function calculateDistanceMeters(aLat, aLon, bLat, bLon) {
12
12
  function mismatch(conditionType, reason, extra = {}) {
13
13
  return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
14
14
  }
15
- function evaluateConditionDetailed(condition, context) {
16
- switch (condition.type) {
15
+ function evaluateConditionDetailed(condition2, context) {
16
+ switch (condition2.type) {
17
17
  case "qr":
18
- return context.type === "qr" && context.token === condition.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
18
+ return context.type === "qr" && context.token === condition2.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
19
19
  case "passcode":
20
- return context.type === "passcode" && (condition.caseSensitive === false ? context.code.toLocaleLowerCase() === condition.code.toLocaleLowerCase() : context.code === condition.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
20
+ return context.type === "passcode" && (condition2.caseSensitive === false ? context.code.toLocaleLowerCase() === condition2.code.toLocaleLowerCase() : context.code === condition2.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
21
21
  case "nfc":
22
- return context.type === "nfc" && context.tagId === condition.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
22
+ return context.type === "nfc" && context.tagId === condition2.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
23
23
  case "custom":
24
24
  return mismatch("custom", "VALIDATOR_FAILED");
25
25
  case "gps": {
26
- if (!Number.isFinite(condition.latitude) || !Number.isFinite(condition.longitude) || !Number.isFinite(condition.radiusMeters) || condition.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
26
+ if (!Number.isFinite(condition2.latitude) || !Number.isFinite(condition2.longitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
27
27
  return mismatch("gps", "INVALID_GEO_INPUT");
28
28
  const distanceMeters = calculateDistanceMeters(
29
- condition.latitude,
30
- condition.longitude,
29
+ condition2.latitude,
30
+ condition2.longitude,
31
31
  context.latitude,
32
32
  context.longitude
33
33
  );
34
- return distanceMeters <= condition.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
34
+ return distanceMeters <= condition2.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
35
35
  distanceMeters,
36
- radiusMeters: condition.radiusMeters
36
+ radiusMeters: condition2.radiusMeters
37
37
  });
38
38
  }
39
39
  }
40
40
  }
41
- function evaluateCondition(condition, context) {
42
- return evaluateConditionDetailed(condition, context).ok;
41
+ function evaluateCondition(condition2, context) {
42
+ return evaluateConditionDetailed(condition2, context).ok;
43
43
  }
44
44
 
45
45
  // src/engine/order.ts
@@ -49,11 +49,11 @@ function getOrderedSpots(spots) {
49
49
 
50
50
  // src/engine/progress.ts
51
51
  function calculateProgress(state, config) {
52
- const ids = new Set(config.spots.map((spot) => spot.id));
52
+ const ids = new Set(config.spots.map((spot2) => spot2.id));
53
53
  const acquired = new Set(
54
54
  state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
55
55
  );
56
- const remaining = config.spots.filter((spot) => !acquired.has(spot.id));
56
+ const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
57
57
  return {
58
58
  acquired: acquired.size,
59
59
  total: config.spots.length,
@@ -93,9 +93,9 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
93
93
  const timestampPart = Number.isNaN(timestamp) ? Date.now() : timestamp;
94
94
  return `CLAIM-${rewardId}-${timestampPart}-${createRandomHash()}`;
95
95
  }
96
- function issueClaimTicketNumber(reward, currentState, options = {}) {
96
+ function issueClaimTicketNumber(reward2, currentState, options = {}) {
97
97
  if (currentState.claimTicketNumber !== void 0) return currentState;
98
- const claimTicketNumber = createClaimTicketNumber(reward.id, options);
98
+ const claimTicketNumber = createClaimTicketNumber(reward2.id, options);
99
99
  return { ...currentState, claimTicketNumber };
100
100
  }
101
101
 
@@ -303,8 +303,8 @@ function normalizePasscode(input, caseSensitive = false) {
303
303
  const normalized = input.normalize("NFKC").trim();
304
304
  return caseSensitive ? normalized : normalized.toUpperCase();
305
305
  }
306
- function verifyPasscode(inputCode, condition) {
307
- return normalizePasscode(inputCode, condition.caseSensitive) === normalizePasscode(condition.code, condition.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
306
+ function verifyPasscode(inputCode, condition2) {
307
+ return normalizePasscode(inputCode, condition2.caseSensitive) === normalizePasscode(condition2.code, condition2.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
308
308
  }
309
309
 
310
310
  // src/detectors/qr.ts
@@ -431,71 +431,71 @@ async function readQrContext(videoElement, options = {}) {
431
431
  // src/engine/transition.ts
432
432
  function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
433
433
  const states = new Map(currentStates.map((state) => [state.rewardId, state]));
434
- return rewards.map((reward) => {
435
- const current = states.get(reward.id);
434
+ return rewards.map((reward2) => {
435
+ const current = states.get(reward2.id);
436
436
  if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
437
- if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now))
438
- return { rewardId: reward.id, status: "EXPIRED" };
439
- if (reward.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward.stockLimit)
440
- return { rewardId: reward.id, status: "EXPIRED" };
441
- if (acquiredStampCount >= reward.requiredStampCount)
437
+ if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(now))
438
+ return { rewardId: reward2.id, status: "EXPIRED" };
439
+ if (reward2.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward2.stockLimit)
440
+ return { rewardId: reward2.id, status: "EXPIRED" };
441
+ if (acquiredStampCount >= reward2.requiredStampCount)
442
442
  return {
443
- rewardId: reward.id,
443
+ rewardId: reward2.id,
444
444
  status: "AVAILABLE",
445
445
  ...current?.unlockedAt === void 0 ? { unlockedAt: now } : { unlockedAt: current.unlockedAt }
446
446
  };
447
- return { rewardId: reward.id, status: "LOCKED" };
447
+ return { rewardId: reward2.id, status: "LOCKED" };
448
448
  });
449
449
  }
450
450
  function consumeReward(params) {
451
- const { reward, currentState } = params;
451
+ const { reward: reward2, currentState } = params;
452
452
  if (currentState.status === "CONSUMED")
453
- return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward.id } };
453
+ return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward2.id } };
454
454
  if (currentState.status !== "AVAILABLE")
455
- return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward.id } };
456
- if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now))
457
- return { ok: false, error: { code: "EXPIRED", rewardId: reward.id } };
458
- if (reward.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward.stockLimit)
459
- return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
460
- if (reward.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward.userClaimLimit)
461
- return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward.id } };
462
- if (reward.redemptionMethod === "staff_passcode") {
463
- if (reward.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward.staffPasscode }).success)
455
+ return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward2.id } };
456
+ if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(params.now))
457
+ return { ok: false, error: { code: "EXPIRED", rewardId: reward2.id } };
458
+ if (reward2.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward2.stockLimit)
459
+ return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward2.id } };
460
+ if (reward2.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward2.userClaimLimit)
461
+ return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward2.id } };
462
+ if (reward2.redemptionMethod === "staff_passcode") {
463
+ if (reward2.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward2.staffPasscode }).success)
464
464
  return {
465
465
  ok: false,
466
466
  error: {
467
467
  code: "INVALID_PASSCODE",
468
- rewardId: reward.id,
468
+ rewardId: reward2.id,
469
469
  message: "The passcode is invalid."
470
470
  }
471
471
  };
472
472
  }
473
- if (reward.redemptionMethod === "view_only") return { ok: true, value: currentState };
473
+ if (reward2.redemptionMethod === "view_only") return { ok: true, value: currentState };
474
474
  return {
475
475
  ok: true,
476
476
  value: {
477
477
  ...currentState,
478
478
  status: "CONSUMED",
479
479
  consumedAt: params.now,
480
- claimTicketNumber: createUniqueClaimTicketNumber(reward.id, params.now),
480
+ claimTicketNumber: createUniqueClaimTicketNumber(reward2.id, params.now),
481
481
  redeemedCount: (currentState.redeemedCount ?? 0) + 1,
482
482
  ...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
483
483
  }
484
484
  };
485
485
  }
486
486
  function processStamp(state, config, spotId, context, now) {
487
- const spot = config.spots.find((item) => item.id === spotId);
488
- if (spot === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
487
+ const spot2 = config.spots.find((item) => item.id === spotId);
488
+ if (spot2 === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
489
489
  if (state.records.some((record2) => record2.stampId === spotId))
490
490
  return { ok: false, error: { code: "STAMP_ALREADY_ACQUIRED", spotId } };
491
491
  const acquired = new Set(state.records.map((record2) => record2.stampId));
492
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
492
+ if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
493
493
  return { ok: false, error: { code: "PREREQUISITES_NOT_MET", spotId } };
494
- for (const condition of spot.conditions) {
495
- if (condition.type === "custom" || !evaluateConditionDetailed(condition, context).ok)
494
+ for (const condition2 of spot2.conditions) {
495
+ if (condition2.type === "custom" || !evaluateConditionDetailed(condition2, context).ok)
496
496
  return {
497
497
  ok: false,
498
- error: condition.type === "custom" ? {
498
+ error: condition2.type === "custom" ? {
499
499
  code: "CUSTOM_VALIDATION_FAILED",
500
500
  spotId,
501
501
  message: "Custom validation requires an async validator."
@@ -959,18 +959,18 @@ function proof(value) {
959
959
  }
960
960
  return "";
961
961
  }
962
- function matches(condition, value) {
963
- if (condition.type === "gps") {
962
+ function matches(condition2, value) {
963
+ if (condition2.type === "gps") {
964
964
  if (typeof value !== "object" || value === null) return false;
965
965
  const item = value;
966
966
  const latitude = item.latitude;
967
967
  const longitude = item.longitude;
968
968
  if (typeof latitude !== "number" || typeof longitude !== "number") return false;
969
969
  const radians = (v) => v * Math.PI / 180;
970
- const dLat = radians(latitude - condition.latitude);
971
- const dLon = radians(longitude - condition.longitude);
972
- const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
973
- return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition.radiusMeters;
970
+ const dLat = radians(latitude - condition2.latitude);
971
+ const dLon = radians(longitude - condition2.longitude);
972
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
973
+ return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
974
974
  }
975
975
  return proof(value).trim() !== "";
976
976
  }
@@ -1057,8 +1057,8 @@ var StampRallyClient = class {
1057
1057
  checkIn(spotId, proofData, options = {}) {
1058
1058
  return this.#enqueue(async () => {
1059
1059
  const current = await this.initialize();
1060
- const spot = this.#config.spots.find((item) => item.id === spotId);
1061
- if (spot === void 0)
1060
+ const spot2 = this.#config.spots.find((item) => item.id === spotId);
1061
+ if (spot2 === void 0)
1062
1062
  return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
1063
1063
  if (current.records.some((record2) => record2.stampId === spotId))
1064
1064
  return this.#fail({
@@ -1067,15 +1067,15 @@ var StampRallyClient = class {
1067
1067
  message: "Spot was already claimed."
1068
1068
  });
1069
1069
  const acquired = new Set(current.records.map((record2) => record2.stampId));
1070
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
1070
+ if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
1071
1071
  return this.#fail({
1072
1072
  code: "PREREQUISITES_NOT_MET",
1073
1073
  spotId,
1074
1074
  message: "Prerequisite spots are not complete."
1075
1075
  });
1076
- for (const condition of spot.conditions) {
1077
- if (condition.type === "custom") {
1078
- const validator = this.#options.customValidators?.[condition.validatorName] ?? this.#options.customValidator;
1076
+ for (const condition2 of spot2.conditions) {
1077
+ if (condition2.type === "custom") {
1078
+ const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
1079
1079
  if (validator === void 0)
1080
1080
  return this.#fail({
1081
1081
  code: "CUSTOM_VALIDATION_FAILED",
@@ -1086,7 +1086,7 @@ var StampRallyClient = class {
1086
1086
  rallyId: this.#config.id,
1087
1087
  spotId,
1088
1088
  proofData,
1089
- condition: { type: "custom", validatorName: condition.validatorName },
1089
+ condition: { type: "custom", validatorName: condition2.validatorName },
1090
1090
  userState: current
1091
1091
  };
1092
1092
  const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
@@ -1096,7 +1096,7 @@ var StampRallyClient = class {
1096
1096
  spotId,
1097
1097
  message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
1098
1098
  });
1099
- } else if (!matches(condition, proofData))
1099
+ } else if (!matches(condition2, proofData))
1100
1100
  return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
1101
1101
  }
1102
1102
  const now = options.now ?? this.#now();
@@ -1216,7 +1216,7 @@ var StampRallyClient = class {
1216
1216
  return Promise.resolve(next);
1217
1217
  }
1218
1218
  #reconcile(state) {
1219
- const ids = new Set(this.#config.spots.map((spot) => spot.id));
1219
+ const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
1220
1220
  const records = state.records.filter(
1221
1221
  (record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
1222
1222
  );
@@ -1396,6 +1396,11 @@ async function decryptPayload(body, secret) {
1396
1396
  }
1397
1397
 
1398
1398
  // src/domain/i18n.ts
1399
+ function updateLocalizedField(current, locale, newValue) {
1400
+ if (typeof current === "string" || current === void 0)
1401
+ return { [locale]: newValue };
1402
+ return { ...current, [locale]: newValue };
1403
+ }
1399
1404
  function resolveLocalizedText(text, locale, fallbackLocale) {
1400
1405
  if (text === void 0 || text === "") return "";
1401
1406
  if (typeof text === "string") return text;
@@ -1422,23 +1427,23 @@ var DEFAULT_SHEET_THEME = {
1422
1427
  unclaimedOpacity: 1,
1423
1428
  fontFamily: "serif"
1424
1429
  };
1425
- function publicCondition(condition) {
1426
- switch (condition.type) {
1430
+ function publicCondition(condition2) {
1431
+ switch (condition2.type) {
1427
1432
  case "qr":
1428
- return condition.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition.qrEntryUrl };
1433
+ return condition2.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition2.qrEntryUrl };
1429
1434
  case "passcode":
1430
1435
  return { type: "passcode" };
1431
1436
  case "gps":
1432
1437
  return {
1433
1438
  type: "gps",
1434
- latitude: condition.latitude,
1435
- longitude: condition.longitude,
1436
- radiusMeters: condition.radiusMeters
1439
+ latitude: condition2.latitude,
1440
+ longitude: condition2.longitude,
1441
+ radiusMeters: condition2.radiusMeters
1437
1442
  };
1438
1443
  case "nfc":
1439
1444
  return { type: "nfc" };
1440
1445
  case "custom":
1441
- return { type: "custom", validatorName: condition.validatorName };
1446
+ return { type: "custom", validatorName: condition2.validatorName };
1442
1447
  }
1443
1448
  }
1444
1449
  function toPublicConfig(config) {
@@ -1448,12 +1453,12 @@ function toPublicConfig(config) {
1448
1453
  title: config.title,
1449
1454
  ...config.description === void 0 ? {} : { description: config.description },
1450
1455
  ...config.theme === void 0 ? {} : { theme: config.theme },
1451
- spots: config.spots.map((spot) => ({
1452
- ...spot,
1453
- conditions: spot.conditions.map(publicCondition)
1456
+ spots: config.spots.map((spot2) => ({
1457
+ ...spot2,
1458
+ conditions: spot2.conditions.map(publicCondition)
1454
1459
  })),
1455
1460
  rewards: config.rewards.map(
1456
- ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward }) => reward
1461
+ ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward2 }) => reward2
1457
1462
  ),
1458
1463
  ...config.metadata === void 0 ? {} : { metadata: config.metadata },
1459
1464
  ...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
@@ -1491,15 +1496,15 @@ function isPublicConfig(value) {
1491
1496
  }
1492
1497
  if (typeof candidate.id !== "string" || typeof candidate.version !== "string" || typeof candidate.title !== "string" && (typeof candidate.title !== "object" || candidate.title === null || Array.isArray(candidate.title)) || !Array.isArray(candidate.spots) || !Array.isArray(candidate.rewards))
1493
1498
  return false;
1494
- return candidate.spots.every((spot) => {
1495
- if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
1496
- const item = spot;
1499
+ return candidate.spots.every((spot2) => {
1500
+ if (typeof spot2 !== "object" || spot2 === null || Array.isArray(spot2)) return false;
1501
+ const item = spot2;
1497
1502
  if (typeof item.id !== "string" || typeof item.orderIndex !== "number" || item.name === void 0 || "secretToken" in item || "code" in item || "tagId" in item || "secretParams" in item)
1498
1503
  return false;
1499
- return Array.isArray(item.conditions) && item.conditions.every((condition) => {
1500
- if (typeof condition !== "object" || condition === null || Array.isArray(condition))
1504
+ return Array.isArray(item.conditions) && item.conditions.every((condition2) => {
1505
+ if (typeof condition2 !== "object" || condition2 === null || Array.isArray(condition2))
1501
1506
  return false;
1502
- const value2 = condition;
1507
+ const value2 = condition2;
1503
1508
  if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
1504
1509
  return false;
1505
1510
  const type = value2.type;
@@ -1507,9 +1512,9 @@ function isPublicConfig(value) {
1507
1512
  if (type === "custom") return typeof value2.validatorName === "string";
1508
1513
  return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
1509
1514
  });
1510
- }) && candidate.rewards.every((reward) => {
1511
- if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
1512
- const item = reward;
1515
+ }) && candidate.rewards.every((reward2) => {
1516
+ if (typeof reward2 !== "object" || reward2 === null || Array.isArray(reward2)) return false;
1517
+ const item = reward2;
1513
1518
  return typeof item.id === "string" && typeof item.requiredStampCount === "number" && (item.type === "digital" || item.type === "in_person") && (item.redemptionMethod === "manual_slide" || item.redemptionMethod === "staff_passcode" || item.redemptionMethod === "view_only" || item.redemptionMethod === "server_claim") && !("staffPasscode" in item || "digitalContentUrl" in item);
1514
1519
  });
1515
1520
  }
@@ -1613,6 +1618,324 @@ var THEME_PRESETS = [
1613
1618
  }
1614
1619
  ];
1615
1620
 
1621
+ // src/domain/validation.ts
1622
+ var ConfigValidationError = class extends Error {
1623
+ constructor(errors) {
1624
+ super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
1625
+ this.errors = errors;
1626
+ }
1627
+ errors;
1628
+ name = "ConfigValidationError";
1629
+ };
1630
+ function isRecord2(value) {
1631
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1632
+ }
1633
+ function hasOwn(value, key) {
1634
+ return Object.hasOwn(value, key);
1635
+ }
1636
+ function add(errors, path, message, code) {
1637
+ errors.push({ path, message, code });
1638
+ }
1639
+ function requiredString(value, key, path, errors) {
1640
+ if (typeof value[key] !== "string" || value[key].length === 0) {
1641
+ add(errors, `${path}.${key}`, "A non-empty string is required.", "required_string");
1642
+ return false;
1643
+ }
1644
+ return true;
1645
+ }
1646
+ function optionalString(value, key, path, errors) {
1647
+ if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "string")
1648
+ add(errors, `${path}.${key}`, "Expected a string.", "invalid_type");
1649
+ }
1650
+ function optionalBoolean(value, key, path, errors) {
1651
+ if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "boolean")
1652
+ add(errors, `${path}.${key}`, "Expected a boolean.", "invalid_type");
1653
+ }
1654
+ function finiteNumber(value, key, path, errors, minimum) {
1655
+ const item = value[key];
1656
+ if (typeof item !== "number" || !Number.isFinite(item)) {
1657
+ add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
1658
+ } else if (minimum !== void 0 && item < minimum) {
1659
+ add(
1660
+ errors,
1661
+ `${path}.${key}`,
1662
+ `Expected a number greater than or equal to ${minimum}.`,
1663
+ "out_of_range"
1664
+ );
1665
+ }
1666
+ }
1667
+ function localizedText(value, path, errors) {
1668
+ if (typeof value === "string") return;
1669
+ if (!isRecord2(value)) {
1670
+ add(errors, path, "Expected a string or a locale map.", "invalid_localized_text");
1671
+ return;
1672
+ }
1673
+ for (const [locale, text] of Object.entries(value)) {
1674
+ if (typeof text !== "string")
1675
+ add(errors, `${path}.${locale}`, "Expected a string.", "invalid_type");
1676
+ }
1677
+ }
1678
+ function theme(value, path, errors) {
1679
+ if (!isRecord2(value)) {
1680
+ add(errors, path, "Expected a theme object.", "invalid_type");
1681
+ return;
1682
+ }
1683
+ for (const key of ["primaryColor", "cardBackgroundColor", "textColor"])
1684
+ requiredString(value, key, path, errors);
1685
+ optionalString(value, "backgroundColor", path, errors);
1686
+ optionalString(value, "backgroundImageUrl", path, errors);
1687
+ optionalString(value, "completedStampColor", path, errors);
1688
+ optionalString(value, "fontFamily", path, errors);
1689
+ const slotShape = value.slotShape;
1690
+ if (slotShape !== "circle" && slotShape !== "square" && slotShape !== "rounded")
1691
+ add(errors, `${path}.slotShape`, "Expected circle, square, or rounded.", "invalid_enum");
1692
+ finiteNumber(value, "gridColumns", path, errors, 1);
1693
+ if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
1694
+ add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
1695
+ if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
1696
+ }
1697
+ function externalReferences(value, path, errors) {
1698
+ if (!Array.isArray(value)) {
1699
+ add(errors, path, "Expected an array.", "invalid_type");
1700
+ return;
1701
+ }
1702
+ value.forEach((item, index) => {
1703
+ const itemPath = `${path}[${index}]`;
1704
+ if (!isRecord2(item)) {
1705
+ add(errors, itemPath, "Expected an object.", "invalid_type");
1706
+ return;
1707
+ }
1708
+ requiredString(item, "type", itemPath, errors);
1709
+ requiredString(item, "id", itemPath, errors);
1710
+ optionalString(item, "url", itemPath, errors);
1711
+ });
1712
+ }
1713
+ function condition(value, path, errors, isPublic) {
1714
+ if (!isRecord2(value)) {
1715
+ add(errors, path, "Expected a condition object.", "invalid_type");
1716
+ return;
1717
+ }
1718
+ const type = value.type;
1719
+ if (type === "qr") {
1720
+ if (isPublic) {
1721
+ if (hasOwn(value, "secretToken"))
1722
+ add(errors, `${path}.secretToken`, "Private field is not allowed.", "private_field");
1723
+ } else requiredString(value, "secretToken", path, errors);
1724
+ optionalString(value, "qrEntryUrl", path, errors);
1725
+ return;
1726
+ }
1727
+ if (type === "passcode") {
1728
+ if (isPublic) {
1729
+ for (const key of ["code", "caseSensitive"])
1730
+ if (hasOwn(value, key))
1731
+ add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
1732
+ } else {
1733
+ requiredString(value, "code", path, errors);
1734
+ optionalBoolean(value, "caseSensitive", path, errors);
1735
+ }
1736
+ return;
1737
+ }
1738
+ if (type === "gps") {
1739
+ finiteNumber(value, "latitude", path, errors);
1740
+ finiteNumber(value, "longitude", path, errors);
1741
+ finiteNumber(value, "radiusMeters", path, errors, 0);
1742
+ if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
1743
+ add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
1744
+ if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
1745
+ add(
1746
+ errors,
1747
+ `${path}.longitude`,
1748
+ "Expected a longitude between -180 and 180.",
1749
+ "out_of_range"
1750
+ );
1751
+ return;
1752
+ }
1753
+ if (type === "nfc") {
1754
+ if (isPublic) {
1755
+ if (hasOwn(value, "tagId"))
1756
+ add(errors, `${path}.tagId`, "Private field is not allowed.", "private_field");
1757
+ } else requiredString(value, "tagId", path, errors);
1758
+ return;
1759
+ }
1760
+ if (type === "custom") {
1761
+ requiredString(value, "validatorName", path, errors);
1762
+ if (isPublic && hasOwn(value, "secretParams"))
1763
+ add(errors, `${path}.secretParams`, "Private field is not allowed.", "private_field");
1764
+ if (!isPublic && hasOwn(value, "secretParams") && !isRecord2(value.secretParams))
1765
+ add(errors, `${path}.secretParams`, "Expected an object.", "invalid_type");
1766
+ return;
1767
+ }
1768
+ add(errors, `${path}.type`, "Unknown condition type.", "invalid_enum");
1769
+ }
1770
+ function unlockCondition(value, path, errors) {
1771
+ if (!isRecord2(value)) {
1772
+ add(errors, path, "Expected an unlock condition object.", "invalid_type");
1773
+ return;
1774
+ }
1775
+ if (value.type === "stamp_count") {
1776
+ finiteNumber(value, "count", path, errors, 0);
1777
+ return;
1778
+ }
1779
+ if (value.type === "stamps") {
1780
+ if (!Array.isArray(value.stampIds))
1781
+ add(errors, `${path}.stampIds`, "Expected an array.", "invalid_type");
1782
+ else
1783
+ value.stampIds.forEach((item, index) => {
1784
+ if (typeof item !== "string" || item.length === 0)
1785
+ add(
1786
+ errors,
1787
+ `${path}.stampIds[${index}]`,
1788
+ "Expected a non-empty string.",
1789
+ "required_string"
1790
+ );
1791
+ });
1792
+ return;
1793
+ }
1794
+ if (value.type === "all" || value.type === "any") {
1795
+ if (!Array.isArray(value.conditions))
1796
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
1797
+ else
1798
+ value.conditions.forEach((item, index) => {
1799
+ unlockCondition(item, `${path}.conditions[${index}]`, errors);
1800
+ });
1801
+ return;
1802
+ }
1803
+ add(errors, `${path}.type`, "Unknown unlock condition type.", "invalid_enum");
1804
+ }
1805
+ function spot(value, path, errors, isPublic) {
1806
+ if (!isRecord2(value)) {
1807
+ add(errors, path, "Expected a spot object.", "invalid_type");
1808
+ return;
1809
+ }
1810
+ requiredString(value, "id", path, errors);
1811
+ finiteNumber(value, "orderIndex", path, errors, 0);
1812
+ if (typeof value.orderIndex === "number" && !Number.isInteger(value.orderIndex))
1813
+ add(errors, `${path}.orderIndex`, "Expected an integer.", "invalid_integer");
1814
+ localizedText(value.name, `${path}.name`, errors);
1815
+ for (const key of ["description", "hint"])
1816
+ if (hasOwn(value, key)) localizedText(value[key], `${path}.${key}`, errors);
1817
+ for (const key of ["imageUrl", "iconUrl", "redirectUrlAfterClaim"])
1818
+ optionalString(value, key, path, errors);
1819
+ if (hasOwn(value, "externalReferences") && value.externalReferences !== void 0)
1820
+ externalReferences(value.externalReferences, `${path}.externalReferences`, errors);
1821
+ if (hasOwn(value, "prerequisites") && value.prerequisites !== void 0) {
1822
+ if (!Array.isArray(value.prerequisites))
1823
+ add(errors, `${path}.prerequisites`, "Expected an array.", "invalid_type");
1824
+ else
1825
+ value.prerequisites.forEach((item, index) => {
1826
+ if (typeof item !== "string" || item.length === 0)
1827
+ add(
1828
+ errors,
1829
+ `${path}.prerequisites[${index}]`,
1830
+ "Expected a non-empty string.",
1831
+ "required_string"
1832
+ );
1833
+ });
1834
+ }
1835
+ if (!Array.isArray(value.conditions))
1836
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
1837
+ else
1838
+ value.conditions.forEach((item, index) => {
1839
+ condition(item, `${path}.conditions[${index}]`, errors, isPublic);
1840
+ });
1841
+ }
1842
+ function reward(value, path, errors, isPublic) {
1843
+ if (!isRecord2(value)) {
1844
+ add(errors, path, "Expected a reward object.", "invalid_type");
1845
+ return;
1846
+ }
1847
+ requiredString(value, "id", path, errors);
1848
+ localizedText(value.title, `${path}.title`, errors);
1849
+ if (hasOwn(value, "description")) localizedText(value.description, `${path}.description`, errors);
1850
+ if (value.type !== "digital" && value.type !== "in_person")
1851
+ add(errors, `${path}.type`, "Unknown reward type.", "invalid_enum");
1852
+ if (!["manual_slide", "staff_passcode", "view_only", "server_claim"].includes(
1853
+ String(value.redemptionMethod)
1854
+ ))
1855
+ add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
1856
+ finiteNumber(value, "requiredStampCount", path, errors, 0);
1857
+ for (const key of ["stockLimit", "userClaimLimit"]) {
1858
+ if (hasOwn(value, key) && value[key] !== void 0) {
1859
+ finiteNumber(value, key, path, errors, 0);
1860
+ if (typeof value[key] === "number" && !Number.isInteger(value[key]))
1861
+ add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
1862
+ }
1863
+ }
1864
+ optionalString(value, "validUntil", path, errors);
1865
+ if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
1866
+ add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
1867
+ if (isPublic) {
1868
+ for (const key of ["staffPasscode", "digitalContentUrl"])
1869
+ if (hasOwn(value, key))
1870
+ add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
1871
+ } else {
1872
+ optionalString(value, "staffPasscode", path, errors);
1873
+ optionalString(value, "digitalContentUrl", path, errors);
1874
+ }
1875
+ if (hasOwn(value, "conditions") && value.conditions !== void 0) {
1876
+ if (!Array.isArray(value.conditions))
1877
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
1878
+ else
1879
+ value.conditions.forEach((item, index) => {
1880
+ unlockCondition(item, `${path}.conditions[${index}]`, errors);
1881
+ });
1882
+ }
1883
+ }
1884
+ function validate(value, isPublic) {
1885
+ const errors = [];
1886
+ if (!isRecord2(value)) {
1887
+ add(errors, "$", "Expected a configuration object.", "invalid_type");
1888
+ return errors;
1889
+ }
1890
+ requiredString(value, "id", "$", errors);
1891
+ requiredString(value, "version", "$", errors);
1892
+ localizedText(value.title, "$.title", errors);
1893
+ if (hasOwn(value, "description")) localizedText(value.description, "$.description", errors);
1894
+ if (hasOwn(value, "theme") && value.theme !== void 0) theme(value.theme, "$.theme", errors);
1895
+ if (!Array.isArray(value.spots)) add(errors, "spots", "Expected an array.", "invalid_type");
1896
+ else
1897
+ value.spots.forEach((item, index) => {
1898
+ spot(item, `spots[${index}]`, errors, isPublic);
1899
+ });
1900
+ if (!Array.isArray(value.rewards)) add(errors, "rewards", "Expected an array.", "invalid_type");
1901
+ else
1902
+ value.rewards.forEach((item, index) => {
1903
+ reward(item, `rewards[${index}]`, errors, isPublic);
1904
+ });
1905
+ if (!isPublic) {
1906
+ optionalString(value, "staffPasscode", "$", errors);
1907
+ if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord2(value.inventory))
1908
+ add(errors, "$.inventory", "Expected an object.", "invalid_type");
1909
+ if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord2(value.serverMetadata))
1910
+ add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
1911
+ optionalString(value, "serverEndpoint", "$", errors);
1912
+ } else {
1913
+ for (const key of ["staffPasscode", "serverMetadata", "inventory"])
1914
+ if (hasOwn(value, key))
1915
+ add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
1916
+ optionalString(value, "serverEndpoint", "$", errors);
1917
+ }
1918
+ return errors;
1919
+ }
1920
+ function safeParseAdminConfig(input) {
1921
+ const errors = validate(input, false);
1922
+ return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
1923
+ }
1924
+ function parseAdminConfig(input) {
1925
+ const result = safeParseAdminConfig(input);
1926
+ if (!result.success) throw new ConfigValidationError(result.errors);
1927
+ return result.data;
1928
+ }
1929
+ function safeParsePublicConfig(input) {
1930
+ const errors = validate(input, true);
1931
+ return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
1932
+ }
1933
+ function parsePublicConfig(input) {
1934
+ const result = safeParsePublicConfig(input);
1935
+ if (!result.success) throw new ConfigValidationError(result.errors);
1936
+ return result.data;
1937
+ }
1938
+
1616
1939
  // src/security/snapshotToken.ts
1617
1940
  var encoder2 = new TextEncoder();
1618
1941
  function cryptoApi2() {
@@ -1732,6 +2055,7 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
1732
2055
  }
1733
2056
  }
1734
2057
 
2058
+ exports.ConfigValidationError = ConfigValidationError;
1735
2059
  exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
1736
2060
  exports.InMemoryStorage = InMemoryStorage;
1737
2061
  exports.IndexedDBAdapter = IndexedDBAdapter;
@@ -1761,14 +2085,19 @@ exports.isRewardState = isRewardState;
1761
2085
  exports.isStampRallyState = isStampRallyState;
1762
2086
  exports.issueClaimTicketNumber = issueClaimTicketNumber;
1763
2087
  exports.normalizePasscode = normalizePasscode;
2088
+ exports.parseAdminConfig = parseAdminConfig;
2089
+ exports.parsePublicConfig = parsePublicConfig;
1764
2090
  exports.processStamp = processStamp;
1765
2091
  exports.readNfcContext = readNfcContext;
1766
2092
  exports.readQrContext = readQrContext;
1767
2093
  exports.reconcileRewardStates = reconcileRewardStates;
1768
2094
  exports.resolveLocalizedText = resolveLocalizedText;
2095
+ exports.safeParseAdminConfig = safeParseAdminConfig;
2096
+ exports.safeParsePublicConfig = safeParsePublicConfig;
1769
2097
  exports.storageKey = storageKey;
1770
2098
  exports.toLocalizedString = toLocalizedString;
1771
2099
  exports.toPublicConfig = toPublicConfig;
2100
+ exports.updateLocalizedField = updateLocalizedField;
1772
2101
  exports.verifyPasscode = verifyPasscode;
1773
2102
  exports.verifySecureToken = verifySecureToken;
1774
2103
  exports.verifySnapshotToken = verifySnapshotToken;