@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.cjs CHANGED
@@ -12,34 +12,34 @@ function calculateDistanceMeters(aLat, aLon, bLat, bLon) {
12
12
  function mismatch(conditionType, reason, extra = {}) {
13
13
  return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
14
14
  }
15
- function evaluateConditionDetailed(condition, context) {
16
- switch (condition.type) {
15
+ function evaluateConditionDetailed(condition2, context) {
16
+ switch (condition2.type) {
17
17
  case "qr":
18
- return context.type === "qr" && context.token === condition.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
18
+ return context.type === "qr" && context.token === condition2.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
19
19
  case "passcode":
20
- return context.type === "passcode" && (condition.caseSensitive === false ? context.code.toLocaleLowerCase() === condition.code.toLocaleLowerCase() : context.code === condition.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
20
+ return context.type === "passcode" && (condition2.caseSensitive === false ? context.code.toLocaleLowerCase() === condition2.code.toLocaleLowerCase() : context.code === condition2.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
21
21
  case "nfc":
22
- return context.type === "nfc" && context.tagId === condition.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
22
+ return context.type === "nfc" && context.tagId === condition2.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
23
23
  case "custom":
24
24
  return mismatch("custom", "VALIDATOR_FAILED");
25
25
  case "gps": {
26
- if (!Number.isFinite(condition.latitude) || !Number.isFinite(condition.longitude) || !Number.isFinite(condition.radiusMeters) || condition.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
26
+ if (!Number.isFinite(condition2.latitude) || !Number.isFinite(condition2.longitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
27
27
  return mismatch("gps", "INVALID_GEO_INPUT");
28
28
  const distanceMeters = calculateDistanceMeters(
29
- condition.latitude,
30
- condition.longitude,
29
+ condition2.latitude,
30
+ condition2.longitude,
31
31
  context.latitude,
32
32
  context.longitude
33
33
  );
34
- return distanceMeters <= condition.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
34
+ return distanceMeters <= condition2.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
35
35
  distanceMeters,
36
- radiusMeters: condition.radiusMeters
36
+ radiusMeters: condition2.radiusMeters
37
37
  });
38
38
  }
39
39
  }
40
40
  }
41
- function evaluateCondition(condition, context) {
42
- return evaluateConditionDetailed(condition, context).ok;
41
+ function evaluateCondition(condition2, context) {
42
+ return evaluateConditionDetailed(condition2, context).ok;
43
43
  }
44
44
 
45
45
  // src/engine/order.ts
@@ -49,11 +49,11 @@ function getOrderedSpots(spots) {
49
49
 
50
50
  // src/engine/progress.ts
51
51
  function calculateProgress(state, config) {
52
- const ids = new Set(config.spots.map((spot) => spot.id));
52
+ const ids = new Set(config.spots.map((spot2) => spot2.id));
53
53
  const acquired = new Set(
54
54
  state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
55
55
  );
56
- const remaining = config.spots.filter((spot) => !acquired.has(spot.id));
56
+ const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
57
57
  return {
58
58
  acquired: acquired.size,
59
59
  total: config.spots.length,
@@ -93,9 +93,9 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
93
93
  const timestampPart = Number.isNaN(timestamp) ? Date.now() : timestamp;
94
94
  return `CLAIM-${rewardId}-${timestampPart}-${createRandomHash()}`;
95
95
  }
96
- function issueClaimTicketNumber(reward, currentState, options = {}) {
96
+ function issueClaimTicketNumber(reward2, currentState, options = {}) {
97
97
  if (currentState.claimTicketNumber !== void 0) return currentState;
98
- const claimTicketNumber = createClaimTicketNumber(reward.id, options);
98
+ const claimTicketNumber = createClaimTicketNumber(reward2.id, options);
99
99
  return { ...currentState, claimTicketNumber };
100
100
  }
101
101
 
@@ -303,8 +303,8 @@ function normalizePasscode(input, caseSensitive = false) {
303
303
  const normalized = input.normalize("NFKC").trim();
304
304
  return caseSensitive ? normalized : normalized.toUpperCase();
305
305
  }
306
- function verifyPasscode(inputCode, condition) {
307
- return normalizePasscode(inputCode, condition.caseSensitive) === normalizePasscode(condition.code, condition.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
306
+ function verifyPasscode(inputCode, condition2) {
307
+ return normalizePasscode(inputCode, condition2.caseSensitive) === normalizePasscode(condition2.code, condition2.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
308
308
  }
309
309
 
310
310
  // src/detectors/qr.ts
@@ -431,71 +431,71 @@ async function readQrContext(videoElement, options = {}) {
431
431
  // src/engine/transition.ts
432
432
  function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
433
433
  const states = new Map(currentStates.map((state) => [state.rewardId, state]));
434
- return rewards.map((reward) => {
435
- const current = states.get(reward.id);
434
+ return rewards.map((reward2) => {
435
+ const current = states.get(reward2.id);
436
436
  if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
437
- if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now))
438
- return { rewardId: reward.id, status: "EXPIRED" };
439
- if (reward.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward.stockLimit)
440
- return { rewardId: reward.id, status: "EXPIRED" };
441
- if (acquiredStampCount >= reward.requiredStampCount)
437
+ if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(now))
438
+ return { rewardId: reward2.id, status: "EXPIRED" };
439
+ if (reward2.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward2.stockLimit)
440
+ return { rewardId: reward2.id, status: "EXPIRED" };
441
+ if (acquiredStampCount >= reward2.requiredStampCount)
442
442
  return {
443
- rewardId: reward.id,
443
+ rewardId: reward2.id,
444
444
  status: "AVAILABLE",
445
445
  ...current?.unlockedAt === void 0 ? { unlockedAt: now } : { unlockedAt: current.unlockedAt }
446
446
  };
447
- return { rewardId: reward.id, status: "LOCKED" };
447
+ return { rewardId: reward2.id, status: "LOCKED" };
448
448
  });
449
449
  }
450
450
  function consumeReward(params) {
451
- const { reward, currentState } = params;
451
+ const { reward: reward2, currentState } = params;
452
452
  if (currentState.status === "CONSUMED")
453
- return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward.id } };
453
+ return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward2.id } };
454
454
  if (currentState.status !== "AVAILABLE")
455
- return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward.id } };
456
- if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now))
457
- return { ok: false, error: { code: "EXPIRED", rewardId: reward.id } };
458
- if (reward.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward.stockLimit)
459
- return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
460
- if (reward.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward.userClaimLimit)
461
- return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward.id } };
462
- if (reward.redemptionMethod === "staff_passcode") {
463
- if (reward.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward.staffPasscode }).success)
455
+ return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward2.id } };
456
+ if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(params.now))
457
+ return { ok: false, error: { code: "EXPIRED", rewardId: reward2.id } };
458
+ if (reward2.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward2.stockLimit)
459
+ return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward2.id } };
460
+ if (reward2.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward2.userClaimLimit)
461
+ return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward2.id } };
462
+ if (reward2.redemptionMethod === "staff_passcode") {
463
+ if (reward2.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward2.staffPasscode }).success)
464
464
  return {
465
465
  ok: false,
466
466
  error: {
467
467
  code: "INVALID_PASSCODE",
468
- rewardId: reward.id,
468
+ rewardId: reward2.id,
469
469
  message: "The passcode is invalid."
470
470
  }
471
471
  };
