@stamprally/core 0.9.0 → 0.11.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
  }
@@ -987,6 +987,7 @@ var StampRallyClient = class {
987
987
  #storage;
988
988
  #options;
989
989
  #config;
990
+ #offlineQueue;
990
991
  #userId;
991
992
  #state = null;
992
993
  #initialization = null;
@@ -996,6 +997,7 @@ var StampRallyClient = class {
996
997
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
997
998
  this.#storage = this.#options.storage ?? new InMemoryStorage();
998
999
  this.#userId = this.#options.userId ?? null;
1000
+ this.#offlineQueue = this.#options.offlineQueue;
999
1001
  }
1000
1002
  getConfig() {
1001
1003
  return this.#config;
@@ -1006,6 +1008,12 @@ var StampRallyClient = class {
1006
1008
  getUserId() {
1007
1009
  return this.#userId;
1008
1010
  }
1011
+ get syncState() {
1012
+ return this.#offlineQueue?.syncState ?? "idle";
1013
+ }
1014
+ get pendingCount() {
1015
+ return this.#offlineQueue?.pendingCount ?? 0;
1016
+ }
1009
1017
  subscribe(listener) {
1010
1018
  this.#listeners.add(listener);
1011
1019
  return () => this.#listeners.delete(listener);
@@ -1055,8 +1063,8 @@ var StampRallyClient = class {
1055
1063
  checkIn(spotId, proofData, options = {}) {
1056
1064
  return this.#enqueue(async () => {
1057
1065
  const current = await this.initialize();
1058
- const spot = this.#config.spots.find((item) => item.id === spotId);
1059
- if (spot === void 0)
1066
+ const spot2 = this.#config.spots.find((item) => item.id === spotId);
1067
+ if (spot2 === void 0)
1060
1068
  return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
1061
1069
  if (current.records.some((record2) => record2.stampId === spotId))
1062
1070
  return this.#fail({
@@ -1065,15 +1073,15 @@ var StampRallyClient = class {
1065
1073
  message: "Spot was already claimed."
1066
1074
  });
1067
1075
  const acquired = new Set(current.records.map((record2) => record2.stampId));
1068
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
1076
+ if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
1069
1077
  return this.#fail({
1070
1078
  code: "PREREQUISITES_NOT_MET",
1071
1079
  spotId,
1072
1080
  message: "Prerequisite spots are not complete."
1073
1081
  });
1074
- for (const condition of spot.conditions) {
1075
- if (condition.type === "custom") {
1076
- const validator = this.#options.customValidators?.[condition.validatorName] ?? this.#options.customValidator;
1082
+ for (const condition2 of spot2.conditions) {
1083
+ if (condition2.type === "custom") {
1084
+ const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
1077
1085
  if (validator === void 0)
1078
1086
  return this.#fail({
1079
1087
  code: "CUSTOM_VALIDATION_FAILED",
@@ -1084,7 +1092,7 @@ var StampRallyClient = class {
1084
1092
  rallyId: this.#config.id,
1085
1093
  spotId,
1086
1094
  proofData,
1087
- condition: { type: "custom", validatorName: condition.validatorName },
1095
+ condition: { type: "custom", validatorName: condition2.validatorName },
1088
1096
  userState: current
1089
1097
  };
1090
1098
  const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
@@ -1094,7 +1102,7 @@ var StampRallyClient = class {
1094
1102
  spotId,
1095
1103
  message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
1096
1104
  });
1097
- } else if (!matches(condition, proofData))
1105
+ } else if (!matches(condition2, proofData))
1098
1106
  return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
1099
1107
  }
1100
1108
  const now = options.now ?? this.#now();
@@ -1109,8 +1117,21 @@ var StampRallyClient = class {
1109
1117
  };
1110
1118
  const remote = this.#options.syncAdapter?.checkIn;
1111
1119
  if (options.sync !== false && remote !== void 0) {
1112
- const result = await remote(request);
1113
- return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1120
+ try {
1121
+ const result = await remote(request);
1122
+ return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1123
+ } catch (error) {
1124
+ if (this.#offlineQueue === void 0) throw error;
1125
+ await this.#offlineQueue.enqueueCheckIn(request);
1126
+ const record2 = { stampId: spotId, acquiredAt: now };
1127
+ const next2 = this.#reconcile({
1128
+ ...current,
1129
+ records: [...current.records, record2],
1130
+ updatedAt: now
1131
+ });
1132
+ await this.#storage.save(next2);
1133
+ return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
1134
+ }
1114
1135
  }
1115
1136
  const record = { stampId: spotId, acquiredAt: now };
1116
1137
  const next = this.#reconcile({
@@ -1152,8 +1173,22 @@ var StampRallyClient = class {
1152
1173
  };
1153
1174
  const remote = this.#options.syncAdapter?.claimReward;
1154
1175
  if (options.sync !== false && remote !== void 0) {
1155
- const result = await remote(request);
1156
- return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1176
+ try {
1177
+ const result = await remote(request);
1178
+ return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1179
+ } catch (error) {
1180
+ if (this.#offlineQueue === void 0) throw error;
1181
+ await this.#offlineQueue.enqueueClaimReward(request);
1182
+ const next2 = {
1183
+ ...current,
1184
+ rewards: current.rewards.map(
1185
+ (item) => item.rewardId === rewardId ? local.value : item
1186
+ ),
1187
+ updatedAt: now
1188
+ };
1189
+ await this.#storage.save(next2);
1190
+ return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
1191
+ }
1157
1192
  }
1158
1193
  const next = {
1159
1194
  ...current,
@@ -1167,6 +1202,18 @@ var StampRallyClient = class {
1167
1202
  sync(adapter = this.#options.syncAdapter) {
1168
1203
  return this.#enqueue(async () => {
1169
1204
  const current = await this.initialize();
1205
+ if (this.#offlineQueue !== void 0 && adapter !== void 0) {
1206
+ await this.#offlineQueue.sync(async (operation) => {
1207
+ if (operation.kind === "checkIn") {
1208
+ if (adapter.checkIn === void 0)
1209
+ throw new Error("No check-in sync adapter is configured.");
1210
+ return adapter.checkIn(operation.request);
1211
+ }
1212
+ if (adapter.claimReward === void 0)
1213
+ throw new Error("No reward sync adapter is configured.");
1214
+ return adapter.claimReward(operation.request);
1215
+ });
1216
+ }
1170
1217
  if (adapter?.sync === void 0) {
1171
1218
  this.#emitEvent({ type: "sync", state: current });
1172
1219
  return;
@@ -1180,6 +1227,9 @@ var StampRallyClient = class {
1180
1227
  this.#emitEvent({ type: "sync", state: next });
1181
1228
  });
1182
1229
  }
1230
+ retrySync() {
1231
+ return this.sync();
1232
+ }
1183
1233
  reset() {
1184
1234
  return this.#enqueue(async () => {
1185
1235
  await this.#storage.remove(this.#config.id, this.#userId);
@@ -1214,7 +1264,7 @@ var StampRallyClient = class {
1214
1264
  return Promise.resolve(next);
1215
1265
  }
1216
1266
  #reconcile(state) {
1217
- const ids = new Set(this.#config.spots.map((spot) => spot.id));
1267
+ const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
1218
1268
  const records = state.records.filter(
1219
1269
  (record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
1220
1270
  );
@@ -1261,6 +1311,205 @@ var StampRallyClient = class {
1261
1311
  }
1262
1312
  };
1263
1313
 
1314
+ // src/client/offlineQueue.ts
1315
+ var MemoryQueueStorage = class {
1316
+ #values = /* @__PURE__ */ new Map();
1317
+ async load(key) {
1318
+ return this.#values.get(key) ?? [];
1319
+ }
1320
+ async save(key, operations) {
1321
+ this.#values.set(key, structuredClone(operations));
1322
+ }
1323
+ };
1324
+ var LocalStorageQueueStorage = class {
1325
+ constructor(storage) {
1326
+ this.storage = storage;
1327
+ }
1328
+ storage;
1329
+ async load(key) {
1330
+ const value = this.storage.getItem(key);
1331
+ if (value === null) return [];
1332
+ try {
1333
+ const parsed = JSON.parse(value);
1334
+ return Array.isArray(parsed) ? parsed : [];
1335
+ } catch {
1336
+ return [];
1337
+ }
1338
+ }
1339
+ async save(key, operations) {
1340
+ this.storage.setItem(key, JSON.stringify(operations));
1341
+ }
1342
+ };
1343
+ var IndexedDBOfflineQueueStorage = class {
1344
+ #providedFactory;
1345
+ #databaseName;
1346
+ #databasePromise = null;
1347
+ constructor(options = {}) {
1348
+ this.#providedFactory = options.indexedDB;
1349
+ this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
1350
+ }
1351
+ async load(key) {
1352
+ const database = await this.#open();
1353
+ return new Promise((resolve, reject) => {
1354
+ const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
1355
+ request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
1356
+ request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
1357
+ });
1358
+ }
1359
+ async save(key, operations) {
1360
+ const database = await this.#open();
1361
+ return new Promise((resolve, reject) => {
1362
+ const transaction = database.transaction("operations", "readwrite");
1363
+ transaction.objectStore("operations").put(structuredClone(operations), key);
1364
+ transaction.oncomplete = () => resolve();
1365
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
1366
+ transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
1367
+ });
1368
+ }
1369
+ #open() {
1370
+ if (this.#databasePromise !== null) return this.#databasePromise;
1371
+ let factory = this.#providedFactory;
1372
+ if (factory === void 0)
1373
+ factory = globalThis.indexedDB;
1374
+ if (factory === void 0 || factory === null)
1375
+ return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
1376
+ this.#databasePromise = new Promise((resolve, reject) => {
1377
+ const request = factory.open(this.#databaseName, 1);
1378
+ request.onupgradeneeded = () => {
1379
+ if (!request.result.objectStoreNames.contains("operations"))
1380
+ request.result.createObjectStore("operations");
1381
+ };
1382
+ request.onsuccess = () => resolve(request.result);
1383
+ request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
1384
+ }).catch((error) => {
1385
+ this.#databasePromise = null;
1386
+ throw error;
1387
+ });
1388
+ return this.#databasePromise;
1389
+ }
1390
+ };
1391
+ function defaultStorage(databaseName) {
1392
+ try {
1393
+ const indexedDB = globalThis.indexedDB;
1394
+ if (indexedDB !== void 0)
1395
+ return new IndexedDBOfflineQueueStorage({
1396
+ indexedDB,
1397
+ ...databaseName === void 0 ? {} : { databaseName }
1398
+ });
1399
+ const storage = globalThis.localStorage;
1400
+ if (storage !== void 0 && storage !== null) return new LocalStorageQueueStorage(storage);
1401
+ } catch {
1402
+ }
1403
+ return new MemoryQueueStorage();
1404
+ }
1405
+ function operationId(operation) {
1406
+ return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}`;
1407
+ }
1408
+ var OfflineQueue = class {
1409
+ #storage;
1410
+ #key;
1411
+ #conflictPolicy;
1412
+ #onSyncConflict;
1413
+ #operations = [];
1414
+ #loaded = false;
1415
+ #state = "idle";
1416
+ #error = null;
1417
+ #sender;
1418
+ #syncPromise = null;
1419
+ constructor(options = {}) {
1420
+ if (options.storage !== void 0) this.#storage = options.storage;
1421
+ else if (options.storageLike !== void 0 && options.storageLike !== null)
1422
+ this.#storage = new LocalStorageQueueStorage(options.storageLike);
1423
+ else this.#storage = defaultStorage(options.databaseName);
1424
+ this.#key = options.key ?? "stamprally:offline-queue";
1425
+ this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1426
+ this.#onSyncConflict = options.onSyncConflict;
1427
+ }
1428
+ get syncState() {
1429
+ return this.#state;
1430
+ }
1431
+ get pendingCount() {
1432
+ return this.#operations.length;
1433
+ }
1434
+ get error() {
1435
+ return this.#error;
1436
+ }
1437
+ get operations() {
1438
+ return this.#operations;
1439
+ }
1440
+ async initialize() {
1441
+ if (this.#loaded) return;
1442
+ this.#operations = [...await this.#storage.load(this.#key)];
1443
+ this.#loaded = true;
1444
+ }
1445
+ setSender(sender) {
1446
+ this.#sender = sender;
1447
+ }
1448
+ async enqueue(operation) {
1449
+ await this.initialize();
1450
+ const id2 = operationId(operation);
1451
+ if (this.#operations.some((item) => operationId(item) === id2)) return;
1452
+ this.#operations = [...this.#operations, operation];
1453
+ await this.#storage.save(this.#key, this.#operations);
1454
+ }
1455
+ async enqueueCheckIn(request) {
1456
+ return this.enqueue({ kind: "checkIn", request });
1457
+ }
1458
+ async enqueueClaimReward(request) {
1459
+ return this.enqueue({ kind: "claimReward", request });
1460
+ }
1461
+ async clear() {
1462
+ await this.initialize();
1463
+ this.#operations = [];
1464
+ await this.#storage.save(this.#key, this.#operations);
1465
+ }
1466
+ async sync(sender = this.#sender) {
1467
+ await this.initialize();
1468
+ if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
1469
+ if (this.#syncPromise !== null) return this.#syncPromise;
1470
+ this.#sender = sender;
1471
+ this.#syncPromise = this.#run(sender).finally(() => {
1472
+ this.#syncPromise = null;
1473
+ });
1474
+ return this.#syncPromise;
1475
+ }
1476
+ async retrySync(sender = this.#sender) {
1477
+ return this.sync(sender);
1478
+ }
1479
+ async #run(sender) {
1480
+ this.#state = "syncing";
1481
+ this.#error = null;
1482
+ try {
1483
+ while (this.#operations.length > 0) {
1484
+ const operation = this.#operations[0];
1485
+ if (operation === void 0) break;
1486
+ let result;
1487
+ try {
1488
+ result = await sender(operation);
1489
+ } catch (cause) {
1490
+ throw cause instanceof Error ? cause : new Error(String(cause));
1491
+ }
1492
+ if ("conflict" in result && result.conflict === true)
1493
+ await this.resolveConflict(operation, result.localState, result.serverState);
1494
+ this.#operations = this.#operations.slice(1);
1495
+ await this.#storage.save(this.#key, this.#operations);
1496
+ }
1497
+ this.#state = "idle";
1498
+ } catch (cause) {
1499
+ this.#state = "error";
1500
+ this.#error = cause instanceof Error ? cause : new Error(String(cause));
1501
+ throw this.#error;
1502
+ }
1503
+ }
1504
+ async resolveConflict(operation, localState, serverState) {
1505
+ const configured = this.#onSyncConflict;
1506
+ const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1507
+ if (policy === "merge") {
1508
+ return;
1509
+ }
1510
+ }
1511
+ };
1512
+
1264
1513
  // src/crypto/token.ts
1265
1514
  var encoder = new TextEncoder();
1266
1515
  var decoder = new TextDecoder();
@@ -1393,22 +1642,6 @@ async function decryptPayload(body, secret) {
1393
1642
  );
1394
1643
  }
1395
1644
 
1396
- // src/domain/i18n.ts
1397
- function resolveLocalizedText(text, locale, fallbackLocale) {
1398
- if (text === void 0 || text === "") return "";
1399
- if (typeof text === "string") return text;
1400
- const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
1401
- return text[locale] || fallback || "";
1402
- }
1403
- function toLocalizedString(text) {
1404
- if (text === void 0) return { ja: "", en: "" };
1405
- return typeof text === "string" ? { ja: text, en: "" } : {
1406
- ja: text["ja"] ?? "",
1407
- en: text["en"] ?? "",
1408
- ...text
1409
- };
1410
- }
1411
-
1412
1645
  // src/domain/models.ts
1413
1646
  var DEFAULT_SHEET_THEME = {
1414
1647
  primaryColor: "#9e551e",
@@ -1420,23 +1653,23 @@ var DEFAULT_SHEET_THEME = {
1420
1653
  unclaimedOpacity: 1,
1421
1654
  fontFamily: "serif"
1422
1655
  };
1423
- function publicCondition(condition) {
1424
- switch (condition.type) {
1656
+ function publicCondition(condition2) {
1657
+ switch (condition2.type) {
1425
1658
  case "qr":
1426
- return condition.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition.qrEntryUrl };
1659
+ return condition2.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition2.qrEntryUrl };
1427
1660
  case "passcode":
1428
1661
  return { type: "passcode" };
1429
1662
  case "gps":
1430
1663
  return {
1431
1664
  type: "gps",
1432
- latitude: condition.latitude,
1433
- longitude: condition.longitude,
1434
- radiusMeters: condition.radiusMeters
1665
+ latitude: condition2.latitude,
1666
+ longitude: condition2.longitude,
1667
+ radiusMeters: condition2.radiusMeters
1435
1668
  };
1436
1669
  case "nfc":
1437
1670
  return { type: "nfc" };
1438
1671
  case "custom":
1439
- return { type: "custom", validatorName: condition.validatorName };
1672
+ return { type: "custom", validatorName: condition2.validatorName };
1440
1673
  }
1441
1674
  }
1442
1675
  function toPublicConfig(config) {
@@ -1446,14 +1679,14 @@ function toPublicConfig(config) {
1446
1679
  title: config.title,
1447
1680
  ...config.description === void 0 ? {} : { description: config.description },
1448
1681
  ...config.theme === void 0 ? {} : { theme: config.theme },
1449
- spots: config.spots.map((spot) => ({
1450
- ...spot,
1451
- conditions: spot.conditions.map(publicCondition)
1682
+ spots: config.spots.map((spot2) => ({
1683
+ ...spot2,
1684
+ conditions: spot2.conditions.map(publicCondition)
1452
1685
  })),
1453
1686
  rewards: config.rewards.map(
1454
- ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward }) => reward
1687
+ ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward2 }) => reward2
1455
1688
  ),
1456
- ...config.metadata === void 0 ? {} : { metadata: config.metadata },
1689
+ ...config.publicMetadata !== void 0 ? { metadata: config.publicMetadata } : config.metadata === void 0 ? {} : { metadata: config.metadata },
1457
1690
  ...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
1458
1691
  };
1459
1692
  }
@@ -1489,15 +1722,15 @@ function isPublicConfig(value) {
1489
1722
  }
1490
1723
  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
1724
  return false;
1492
- return candidate.spots.every((spot) => {
1493
- if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
1494
- const item = spot;
1725
+ return candidate.spots.every((spot2) => {
1726
+ if (typeof spot2 !== "object" || spot2 === null || Array.isArray(spot2)) return false;
1727
+ const item = spot2;
1495
1728
  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
1729
  return false;
1497
- return Array.isArray(item.conditions) && item.conditions.every((condition) => {
1498
- if (typeof condition !== "object" || condition === null || Array.isArray(condition))
1730
+ return Array.isArray(item.conditions) && item.conditions.every((condition2) => {
1731
+ if (typeof condition2 !== "object" || condition2 === null || Array.isArray(condition2))
1499
1732
  return false;
1500
- const value2 = condition;
1733
+ const value2 = condition2;
1501
1734
  if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
1502
1735
  return false;
1503
1736
  const type = value2.type;
@@ -1505,13 +1738,125 @@ function isPublicConfig(value) {
1505
1738
  if (type === "custom") return typeof value2.validatorName === "string";
1506
1739
  return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
1507
1740
  });
1508
- }) && candidate.rewards.every((reward) => {
1509
- if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
1510
- const item = reward;
1741
+ }) && candidate.rewards.every((reward2) => {
1742
+ if (typeof reward2 !== "object" || reward2 === null || Array.isArray(reward2)) return false;
1743
+ const item = reward2;
1511
1744
  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
1745
  });
1513
1746
  }
1514
1747
 
1748
+ // src/domain/configTransform.ts
1749
+ var PRIVATE_KEYS = /* @__PURE__ */ new Set([
1750
+ "staffPasscode",
1751
+ "serverMetadata",
1752
+ "inventory",
1753
+ "secretToken",
1754
+ "secretParams",
1755
+ "digitalContentUrl",
1756
+ "code",
1757
+ "tagId"
1758
+ ]);
1759
+ var SENSITIVE_KEY_PATTERN = /^(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|private[_-]?key|secret)$/i;
1760
+ function isRecord2(value) {
1761
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1762
+ }
1763
+ function isPrivateKey(key) {
1764
+ return PRIVATE_KEYS.has(key) || SENSITIVE_KEY_PATTERN.test(key);
1765
+ }
1766
+ function sanitizeValue(value, customFilter, seen) {
1767
+ if (Array.isArray(value)) {
1768
+ if (seen.has(value)) return void 0;
1769
+ seen.add(value);
1770
+ const items = value.map((item) => sanitizeValue(item, customFilter, seen)).filter((item) => item !== void 0);
1771
+ return items;
1772
+ }
1773
+ if (!isRecord2(value)) return value;
1774
+ if (seen.has(value)) return void 0;
1775
+ seen.add(value);
1776
+ const result = {};
1777
+ for (const [key, item] of Object.entries(value)) {
1778
+ if (isPrivateKey(key) || customFilter?.(key, item) === false) continue;
1779
+ const sanitized = sanitizeValue(item, customFilter, seen);
1780
+ if (sanitized !== void 0) result[key] = sanitized;
1781
+ }
1782
+ return result;
1783
+ }
1784
+ function sanitizeSpot(spot2, customFilter) {
1785
+ return sanitizeValue(
1786
+ toPublicConfig({
1787
+ id: "__spot__",
1788
+ version: "1",
1789
+ title: "__spot__",
1790
+ spots: [spot2],
1791
+ rewards: []
1792
+ }).spots[0],
1793
+ customFilter,
1794
+ /* @__PURE__ */ new WeakSet()
1795
+ );
1796
+ }
1797
+ function sanitizeReward(reward2, customFilter) {
1798
+ return sanitizeValue(
1799
+ toPublicConfig({
1800
+ id: "__reward__",
1801
+ version: "1",
1802
+ title: "__reward__",
1803
+ spots: [],
1804
+ rewards: [reward2]
1805
+ }).rewards[0],
1806
+ customFilter,
1807
+ /* @__PURE__ */ new WeakSet()
1808
+ );
1809
+ }
1810
+ function sanitizeAdminConfig(admin, customFilter) {
1811
+ const publicConfig = toPublicConfig(admin);
1812
+ const sanitized = sanitizeValue(publicConfig, customFilter, /* @__PURE__ */ new WeakSet());
1813
+ sanitized.spots = admin.spots.map((spot2) => sanitizeSpot(spot2, customFilter));
1814
+ sanitized.rewards = admin.rewards.map((reward2) => sanitizeReward(reward2, customFilter));
1815
+ return sanitized;
1816
+ }
1817
+ function validatePublicConfigSafety(publicConfig) {
1818
+ const leakedKeys = [];
1819
+ const seen = /* @__PURE__ */ new WeakSet();
1820
+ const visit = (value, path) => {
1821
+ if (Array.isArray(value)) {
1822
+ value.forEach((item, index) => {
1823
+ visit(item, `${path}[${index}]`);
1824
+ });
1825
+ return;
1826
+ }
1827
+ if (!isRecord2(value) || seen.has(value)) return;
1828
+ seen.add(value);
1829
+ for (const [key, item] of Object.entries(value)) {
1830
+ const nextPath = path === "$" ? key : `${path}.${key}`;
1831
+ if (isPrivateKey(key)) leakedKeys.push(nextPath);
1832
+ visit(item, nextPath);
1833
+ }
1834
+ };
1835
+ visit(publicConfig, "$");
1836
+ return { safe: leakedKeys.length === 0, leakedKeys };
1837
+ }
1838
+
1839
+ // src/domain/i18n.ts
1840
+ function updateLocalizedField(current, locale, newValue) {
1841
+ if (typeof current === "string" || current === void 0)
1842
+ return { [locale]: newValue };
1843
+ return { ...current, [locale]: newValue };
1844
+ }
1845
+ function resolveLocalizedText(text, locale, fallbackLocale) {
1846
+ if (text === void 0 || text === "") return "";
1847
+ if (typeof text === "string") return text;
1848
+ const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
1849
+ return text[locale] || fallback || "";
1850
+ }
1851
+ function toLocalizedString(text) {
1852
+ if (text === void 0) return { ja: "", en: "" };
1853
+ return typeof text === "string" ? { ja: text, en: "" } : {
1854
+ ja: text["ja"] ?? "",
1855
+ en: text["en"] ?? "",
1856
+ ...text
1857
+ };
1858
+ }
1859
+
1515
1860
  // src/domain/themePresets.ts
1516
1861
  var THEME_PRESETS = [
1517
1862
  {
@@ -1611,6 +1956,326 @@ var THEME_PRESETS = [
1611
1956
  }
1612
1957
  ];
1613
1958
 
1959
+ // src/domain/validation.ts
1960
+ var ConfigValidationError = class extends Error {
1961
+ constructor(errors) {
1962
+ super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
1963
+ this.errors = errors;
1964
+ }
1965
+ errors;
1966
+ name = "ConfigValidationError";
1967
+ };
1968
+ function isRecord3(value) {
1969
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1970
+ }
1971
+ function hasOwn(value, key) {
1972
+ return Object.hasOwn(value, key);
1973
+ }
1974
+ function add(errors, path, message, code) {
1975
+ errors.push({ path, message, code });
1976
+ }
1977
+ function requiredString(value, key, path, errors) {
1978
+ if (typeof value[key] !== "string" || value[key].length === 0) {
1979
+ add(errors, `${path}.${key}`, "A non-empty string is required.", "required_string");
1980
+ return false;
1981
+ }
1982
+ return true;
1983
+ }
1984
+ function optionalString(value, key, path, errors) {
1985
+ if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "string")
1986
+ add(errors, `${path}.${key}`, "Expected a string.", "invalid_type");
1987
+ }
1988
+ function optionalBoolean(value, key, path, errors) {
1989
+ if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "boolean")
1990
+ add(errors, `${path}.${key}`, "Expected a boolean.", "invalid_type");
1991
+ }
1992
+ function finiteNumber(value, key, path, errors, minimum) {
1993
+ const item = value[key];
1994
+ if (typeof item !== "number" || !Number.isFinite(item)) {
1995
+ add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
1996
+ } else if (minimum !== void 0 && item < minimum) {
1997
+ add(
1998
+ errors,
1999
+ `${path}.${key}`,
2000
+ `Expected a number greater than or equal to ${minimum}.`,
2001
+ "out_of_range"
2002
+ );
2003
+ }
2004
+ }
2005
+ function localizedText(value, path, errors) {
2006
+ if (typeof value === "string") return;
2007
+ if (!isRecord3(value)) {
2008
+ add(errors, path, "Expected a string or a locale map.", "invalid_localized_text");
2009
+ return;
2010
+ }
2011
+ for (const [locale, text] of Object.entries(value)) {
2012
+ if (typeof text !== "string")
2013
+ add(errors, `${path}.${locale}`, "Expected a string.", "invalid_type");
2014
+ }
2015
+ }
2016
+ function theme(value, path, errors) {
2017
+ if (!isRecord3(value)) {
2018
+ add(errors, path, "Expected a theme object.", "invalid_type");
2019
+ return;
2020
+ }
2021
+ for (const key of ["primaryColor", "cardBackgroundColor", "textColor"])
2022
+ requiredString(value, key, path, errors);
2023
+ optionalString(value, "backgroundColor", path, errors);
2024
+ optionalString(value, "backgroundImageUrl", path, errors);
2025
+ optionalString(value, "completedStampColor", path, errors);
2026
+ optionalString(value, "fontFamily", path, errors);
2027
+ const slotShape = value.slotShape;
2028
+ if (slotShape !== "circle" && slotShape !== "square" && slotShape !== "rounded")
2029
+ add(errors, `${path}.slotShape`, "Expected circle, square, or rounded.", "invalid_enum");
2030
+ finiteNumber(value, "gridColumns", path, errors, 1);
2031
+ if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
2032
+ add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
2033
+ if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2034
+ }
2035
+ function externalReferences(value, path, errors) {
2036
+ if (!Array.isArray(value)) {
2037
+ add(errors, path, "Expected an array.", "invalid_type");
2038
+ return;
2039
+ }
2040
+ value.forEach((item, index) => {
2041
+ const itemPath = `${path}[${index}]`;
2042
+ if (!isRecord3(item)) {
2043
+ add(errors, itemPath, "Expected an object.", "invalid_type");
2044
+ return;
2045
+ }
2046
+ requiredString(item, "type", itemPath, errors);
2047
+ requiredString(item, "id", itemPath, errors);
2048
+ optionalString(item, "url", itemPath, errors);
2049
+ });
2050
+ }
2051
+ function condition(value, path, errors, isPublic) {
2052
+ if (!isRecord3(value)) {
2053
+ add(errors, path, "Expected a condition object.", "invalid_type");
2054
+ return;
2055
+ }
2056
+ const type = value.type;
2057
+ if (type === "qr") {
2058
+ if (isPublic) {
2059
+ if (hasOwn(value, "secretToken"))
2060
+ add(errors, `${path}.secretToken`, "Private field is not allowed.", "private_field");
2061
+ } else requiredString(value, "secretToken", path, errors);
2062
+ optionalString(value, "qrEntryUrl", path, errors);
2063
+ return;
2064
+ }
2065
+ if (type === "passcode") {
2066
+ if (isPublic) {
2067
+ for (const key of ["code", "caseSensitive"])
2068
+ if (hasOwn(value, key))
2069
+ add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
2070
+ } else {
2071
+ requiredString(value, "code", path, errors);
2072
+ optionalBoolean(value, "caseSensitive", path, errors);
2073
+ }
2074
+ return;
2075
+ }
2076
+ if (type === "gps") {
2077
+ finiteNumber(value, "latitude", path, errors);
2078
+ finiteNumber(value, "longitude", path, errors);
2079
+ finiteNumber(value, "radiusMeters", path, errors, 0);
2080
+ if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
2081
+ add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
2082
+ if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
2083
+ add(
2084
+ errors,
2085
+ `${path}.longitude`,
2086
+ "Expected a longitude between -180 and 180.",
2087
+ "out_of_range"
2088
+ );
2089
+ return;
2090
+ }
2091
+ if (type === "nfc") {
2092
+ if (isPublic) {
2093
+ if (hasOwn(value, "tagId"))
2094
+ add(errors, `${path}.tagId`, "Private field is not allowed.", "private_field");
2095
+ } else requiredString(value, "tagId", path, errors);
2096
+ return;
2097
+ }
2098
+ if (type === "custom") {
2099
+ requiredString(value, "validatorName", path, errors);
2100
+ if (isPublic && hasOwn(value, "secretParams"))
2101
+ add(errors, `${path}.secretParams`, "Private field is not allowed.", "private_field");
2102
+ if (!isPublic && hasOwn(value, "secretParams") && !isRecord3(value.secretParams))
2103
+ add(errors, `${path}.secretParams`, "Expected an object.", "invalid_type");
2104
+ return;
2105
+ }
2106
+ add(errors, `${path}.type`, "Unknown condition type.", "invalid_enum");
2107
+ }
2108
+ function unlockCondition(value, path, errors) {
2109
+ if (!isRecord3(value)) {
2110
+ add(errors, path, "Expected an unlock condition object.", "invalid_type");
2111
+ return;
2112
+ }
2113
+ if (value.type === "stamp_count") {
2114
+ finiteNumber(value, "count", path, errors, 0);
2115
+ return;
2116
+ }
2117
+ if (value.type === "stamps") {
2118
+ if (!Array.isArray(value.stampIds))
2119
+ add(errors, `${path}.stampIds`, "Expected an array.", "invalid_type");
2120
+ else
2121
+ value.stampIds.forEach((item, index) => {
2122
+ if (typeof item !== "string" || item.length === 0)
2123
+ add(
2124
+ errors,
2125
+ `${path}.stampIds[${index}]`,
2126
+ "Expected a non-empty string.",
2127
+ "required_string"
2128
+ );
2129
+ });
2130
+ return;
2131
+ }
2132
+ if (value.type === "all" || value.type === "any") {
2133
+ if (!Array.isArray(value.conditions))
2134
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
2135
+ else
2136
+ value.conditions.forEach((item, index) => {
2137
+ unlockCondition(item, `${path}.conditions[${index}]`, errors);
2138
+ });
2139
+ return;
2140
+ }
2141
+ add(errors, `${path}.type`, "Unknown unlock condition type.", "invalid_enum");
2142
+ }
2143
+ function spot(value, path, errors, isPublic) {
2144
+ if (!isRecord3(value)) {
2145
+ add(errors, path, "Expected a spot object.", "invalid_type");
2146
+ return;
2147
+ }
2148
+ requiredString(value, "id", path, errors);
2149
+ finiteNumber(value, "orderIndex", path, errors, 0);
2150
+ if (typeof value.orderIndex === "number" && !Number.isInteger(value.orderIndex))
2151
+ add(errors, `${path}.orderIndex`, "Expected an integer.", "invalid_integer");
2152
+ localizedText(value.name, `${path}.name`, errors);
2153
+ for (const key of ["description", "hint"])
2154
+ if (hasOwn(value, key)) localizedText(value[key], `${path}.${key}`, errors);
2155
+ for (const key of ["imageUrl", "iconUrl", "redirectUrlAfterClaim"])
2156
+ optionalString(value, key, path, errors);
2157
+ if (hasOwn(value, "externalReferences") && value.externalReferences !== void 0)
2158
+ externalReferences(value.externalReferences, `${path}.externalReferences`, errors);
2159
+ if (hasOwn(value, "prerequisites") && value.prerequisites !== void 0) {
2160
+ if (!Array.isArray(value.prerequisites))
2161
+ add(errors, `${path}.prerequisites`, "Expected an array.", "invalid_type");
2162
+ else
2163
+ value.prerequisites.forEach((item, index) => {
2164
+ if (typeof item !== "string" || item.length === 0)
2165
+ add(
2166
+ errors,
2167
+ `${path}.prerequisites[${index}]`,
2168
+ "Expected a non-empty string.",
2169
+ "required_string"
2170
+ );
2171
+ });
2172
+ }
2173
+ if (!Array.isArray(value.conditions))
2174
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
2175
+ else
2176
+ value.conditions.forEach((item, index) => {
2177
+ condition(item, `${path}.conditions[${index}]`, errors, isPublic);
2178
+ });
2179
+ }
2180
+ function reward(value, path, errors, isPublic) {
2181
+ if (!isRecord3(value)) {
2182
+ add(errors, path, "Expected a reward object.", "invalid_type");
2183
+ return;
2184
+ }
2185
+ requiredString(value, "id", path, errors);
2186
+ localizedText(value.title, `${path}.title`, errors);
2187
+ if (hasOwn(value, "description")) localizedText(value.description, `${path}.description`, errors);
2188
+ if (value.type !== "digital" && value.type !== "in_person")
2189
+ add(errors, `${path}.type`, "Unknown reward type.", "invalid_enum");
2190
+ if (!["manual_slide", "staff_passcode", "view_only", "server_claim"].includes(
2191
+ String(value.redemptionMethod)
2192
+ ))
2193
+ add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
2194
+ finiteNumber(value, "requiredStampCount", path, errors, 0);
2195
+ for (const key of ["stockLimit", "userClaimLimit"]) {
2196
+ if (hasOwn(value, key) && value[key] !== void 0) {
2197
+ finiteNumber(value, key, path, errors, 0);
2198
+ if (typeof value[key] === "number" && !Number.isInteger(value[key]))
2199
+ add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
2200
+ }
2201
+ }
2202
+ optionalString(value, "validUntil", path, errors);
2203
+ if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
2204
+ add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
2205
+ if (isPublic) {
2206
+ for (const key of ["staffPasscode", "digitalContentUrl"])
2207
+ if (hasOwn(value, key))
2208
+ add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
2209
+ } else {
2210
+ optionalString(value, "staffPasscode", path, errors);
2211
+ optionalString(value, "digitalContentUrl", path, errors);
2212
+ }
2213
+ if (hasOwn(value, "conditions") && value.conditions !== void 0) {
2214
+ if (!Array.isArray(value.conditions))
2215
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
2216
+ else
2217
+ value.conditions.forEach((item, index) => {
2218
+ unlockCondition(item, `${path}.conditions[${index}]`, errors);
2219
+ });
2220
+ }
2221
+ }
2222
+ function validate(value, isPublic) {
2223
+ const errors = [];
2224
+ if (!isRecord3(value)) {
2225
+ add(errors, "$", "Expected a configuration object.", "invalid_type");
2226
+ return errors;
2227
+ }
2228
+ requiredString(value, "id", "$", errors);
2229
+ requiredString(value, "version", "$", errors);
2230
+ localizedText(value.title, "$.title", errors);
2231
+ if (hasOwn(value, "description")) localizedText(value.description, "$.description", errors);
2232
+ if (hasOwn(value, "theme") && value.theme !== void 0) theme(value.theme, "$.theme", errors);
2233
+ if (!Array.isArray(value.spots)) add(errors, "spots", "Expected an array.", "invalid_type");
2234
+ else
2235
+ value.spots.forEach((item, index) => {
2236
+ spot(item, `spots[${index}]`, errors, isPublic);
2237
+ });
2238
+ if (!Array.isArray(value.rewards)) add(errors, "rewards", "Expected an array.", "invalid_type");
2239
+ else
2240
+ value.rewards.forEach((item, index) => {
2241
+ reward(item, `rewards[${index}]`, errors, isPublic);
2242
+ });
2243
+ if (!isPublic) {
2244
+ optionalString(value, "staffPasscode", "$", errors);
2245
+ if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2246
+ add(errors, "$.inventory", "Expected an object.", "invalid_type");
2247
+ if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2248
+ add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2249
+ if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2250
+ add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2251
+ optionalString(value, "serverEndpoint", "$", errors);
2252
+ } else {
2253
+ for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2254
+ if (hasOwn(value, key))
2255
+ add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2256
+ optionalString(value, "serverEndpoint", "$", errors);
2257
+ }
2258
+ return errors;
2259
+ }
2260
+ function safeParseAdminConfig(input) {
2261
+ const errors = validate(input, false);
2262
+ return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2263
+ }
2264
+ function parseAdminConfig(input) {
2265
+ const result = safeParseAdminConfig(input);
2266
+ if (!result.success) throw new ConfigValidationError(result.errors);
2267
+ return result.data;
2268
+ }
2269
+ function safeParsePublicConfig(input) {
2270
+ const errors = validate(input, true);
2271
+ return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2272
+ }
2273
+ function parsePublicConfig(input) {
2274
+ const result = safeParsePublicConfig(input);
2275
+ if (!result.success) throw new ConfigValidationError(result.errors);
2276
+ return result.data;
2277
+ }
2278
+
1614
2279
  // src/security/snapshotToken.ts
1615
2280
  var encoder2 = new TextEncoder();
1616
2281
  function cryptoApi2() {
@@ -1730,6 +2395,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
1730
2395
  }
1731
2396
  }
1732
2397
 
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 };
2398
+ export { ConfigValidationError, DEFAULT_SHEET_THEME, MemoryQueueStorage as InMemoryOfflineQueueStorage, InMemoryStorage, IndexedDBAdapter, IndexedDBOfflineQueueStorage, LocalStorageAdapter, OfflineQueue, 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, sanitizeAdminConfig, storageKey, toLocalizedString, toPublicConfig, updateLocalizedField, validatePublicConfigSafety, verifyPasscode, verifySecureToken, verifySnapshotToken };
1734
2399
  //# sourceMappingURL=index.js.map
1735
2400
  //# sourceMappingURL=index.js.map