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