472
472
  }
473
- if (reward.redemptionMethod === "view_only") return { ok: true, value: currentState };
473
+ if (reward2.redemptionMethod === "view_only") return { ok: true, value: currentState };
474
474
  return {
475
475
  ok: true,
476
476
  value: {
477
477
  ...currentState,
478
478
  status: "CONSUMED",
479
479
  consumedAt: params.now,
480
- claimTicketNumber: createUniqueClaimTicketNumber(reward.id, params.now),
480
+ claimTicketNumber: createUniqueClaimTicketNumber(reward2.id, params.now),
481
481
  redeemedCount: (currentState.redeemedCount ?? 0) + 1,
482
482
  ...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
483
483
  }
484
484
  };
485
485
  }
486
486
  function processStamp(state, config, spotId, context, now) {
487
- const spot = config.spots.find((item) => item.id === spotId);
488
- if (spot === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
487
+ const spot2 = config.spots.find((item) => item.id === spotId);
488
+ if (spot2 === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
489
489
  if (state.records.some((record2) => record2.stampId === spotId))
490
490
  return { ok: false, error: { code: "STAMP_ALREADY_ACQUIRED", spotId } };
491
491
  const acquired = new Set(state.records.map((record2) => record2.stampId));
492
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
492
+ if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
493
493
  return { ok: false, error: { code: "PREREQUISITES_NOT_MET", spotId } };
494
- for (const condition of spot.conditions) {
495
- if (condition.type === "custom" || !evaluateConditionDetailed(condition, context).ok)
494
+ for (const condition2 of spot2.conditions) {
495
+ if (condition2.type === "custom" || !evaluateConditionDetailed(condition2, context).ok)
496
496
  return {
497
497
  ok: false,
498
- error: condition.type === "custom" ? {
498
+ error: condition2.type === "custom" ? {
499
499
  code: "CUSTOM_VALIDATION_FAILED",
500
500
  spotId,
501
501
  message: "Custom validation requires an async validator."
@@ -959,18 +959,18 @@ function proof(value) {
959
959
  }
960
960
  return "";
961
961
  }
962
- function matches(condition, value) {
963
- if (condition.type === "gps") {
962
+ function matches(condition2, value) {
963
+ if (condition2.type === "gps") {
964
964
  if (typeof value !== "object" || value === null) return false;
965
965
  const item = value;
966
966
  const latitude = item.latitude;
967
967
  const longitude = item.longitude;
968
968
  if (typeof latitude !== "number" || typeof longitude !== "number") return false;
969
969
  const radians = (v) => v * Math.PI / 180;
970
- const dLat = radians(latitude - condition.latitude);
971
- const dLon = radians(longitude - condition.longitude);
972
- const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
973
- return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition.radiusMeters;
970
+ const dLat = radians(latitude - condition2.latitude);
971
+ const dLon = radians(longitude - condition2.longitude);
972
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
973
+ return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
974
974
  }
975
975
  return proof(value).trim() !== "";
976
976
  }
@@ -989,6 +989,7 @@ var StampRallyClient = class {
989
989
  #storage;
990
990
  #options;
991
991
  #config;
992
+ #offlineQueue;
992
993
  #userId;
993
994
  #state = null;
994
995
  #initialization = null;
@@ -998,6 +999,7 @@ var StampRallyClient = class {
998
999
  this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
999
1000
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1000
1001
  this.#userId = this.#options.userId ?? null;
1002
+ this.#offlineQueue = this.#options.offlineQueue;
1001
1003
  }
1002
1004
  getConfig() {
1003
1005
  return this.#config;
@@ -1008,6 +1010,12 @@ var StampRallyClient = class {
1008
1010
  getUserId() {
1009
1011
  return this.#userId;
1010
1012
  }
1013
+ get syncState() {
1014
+ return this.#offlineQueue?.syncState ?? "idle";
1015
+ }
1016
+ get pendingCount() {
1017
+ return this.#offlineQueue?.pendingCount ?? 0;
1018
+ }
1011
1019
  subscribe(listener) {
1012
1020
  this.#listeners.add(listener);
1013
1021
  return () => this.#listeners.delete(listener);
@@ -1057,8 +1065,8 @@ var StampRallyClient = class {
1057
1065
  checkIn(spotId, proofData, options = {}) {
1058
1066
  return this.#enqueue(async () => {
1059
1067
  const current = await this.initialize();
1060
- const spot = this.#config.spots.find((item) => item.id === spotId);
1061
- if (spot === void 0)
1068
+ const spot2 = this.#config.spots.find((item) => item.id === spotId);
1069
+ if (spot2 === void 0)
1062
1070
  return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
1063
1071
  if (current.records.some((record2) => record2.stampId === spotId))
1064
1072
  return this.#fail({
@@ -1067,15 +1075,15 @@ var StampRallyClient = class {
1067
1075
  message: "Spot was already claimed."
1068
1076
  });
1069
1077
  const acquired = new Set(current.records.map((record2) => record2.stampId));
1070
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
1078
+ if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
1071
1079
  return this.#fail({
1072
1080
  code: "PREREQUISITES_NOT_MET",
1073
1081
  spotId,
1074
1082
  message: "Prerequisite spots are not complete."
1075
1083
  });
1076
- for (const condition of spot.conditions) {
1077
- if (condition.type === "custom") {
1078
- const validator = this.#options.customValidators?.[condition.validatorName] ?? this.#options.customValidator;
1084
+ for (const condition2 of spot2.conditions) {
1085
+ if (condition2.type === "custom") {
1086
+ const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
1079
1087
  if (validator === void 0)
1080
1088
  return this.#fail({
1081
1089
  code: "CUSTOM_VALIDATION_FAILED",
@@ -1086,7 +1094,7 @@ var StampRallyClient = class {
1086
1094
  rallyId: this.#config.id,
1087
1095
  spotId,
1088
1096
  proofData,
1089
- condition: { type: "custom", validatorName: condition.validatorName },
1097
+ condition: { type: "custom", validatorName: condition2.validatorName },
1090
1098
  userState: current
1091
1099
  };
1092
1100
  const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
@@ -1096,7 +1104,7 @@ var StampRallyClient = class {
1096
1104
  spotId,
1097
1105
  message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
1098
1106
  });
1099
- } else if (!matches(condition, proofData))
1107
+ } else if (!matches(condition2, proofData))
1100
1108
  return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
1101
1109
  }
1102
1110
  const now = options.now ?? this.#now();
@@ -1111,8 +1119,21 @@ var StampRallyClient = class {
1111
1119
  };
1112
1120
  const remote = this.#options.syncAdapter?.checkIn;
1113
1121
  if (options.sync !== false && remote !== void 0) {
1114
- const result = await remote(request);
1115
- return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1122
+ try {
1123
+ const result = await remote(request);
1124
+ return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1125
+ } catch (error) {
1126
+ if (this.#offlineQueue === void 0) throw error;
1127
+ await this.#offlineQueue.enqueueCheckIn(request);
1128
+ const record2 = { stampId: spotId, acquiredAt: now };
1129
+ const next2 = this.#reconcile({
1130
+ ...current,
1131
+ records: [...current.records, record2],
1132
+ updatedAt: now
1133
+ });
1134
+ await this.#storage.save(next2);
1135
+ return this.#commitCheckIn({ ok: true, value: { state: next2, record: record2 } });
1136
+ }
1116
1137
  }
1117
1138
  const record = { stampId: spotId, acquiredAt: now };
1118
1139
  const next = this.#reconcile({
@@ -1154,8 +1175,22 @@ var StampRallyClient = class {
1154
1175
  };
1155
1176
  const remote = this.#options.syncAdapter?.claimReward;
1156
1177
  if (options.sync !== false && remote !== void 0) {
1157
- const result = await remote(request);
1158
- return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1178
+ try {
1179
+ const result = await remote(request);
1180
+ return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1181
+ } catch (error) {
1182
+ if (this.#offlineQueue === void 0) throw error;
1183
+ await this.#offlineQueue.enqueueClaimReward(request);
1184
+ const next2 = {
1185
+ ...current,
1186
+ rewards: current.rewards.map(
1187
+ (item) => item.rewardId === rewardId ? local.value : item
1188
+ ),
1189
+ updatedAt: now
1190
+ };
1191
+ await this.#storage.save(next2);
1192
+ return this.#commitClaim({ ok: true, value: { state: next2, reward: local.value } });
1193
+ }
1159
1194
  }
1160
1195
  const next = {
1161
1196
  ...current,
@@ -1169,6 +1204,18 @@ var StampRallyClient = class {
1169
1204
  sync(adapter = this.#options.syncAdapter) {
1170
1205
  return this.#enqueue(async () => {
1171
1206
  const current = await this.initialize();
1207
+ if (this.#offlineQueue !== void 0 && adapter !== void 0) {
1208
+ await this.#offlineQueue.sync(async (operation) => {
1209
+ if (operation.kind === "checkIn") {
1210
+ if (adapter.checkIn === void 0)
1211
+ throw new Error("No check-in sync adapter is configured.");
1212
+ return adapter.checkIn(operation.request);
1213
+ }
1214
+ if (adapter.claimReward === void 0)
1215
+ throw new Error("No reward sync adapter is configured.");
1216
+ return adapter.claimReward(operation.request);
1217
+ });
1218
+ }
1172
1219
  if (adapter?.sync === void 0) {
1173
1220
  this.#emitEvent({ type: "sync", state: current });
1174
1221
  return;
@@ -1182,6 +1229,9 @@ var StampRallyClient = class {
1182
1229
  this.#emitEvent({ type: "sync", state: next });
1183
1230
  });
1184
1231
  }
1232
+ retrySync() {
1233
+ return this.sync();
1234
+ }
1185
1235
  reset() {
1186
1236
  return this.#enqueue(async () => {
1187
1237
  await this.#storage.remove(this.#config.id, this.#userId);
@@ -1216,7 +1266,7 @@ var StampRallyClient = class {
1216
1266
  return Promise.resolve(next);
1217
1267
  }
1218
1268
  #reconcile(state) {
1219
- const ids = new Set(this.#config.spots.map((spot) => spot.id));
1269
+ const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
1220
1270
  const records = state.records.filter(
1221
1271
  (record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
1222
1272
  );
@@ -1263,6 +1313,205 @@ var StampRallyClient = class {
1263
1313
  }
1264
1314
  };
1265
1315
 
1316
+ // src/client/offlineQueue.ts
1317
+ var MemoryQueueStorage = class {
1318
+ #values = /* @__PURE__ */ new Map();
1319
+ async load(key) {
1320
+ return this.#values.get(key) ?? [];
1321
+ }
1322
+ async save(key, operations) {
1323
+ this.#values.set(key, structuredClone(operations));
1324
+ }
1325
+ };
1326
+ var LocalStorageQueueStorage = class {
1327
+ constructor(storage) {
1328
+ this.storage = storage;
1329
+ }
1330
+ storage;
1331
+ async load(key) {
1332
+ const value = this.storage.getItem(key);
1333
+ if (value === null) return [];
1334
+ try {
1335
+ const parsed = JSON.parse(value);
1336
+ return Array.isArray(parsed) ? parsed : [];
1337
+ } catch {
1338
+ return [];
1339
+ }
1340
+ }
1341
+ async save(key, operations) {
1342
+ this.storage.setItem(key, JSON.stringify(operations));
1343
+ }
1344
+ };
1345
+ var IndexedDBOfflineQueueStorage = class {
1346
+ #providedFactory;
1347
+ #databaseName;
1348
+ #databasePromise = null;
1349
+ constructor(options = {}) {
1350
+ this.#providedFactory = options.indexedDB;
1351
+ this.#databaseName = options.databaseName ?? "stamprally-offline-queue";
1352
+ }
1353
+ async load(key) {
1354
+ const database = await this.#open();
1355
+ return new Promise((resolve, reject) => {
1356
+ const request = database.transaction("operations", "readonly").objectStore("operations").get(key);
1357
+ request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
1358
+ request.onerror = () => reject(request.error ?? new Error("Failed to read offline queue."));
1359
+ });
1360
+ }
1361
+ async save(key, operations) {
1362
+ const database = await this.#open();
1363
+ return new Promise((resolve, reject) => {
1364
+ const transaction = database.transaction("operations", "readwrite");
1365
+ transaction.objectStore("operations").put(structuredClone(operations), key);
1366
+ transaction.oncomplete = () => resolve();
1367
+ transaction.onerror = () => reject(transaction.error ?? new Error("Failed to save offline queue."));
1368
+ transaction.onabort = () => reject(transaction.error ?? new Error("Offline queue write aborted."));
1369
+ });
1370
+ }
1371
+ #open() {
1372
+ if (this.#databasePromise !== null) return this.#databasePromise;
1373
+ let factory = this.#providedFactory;
1374
+ if (factory === void 0)
1375
+ factory = globalThis.indexedDB;
1376
+ if (factory === void 0 || factory === null)
1377
+ return Promise.reject(new Error("IndexedDB is unavailable in this environment."));
1378
+ this.#databasePromise = new Promise((resolve, reject) => {
1379
+ const request = factory.open(this.#databaseName, 1);
1380
+ request.onupgradeneeded = () => {
1381
+ if (!request.result.objectStoreNames.contains("operations"))
1382
+ request.result.createObjectStore("operations");
1383
+ };
1384
+ request.onsuccess = () => resolve(request.result);
1385
+ request.onerror = () => reject(request.error ?? new Error("Failed to open offline queue."));
1386
+ }).catch((error) => {
1387
+ this.#databasePromise = null;
1388
+ throw error;
1389
+ });
1390
+ return this.#databasePromise;
1391
+ }
1392
+ };
1393
+ function defaultStorage(databaseName) {
1394
+ try {
1395
+ const indexedDB = globalThis.indexedDB;
1396
+ if (indexedDB !== void 0)
1397
+ return new IndexedDBOfflineQueueStorage({
1398
+ indexedDB,
1399
+ ...databaseName === void 0 ? {} : { databaseName }
1400
+ });
1401
+ const storage = globalThis.localStorage;
1402
+ if (storage !== void 0 && storage !== null) return new LocalStorageQueueStorage(storage);
1403
+ } catch {
1404
+ }
1405
+ return new MemoryQueueStorage();
1406
+ }
1407
+ function operationId(operation) {
1408
+ 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}`;
1409
+ }
1410
+ var OfflineQueue = class {
1411
+ #storage;
1412
+ #key;
1413
+ #conflictPolicy;
1414
+ #onSyncConflict;
1415
+ #operations = [];
1416
+ #loaded = false;
1417
+ #state = "idle";
1418
+ #error = null;
1419
+ #sender;
1420
+ #syncPromise = null;
1421
+ constructor(options = {}) {
1422
+ if (options.storage !== void 0) this.#storage = options.storage;
1423
+ else if (options.storageLike !== void 0 && options.storageLike !== null)
1424
+ this.#storage = new LocalStorageQueueStorage(options.storageLike);
1425
+ else this.#storage = defaultStorage(options.databaseName);
1426
+ this.#key = options.key ?? "stamprally:offline-queue";
1427
+ this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1428
+ this.#onSyncConflict = options.onSyncConflict;
1429
+ }
1430
+ get syncState() {
1431
+ return this.#state;
1432
+ }
1433
+ get pendingCount() {
1434
+ return this.#operations.length;
1435
+ }
1436
+ get error() {
1437
+ return this.#error;
1438
+ }
1439
+ get operations() {
1440
+ return this.#operations;
1441
+ }
1442
+ async initialize() {
1443
+ if (this.#loaded) return;
1444
+ this.#operations = [...await this.#storage.load(this.#key)];
1445
+ this.#loaded = true;
1446
+ }
1447
+ setSender(sender) {
1448
+ this.#sender = sender;
1449
+ }
1450
+ async enqueue(operation) {
1451
+ await this.initialize();
1452
+ const id2 = operationId(operation);
1453
+ if (this.#operations.some((item) => operationId(item) === id2)) return;
1454
+ this.#operations = [...this.#operations, operation];
1455
+ await this.#storage.save(this.#key, this.#operations);
1456
+ }
1457
+ async enqueueCheckIn(request) {
1458
+ return this.enqueue({ kind: "checkIn", request });
1459
+ }
1460
+ async enqueueClaimReward(request) {
1461
+ return this.enqueue({ kind: "claimReward", request });
1462
+ }
1463
+ async clear() {
1464
+ await this.initialize();
1465
+ this.#operations = [];
1466
+ await this.#storage.save(this.#key, this.#operations);
1467
+ }
1468
+ async sync(sender = this.#sender) {
1469
+ await this.initialize();
1470
+ if (sender === void 0) throw new Error("OfflineQueue.sync requires a sender.");
1471
+ if (this.#syncPromise !== null) return this.#syncPromise;
1472
+ this.#sender = sender;
1473
+ this.#syncPromise = this.#run(sender).finally(() => {
1474
+ this.#syncPromise = null;
1475
+ });
1476
+ return this.#syncPromise;
1477
+ }
1478
+ async retrySync(sender = this.#sender) {
1479
+ return this.sync(sender);
1480
+ }
1481
+ async #run(sender) {
1482
+ this.#state = "syncing";
1483
+ this.#error = null;
1484
+ try {
1485
+ while (this.#operations.length > 0) {
1486
+ const operation = this.#operations[0];
1487
+ if (operation === void 0) break;
1488
+ let result;
1489
+ try {
1490
+ result = await sender(operation);
1491
+ } catch (cause) {
1492
+ throw cause instanceof Error ? cause : new Error(String(cause));
1493
+ }
1494
+ if ("conflict" in result && result.conflict === true)
1495
+ await this.resolveConflict(operation, result.localState, result.serverState);
1496
+ this.#operations = this.#operations.slice(1);
1497
+ await this.#storage.save(this.#key, this.#operations);
1498
+ }
1499
+ this.#state = "idle";
1500
+ } catch (cause) {
1501
+ this.#state = "error";
1502
+ this.#error = cause instanceof Error ? cause : new Error(String(cause));
1503
+ throw this.#error;
1504
+ }
1505
+ }
1506
+ async resolveConflict(operation, localState, serverState) {
1507
+ const configured = this.#onSyncConflict;
1508
+ const policy = (typeof configured === "function" ? await configured({ operation, localState, serverState }) : configured) ?? this.#conflictPolicy;
1509
+ if (policy === "merge") {
1510
+ return;
1511
+ }
1512
+ }
1513
+ };
1514
+
1266
1515
  // src/crypto/token.ts
1267
1516
  var encoder = new TextEncoder();
1268
1517
  var decoder = new TextDecoder();
@@ -1395,22 +1644,6 @@ async function decryptPayload(body, secret) {
1395
1644
  );
1396
1645
  }
1397
1646
 
1398
- // src/domain/i18n.ts
1399
- function resolveLocalizedText(text, locale, fallbackLocale) {
1400
- if (text === void 0 || text === "") return "";
1401
- if (typeof text === "string") return text;
1402
- const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
1403
- return text[locale] || fallback || "";
1404
- }
1405
- function toLocalizedString(text) {
1406
- if (text === void 0) return { ja: "", en: "" };
1407
- return typeof text === "string" ? { ja: text, en: "" } : {
1408
- ja: text["ja"] ?? "",
1409
- en: text["en"] ?? "",
1410
- ...text
1411
- };
1412
- }
1413
-
1414
1647
  // src/domain/models.ts
1415
1648
  var DEFAULT_SHEET_THEME = {
1416
1649
  primaryColor: "#9e551e",
@@ -1422,23 +1655,23 @@ var DEFAULT_SHEET_THEME = {
1422
1655
  unclaimedOpacity: 1,
1423
1656
  fontFamily: "serif"
1424
1657
  };
1425
- function publicCondition(condition) {
1426
- switch (condition.type) {
1658
+ function publicCondition(condition2) {
1659
+ switch (condition2.type) {
1427
1660
  case "qr":
1428
- return condition.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition.qrEntryUrl };
1661
+ return condition2.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition2.qrEntryUrl };
1429
1662
  case "passcode":
1430
1663
  return { type: "passcode" };
1431
1664
  case "gps":
1432
1665
  return {
1433
1666
  type: "gps",
1434
- latitude: condition.latitude,
1435
- longitude: condition.longitude,
1436
- radiusMeters: condition.radiusMeters
1667
+ latitude: condition2.latitude,
1668
+ longitude: condition2.longitude,
1669
+ radiusMeters: condition2.radiusMeters
1437
1670
  };
1438
1671
  case "nfc":
1439
1672
  return { type: "nfc" };
1440
1673
  case "custom":
1441
- return { type: "custom", validatorName: condition.validatorName };
1674
+ return { type: "custom", validatorName: condition2.validatorName };
1442
1675
  }
1443
1676
  }
1444
1677
  function toPublicConfig(config) {
@@ -1448,14 +1681,14 @@ function toPublicConfig(config) {
1448
1681
  title: config.title,
1449
1682
  ...config.description === void 0 ? {} : { description: config.description },
1450
1683
  ...config.theme === void 0 ? {} : { theme: config.theme },
1451
- spots: config.spots.map((spot) => ({
1452
- ...spot,
1453
- conditions: spot.conditions.map(publicCondition)
1684
+ spots: config.spots.map((spot2) => ({
1685
+ ...spot2,
1686
+ conditions: spot2.conditions.map(publicCondition)
1454
1687
  })),
1455
1688
  rewards: config.rewards.map(
1456
- ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward }) => reward
1689
+ ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward2 }) => reward2
1457
1690
  ),
1458
- ...config.metadata === void 0 ? {} : { metadata: config.metadata },
1691
+ ...config.publicMetadata !== void 0 ? { metadata: config.publicMetadata } : config.metadata === void 0 ? {} : { metadata: config.metadata },
1459
1692
  ...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
1460
1693
  };
1461
1694
  }
@@ -1491,15 +1724,15 @@ function isPublicConfig(value) {
1491
1724
  }
1492
1725
  if (typeof candidate.id !== "string" || typeof candidate.version !== "string" || typeof candidate.title !== "string" && (typeof candidate.title !== "object" || candidate.title === null || Array.isArray(candidate.title)) || !Array.isArray(candidate.spots) || !Array.isArray(candidate.rewards))
1493
1726
  return false;
1494
- return candidate.spots.every((spot) => {
1495
- if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
1496
- const item = spot;
1727
+ return candidate.spots.every((spot2) => {
1728
+ if (typeof spot2 !== "object" || spot2 === null || Array.isArray(spot2)) return false;
1729
+ const item = spot2;
1497
1730
  if (typeof item.id !== "string" || typeof item.orderIndex !== "number" || item.name === void 0 || "secretToken" in item || "code" in item || "tagId" in item || "secretParams" in item)
1498
1731
  return false;
1499
- return Array.isArray(item.conditions) && item.conditions.every((condition) => {
1500
- if (typeof condition !== "object" || condition === null || Array.isArray(condition))
1732
+ return Array.isArray(item.conditions) && item.conditions.every((condition2) => {
1733
+ if (typeof condition2 !== "object" || condition2 === null || Array.isArray(condition2))
1501
1734
  return false;
1502
- const value2 = condition;
1735
+ const value2 = condition2;
1503
1736
  if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
1504
1737
  return false;
1505
1738
  const type = value2.type;
@@ -1507,13 +1740,125 @@ function isPublicConfig(value) {
1507
1740
  if (type === "custom") return typeof value2.validatorName === "string";
1508
1741
  return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
1509
1742
  });
1510
- }) && candidate.rewards.every((reward) => {
1511
- if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
1512
- const item = reward;
1743
+ }) && candidate.rewards.every((reward2) => {
1744
+ if (typeof reward2 !== "object" || reward2 === null || Array.isArray(reward2)) return false;
1745
+ const item = reward2;
1513
1746
  return typeof item.id === "string" && typeof item.requiredStampCount === "number" && (item.type === "digital" || item.type === "in_person") && (item.redemptionMethod === "manual_slide" || item.redemptionMethod === "staff_passcode" || item.redemptionMethod === "view_only" || item.redemptionMethod === "server_claim") && !("staffPasscode" in item || "digitalContentUrl" in item);
1514
1747
  });
1515
1748
  }
1516
1749
 
1750
+ // src/domain/configTransform.ts
1751
+ var PRIVATE_KEYS = /* @__PURE__ */ new Set([
1752
+ "staffPasscode",
1753
+ "serverMetadata",
1754
+ "inventory",
1755
+ "secretToken",
1756
+ "secretParams",
1757
+ "digitalContentUrl",
1758
+ "code",
1759
+ "tagId"
1760
+ ]);
1761
+ var SENSITIVE_KEY_PATTERN = /^(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|private[_-]?key|secret)$/i;
1762
+ function isRecord2(value) {
1763
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1764
+ }
1765
+ function isPrivateKey(key) {
1766
+ return PRIVATE_KEYS.has(key) || SENSITIVE_KEY_PATTERN.test(key);
1767
+ }
1768
+ function sanitizeValue(value, customFilter, seen) {
1769
+ if (Array.isArray(value)) {
1770
+ if (seen.has(value)) return void 0;
1771
+ seen.add(value);
1772
+ const items = value.map((item) => sanitizeValue(item, customFilter, seen)).filter((item) => item !== void 0);
1773
+ return items;
1774
+ }
1775
+ if (!isRecord2(value)) return value;
1776
+ if (seen.has(value)) return void 0;
1777
+ seen.add(value);
1778
+ const result = {};
1779
+ for (const [key, item] of Object.entries(value)) {
1780
+ if (isPrivateKey(key) || customFilter?.(key, item) === false) continue;
1781
+ const sanitized = sanitizeValue(item, customFilter, seen);
1782
+ if (sanitized !== void 0) result[key] = sanitized;
1783
+ }
1784
+ return result;
1785
+ }
1786
+ function sanitizeSpot(spot2, customFilter) {
1787
+ return sanitizeValue(
1788
+ toPublicConfig({
1789
+ id: "__spot__",
1790
+ version: "1",
1791
+ title: "__spot__",
1792
+ spots: [spot2],
1793
+ rewards: []
1794
+ }).spots[0],
1795
+ customFilter,
1796
+ /* @__PURE__ */ new WeakSet()
1797
+ );
1798
+ }
1799
+ function sanitizeReward(reward2, customFilter) {
1800
+ return sanitizeValue(
1801
+ toPublicConfig({
1802
+ id: "__reward__",
1803
+ version: "1",
1804
+ title: "__reward__",
1805
+ spots: [],
1806
+ rewards: [reward2]
1807
+ }).rewards[0],
1808
+ customFilter,
1809
+ /* @__PURE__ */ new WeakSet()
1810
+ );
1811
+ }
1812
+ function sanitizeAdminConfig(admin, customFilter) {
1813
+ const publicConfig = toPublicConfig(admin);
1814
+ const sanitized = sanitizeValue(publicConfig, customFilter, /* @__PURE__ */ new WeakSet());
1815
+ sanitized.spots = admin.spots.map((spot2) => sanitizeSpot(spot2, customFilter));
1816
+ sanitized.rewards = admin.rewards.map((reward2) => sanitizeReward(reward2, customFilter));
1817
+ return sanitized;
1818
+ }
1819
+ function validatePublicConfigSafety(publicConfig) {
1820
+ const leakedKeys = [];
1821
+ const seen = /* @__PURE__ */ new WeakSet();
1822
+ const visit = (value, path) => {
1823
+ if (Array.isArray(value)) {
1824
+ value.forEach((item, index) => {
1825
+ visit(item, `${path}[${index}]`);
1826
+ });
1827
+ return;
1828
+ }
1829
+ if (!isRecord2(value) || seen.has(value)) return;
1830
+ seen.add(value);
1831
+ for (const [key, item] of Object.entries(value)) {
1832
+ const nextPath = path === "$" ? key : `${path}.${key}`;
1833
+ if (isPrivateKey(key)) leakedKeys.push(nextPath);
1834
+ visit(item, nextPath);
1835
+ }
1836
+ };
1837
+ visit(publicConfig, "$");
1838
+ return { safe: leakedKeys.length === 0, leakedKeys };
1839
+ }
1840
+
1841
+ // src/domain/i18n.ts
1842
+ function updateLocalizedField(current, locale, newValue) {
1843
+ if (typeof current === "string" || current === void 0)
1844
+ return { [locale]: newValue };
1845
+ return { ...current, [locale]: newValue };
1846
+ }
1847
+ function resolveLocalizedText(text, locale, fallbackLocale) {
1848
+ if (text === void 0 || text === "") return "";
1849
+ if (typeof text === "string") return text;
1850
+ const fallback = fallbackLocale === void 0 ? Object.values(text).find((value) => typeof value === "string") : text[fallbackLocale];
1851
+ return text[locale] || fallback || "";
1852
+ }
1853
+ function toLocalizedString(text) {
1854
+ if (text === void 0) return { ja: "", en: "" };
1855
+ return typeof text === "string" ? { ja: text, en: "" } : {
1856
+ ja: text["ja"] ?? "",
1857
+ en: text["en"] ?? "",
1858
+ ...text
1859
+ };
1860
+ }
1861
+
1517
1862
  // src/domain/themePresets.ts
1518
1863
  var THEME_PRESETS = [
1519
1864
  {
@@ -1613,6 +1958,326 @@ var THEME_PRESETS = [
1613
1958
  }
1614
1959
  ];
1615
1960
 
1961
+ // src/domain/validation.ts
1962
+ var ConfigValidationError = class extends Error {
1963
+ constructor(errors) {
1964
+ super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
1965
+ this.errors = errors;
1966
+ }
1967
+ errors;
1968
+ name = "ConfigValidationError";
1969
+ };
1970
+ function isRecord3(value) {
1971
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1972
+ }
1973
+ function hasOwn(value, key) {
1974
+ return Object.hasOwn(value, key);
1975
+ }
1976
+ function add(errors, path, message, code) {
1977
+ errors.push({ path, message, code });
1978
+ }
1979
+ function requiredString(value, key, path, errors) {
1980
+ if (typeof value[key] !== "string" || value[key].length === 0) {
1981
+ add(errors, `${path}.${key}`, "A non-empty string is required.", "required_string");
1982
+ return false;
1983
+ }
1984
+ return true;
1985
+ }
1986
+ function optionalString(value, key, path, errors) {
1987
+ if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "string")
1988
+ add(errors, `${path}.${key}`, "Expected a string.", "invalid_type");
1989
+ }
1990
+ function optionalBoolean(value, key, path, errors) {
1991
+ if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "boolean")
1992
+ add(errors, `${path}.${key}`, "Expected a boolean.", "invalid_type");
1993
+ }
1994
+ function finiteNumber(value, key, path, errors, minimum) {
1995
+ const item = value[key];
1996
+ if (typeof item !== "number" || !Number.isFinite(item)) {
1997
+ add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
1998
+ } else if (minimum !== void 0 && item < minimum) {
1999
+ add(
2000
+ errors,
2001
+ `${path}.${key}`,
2002
+ `Expected a number greater than or equal to ${minimum}.`,
2003
+ "out_of_range"
2004
+ );
2005
+ }
2006
+ }
2007
+ function localizedText(value, path, errors) {
2008
+ if (typeof value === "string") return;
2009
+ if (!isRecord3(value)) {
2010
+ add(errors, path, "Expected a string or a locale map.", "invalid_localized_text");
2011
+ return;
2012
+ }
2013
+ for (const [locale, text] of Object.entries(value)) {
2014
+ if (typeof text !== "string")
2015
+ add(errors, `${path}.${locale}`, "Expected a string.", "invalid_type");
2016
+ }
2017
+ }
2018
+ function theme(value, path, errors) {
2019
+ if (!isRecord3(value)) {
2020
+ add(errors, path, "Expected a theme object.", "invalid_type");
2021
+ return;
2022
+ }
2023
+ for (const key of ["primaryColor", "cardBackgroundColor", "textColor"])
2024
+ requiredString(value, key, path, errors);
2025
+ optionalString(value, "backgroundColor", path, errors);
2026
+ optionalString(value, "backgroundImageUrl", path, errors);
2027
+ optionalString(value, "completedStampColor", path, errors);
2028
+ optionalString(value, "fontFamily", path, errors);
2029
+ const slotShape = value.slotShape;
2030
+ if (slotShape !== "circle" && slotShape !== "square" && slotShape !== "rounded")
2031
+ add(errors, `${path}.slotShape`, "Expected circle, square, or rounded.", "invalid_enum");
2032
+ finiteNumber(value, "gridColumns", path, errors, 1);
2033
+ if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
2034
+ add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
2035
+ if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
2036
+ }
2037
+ function externalReferences(value, path, errors) {
2038
+ if (!Array.isArray(value)) {
2039
+ add(errors, path, "Expected an array.", "invalid_type");
2040
+ return;
2041
+ }
2042
+ value.forEach((item, index) => {
2043
+ const itemPath = `${path}[${index}]`;
2044
+ if (!isRecord3(item)) {
2045
+ add(errors, itemPath, "Expected an object.", "invalid_type");
2046
+ return;
2047
+ }
2048
+ requiredString(item, "type", itemPath, errors);
2049
+ requiredString(item, "id", itemPath, errors);
2050
+ optionalString(item, "url", itemPath, errors);
2051
+ });
2052
+ }
2053
+ function condition(value, path, errors, isPublic) {
2054
+ if (!isRecord3(value)) {
2055
+ add(errors, path, "Expected a condition object.", "invalid_type");
2056
+ return;
2057
+ }
2058
+ const type = value.type;
2059
+ if (type === "qr") {
2060
+ if (isPublic) {
2061
+ if (hasOwn(value, "secretToken"))
2062
+ add(errors, `${path}.secretToken`, "Private field is not allowed.", "private_field");
2063
+ } else requiredString(value, "secretToken", path, errors);
2064
+ optionalString(value, "qrEntryUrl", path, errors);
2065
+ return;
2066
+ }
2067
+ if (type === "passcode") {
2068
+ if (isPublic) {
2069
+ for (const key of ["code", "caseSensitive"])
2070
+ if (hasOwn(value, key))
2071
+ add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
2072
+ } else {
2073
+ requiredString(value, "code", path, errors);
2074
+ optionalBoolean(value, "caseSensitive", path, errors);
2075
+ }
2076
+ return;
2077
+ }
2078
+ if (type === "gps") {
2079
+ finiteNumber(value, "latitude", path, errors);
2080
+ finiteNumber(value, "longitude", path, errors);
2081
+ finiteNumber(value, "radiusMeters", path, errors, 0);
2082
+ if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
2083
+ add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
2084
+ if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
2085
+ add(
2086
+ errors,
2087
+ `${path}.longitude`,
2088
+ "Expected a longitude between -180 and 180.",
2089
+ "out_of_range"
2090
+ );
2091
+ return;
2092
+ }
2093
+ if (type === "nfc") {
2094
+ if (isPublic) {
2095
+ if (hasOwn(value, "tagId"))
2096
+ add(errors, `${path}.tagId`, "Private field is not allowed.", "private_field");
2097
+ } else requiredString(value, "tagId", path, errors);
2098
+ return;
2099
+ }
2100
+ if (type === "custom") {
2101
+ requiredString(value, "validatorName", path, errors);
2102
+ if (isPublic && hasOwn(value, "secretParams"))
2103
+ add(errors, `${path}.secretParams`, "Private field is not allowed.", "private_field");
2104
+ if (!isPublic && hasOwn(value, "secretParams") && !isRecord3(value.secretParams))
2105
+ add(errors, `${path}.secretParams`, "Expected an object.", "invalid_type");
2106
+ return;
2107
+ }
2108
+ add(errors, `${path}.type`, "Unknown condition type.", "invalid_enum");
2109
+ }
2110
+ function unlockCondition(value, path, errors) {
2111
+ if (!isRecord3(value)) {
2112
+ add(errors, path, "Expected an unlock condition object.", "invalid_type");
2113
+ return;
2114
+ }
2115
+ if (value.type === "stamp_count") {
2116
+ finiteNumber(value, "count", path, errors, 0);
2117
+ return;
2118
+ }
2119
+ if (value.type === "stamps") {
2120
+ if (!Array.isArray(value.stampIds))
2121
+ add(errors, `${path}.stampIds`, "Expected an array.", "invalid_type");
2122
+ else
2123
+ value.stampIds.forEach((item, index) => {
2124
+ if (typeof item !== "string" || item.length === 0)
2125
+ add(
2126
+ errors,
2127
+ `${path}.stampIds[${index}]`,
2128
+ "Expected a non-empty string.",
2129
+ "required_string"
2130
+ );
2131
+ });
2132
+ return;
2133
+ }
2134
+ if (value.type === "all" || value.type === "any") {
2135
+ if (!Array.isArray(value.conditions))
2136
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
2137
+ else
2138
+ value.conditions.forEach((item, index) => {
2139
+ unlockCondition(item, `${path}.conditions[${index}]`, errors);
2140
+ });
2141
+ return;
2142
+ }
2143
+ add(errors, `${path}.type`, "Unknown unlock condition type.", "invalid_enum");
2144
+ }
2145
+ function spot(value, path, errors, isPublic) {
2146
+ if (!isRecord3(value)) {
2147
+ add(errors, path, "Expected a spot object.", "invalid_type");
2148
+ return;
2149
+ }
2150
+ requiredString(value, "id", path, errors);
2151
+ finiteNumber(value, "orderIndex", path, errors, 0);
2152
+ if (typeof value.orderIndex === "number" && !Number.isInteger(value.orderIndex))
2153
+ add(errors, `${path}.orderIndex`, "Expected an integer.", "invalid_integer");
2154
+ localizedText(value.name, `${path}.name`, errors);
2155
+ for (const key of ["description", "hint"])
2156
+ if (hasOwn(value, key)) localizedText(value[key], `${path}.${key}`, errors);
2157
+ for (const key of ["imageUrl", "iconUrl", "redirectUrlAfterClaim"])
2158
+ optionalString(value, key, path, errors);
2159
+ if (hasOwn(value, "externalReferences") && value.externalReferences !== void 0)
2160
+ externalReferences(value.externalReferences, `${path}.externalReferences`, errors);
2161
+ if (hasOwn(value, "prerequisites") && value.prerequisites !== void 0) {
2162
+ if (!Array.isArray(value.prerequisites))
2163
+ add(errors, `${path}.prerequisites`, "Expected an array.", "invalid_type");
2164
+ else
2165
+ value.prerequisites.forEach((item, index) => {
2166
+ if (typeof item !== "string" || item.length === 0)
2167
+ add(
2168
+ errors,
2169
+ `${path}.prerequisites[${index}]`,
2170
+ "Expected a non-empty string.",
2171
+ "required_string"
2172
+ );
2173
+ });
2174
+ }
2175
+ if (!Array.isArray(value.conditions))
2176
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
2177
+ else
2178
+ value.conditions.forEach((item, index) => {
2179
+ condition(item, `${path}.conditions[${index}]`, errors, isPublic);
2180
+ });
2181
+ }
2182
+ function reward(value, path, errors, isPublic) {
2183
+ if (!isRecord3(value)) {
2184
+ add(errors, path, "Expected a reward object.", "invalid_type");
2185
+ return;
2186
+ }
2187
+ requiredString(value, "id", path, errors);
2188
+ localizedText(value.title, `${path}.title`, errors);
2189
+ if (hasOwn(value, "description")) localizedText(value.description, `${path}.description`, errors);
2190
+ if (value.type !== "digital" && value.type !== "in_person")
2191
+ add(errors, `${path}.type`, "Unknown reward type.", "invalid_enum");
2192
+ if (!["manual_slide", "staff_passcode", "view_only", "server_claim"].includes(
2193
+ String(value.redemptionMethod)
2194
+ ))
2195
+ add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
2196
+ finiteNumber(value, "requiredStampCount", path, errors, 0);
2197
+ for (const key of ["stockLimit", "userClaimLimit"]) {
2198
+ if (hasOwn(value, key) && value[key] !== void 0) {
2199
+ finiteNumber(value, key, path, errors, 0);
2200
+ if (typeof value[key] === "number" && !Number.isInteger(value[key]))
2201
+ add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
2202
+ }
2203
+ }
2204
+ optionalString(value, "validUntil", path, errors);
2205
+ if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
2206
+ add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
2207
+ if (isPublic) {
2208
+ for (const key of ["staffPasscode", "digitalContentUrl"])
2209
+ if (hasOwn(value, key))
2210
+ add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
2211
+ } else {
2212
+ optionalString(value, "staffPasscode", path, errors);
2213
+ optionalString(value, "digitalContentUrl", path, errors);
2214
+ }
2215
+ if (hasOwn(value, "conditions") && value.conditions !== void 0) {
2216
+ if (!Array.isArray(value.conditions))
2217
+ add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
2218
+ else
2219
+ value.conditions.forEach((item, index) => {
2220
+ unlockCondition(item, `${path}.conditions[${index}]`, errors);
2221
+ });
2222
+ }
2223
+ }
2224
+ function validate(value, isPublic) {
2225
+ const errors = [];
2226
+ if (!isRecord3(value)) {
2227
+ add(errors, "$", "Expected a configuration object.", "invalid_type");
2228
+ return errors;
2229
+ }
2230
+ requiredString(value, "id", "$", errors);
2231
+ requiredString(value, "version", "$", errors);
2232
+ localizedText(value.title, "$.title", errors);
2233
+ if (hasOwn(value, "description")) localizedText(value.description, "$.description", errors);
2234
+ if (hasOwn(value, "theme") && value.theme !== void 0) theme(value.theme, "$.theme", errors);
2235
+ if (!Array.isArray(value.spots)) add(errors, "spots", "Expected an array.", "invalid_type");
2236
+ else
2237
+ value.spots.forEach((item, index) => {
2238
+ spot(item, `spots[${index}]`, errors, isPublic);
2239
+ });
2240
+ if (!Array.isArray(value.rewards)) add(errors, "rewards", "Expected an array.", "invalid_type");
2241
+ else
2242
+ value.rewards.forEach((item, index) => {
2243
+ reward(item, `rewards[${index}]`, errors, isPublic);
2244
+ });
2245
+ if (!isPublic) {
2246
+ optionalString(value, "staffPasscode", "$", errors);
2247
+ if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2248
+ add(errors, "$.inventory", "Expected an object.", "invalid_type");
2249
+ if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2250
+ add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2251
+ if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2252
+ add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2253
+ optionalString(value, "serverEndpoint", "$", errors);
2254
+ } else {
2255
+ for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2256
+ if (hasOwn(value, key))
2257
+ add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2258
+ optionalString(value, "serverEndpoint", "$", errors);
2259
+ }
2260
+ return errors;
2261
+ }
2262
+ function safeParseAdminConfig(input) {
2263
+ const errors = validate(input, false);
2264
+ return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2265
+ }
2266
+ function parseAdminConfig(input) {
2267
+ const result = safeParseAdminConfig(input);
2268
+ if (!result.success) throw new ConfigValidationError(result.errors);
2269
+ return result.data;
2270
+ }
2271
+ function safeParsePublicConfig(input) {
2272
+ const errors = validate(input, true);
2273
+ return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2274
+ }
2275
+ function parsePublicConfig(input) {
2276
+ const result = safeParsePublicConfig(input);
2277
+ if (!result.success) throw new ConfigValidationError(result.errors);
2278
+ return result.data;
2279
+ }
2280
+
1616
2281
  // src/security/snapshotToken.ts
1617
2282
  var encoder2 = new TextEncoder();
1618
2283
  function cryptoApi2() {
@@ -1732,10 +2397,14 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
1732
2397
  }
1733
2398
  }
1734
2399
 
2400
+ exports.ConfigValidationError = ConfigValidationError;
1735
2401
  exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
2402
+ exports.InMemoryOfflineQueueStorage = MemoryQueueStorage;
1736
2403
  exports.InMemoryStorage = InMemoryStorage;
1737
2404
  exports.IndexedDBAdapter = IndexedDBAdapter;
2405
+ exports.IndexedDBOfflineQueueStorage = IndexedDBOfflineQueueStorage;
1738
2406
  exports.LocalStorageAdapter = LocalStorageAdapter;
2407
+ exports.OfflineQueue = OfflineQueue;
1739
2408
  exports.StampRallyClient = StampRallyClient;
1740
2409
  exports.StorageAdapterError = StorageAdapterError;
1741
2410
  exports.THEME_PRESETS = THEME_PRESETS;
@@ -1761,14 +2430,21 @@ exports.isRewardState = isRewardState;
1761
2430
  exports.isStampRallyState = isStampRallyState;
1762
2431
  exports.issueClaimTicketNumber = issueClaimTicketNumber;
1763
2432
  exports.normalizePasscode = normalizePasscode;
2433
+ exports.parseAdminConfig = parseAdminConfig;
2434
+ exports.parsePublicConfig = parsePublicConfig;
1764
2435
  exports.processStamp = processStamp;
1765
2436
  exports.readNfcContext = readNfcContext;
1766
2437
  exports.readQrContext = readQrContext;
1767
2438
  exports.reconcileRewardStates = reconcileRewardStates;
1768
2439
  exports.resolveLocalizedText = resolveLocalizedText;
2440
+ exports.safeParseAdminConfig = safeParseAdminConfig;
2441
+ exports.safeParsePublicConfig = safeParsePublicConfig;
2442
+ exports.sanitizeAdminConfig = sanitizeAdminConfig;
1769
2443
  exports.storageKey = storageKey;
1770
2444
  exports.toLocalizedString = toLocalizedString;
1771
2445
  exports.toPublicConfig = toPublicConfig;
2446
+ exports.updateLocalizedField = updateLocalizedField;
2447
+ exports.validatePublicConfigSafety = validatePublicConfigSafety;
1772
2448
  exports.verifyPasscode = verifyPasscode;
1773
2449
  exports.verifySecureToken = verifySecureToken;
1774
2450
  exports.verifySnapshotToken = verifySnapshotToken;