@stamprally/core 0.8.0 → 0.9.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
@@ -2,252 +2,64 @@
2
2
 
3
3
  // src/engine/evaluate.ts
4
4
  var EARTH_RADIUS_METERS = 6371e3;
5
- function toRadians(degrees) {
6
- return degrees * Math.PI / 180;
7
- }
8
- function calculateDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
9
- const latitudeDelta = toRadians(latitudeB - latitudeA);
10
- const longitudeDelta = toRadians(longitudeB - longitudeA);
11
- const latitudeARadians = toRadians(latitudeA);
12
- const latitudeBRadians = toRadians(latitudeB);
13
- const haversine = Math.sin(latitudeDelta / 2) ** 2 + Math.cos(latitudeARadians) * Math.cos(latitudeBRadians) * Math.sin(longitudeDelta / 2) ** 2;
14
- return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(Math.min(1, haversine)));
15
- }
16
- function isValidCoordinate(latitude, longitude) {
17
- return Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 && Number.isFinite(longitude) && longitude >= -180 && longitude <= 180;
18
- }
19
- function contextTypeMismatch(conditionType, expectedContextType, actualContextType) {
20
- return {
21
- ok: false,
22
- error: {
23
- code: "CONDITION_MISMATCH",
24
- conditionType,
25
- reason: "CONTEXT_TYPE_MISMATCH",
26
- expectedContextType,
27
- actualContextType
28
- }
29
- };
5
+ function calculateDistanceMeters(aLat, aLon, bLat, bLon) {
6
+ const radians = (value2) => value2 * Math.PI / 180;
7
+ const dLat = radians(bLat - aLat);
8
+ const dLon = radians(bLon - aLon);
9
+ const value = Math.sin(dLat / 2) ** 2 + Math.cos(radians(aLat)) * Math.cos(radians(bLat)) * Math.sin(dLon / 2) ** 2;
10
+ return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(Math.min(1, value)));
30
11
  }
31
- function assertNever(value) {
32
- throw new Error(`Unexpected condition: ${JSON.stringify(value)}`);
12
+ function mismatch(conditionType, reason, extra = {}) {
13
+ return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
33
14
  }
34
- function evaluateConditionDetailed(condition2, context, now2) {
35
- switch (condition2.type) {
36
- case "instant":
37
- return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
38
- case "token":
39
- if (context.type !== "token") {
40
- return contextTypeMismatch("token", "token", context.type);
41
- }
42
- return context.token === condition2.token ? { ok: true, value: { conditionType: "token" } } : {
43
- ok: false,
44
- error: {
45
- code: "CONDITION_MISMATCH",
46
- conditionType: "token",
47
- reason: "TOKEN_MISMATCH"
48
- }
49
- };
50
- case "geo": {
51
- if (context.type !== "geo") {
52
- return contextTypeMismatch("geo", "geo", context.type);
53
- }
54
- if (!isValidCoordinate(condition2.latitude, condition2.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0) {
55
- return {
56
- ok: false,
57
- error: {
58
- code: "CONDITION_MISMATCH",
59
- conditionType: "geo",
60
- reason: "INVALID_GEO_INPUT"
61
- }
62
- };
63
- }
64
- const distanceMeters2 = calculateDistanceMeters(
65
- condition2.latitude,
66
- condition2.longitude,
67
- context.currentLatitude,
68
- context.currentLongitude
15
+ function evaluateConditionDetailed(condition, context) {
16
+ switch (condition.type) {
17
+ case "qr":
18
+ return context.type === "qr" && context.token === condition.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
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");
21
+ case "nfc":
22
+ return context.type === "nfc" && context.tagId === condition.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
23
+ case "custom":
24
+ return mismatch("custom", "VALIDATOR_FAILED");
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))
27
+ return mismatch("gps", "INVALID_GEO_INPUT");
28
+ const distanceMeters = calculateDistanceMeters(
29
+ condition.latitude,
30
+ condition.longitude,
31
+ context.latitude,
32
+ context.longitude
69
33
  );
70
- if (distanceMeters2 <= condition2.radiusMeters) {
71
- return { ok: true, value: { conditionType: "geo", distanceMeters: distanceMeters2 } };
72
- }
73
- return {
74
- ok: false,
75
- error: {
76
- code: "CONDITION_MISMATCH",
77
- conditionType: "geo",
78
- reason: "OUTSIDE_RADIUS",
79
- distanceMeters: distanceMeters2,
80
- radiusMeters: condition2.radiusMeters,
81
- differenceMeters: distanceMeters2 - condition2.radiusMeters
82
- }
83
- };
84
- }
85
- case "composite": {
86
- if (context.type !== "composite") {
87
- return contextTypeMismatch("composite", "composite", context.type);
88
- }
89
- if (condition2.conditions.length !== context.contexts.length) {
90
- return {
91
- ok: false,
92
- error: {
93
- code: "CONDITION_MISMATCH",
94
- conditionType: "composite",
95
- reason: "CONTEXT_LENGTH_MISMATCH",
96
- expectedCount: condition2.conditions.length,
97
- actualCount: context.contexts.length
98
- }
99
- };
100
- }
101
- const failures = [];
102
- let matchedCount = 0;
103
- for (const [index, childCondition] of condition2.conditions.entries()) {
104
- const childContext = context.contexts[index];
105
- if (childContext === void 0) continue;
106
- const result = evaluateConditionDetailed(childCondition, childContext, now2);
107
- if (result.ok) matchedCount += 1;
108
- else failures.push({ index, error: result.error });
109
- }
110
- if (condition2.operator === "AND" && failures.length === 0) {
111
- return { ok: true, value: { conditionType: "composite" } };
112
- }
113
- if (condition2.operator === "OR" && matchedCount > 0) {
114
- return { ok: true, value: { conditionType: "composite" } };
115
- }
116
- return {
117
- ok: false,
118
- error: {
119
- code: "CONDITION_MISMATCH",
120
- conditionType: "composite",
121
- reason: condition2.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
122
- failures
123
- }
124
- };
125
- }
126
- case "time_window": {
127
- const startsAt = Date.parse(condition2.startsAt);
128
- const endsAt = Date.parse(condition2.endsAt);
129
- const currentTime = Date.parse(now2);
130
- if (!Number.isFinite(currentTime)) {
131
- return {
132
- ok: false,
133
- error: {
134
- code: "CONDITION_MISMATCH",
135
- conditionType: "time_window",
136
- reason: "INVALID_NOW",
137
- now: now2,
138
- startsAt: condition2.startsAt,
139
- endsAt: condition2.endsAt
140
- }
141
- };
142
- }
143
- if (!Number.isFinite(startsAt) || !Number.isFinite(endsAt) || startsAt > endsAt) {
144
- return {
145
- ok: false,
146
- error: {
147
- code: "CONDITION_MISMATCH",
148
- conditionType: "time_window",
149
- reason: "INVALID_TIME_WINDOW",
150
- now: now2,
151
- startsAt: condition2.startsAt,
152
- endsAt: condition2.endsAt
153
- }
154
- };
155
- }
156
- if (currentTime < startsAt || currentTime > endsAt) {
157
- return {
158
- ok: false,
159
- error: {
160
- code: "CONDITION_MISMATCH",
161
- conditionType: "time_window",
162
- reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
163
- now: now2,
164
- startsAt: condition2.startsAt,
165
- endsAt: condition2.endsAt
166
- }
167
- };
168
- }
169
- const childResult = evaluateConditionDetailed(condition2.condition, context, now2);
170
- return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
34
+ return distanceMeters <= condition.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
35
+ distanceMeters,
36
+ radiusMeters: condition.radiusMeters
37
+ });
171
38
  }
172
- default:
173
- return assertNever(condition2);
174
39
  }
175
40
  }
176
- function evaluateCondition(condition2, context, now2) {
177
- return evaluateConditionDetailed(condition2, context, now2 ?? "").ok;
178
- }
179
-
180
- // src/engine/checkIn.ts
181
- function unwrapContext(input) {
182
- if ("verificationContext" in input) {
183
- return {
184
- context: input.verificationContext,
185
- now: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
186
- ...input.expectedStampId === void 0 ? {} : { expectedStampId: input.expectedStampId },
187
- alreadyClaimed: input.alreadyClaimed ?? false
188
- };
189
- }
190
- return {
191
- context: input,
192
- now: "now" in input && typeof input.now === "string" ? input.now : (/* @__PURE__ */ new Date()).toISOString(),
193
- ..."expectedStampId" in input && typeof input.expectedStampId === "string" ? { expectedStampId: input.expectedStampId } : {},
194
- alreadyClaimed: "alreadyClaimed" in input && input.alreadyClaimed === true
195
- };
196
- }
197
- function evaluateCheckIn(spot, input) {
198
- const context = unwrapContext(input);
199
- if (context.alreadyClaimed) {
200
- return {
201
- ok: false,
202
- success: false,
203
- code: "ALREADY_CLAIMED",
204
- stampId: spot.id,
205
- message: "This spot has already been claimed."
206
- };
207
- }
208
- if (context.expectedStampId !== void 0 && context.expectedStampId !== spot.id) {
209
- return {
210
- ok: false,
211
- success: false,
212
- code: "ORDER_VIOLATION",
213
- stampId: spot.id,
214
- message: `The next required spot is '${context.expectedStampId}'.`
215
- };
216
- }
217
- const evaluated = evaluateConditionDetailed(spot.condition, context.context, context.now);
218
- if (evaluated.ok) {
219
- return { ok: true, success: true, stampId: spot.id, checkedAt: context.now };
220
- }
221
- const code = evaluated.error.reason === "OUTSIDE_RADIUS" ? "OUT_OF_RANGE" : evaluated.error.reason === "BEFORE_START" || evaluated.error.reason === "AFTER_END" ? "EXPIRED" : evaluated.error.reason === "TOKEN_MISMATCH" ? "INVALID_PROOF" : "INVALID_CONTEXT";
222
- return { ok: false, success: false, code, stampId: spot.id, message: evaluated.error.reason };
41
+ function evaluateCondition(condition, context) {
42
+ return evaluateConditionDetailed(condition, context).ok;
223
43
  }
224
44
 
225
45
  // src/engine/order.ts
226
- function getOrderedStamps(config) {
227
- return config.stamps.map((stamp, index) => ({ stamp, index })).sort((left, right) => {
228
- const orderDifference = (left.stamp.orderIndex ?? left.stamp.order ?? Number.POSITIVE_INFINITY) - (right.stamp.orderIndex ?? right.stamp.order ?? Number.POSITIVE_INFINITY);
229
- return orderDifference === 0 ? left.index - right.index : orderDifference;
230
- }).map(({ stamp }) => stamp);
46
+ function getOrderedSpots(spots) {
47
+ return [...spots].sort((left, right) => left.orderIndex - right.orderIndex);
231
48
  }
232
49
 
233
50
  // src/engine/progress.ts
234
51
  function calculateProgress(state, config) {
235
- const configuredStampIds = new Set(config.stamps.map((stamp) => stamp.id));
236
- const acquiredStampIds = new Set(
237
- state.records.map((record) => record.stampId).filter((stampId) => configuredStampIds.has(stampId))
52
+ const ids = new Set(config.spots.map((spot) => spot.id));
53
+ const acquired = new Set(
54
+ state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
238
55
  );
239
- const total = config.stamps.length;
240
- const acquired = acquiredStampIds.size;
241
- const isCompleted = total > 0 && acquired === total;
242
- const remainingStamps = config.stamps.filter((stamp) => !acquiredStampIds.has(stamp.id));
243
- const nextAvailableStamps = config.isSequential === true ? getOrderedStamps(config).filter((stamp) => !acquiredStampIds.has(stamp.id)).slice(0, 1) : remainingStamps;
56
+ const remaining = config.spots.filter((spot) => !acquired.has(spot.id));
244
57
  return {
245
- acquired,
246
- total,
247
- percentage: total === 0 ? 0 : acquired / total * 100,
248
- isCompleted,
249
- isComplete: isCompleted,
250
- nextAvailableStamps
58
+ acquired: acquired.size,
59
+ total: config.spots.length,
60
+ percentage: config.spots.length === 0 ? 0 : acquired.size / config.spots.length * 100,
61
+ isCompleted: config.spots.length > 0 && acquired.size === config.spots.length,
62
+ nextAvailableSpots: [...remaining].sort((left, right) => left.orderIndex - right.orderIndex)
251
63
  };
252
64
  }
253
65
 
@@ -283,7 +95,7 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
283
95
  }
284
96
  function issueClaimTicketNumber(reward, currentState, options = {}) {
285
97
  if (currentState.claimTicketNumber !== void 0) return currentState;
286
- const claimTicketNumber = reward.claimTicketNumber ?? createClaimTicketNumber(reward.id, options);
98
+ const claimTicketNumber = createClaimTicketNumber(reward.id, options);
287
99
  return { ...currentState, claimTicketNumber };
288
100
  }
289
101
 
@@ -352,9 +164,9 @@ function getCurrentGeoContext(options = {}) {
352
164
  try {
353
165
  navigator.geolocation.getCurrentPosition(
354
166
  (position) => {
355
- const currentLatitude = position.coords.latitude;
356
- const currentLongitude = position.coords.longitude;
357
- if (!Number.isFinite(currentLatitude) || currentLatitude < -90 || currentLatitude > 90 || !Number.isFinite(currentLongitude) || currentLongitude < -180 || currentLongitude > 180) {
167
+ const latitude = position.coords.latitude;
168
+ const longitude = position.coords.longitude;
169
+ if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
358
170
  resolve({
359
171
  ok: false,
360
172
  error: createDetectorError(
@@ -367,7 +179,7 @@ function getCurrentGeoContext(options = {}) {
367
179
  }
368
180
  resolve({
369
181
  ok: true,
370
- value: { type: "geo", currentLatitude, currentLongitude }
182
+ value: { type: "gps", latitude, longitude }
371
183
  });
372
184
  },
373
185
  (error) => {
@@ -475,7 +287,7 @@ function readNfcContext(options = {}) {
475
287
  fail(createDetectorError("nfc", "NO_TOKEN", "The NFC tag has no text token."));
476
288
  return;
477
289
  }
478
- finish({ ok: true, value: { type: "token", token } });
290
+ finish({ ok: true, value: { type: "nfc", tagId: token } });
479
291
  };
480
292
  reader.onreadingerror = (event) => fail(createDetectorError("nfc", "READ_FAILED", "The NFC tag could not be read.", event));
481
293
  try {
@@ -491,14 +303,8 @@ function normalizePasscode(input, caseSensitive = false) {
491
303
  const normalized = input.normalize("NFKC").trim();
492
304
  return caseSensitive ? normalized : normalized.toUpperCase();
493
305
  }
494
- function verifyPasscode(inputCode, condition2) {
495
- const input = normalizePasscode(inputCode, condition2.caseSensitive);
496
- const expected = normalizePasscode(condition2.passcode, condition2.caseSensitive);
497
- return input === expected ? { success: true } : {
498
- success: false,
499
- reason: "INVALID_PASSCODE",
500
- message: "The passcode is invalid."
501
- };
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." };
502
308
  }
503
309
 
504
310
  // src/detectors/qr.ts
@@ -605,7 +411,7 @@ async function readQrContext(videoElement, options = {}) {
605
411
  if (!detection.ok) return detection;
606
412
  const token = detection.value.find((barcode) => barcode.rawValue.length > 0)?.rawValue;
607
413
  if (token !== void 0) {
608
- return { ok: true, value: { type: "token", token } };
414
+ return { ok: true, value: { type: "qr", token } };
609
415
  }
610
416
  const interval = await raceWithTermination(waitForNextScan(), termination.promise);
611
417
  if (!interval.ok) return interval;
@@ -623,81 +429,48 @@ async function readQrContext(videoElement, options = {}) {
623
429
  }
624
430
 
625
431
  // src/engine/transition.ts
626
- function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now2) {
627
- const statesById = new Map(currentStates.map((state) => [state.rewardId, state]));
432
+ function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
433
+ const states = new Map(currentStates.map((state) => [state.rewardId, state]));
628
434
  return rewards.map((reward) => {
629
- const current = statesById.get(reward.id);
630
- if (current?.status === "CONSUMED" || current?.status === "EXPIRED") {
631
- return current;
632
- }
633
- if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now2)) {
435
+ const current = states.get(reward.id);
436
+ if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
437
+ if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now))
634
438
  return { rewardId: reward.id, status: "EXPIRED" };
635
- }
636
- const stockLimit = reward.stockLimit ?? reward.maxStock;
637
- if (stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= stockLimit) {
638
- return {
639
- rewardId: reward.id,
640
- status: "EXPIRED",
641
- ...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
642
- };
643
- }
644
- if (acquiredStampCount >= reward.requiredStampCount) {
645
- if (current?.status === "AVAILABLE") return current;
439
+ if (reward.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward.stockLimit)
440
+ return { rewardId: reward.id, status: "EXPIRED" };
441
+ if (acquiredStampCount >= reward.requiredStampCount)
646
442
  return {
647
443
  rewardId: reward.id,
648
444
  status: "AVAILABLE",
649
- unlockedAt: current?.unlockedAt ?? now2,
650
- ...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
445
+ ...current?.unlockedAt === void 0 ? { unlockedAt: now } : { unlockedAt: current.unlockedAt }
651
446
  };
652
- }
653
- if (current?.status === "LOCKED" && current.unlockedAt === void 0) return current;
654
447
  return { rewardId: reward.id, status: "LOCKED" };
655
448
  });
656
449
  }
657
450
  function consumeReward(params) {
658
451
  const { reward, currentState } = params;
659
- if (currentState.status === "CONSUMED") {
660
- return {
661
- ok: false,
662
- error: { code: "ALREADY_CONSUMED", rewardId: reward.id }
663
- };
664
- }
665
- if (currentState.status !== "AVAILABLE") {
666
- return {
667
- ok: false,
668
- error: { code: "NOT_AVAILABLE", rewardId: reward.id }
669
- };
670
- }
671
- if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now)) {
672
- return { ok: false, error: { code: "EXPIRED", reason: "EXPIRED", rewardId: reward.id } };
673
- }
674
- const stockLimit = reward.stockLimit ?? reward.maxStock;
675
- const userClaimLimit = reward.userClaimLimit ?? reward.limitPerUser;
676
- if (stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= stockLimit) {
452
+ if (currentState.status === "CONSUMED")
453
+ return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward.id } };
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)
677
459
  return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
678
- }
679
- if (userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= userClaimLimit) {
680
- return {
681
- ok: false,
682
- error: { code: "USER_LIMIT_REACHED", reason: "LIMIT_EXCEEDED", rewardId: reward.id }
683
- };
684
- }
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 } };
685
462
  if (reward.redemptionMethod === "staff_passcode") {
686
- const passcodeResult = reward.staffPasscode === void 0 ? null : verifyPasscode(params.inputPasscode ?? "", { passcode: reward.staffPasscode });
687
- if (passcodeResult === null || !passcodeResult.success) {
463
+ if (reward.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward.staffPasscode }).success)
688
464
  return {
689
465
  ok: false,
690
466
  error: {
691
467
  code: "INVALID_PASSCODE",
692
468
  rewardId: reward.id,
693
- message: passcodeResult?.message ?? "The passcode is invalid."
469
+ message: "The passcode is invalid."
694
470
  }
695
471
  };
696
- }
697
- }
698
- if (reward.redemptionMethod === "view_only") {
699
- return { ok: true, value: currentState };
700
472
  }
473
+ if (reward.redemptionMethod === "view_only") return { ok: true, value: currentState };
701
474
  return {
702
475
  ok: true,
703
476
  value: {
@@ -705,71 +478,40 @@ function consumeReward(params) {
705
478
  status: "CONSUMED",
706
479
  consumedAt: params.now,
707
480
  claimTicketNumber: createUniqueClaimTicketNumber(reward.id, params.now),
708
- ...stockLimit !== void 0 || params.userId !== void 0 || params.userRedemptionCount !== void 0 ? { redeemedCount: (currentState.redeemedCount ?? 0) + 1 } : {},
709
- ...params.userId === void 0 ? {} : { userRedemptionCount: (currentState.userRedemptionCount ?? 0) + 1 },
481
+ redeemedCount: (currentState.redeemedCount ?? 0) + 1,
710
482
  ...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
711
483
  }
712
484
  };
713
485
  }
714
- function processStamp(state, config, targetStampId, context, now2) {
715
- const targetStamp = config.stamps.find((stamp) => stamp.id === targetStampId);
716
- if (targetStamp === void 0) {
717
- return { ok: false, error: { code: "STAMP_NOT_FOUND", stampId: targetStampId } };
718
- }
719
- const acquiredStampIds = new Set(state.records.map((record2) => record2.stampId));
720
- if (acquiredStampIds.has(targetStampId)) {
721
- return {
722
- ok: false,
723
- error: { code: "STAMP_ALREADY_ACQUIRED", stampId: targetStampId }
724
- };
725
- }
726
- if (config.isSequential === true) {
727
- const expectedStamp = getOrderedStamps(config).find((stamp) => !acquiredStampIds.has(stamp.id));
728
- if (expectedStamp !== void 0 && expectedStamp.id !== targetStampId) {
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 } };
489
+ if (state.records.some((record2) => record2.stampId === spotId))
490
+ return { ok: false, error: { code: "STAMP_ALREADY_ACQUIRED", spotId } };
491
+ const acquired = new Set(state.records.map((record2) => record2.stampId));
492
+ if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
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)
729
496
  return {
730
497
  ok: false,
731
- error: {
732
- code: "INVALID_ORDER",
733
- stampId: targetStampId,
734
- expectedStampId: expectedStamp.id
735
- }
498
+ error: condition.type === "custom" ? {
499
+ code: "CUSTOM_VALIDATION_FAILED",
500
+ spotId,
501
+ message: "Custom validation requires an async validator."
502
+ } : { code: "INVALID_PROOF", spotId }
736
503
  };
737
- }
738
504
  }
739
- const conditionResult = evaluateConditionDetailed(targetStamp.condition, context, now2);
740
- if (!conditionResult.ok) {
741
- return {
742
- ok: false,
743
- error: {
744
- code: "CONDITION_MISMATCH",
745
- stampId: targetStampId,
746
- mismatch: conditionResult.error
747
- }
748
- };
749
- }
750
- const record = { stampId: targetStampId, acquiredAt: now2 };
751
- const nextRecords = [...state.records, record];
752
- const nextRewards = config.rewards === void 0 && state.rewards === void 0 ? void 0 : reconcileRewardStates(config.rewards ?? [], state.rewards ?? [], nextRecords.length, now2);
753
- const nextState = {
754
- ...state,
755
- records: nextRecords,
756
- ...nextRewards === void 0 ? {} : { rewards: nextRewards },
757
- updatedAt: now2
758
- };
759
- const events = [{ type: "stampAcquired", record }];
760
- if (nextRewards !== void 0) {
761
- for (const rewardState of nextRewards) {
762
- const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
763
- if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
764
- events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now2 });
765
- }
505
+ const record = { stampId: spotId, acquiredAt: now };
506
+ const records = [...state.records, record];
507
+ const rewards = reconcileRewardStates(config.rewards, state.rewards, records.length, now);
508
+ return {
509
+ ok: true,
510
+ value: {
511
+ nextState: { ...state, records, rewards, updatedAt: now },
512
+ events: [{ type: "stampAcquired", record }]
766
513
  }
767
- }
768
- const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
769
- if (completed) {
770
- events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now2 });
771
- }
772
- return { ok: true, value: { nextState, events } };
514
+ };
773
515
  }
774
516
 
775
517
  // src/client/storage.ts
@@ -815,7 +557,7 @@ function isRewardState(value) {
815
557
  function isStampRallyState(value) {
816
558
  if (typeof value !== "object" || value === null) return false;
817
559
  const state = value;
818
- return typeof state.rallyId === "string" && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
560
+ return typeof state.rallyId === "string" && (typeof state.userId === "string" || state.userId === null) && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
819
561
  }
820
562
  function isValidDate(value) {
821
563
  return typeof value === "string" && !Number.isNaN(Date.parse(value));
@@ -826,7 +568,7 @@ function isSnapshotRecord(value) {
826
568
  function isRallySnapshot(value) {
827
569
  if (typeof value !== "object" || value === null) return false;
828
570
  const snapshot = value;
829
- return snapshot.version === 1 && typeof snapshot.rallyId === "string" && Array.isArray(snapshot.stamps) && snapshot.stamps.every(isSnapshotRecord) && Array.isArray(snapshot.rewards) && snapshot.rewards.every(isRewardState) && isValidDate(snapshot.exportedAt);
571
+ return snapshot.version === 1 && typeof snapshot.rallyId === "string" && (typeof snapshot.userId === "string" || snapshot.userId === null) && Array.isArray(snapshot.records) && snapshot.records.every(isSnapshotRecord) && Array.isArray(snapshot.rewards) && snapshot.rewards.every(isRewardState) && isValidDate(snapshot.exportedAt);
830
572
  }
831
573
  function exportProgressToken(snapshot) {
832
574
  return globalThis.btoa(encodeURIComponent(JSON.stringify(snapshot)));
@@ -837,7 +579,7 @@ function importProgressToken(token, currentRallyId) {
837
579
  if (!isRallySnapshot(parsed) || parsed.rallyId !== currentRallyId) return null;
838
580
  return {
839
581
  ...parsed,
840
- stamps: parsed.stamps.map(cloneRecord),
582
+ records: parsed.records.map(cloneRecord),
841
583
  rewards: parsed.rewards.map(cloneRewardState)
842
584
  };
843
585
  } catch {
@@ -846,17 +588,20 @@ function importProgressToken(token, currentRallyId) {
846
588
  }
847
589
  var InMemoryStorage = class {
848
590
  #states = /* @__PURE__ */ new Map();
849
- async load(rallyId) {
850
- const state = this.#states.get(rallyId);
591
+ async load(rallyId, userId) {
592
+ const state = this.#states.get(storageKey(rallyId, userId));
851
593
  return state === void 0 ? null : cloneState(state);
852
594
  }
853
595
  async save(state) {
854
- this.#states.set(state.rallyId, cloneState(state));
596
+ this.#states.set(storageKey(state.rallyId, state.userId), cloneState(state));
855
597
  }
856
- async remove(rallyId) {
857
- this.#states.delete(rallyId);
598
+ async remove(rallyId, userId) {
599
+ this.#states.delete(storageKey(rallyId, userId));
858
600
  }
859
601
  };
602
+ function storageKey(rallyId, userId) {
603
+ return `stamprally:${rallyId}:${userId ?? "anonymous"}`;
604
+ }
860
605
  var defaultStorageWarningHandler = (error) => {
861
606
  console.warn(`[@stamprally/core] ${error.message}`, error);
862
607
  };
@@ -873,10 +618,10 @@ var LocalStorageAdapter = class {
873
618
  this.#failureMode = options.failureMode ?? "fallback";
874
619
  this.#onWarning = options.onWarning ?? defaultStorageWarningHandler;
875
620
  }
876
- async load(rallyId) {
877
- if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId);
621
+ async load(rallyId, userId) {
622
+ if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId, userId);
878
623
  try {
879
- const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId));
624
+ const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId, userId));
880
625
  if (serialized === null) return null;
881
626
  let parsed;
882
627
  try {
@@ -907,7 +652,7 @@ var LocalStorageAdapter = class {
907
652
  `Failed to read rally '${rallyId}' from localStorage.`,
908
653
  rallyId
909
654
  ),
910
- () => this.#fallbackStorage.load(rallyId)
655
+ () => this.#fallbackStorage.load(rallyId, userId)
911
656
  );
912
657
  }
913
658
  }
@@ -915,7 +660,7 @@ var LocalStorageAdapter = class {
915
660
  if (this.#isFallbackActive) return this.#fallbackStorage.save(state);
916
661
  try {
917
662
  this.#getStorage("save", state.rallyId).setItem(
918
- this.#key(state.rallyId),
663
+ this.#key(state.rallyId, state.userId),
919
664
  JSON.stringify(state)
920
665
  );
921
666
  } catch (cause) {
@@ -931,10 +676,10 @@ var LocalStorageAdapter = class {
931
676
  );
932
677
  }
933
678
  }
934
- async remove(rallyId) {
935
- if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId);
679
+ async remove(rallyId, userId) {
680
+ if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId, userId);
936
681
  try {
937
- this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId));
682
+ this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId, userId));
938
683
  } catch (cause) {
939
684
  return this.#handleFailure(
940
685
  this.#normalizeError(
@@ -944,12 +689,12 @@ var LocalStorageAdapter = class {
944
689
  `Failed to remove rally '${rallyId}' from localStorage.`,
945
690
  rallyId
946
691
  ),
947
- () => this.#fallbackStorage.remove(rallyId)
692
+ () => this.#fallbackStorage.remove(rallyId, userId)
948
693
  );
949
694
  }
950
695
  }
951
- #key(rallyId) {
952
- return `${this.#keyPrefix}${rallyId}`;
696
+ #key(rallyId, userId) {
697
+ return `${this.#keyPrefix}${rallyId}:${userId ?? "anonymous"}`;
953
698
  }
954
699
  #getStorage(operation, rallyId) {
955
700
  if (this.#providedStorage === null) {
@@ -1009,12 +754,12 @@ var IndexedDBAdapter = class {
1009
754
  this.#providedFactory = options.indexedDB;
1010
755
  this.#databaseName = options.databaseName ?? "stamprally";
1011
756
  }
1012
- async load(rallyId) {
757
+ async load(rallyId, userId) {
1013
758
  const database = await this.#openDatabase(rallyId);
1014
759
  return new Promise((resolve, reject) => {
1015
760
  try {
1016
761
  const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readonly");
1017
- const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(rallyId);
762
+ const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(storageKey(rallyId, userId));
1018
763
  request.onsuccess = () => {
1019
764
  const value = request.result;
1020
765
  if (value === void 0) {
@@ -1061,7 +806,7 @@ var IndexedDBAdapter = class {
1061
806
  return new Promise((resolve, reject) => {
1062
807
  try {
1063
808
  const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
1064
- transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), state.rallyId);
809
+ transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), storageKey(state.rallyId, state.userId));
1065
810
  transaction.oncomplete = () => resolve();
1066
811
  transaction.onerror = () => {
1067
812
  reject(
@@ -1086,12 +831,12 @@ var IndexedDBAdapter = class {
1086
831
  }
1087
832
  });
1088
833
  }
1089
- async remove(rallyId) {
834
+ async remove(rallyId, userId) {
1090
835
  const database = await this.#openDatabase(rallyId);
1091
836
  return new Promise((resolve, reject) => {
1092
837
  try {
1093
838
  const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
1094
- transaction.objectStore(INDEXED_DB_STORE_NAME).delete(rallyId);
839
+ transaction.objectStore(INDEXED_DB_STORE_NAME).delete(storageKey(rallyId, userId));
1095
840
  transaction.oncomplete = () => resolve();
1096
841
  transaction.onerror = () => {
1097
842
  reject(
@@ -1199,255 +944,60 @@ var IndexedDBAdapter = class {
1199
944
  };
1200
945
 
1201
946
  // src/client/client.ts
1202
- var systemClock = () => (/* @__PURE__ */ new Date()).toISOString();
1203
- var StampRallyClient = class {
1204
- #listeners = /* @__PURE__ */ new Set();
1205
- #eventListeners = /* @__PURE__ */ new Set();
1206
- #config;
1207
- #storage;
1208
- #clock;
1209
- #currentState = null;
1210
- #initialization = null;
1211
- #operationQueue = Promise.resolve();
1212
- constructor(config, storage, clock = systemClock) {
1213
- this.#config = config;
1214
- this.#storage = storage;
1215
- this.#clock = clock;
1216
- }
1217
- getState() {
1218
- return this.#currentState;
1219
- }
1220
- getConfig() {
1221
- return this.#config;
1222
- }
1223
- subscribe(listener, options = {}) {
1224
- if (options.events === true) {
1225
- this.#eventListeners.add(listener);
1226
- return () => this.#eventListeners.delete(listener);
1227
- }
1228
- this.#listeners.add(listener);
1229
- return () => {
1230
- this.#listeners.delete(listener);
1231
- };
1232
- }
1233
- subscribeEvents(listener) {
1234
- this.#eventListeners.add(listener);
1235
- return () => this.#eventListeners.delete(listener);
1236
- }
1237
- async updateConfig(newConfig) {
1238
- return this.#enqueue(async () => {
1239
- const current = await this.initialize();
1240
- this.#config = newConfig;
1241
- const next = this.#reconcileState(cloneState(current), this.#clock());
1242
- await this.#storage.save(next);
1243
- this.#currentState = next;
1244
- this.#initialization = Promise.resolve(next);
1245
- this.#emit(next);
1246
- return next;
1247
- });
1248
- }
1249
- notifyRewardClaimed(rewardId, state = this.#currentState) {
1250
- if (state !== null) this.#emitEvent({ type: "rewardClaimed", rewardId, state });
1251
- }
1252
- notifySyncCompleted(state = this.#currentState) {
1253
- if (state !== null) this.#emitEvent({ type: "syncCompleted", state });
1254
- }
1255
- init() {
1256
- return this.initialize();
1257
- }
1258
- initialize() {
1259
- if (this.#currentState !== null) {
1260
- return Promise.resolve(this.#currentState);
1261
- }
1262
- if (this.#initialization === null) {
1263
- this.#initialization = this.#storage.load(this.#config.id).then((storedState) => {
1264
- const state = storedState === null ? this.#createEmptyState(this.#clock()) : this.#reconcileState(cloneState(storedState), storedState.updatedAt);
1265
- this.#currentState = state;
1266
- this.#emit(state);
1267
- return state;
1268
- }).catch((error) => {
1269
- this.#initialization = null;
1270
- throw error;
1271
- });
1272
- }
1273
- return this.#initialization;
1274
- }
1275
- acquire(stampId, context, now2 = this.#clock()) {
1276
- return this.#enqueue(async () => {
1277
- const currentState = await this.initialize();
1278
- const result = processStamp(currentState, this.#config, stampId, context, now2);
1279
- if (!result.ok) {
1280
- this.#emitEvent({ type: "error", error: result.error });
1281
- return result;
1282
- }
1283
- await this.#storage.save(result.value.nextState);
1284
- this.#currentState = result.value.nextState;
1285
- this.#emit(result.value.nextState);
1286
- this.#emitEvent({ type: "checkIn", stampId, state: result.value.nextState });
1287
- return result;
1288
- });
1289
- }
1290
- reset(now2 = this.#clock()) {
1291
- return this.#enqueue(async () => {
1292
- const initialization = this.#initialization;
1293
- if (initialization !== null) {
1294
- await initialization.catch(() => void 0);
1295
- }
1296
- await this.#storage.remove(this.#config.id);
1297
- const nextState = this.#createEmptyState(now2);
1298
- this.#currentState = nextState;
1299
- this.#initialization = Promise.resolve(nextState);
1300
- this.#emit(nextState);
1301
- return nextState;
1302
- });
1303
- }
1304
- restore(state) {
1305
- return this.#enqueue(async () => {
1306
- const initialization = this.#initialization;
1307
- if (initialization !== null) {
1308
- await initialization.catch(() => void 0);
1309
- }
1310
- if (state.rallyId !== this.#config.id) {
1311
- throw new Error(
1312
- `Cannot restore rally '${state.rallyId}' into client '${this.#config.id}'.`
1313
- );
1314
- }
1315
- const nextState = this.#reconcileState(cloneState(state), state.updatedAt);
1316
- await this.#storage.save(nextState);
1317
- this.#currentState = nextState;
1318
- this.#initialization = Promise.resolve(nextState);
1319
- this.#emit(nextState);
1320
- return nextState;
1321
- });
1322
- }
1323
- #enqueue(operation) {
1324
- const next = this.#operationQueue.then(operation, operation);
1325
- this.#operationQueue = next.then(
1326
- () => void 0,
1327
- () => void 0
1328
- );
1329
- return next;
1330
- }
1331
- #emit(state) {
1332
- for (const listener of this.#listeners) {
1333
- listener(state);
1334
- }
1335
- }
1336
- #emitEvent(event) {
1337
- for (const listener of this.#eventListeners) listener(event);
1338
- }
1339
- #createEmptyState(now2) {
1340
- const state = {
1341
- rallyId: this.#config.id,
1342
- records: [],
1343
- ...this.#config.rewards === void 0 ? {} : {
1344
- rewards: reconcileRewardStates(this.#config.rewards, [], 0, now2)
1345
- },
1346
- updatedAt: now2
1347
- };
1348
- return state;
1349
- }
1350
- #reconcileState(state, now2) {
1351
- const configuredStampIds = new Set(this.#config.stamps.map((stamp) => stamp.id));
1352
- const seenStampIds = /* @__PURE__ */ new Set();
1353
- const records = state.records.filter((record) => {
1354
- if (!configuredStampIds.has(record.stampId) || seenStampIds.has(record.stampId)) return false;
1355
- seenStampIds.add(record.stampId);
1356
- return true;
1357
- });
1358
- if (this.#config.rewards === void 0 && state.rewards === void 0) {
1359
- return records.length === state.records.length ? state : { ...state, records };
1360
- }
1361
- return {
1362
- ...state,
1363
- records,
1364
- rewards: reconcileRewardStates(
1365
- this.#config.rewards ?? [],
1366
- state.rewards ?? [],
1367
- records.length,
1368
- now2
1369
- )
1370
- };
1371
- }
1372
- };
1373
-
1374
- // src/client/universalClient.ts
1375
947
  function isStorage(value) {
1376
948
  return "load" in value && "save" in value && "remove" in value;
1377
949
  }
1378
- function randomId(prefix) {
950
+ function id(prefix) {
1379
951
  return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
1380
952
  }
1381
- function now() {
1382
- return (/* @__PURE__ */ new Date()).toISOString();
1383
- }
1384
- function text(value) {
1385
- if (typeof value === "string") return value;
1386
- if (typeof value === "object" && value !== null) {
1387
- const first = Object.values(value).find((item) => typeof item === "string");
1388
- if (typeof first === "string") return first;
1389
- }
1390
- return "";
1391
- }
1392
- function proofString(value) {
953
+ function proof(value) {
1393
954
  if (typeof value === "string") return value;
1394
955
  if (typeof value === "object" && value !== null) {
1395
- const record = value;
1396
- for (const key of ["token", "code", "passcode", "value"]) {
1397
- if (typeof record[key] === "string") return record[key];
1398
- }
956
+ const item = value;
957
+ for (const key of ["token", "code", "passcode", "value", "tagId"])
958
+ if (typeof item[key] === "string") return item[key];
1399
959
  }
1400
960
  return "";
1401
961
  }
1402
- function distanceMeters(aLat, aLon, bLat, bLon) {
1403
- const radians = (degrees) => degrees * Math.PI / 180;
1404
- const dLat = radians(bLat - aLat);
1405
- const dLon = radians(bLon - aLon);
1406
- const value = Math.sin(dLat / 2) ** 2 + Math.cos(radians(aLat)) * Math.cos(radians(bLat)) * Math.sin(dLon / 2) ** 2;
1407
- return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
1408
- }
1409
- function conditionMatches(condition2, proofData) {
1410
- switch (condition2.type) {
1411
- case "qr":
1412
- return proofString(proofData).trim() !== "";
1413
- case "passcode":
1414
- return proofString(proofData).trim() !== "";
1415
- case "gps": {
1416
- if (typeof proofData !== "object" || proofData === null) return false;
1417
- const value = proofData;
1418
- const latitude = typeof value.latitude === "number" ? value.latitude : Number.NaN;
1419
- const longitude = typeof value.longitude === "number" ? value.longitude : Number.NaN;
1420
- return Number.isFinite(latitude) && Number.isFinite(longitude) && distanceMeters(condition2.latitude, condition2.longitude, latitude, longitude) <= condition2.radiusMeters;
1421
- }
1422
- case "custom":
1423
- return true;
1424
- }
1425
- }
1426
- function asReward(reward) {
1427
- return { ...reward, description: text(reward.description) };
1428
- }
1429
- function initialState(config, timestamp) {
1430
- const rewards = config.rewards.map(asReward);
962
+ function matches(condition, value) {
963
+ if (condition.type === "gps") {
964
+ if (typeof value !== "object" || value === null) return false;
965
+ const item = value;
966
+ const latitude = item.latitude;
967
+ const longitude = item.longitude;
968
+ if (typeof latitude !== "number" || typeof longitude !== "number") return false;
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;
974
+ }
975
+ return proof(value).trim() !== "";
976
+ }
977
+ function emptyState(config, userId, now) {
1431
978
  return {
1432
979
  rallyId: config.id,
980
+ userId,
1433
981
  records: [],
1434
- rewards: reconcileRewardStates(rewards, [], 0, timestamp),
1435
- updatedAt: timestamp
982
+ rewards: reconcileRewardStates(config.rewards, [], 0, now),
983
+ updatedAt: now
1436
984
  };
1437
985
  }
1438
- var UniversalStampRallyClient = class {
986
+ var StampRallyClient = class {
1439
987
  #listeners = /* @__PURE__ */ new Set();
1440
988
  #eventListeners = /* @__PURE__ */ new Set();
1441
- #config;
1442
989
  #storage;
1443
990
  #options;
991
+ #config;
992
+ #userId;
1444
993
  #state = null;
1445
994
  #initialization = null;
1446
995
  #queue = Promise.resolve();
1447
- constructor(config, storageOrOptions = {}, clock) {
996
+ constructor(config, storageOrOptions = {}) {
1448
997
  this.#config = config;
1449
- this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions, ...clock === void 0 ? {} : { clock } } : storageOrOptions;
998
+ this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions } : storageOrOptions;
1450
999
  this.#storage = this.#options.storage ?? new InMemoryStorage();
1000
+ this.#userId = this.#options.userId ?? null;
1451
1001
  }
1452
1002
  getConfig() {
1453
1003
  return this.#config;
@@ -1455,6 +1005,9 @@ var UniversalStampRallyClient = class {
1455
1005
  getState() {
1456
1006
  return this.#state;
1457
1007
  }
1008
+ getUserId() {
1009
+ return this.#userId;
1010
+ }
1458
1011
  subscribe(listener) {
1459
1012
  this.#listeners.add(listener);
1460
1013
  return () => this.#listeners.delete(listener);
@@ -1469,11 +1022,11 @@ var UniversalStampRallyClient = class {
1469
1022
  initialize() {
1470
1023
  if (this.#state !== null) return Promise.resolve(this.#state);
1471
1024
  if (this.#initialization === null) {
1472
- this.#initialization = this.#storage.load(this.#config.id).then((stored) => {
1473
- const state = stored === null ? initialState(this.#config, this.#now()) : this.#reconcile(stored);
1474
- this.#state = state;
1475
- this.#emit(state);
1476
- return state;
1025
+ this.#initialization = this.#storage.load(this.#config.id, this.#userId).then((state) => {
1026
+ const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
1027
+ this.#state = next;
1028
+ this.#emit(next);
1029
+ return next;
1477
1030
  }).catch((error) => {
1478
1031
  this.#initialization = null;
1479
1032
  throw error;
@@ -1481,6 +1034,26 @@ var UniversalStampRallyClient = class {
1481
1034
  }
1482
1035
  return this.#initialization;
1483
1036
  }
1037
+ switchUser(newUserId) {
1038
+ return this.#enqueue(async () => {
1039
+ if (this.#userId === newUserId && this.#state !== null) return this.#state;
1040
+ this.#userId = newUserId;
1041
+ this.#state = null;
1042
+ this.#initialization = null;
1043
+ return this.initialize();
1044
+ });
1045
+ }
1046
+ async getUserState(rallyId, userId) {
1047
+ return this.#storage.load(rallyId, userId);
1048
+ }
1049
+ clearUserState(userId = this.#userId) {
1050
+ return this.#enqueue(async () => {
1051
+ await this.#storage.remove(this.#config.id, userId);
1052
+ if (userId === this.#userId) {
1053
+ await this.#initializeFresh();
1054
+ }
1055
+ });
1056
+ }
1484
1057
  checkIn(spotId, proofData, options = {}) {
1485
1058
  return this.#enqueue(async () => {
1486
1059
  const current = await this.initialize();
@@ -1494,70 +1067,61 @@ var UniversalStampRallyClient = class {
1494
1067
  message: "Spot was already claimed."
1495
1068
  });
1496
1069
  const acquired = new Set(current.records.map((record2) => record2.stampId));
1497
- if (spot.prerequisites?.some((id) => !acquired.has(id)))
1070
+ if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
1498
1071
  return this.#fail({
1499
1072
  code: "PREREQUISITES_NOT_MET",
1500
1073
  spotId,
1501
1074
  message: "Prerequisite spots are not complete."
1502
1075
  });
1503
- for (const condition2 of spot.conditions) {
1504
- if (condition2.type === "custom") {
1505
- const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
1506
- if (validator !== void 0) {
1507
- try {
1508
- const validation = await validator({
1509
- spotId,
1510
- proofData,
1511
- config: this.#config,
1512
- userState: current
1513
- });
1514
- if (!validation.success)
1515
- return this.#fail({
1516
- code: "CUSTOM_VALIDATION_FAILED",
1517
- spotId,
1518
- message: validation.error ?? "Custom validation failed."
1519
- });
1520
- } catch (error) {
1521
- return this.#fail({
1522
- code: "CUSTOM_VALIDATION_FAILED",
1523
- spotId,
1524
- message: error instanceof Error ? error.message : "Custom validation failed."
1525
- });
1526
- }
1527
- } else {
1076
+ for (const condition of spot.conditions) {
1077
+ if (condition.type === "custom") {
1078
+ const validator = this.#options.customValidators?.[condition.validatorName] ?? this.#options.customValidator;
1079
+ if (validator === void 0)
1528
1080
  return this.#fail({
1529
1081
  code: "CUSTOM_VALIDATION_FAILED",
1530
1082
  spotId,
1531
1083
  message: "No custom validator is registered."
1532
1084
  });
1533
- }
1534
- } else if (!conditionMatches(condition2, proofData)) {
1085
+ const context = {
1086
+ rallyId: this.#config.id,
1087
+ spotId,
1088
+ proofData,
1089
+ condition: { type: "custom", validatorName: condition.validatorName },
1090
+ userState: current
1091
+ };
1092
+ const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
1093
+ if (result === false || typeof result === "object" && !result.valid)
1094
+ return this.#fail({
1095
+ code: "CUSTOM_VALIDATION_FAILED",
1096
+ spotId,
1097
+ message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
1098
+ });
1099
+ } else if (!matches(condition, proofData))
1535
1100
  return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
1536
- }
1537
1101
  }
1538
- const timestamp = options.now ?? this.#now();
1102
+ const now = options.now ?? this.#now();
1539
1103
  const request = {
1540
1104
  rallyId: this.#config.id,
1105
+ userId: this.#userId,
1541
1106
  spotId,
1542
1107
  proofData,
1543
- idempotencyKey: options.idempotencyKey ?? randomId("check-in"),
1544
- now: timestamp,
1108
+ idempotencyKey: options.idempotencyKey ?? id("check-in"),
1109
+ now,
1545
1110
  state: current
1546
1111
  };
1547
1112
  const remote = this.#options.syncAdapter?.checkIn;
1548
1113
  if (options.sync !== false && remote !== void 0) {
1549
1114
  const result = await remote(request);
1550
- if (!result.ok) return this.#fail(result.error);
1551
- return this.#commitCheckIn(result.value.state, result);
1115
+ return result.ok ? this.#commitCheckIn(result) : this.#fail(result.error);
1552
1116
  }
1553
- const record = { stampId: spotId, acquiredAt: timestamp };
1117
+ const record = { stampId: spotId, acquiredAt: now };
1554
1118
  const next = this.#reconcile({
1555
1119
  ...current,
1556
1120
  records: [...current.records, record],
1557
- updatedAt: timestamp
1121
+ updatedAt: now
1558
1122
  });
1559
1123
  await this.#storage.save(next);
1560
- return this.#commitCheckIn(next, { ok: true, value: { state: next, record } });
1124
+ return this.#commitCheckIn({ ok: true, value: { state: next, record } });
1561
1125
  });
1562
1126
  }
1563
1127
  claimReward(rewardId, options = {}) {
@@ -1566,107 +1130,128 @@ var UniversalStampRallyClient = class {
1566
1130
  const configured = this.#config.rewards.find((item) => item.id === rewardId);
1567
1131
  if (configured === void 0)
1568
1132
  return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
1569
- const timestamp = options.now ?? this.#now();
1570
- const state = current.rewards?.find((item) => item.rewardId === rewardId) ?? {
1133
+ const now = options.now ?? this.#now();
1134
+ const state = current.rewards.find((item) => item.rewardId === rewardId) ?? {
1571
1135
  rewardId,
1572
1136
  status: "LOCKED"
1573
1137
  };
1574
1138
  const local = consumeReward({
1575
- reward: asReward(configured),
1139
+ reward: configured,
1576
1140
  currentState: state,
1577
- now: timestamp,
1141
+ now,
1578
1142
  ...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
1579
1143
  ...options.staffId === void 0 ? {} : { staffId: options.staffId }
1580
1144
  });
1581
1145
  if (!local.ok) return this.#fail(local.error);
1582
1146
  const request = {
1583
1147
  rallyId: this.#config.id,
1148
+ userId: this.#userId,
1584
1149
  rewardId,
1585
- idempotencyKey: options.idempotencyKey ?? randomId("claim"),
1586
- now: timestamp,
1150
+ idempotencyKey: options.idempotencyKey ?? id("claim"),
1151
+ now,
1587
1152
  options,
1588
1153
  state: current
1589
1154
  };
1590
1155
  const remote = this.#options.syncAdapter?.claimReward;
1591
1156
  if (options.sync !== false && remote !== void 0) {
1592
1157
  const result = await remote(request);
1593
- if (!result.ok) return this.#fail(result.error);
1594
- return this.#commitClaim(result.value.state, result);
1158
+ return result.ok ? this.#commitClaim(result) : this.#fail(result.error);
1595
1159
  }
1596
- const nextRewards = (current.rewards ?? []).map(
1597
- (item) => item.rewardId === rewardId ? local.value : item
1598
- );
1599
- const next = { ...current, rewards: nextRewards, updatedAt: timestamp };
1160
+ const next = {
1161
+ ...current,
1162
+ rewards: current.rewards.map((item) => item.rewardId === rewardId ? local.value : item),
1163
+ updatedAt: now
1164
+ };
1600
1165
  await this.#storage.save(next);
1601
- return this.#commitClaim(next, { ok: true, value: { state: next, reward: local.value } });
1166
+ return this.#commitClaim({ ok: true, value: { state: next, reward: local.value } });
1602
1167
  });
1603
1168
  }
1604
- sync(adapter = this.#options.syncAdapter ?? {}) {
1169
+ sync(adapter = this.#options.syncAdapter) {
1605
1170
  return this.#enqueue(async () => {
1606
1171
  const current = await this.initialize();
1607
- if (adapter.sync === void 0) {
1172
+ if (adapter?.sync === void 0) {
1608
1173
  this.#emitEvent({ type: "sync", state: current });
1609
1174
  return;
1610
1175
  }
1611
- try {
1612
- const next = this.#reconcile(
1613
- await adapter.sync({ rallyId: this.#config.id, state: current })
1614
- );
1615
- await this.#storage.save(next);
1616
- this.#state = next;
1617
- this.#emit(next);
1618
- this.#emitEvent({ type: "sync", state: next });
1619
- } catch (error) {
1620
- const failure = {
1621
- code: "SYNC_FAILED",
1622
- message: error instanceof Error ? error.message : String(error)
1623
- };
1624
- this.#emitEvent({ type: "error", error: failure });
1625
- throw error;
1626
- }
1176
+ const next = this.#reconcile(
1177
+ await adapter.sync({ rallyId: this.#config.id, userId: this.#userId, state: current })
1178
+ );
1179
+ await this.#storage.save(next);
1180
+ this.#state = next;
1181
+ this.#emit(next);
1182
+ this.#emitEvent({ type: "sync", state: next });
1627
1183
  });
1628
1184
  }
1629
- #now() {
1630
- return this.#options.clock?.() ?? now();
1185
+ reset() {
1186
+ return this.#enqueue(async () => {
1187
+ await this.#storage.remove(this.#config.id, this.#userId);
1188
+ return this.#initializeFresh();
1189
+ });
1190
+ }
1191
+ restore(state) {
1192
+ return this.#enqueue(async () => {
1193
+ if (state.rallyId !== this.#config.id || state.userId !== this.#userId)
1194
+ throw new Error("State belongs to another rally or user.");
1195
+ const next = this.#reconcile(state);
1196
+ await this.#storage.save(next);
1197
+ this.#state = next;
1198
+ this.#initialization = Promise.resolve(next);
1199
+ this.#emit(next);
1200
+ return next;
1201
+ });
1202
+ }
1203
+ #enqueue(operation) {
1204
+ const next = this.#queue.then(operation, operation);
1205
+ this.#queue = next.then(
1206
+ () => void 0,
1207
+ () => void 0
1208
+ );
1209
+ return next;
1210
+ }
1211
+ #initializeFresh() {
1212
+ const next = emptyState(this.#config, this.#userId, this.#now());
1213
+ this.#state = next;
1214
+ this.#initialization = Promise.resolve(next);
1215
+ this.#emit(next);
1216
+ return Promise.resolve(next);
1631
1217
  }
1632
1218
  #reconcile(state) {
1633
1219
  const ids = new Set(this.#config.spots.map((spot) => spot.id));
1634
- const seen = /* @__PURE__ */ new Set();
1635
1220
  const records = state.records.filter(
1636
- (record) => ids.has(record.stampId) && !seen.has(record.stampId) && seen.add(record.stampId)
1221
+ (record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
1637
1222
  );
1638
1223
  return {
1639
1224
  ...cloneState(state),
1225
+ userId: this.#userId,
1640
1226
  records,
1641
1227
  rewards: reconcileRewardStates(
1642
- this.#config.rewards.map(asReward),
1643
- state.rewards ?? [],
1228
+ this.#config.rewards,
1229
+ state.rewards,
1644
1230
  records.length,
1645
1231
  state.updatedAt
1646
1232
  )
1647
1233
  };
1648
1234
  }
1649
- #enqueue(operation) {
1650
- const next = this.#queue.then(operation, operation);
1651
- this.#queue = next.then(
1652
- () => void 0,
1653
- () => void 0
1654
- );
1655
- return next;
1235
+ #now() {
1236
+ return this.#options.clock?.() ?? (/* @__PURE__ */ new Date()).toISOString();
1656
1237
  }
1657
1238
  #fail(error) {
1658
1239
  this.#emitEvent({ type: "error", error });
1659
1240
  return { ok: false, error };
1660
1241
  }
1661
- #commitCheckIn(state, result) {
1662
- this.#state = state;
1663
- this.#emit(state);
1242
+ #commitCheckIn(result) {
1243
+ if (result.ok) {
1244
+ this.#state = result.value.state;
1245
+ this.#emit(this.#state);
1246
+ }
1664
1247
  this.#emitEvent({ type: "checkIn", result });
1665
1248
  return result;
1666
1249
  }
1667
- #commitClaim(state, result) {
1668
- this.#state = state;
1669
- this.#emit(state);
1250
+ #commitClaim(result) {
1251
+ if (result.ok) {
1252
+ this.#state = result.value.state;
1253
+ this.#emit(this.#state);
1254
+ }
1670
1255
  this.#emitEvent({ type: "rewardClaimed", result });
1671
1256
  return result;
1672
1257
  }
@@ -1739,7 +1324,7 @@ async function encryptPayload(plaintext, secret, api) {
1739
1324
  );
1740
1325
  return encode(new Uint8Array([...iv, ...encrypted]));
1741
1326
  }
1742
- async function verifySecureToken(token, secretKey, now2 = Date.now()) {
1327
+ async function verifySecureToken(token, secretKey, now = Date.now()) {
1743
1328
  try {
1744
1329
  const parts = token.split(".");
1745
1330
  if (parts.length !== 4 || parts[0] !== "sr3" || parts[1] !== "e" && parts[1] !== "p") {
@@ -1782,7 +1367,7 @@ async function verifySecureToken(token, secretKey, now2 = Date.now()) {
1782
1367
  };
1783
1368
  }
1784
1369
  const payload = parsed;
1785
- if (typeof payload.exp === "number" && now2 >= payload.exp * 1e3) {
1370
+ if (typeof payload.exp === "number" && now >= payload.exp * 1e3) {
1786
1371
  return {
1787
1372
  ok: false,
1788
1373
  valid: false,
@@ -1811,367 +1396,18 @@ async function decryptPayload(body, secret) {
1811
1396
  }
1812
1397
 
1813
1398
  // src/domain/i18n.ts
1814
- function resolveLocalizedText(text3, locale, fallbackLocale) {
1815
- if (text3 === void 0 || text3 === "") return "";
1816
- if (typeof text3 === "string") return text3;
1817
- const fallback = fallbackLocale === void 0 ? Object.values(text3).find((value) => typeof value === "string") : text3[fallbackLocale];
1818
- return text3[locale] || fallback || "";
1819
- }
1820
- function toLocalizedString(text3) {
1821
- if (text3 === void 0) return { ja: "", en: "" };
1822
- return typeof text3 === "string" ? { ja: text3, en: "" } : {
1823
- ja: text3["ja"] ?? "",
1824
- en: text3["en"] ?? "",
1825
- ...text3
1826
- };
1827
- }
1828
-
1829
- // src/domain/validation.ts
1830
- var CURRENT_RALLY_CONFIG_VERSION = 2;
1831
- function isObject(value) {
1832
- return typeof value === "object" && value !== null && !Array.isArray(value);
1833
- }
1834
- function add(errors, path, code, message) {
1835
- errors.push({ path, code, message });
1836
- }
1837
- function hasText(value) {
1838
- if (typeof value === "string") return value.trim() !== "";
1839
- if (!isObject(value)) return false;
1840
- return Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
1841
- }
1842
- function validateCondition(value, path, errors) {
1843
- if (!isObject(value) || typeof value.type !== "string") {
1844
- add(errors, path, "INVALID_TYPE", "Condition must be an object with a type.");
1845
- return;
1846
- }
1847
- switch (value.type) {
1848
- case "instant":
1849
- return;
1850
- case "token":
1851
- if (typeof value.token !== "string" || value.token.trim() === "") {
1852
- add(errors, `${path}.token`, "REQUIRED", "Token must not be empty.");
1853
- }
1854
- return;
1855
- case "geo": {
1856
- const latitude = value.latitude;
1857
- const longitude = value.longitude;
1858
- if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
1859
- add(
1860
- errors,
1861
- path,
1862
- "INVALID_COORDINATES",
1863
- "Latitude must be between -90 and 90 and longitude between -180 and 180."
1864
- );
1865
- }
1866
- if (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0) {
1867
- add(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "Radius must be greater than zero.");
1868
- }
1869
- return;
1870
- }
1871
- case "composite":
1872
- if (value.operator !== "AND" && value.operator !== "OR") {
1873
- add(errors, `${path}.operator`, "INVALID_TYPE", "Operator must be AND or OR.");
1874
- }
1875
- if (!Array.isArray(value.conditions)) {
1876
- add(errors, `${path}.conditions`, "INVALID_TYPE", "Composite conditions must be an array.");
1877
- return;
1878
- }
1879
- value.conditions.forEach((child, index) => {
1880
- validateCondition(child, `${path}.conditions[${index}]`, errors);
1881
- });
1882
- return;
1883
- case "time_window":
1884
- if (typeof value.startsAt !== "string" || Number.isNaN(Date.parse(value.startsAt))) {
1885
- add(errors, `${path}.startsAt`, "INVALID_DATE", "Start must be a valid ISO date.");
1886
- }
1887
- if (typeof value.endsAt !== "string" || Number.isNaN(Date.parse(value.endsAt))) {
1888
- add(errors, `${path}.endsAt`, "INVALID_DATE", "End must be a valid ISO date.");
1889
- }
1890
- if (typeof value.startsAt === "string" && typeof value.endsAt === "string" && !Number.isNaN(Date.parse(value.startsAt)) && !Number.isNaN(Date.parse(value.endsAt)) && Date.parse(value.startsAt) >= Date.parse(value.endsAt)) {
1891
- add(errors, path, "INVALID_DATE", "Time window start must be before its end.");
1892
- }
1893
- validateCondition(value.condition, `${path}.condition`, errors);
1894
- return;
1895
- default:
1896
- add(errors, `${path}.type`, "INVALID_TYPE", "Unsupported condition type.");
1897
- }
1898
- }
1899
- function validateSpot(value, index, errors) {
1900
- const path = `stamps[${index}]`;
1901
- if (!isObject(value)) {
1902
- add(errors, path, "INVALID_TYPE", "Spot must be an object.");
1903
- return;
1904
- }
1905
- if (typeof value.id !== "string") add(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
1906
- else if (value.id.trim() === "")
1907
- add(errors, `${path}.id`, "EMPTY_STRING", "Spot ID must not be empty.");
1908
- if (value.name === void 0) add(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
1909
- else if (!hasText(value.name))
1910
- add(errors, `${path}.name`, "EMPTY_STRING", "Spot name must not be empty.");
1911
- if (value.order !== void 0 && (typeof value.order !== "number" || !Number.isFinite(value.order))) {
1912
- add(errors, `${path}.order`, "INVALID_TYPE", "Spot order must be a finite number.");
1913
- }
1914
- validateCondition(value.condition, `${path}.condition`, errors);
1915
- for (const field of ["dependsOn", "requiresStampIds"]) {
1916
- if (value[field] !== void 0 && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== "string"))) {
1917
- add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be an array of IDs.`);
1918
- }
1919
- }
1920
- for (const field of ["order", "orderIndex"]) {
1921
- if (value[field] !== void 0 && (typeof value[field] !== "number" || !Number.isFinite(value[field]))) {
1922
- add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be a finite number.`);
1923
- }
1924
- }
1925
- }
1926
- function validateReward(value, index, stampCount, errors) {
1927
- const path = `rewards[${index}]`;
1928
- if (!isObject(value)) {
1929
- add(errors, path, "INVALID_TYPE", "Reward must be an object.");
1930
- return;
1931
- }
1932
- if (typeof value.id !== "string" || value.id.trim() === "") {
1933
- add(errors, `${path}.id`, "REQUIRED", "Reward ID must not be empty.");
1934
- }
1935
- if (!hasText(value.title)) {
1936
- add(errors, `${path}.title`, "EMPTY_STRING", "Reward title must not be empty.");
1937
- }
1938
- if (!hasText(value.description)) {
1939
- add(errors, `${path}.description`, "EMPTY_STRING", "Reward description must not be empty.");
1940
- }
1941
- const required = value.requiredStampCount;
1942
- if (typeof required !== "number" || !Number.isInteger(required) || required < 0 || required > stampCount) {
1943
- add(
1944
- errors,
1945
- `${path}.requiredStampCount`,
1946
- "INVALID_REWARD",
1947
- "Required stamps must be an integer within the rally."
1948
- );
1949
- }
1950
- if (value.validUntil !== void 0 && (typeof value.validUntil !== "string" || Number.isNaN(Date.parse(value.validUntil)))) {
1951
- add(errors, `${path}.validUntil`, "INVALID_DATE", "Reward expiry must be a valid ISO date.");
1952
- }
1953
- for (const field of ["maxStock", "limitPerUser", "stockLimit", "userClaimLimit"]) {
1954
- const count = value[field];
1955
- if (count !== void 0 && (typeof count !== "number" || !Number.isInteger(count) || count <= 0)) {
1956
- add(errors, `${path}.${field}`, "INVALID_REWARD", `${field} must be a positive integer.`);
1957
- }
1958
- }
1959
- }
1960
- function collectDependencies(spot) {
1961
- const dependencies = /* @__PURE__ */ new Set();
1962
- for (const field of ["dependsOn", "requiresStampIds"]) {
1963
- const values = spot[field];
1964
- if (Array.isArray(values)) {
1965
- for (const value of values) if (typeof value === "string") dependencies.add(value);
1966
- }
1967
- }
1968
- return [...dependencies];
1969
- }
1970
- function validateDag(stamps, errors) {
1971
- const graph = /* @__PURE__ */ new Map();
1972
- for (const value of stamps) {
1973
- if (!isObject(value) || typeof value.id !== "string") continue;
1974
- graph.set(value.id, [...graph.get(value.id) ?? [], ...collectDependencies(value)]);
1975
- }
1976
- const visiting = /* @__PURE__ */ new Set();
1977
- const visited = /* @__PURE__ */ new Set();
1978
- const visit = (id, path) => {
1979
- if (visiting.has(id)) {
1980
- add(errors, path, "CYCLE_DETECTED", `Dependency cycle detected at '${id}'.`);
1981
- return;
1982
- }
1983
- if (visited.has(id)) return;
1984
- visiting.add(id);
1985
- for (const dependency of graph.get(id) ?? [])
1986
- if (graph.has(dependency)) visit(dependency, path);
1987
- visiting.delete(id);
1988
- visited.add(id);
1989
- };
1990
- for (const id of graph.keys()) visit(id, `stamps[${id}]`);
1991
- }
1992
- function validateRallyConfig(config) {
1993
- const errors = [];
1994
- if (!isObject(config))
1995
- return {
1996
- valid: false,
1997
- errors: [{ path: "", code: "INVALID_TYPE", message: "Rally config must be an object." }]
1998
- };
1999
- if (typeof config.id !== "string") add(errors, "id", "REQUIRED", "Rally ID is required.");
2000
- else if (config.id.trim() === "")
2001
- add(errors, "id", "EMPTY_STRING", "Rally ID must not be empty.");
2002
- if (config.title !== void 0 && !hasText(config.title))
2003
- add(errors, "title", "EMPTY_STRING", "Rally title must not be empty.");
2004
- if (config.version !== void 0 && config.version !== CURRENT_RALLY_CONFIG_VERSION) {
2005
- add(
2006
- errors,
2007
- "version",
2008
- "INVALID_VERSION",
2009
- `Config version must be ${CURRENT_RALLY_CONFIG_VERSION}.`
2010
- );
2011
- }
2012
- for (const field of ["startDate", "endDate"]) {
2013
- if (config[field] !== void 0 && (typeof config[field] !== "string" || Number.isNaN(Date.parse(config[field])))) {
2014
- add(errors, field, "INVALID_DATE", `${field} must be a valid ISO date.`);
2015
- }
2016
- }
2017
- if (typeof config.startDate === "string" && typeof config.endDate === "string" && !Number.isNaN(Date.parse(config.startDate)) && !Number.isNaN(Date.parse(config.endDate)) && Date.parse(config.startDate) >= Date.parse(config.endDate)) {
2018
- add(errors, "startDate", "INVALID_DATE", "startDate must be before endDate.");
2019
- }
2020
- if (!Array.isArray(config.stamps)) {
2021
- add(errors, "stamps", "INVALID_TYPE", "stamps must be an array.");
2022
- } else {
2023
- config.stamps.forEach((spot, index) => {
2024
- validateSpot(spot, index, errors);
2025
- });
2026
- const ids = config.stamps.map(
2027
- (spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : ""
2028
- );
2029
- ids.forEach((id, index) => {
2030
- if (id !== "" && ids.indexOf(id) !== index)
2031
- add(errors, `stamps[${index}].id`, "DUPLICATE_ID", `Duplicate spot ID '${id}'.`);
2032
- });
2033
- validateDag(config.stamps, errors);
2034
- }
2035
- if (config.rewards !== void 0 && !Array.isArray(config.rewards)) {
2036
- add(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
2037
- } else if (Array.isArray(config.rewards)) {
2038
- const stampCount = Array.isArray(config.stamps) ? config.stamps.length : 0;
2039
- config.rewards.forEach((reward, index) => {
2040
- validateReward(reward, index, stampCount, errors);
2041
- });
2042
- const ids = config.rewards.map(
2043
- (reward) => isObject(reward) && typeof reward.id === "string" ? reward.id : ""
2044
- );
2045
- ids.forEach((id, index) => {
2046
- if (id !== "" && ids.indexOf(id) !== index)
2047
- add(errors, `rewards[${index}].id`, "DUPLICATE_ID", `Duplicate reward ID '${id}'.`);
2048
- });
2049
- if (Array.isArray(config.stamps)) {
2050
- const stampIds = new Set(
2051
- config.stamps.map((spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : "")
2052
- );
2053
- ids.forEach((id, index) => {
2054
- if (id !== "" && stampIds.has(id))
2055
- add(
2056
- errors,
2057
- `rewards[${index}].id`,
2058
- "DUPLICATE_ID",
2059
- `Reward ID '${id}' is already used by a spot.`
2060
- );
2061
- });
2062
- }
2063
- }
2064
- return { valid: errors.length === 0, errors };
2065
- }
2066
-
2067
- // src/domain/migration.ts
2068
- function isObject2(value) {
2069
- return typeof value === "object" && value !== null && !Array.isArray(value);
2070
- }
2071
- function text2(value) {
2072
- if (typeof value === "string") return value;
2073
- if (!isObject2(value)) return void 0;
2074
- const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
2075
- return entries.length === 0 ? void 0 : Object.fromEntries(entries);
2076
- }
2077
- function condition(value) {
2078
- if (!isObject2(value) || typeof value.type !== "string") return { type: "instant" };
2079
- switch (value.type) {
2080
- case "instant":
2081
- return { type: "instant" };
2082
- case "token":
2083
- case "passcode":
2084
- return {
2085
- type: "token",
2086
- token: typeof value.token === "string" ? value.token : typeof value.passcode === "string" ? value.passcode : ""
2087
- };
2088
- case "geo":
2089
- return {
2090
- type: "geo",
2091
- latitude: typeof value.latitude === "number" ? value.latitude : 0,
2092
- longitude: typeof value.longitude === "number" ? value.longitude : 0,
2093
- radiusMeters: typeof value.radiusMeters === "number" ? value.radiusMeters : typeof value.radius === "number" ? value.radius : 1
2094
- };
2095
- case "composite":
2096
- return {
2097
- type: "composite",
2098
- operator: value.operator === "OR" ? "OR" : "AND",
2099
- conditions: Array.isArray(value.conditions) ? value.conditions.map(condition) : []
2100
- };
2101
- case "time_window":
2102
- return {
2103
- type: "time_window",
2104
- startsAt: typeof value.startsAt === "string" ? value.startsAt : "1970-01-01T00:00:00.000Z",
2105
- endsAt: typeof value.endsAt === "string" ? value.endsAt : "9999-12-31T23:59:59.999Z",
2106
- condition: condition(value.condition)
2107
- };
2108
- default:
2109
- return { type: "instant" };
2110
- }
2111
- }
2112
- function migrateSpot(value, index) {
2113
- const source2 = isObject2(value) ? value : {};
2114
- const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `spot-${index + 1}`;
2115
- const description = text2(source2.description);
2116
- const hint = text2(source2.hint);
2117
- return {
2118
- id,
2119
- name: text2(source2.name) ?? `Spot ${index + 1}`,
2120
- ...description === void 0 ? {} : { description },
2121
- ...hint === void 0 ? {} : { hint },
2122
- condition: condition(
2123
- source2.condition ?? (typeof source2.token === "string" ? { type: "token", token: source2.token } : void 0)
2124
- ),
2125
- ...typeof source2.orderIndex === "number" ? { orderIndex: source2.orderIndex } : typeof source2.order === "number" ? { order: source2.order } : {},
2126
- ...typeof source2.deckId === "string" ? { deckId: source2.deckId } : {},
2127
- ...typeof source2.groupId === "string" ? { groupId: source2.groupId } : {},
2128
- ...typeof source2.guideId === "string" ? { guideId: source2.guideId } : {},
2129
- ...typeof source2.iconUrl === "string" ? { iconUrl: source2.iconUrl } : {},
2130
- ...typeof source2.imageUrl === "string" ? { imageUrl: source2.imageUrl } : {},
2131
- ...typeof source2.externalUrl === "string" ? { externalUrl: source2.externalUrl } : {},
2132
- ...typeof source2.redirectUrlAfterClaim === "string" ? { redirectUrlAfterClaim: source2.redirectUrlAfterClaim } : {},
2133
- ...isObject2(source2.metadata) ? { metadata: source2.metadata } : {},
2134
- ...Array.isArray(source2.dependsOn) ? { dependsOn: source2.dependsOn.filter((item) => typeof item === "string") } : {}
2135
- };
2136
- }
2137
- function migrateReward(value, index) {
2138
- const source2 = isObject2(value) ? value : {};
2139
- return {
2140
- id: typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `reward-${index + 1}`,
2141
- title: text2(source2.title) ?? `Reward ${index + 1}`,
2142
- description: text2(source2.description) ?? "",
2143
- type: source2.type === "digital" ? "digital" : "in_person",
2144
- redemptionMethod: source2.redemptionMethod === "staff_passcode" || source2.redemptionMethod === "view_only" || source2.redemptionMethod === "server_claim" ? source2.redemptionMethod : "manual_slide",
2145
- requiredStampCount: typeof source2.requiredStampCount === "number" && Number.isFinite(source2.requiredStampCount) ? Math.max(0, Math.trunc(source2.requiredStampCount)) : 0,
2146
- ...typeof source2.digitalContentUrl === "string" ? { digitalContentUrl: source2.digitalContentUrl } : {},
2147
- ...typeof source2.staffPasscode === "string" ? { staffPasscode: source2.staffPasscode } : {},
2148
- ...typeof source2.validUntil === "string" ? { validUntil: source2.validUntil } : {},
2149
- ...typeof source2.maxStock === "number" ? { maxStock: source2.maxStock } : {},
2150
- ...typeof source2.limitPerUser === "number" ? { limitPerUser: source2.limitPerUser } : {},
2151
- ...typeof source2.stockLimit === "number" ? { stockLimit: source2.stockLimit } : {},
2152
- ...typeof source2.userClaimLimit === "number" ? { userClaimLimit: source2.userClaimLimit } : {},
2153
- ...typeof source2.claimTicketNumber === "string" ? { claimTicketNumber: source2.claimTicketNumber } : {}
2154
- };
2155
- }
2156
- function migrateRallyConfig(raw) {
2157
- const source2 = isObject2(raw) ? raw : {};
2158
- const rawStamps = Array.isArray(source2.stamps) ? source2.stamps : Array.isArray(source2.spots) ? source2.spots : [];
2159
- const rawRewards = Array.isArray(source2.rewards) ? source2.rewards : [];
2160
- const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : "migrated-rally";
2161
- const title = text2(source2.title);
2162
- const description = text2(source2.description);
2163
- const theme = isObject2(source2.theme) ? source2.theme : void 0;
2164
- return {
2165
- id,
2166
- ...title === void 0 ? {} : { title },
2167
- ...description === void 0 ? {} : { description },
2168
- stamps: rawStamps.map(migrateSpot),
2169
- ...rawRewards.length === 0 ? {} : { rewards: rawRewards.map(migrateReward) },
2170
- ...typeof source2.isSequential === "boolean" ? { isSequential: source2.isSequential } : {},
2171
- ...theme === void 0 ? {} : { theme },
2172
- version: CURRENT_RALLY_CONFIG_VERSION,
2173
- ...typeof source2.startDate === "string" ? { startDate: source2.startDate } : typeof source2.startsAt === "string" ? { startDate: source2.startsAt } : {},
2174
- ...typeof source2.endDate === "string" ? { endDate: source2.endDate } : typeof source2.endsAt === "string" ? { endDate: source2.endsAt } : {}
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
2175
1411
  };
2176
1412
  }
2177
1413
 
@@ -2186,69 +1422,98 @@ var DEFAULT_SHEET_THEME = {
2186
1422
  unclaimedOpacity: 1,
2187
1423
  fontFamily: "serif"
2188
1424
  };
2189
-
2190
- // src/domain/universalModel.ts
2191
- function toPublicCondition(condition2) {
2192
- switch (condition2.type) {
1425
+ function publicCondition(condition) {
1426
+ switch (condition.type) {
2193
1427
  case "qr":
2194
- return { type: "qr", qrEntryUrl: condition2.qrEntryUrl ?? "" };
1428
+ return condition.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition.qrEntryUrl };
2195
1429
  case "passcode":
2196
1430
  return { type: "passcode" };
2197
1431
  case "gps":
2198
1432
  return {
2199
1433
  type: "gps",
2200
- latitude: condition2.latitude,
2201
- longitude: condition2.longitude,
2202
- radiusMeters: condition2.radiusMeters
1434
+ latitude: condition.latitude,
1435
+ longitude: condition.longitude,
1436
+ radiusMeters: condition.radiusMeters
2203
1437
  };
1438
+ case "nfc":
1439
+ return { type: "nfc" };
2204
1440
  case "custom":
2205
- return { type: "custom", validatorName: condition2.validatorName };
1441
+ return { type: "custom", validatorName: condition.validatorName };
2206
1442
  }
2207
1443
  }
2208
- function toPublicRallyConfig(config) {
1444
+ function toPublicConfig(config) {
2209
1445
  return {
2210
- ...config,
1446
+ id: config.id,
1447
+ version: config.version,
1448
+ title: config.title,
1449
+ ...config.description === void 0 ? {} : { description: config.description },
1450
+ ...config.theme === void 0 ? {} : { theme: config.theme },
2211
1451
  spots: config.spots.map((spot) => ({
2212
1452
  ...spot,
2213
- conditions: spot.conditions.map(toPublicCondition)
1453
+ conditions: spot.conditions.map(publicCondition)
2214
1454
  })),
2215
1455
  rewards: config.rewards.map(
2216
- ({ staffPasscode: _staffPasscode, digitalContentUrl: _content, ...reward }) => reward
2217
- )
1456
+ ({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward }) => reward
1457
+ ),
1458
+ ...config.metadata === void 0 ? {} : { metadata: config.metadata },
1459
+ ...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
2218
1460
  };
2219
1461
  }
2220
- function isPublicRallyConfig(value) {
1462
+ function assertPublicConfig(config) {
1463
+ if (!isPublicConfig(config)) throw new Error("Configuration contains private rally fields.");
1464
+ }
1465
+ function isPublicConfig(value) {
2221
1466
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2222
1467
  const candidate = value;
2223
- if (candidate.secretKey !== void 0 || candidate.verificationSecrets !== void 0)
1468
+ const seen = /* @__PURE__ */ new Set();
1469
+ const containsPrivateField = (item) => {
1470
+ if (typeof item !== "object" || item === null) return false;
1471
+ if (seen.has(item)) return false;
1472
+ seen.add(item);
1473
+ if (Array.isArray(item)) return item.some(containsPrivateField);
1474
+ const record = item;
1475
+ if (["staffPasscode", "secretToken", "serverMetadata", "secretParams", "digitalContentUrl"].some(
1476
+ (key) => key in record
1477
+ ))
1478
+ return true;
1479
+ return Object.values(record).some(containsPrivateField);
1480
+ };
1481
+ if (containsPrivateField(candidate)) return false;
1482
+ for (const key of [
1483
+ "staffPasscode",
1484
+ "secretToken",
1485
+ "serverMetadata",
1486
+ "secretParams",
1487
+ "code",
1488
+ "tagId"
1489
+ ]) {
1490
+ if (key in candidate) return false;
1491
+ }
1492
+ 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))
2224
1493
  return false;
2225
- return Array.isArray(candidate.spots) && Array.isArray(candidate.rewards) && candidate.spots.every((spot) => {
1494
+ return candidate.spots.every((spot) => {
2226
1495
  if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
2227
- const conditions = spot.conditions;
2228
- return Array.isArray(conditions) && conditions.every((condition2) => {
2229
- if (typeof condition2 !== "object" || condition2 === null) return false;
2230
- const type = condition2.type;
2231
- return type === "qr" || type === "passcode" || type === "gps" || type === "custom";
1496
+ const item = spot;
1497
+ 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
+ return false;
1499
+ return Array.isArray(item.conditions) && item.conditions.every((condition) => {
1500
+ if (typeof condition !== "object" || condition === null || Array.isArray(condition))
1501
+ return false;
1502
+ const value2 = condition;
1503
+ if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
1504
+ return false;
1505
+ const type = value2.type;
1506
+ if (type === "qr" || type === "passcode" || type === "nfc") return true;
1507
+ if (type === "custom") return typeof value2.validatorName === "string";
1508
+ return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
2232
1509
  });
2233
1510
  }) && candidate.rewards.every((reward) => {
2234
1511
  if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
2235
1512
  const item = reward;
2236
- return item.staffPasscode === void 0 && item.digitalContentUrl === void 0;
1513
+ 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);
2237
1514
  });
2238
1515
  }
2239
1516
 
2240
- // src/domain/publicConfig.ts
2241
- function stripSensitiveConfig(config) {
2242
- if ("spots" in config && !("stamps" in config)) {
2243
- return toPublicRallyConfig(config);
2244
- }
2245
- const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
2246
- return {
2247
- ...config,
2248
- ...rewards === void 0 ? {} : { rewards }
2249
- };
2250
- }
2251
-
2252
1517
  // src/domain/themePresets.ts
2253
1518
  var THEME_PRESETS = [
2254
1519
  {
@@ -2348,180 +1613,6 @@ var THEME_PRESETS = [
2348
1613
  }
2349
1614
  ];
2350
1615
 
2351
- // src/domain/universalValidation.ts
2352
- function isObject3(value) {
2353
- return typeof value === "object" && value !== null && !Array.isArray(value);
2354
- }
2355
- function add2(errors, path, code, message) {
2356
- errors.push({ path, code, message });
2357
- }
2358
- function hasText2(value) {
2359
- if (typeof value === "string") return value.trim() !== "";
2360
- return isObject3(value) && Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
2361
- }
2362
- function validateCondition2(condition2, path, errors) {
2363
- if (!isObject3(condition2) || typeof condition2.type !== "string") {
2364
- add2(errors, path, "INVALID_TYPE", "Condition must have a type.");
2365
- return;
2366
- }
2367
- switch (condition2.type) {
2368
- case "qr":
2369
- if (typeof condition2.secretToken !== "string" || condition2.secretToken.trim() === "")
2370
- add2(errors, `${path}.secretToken`, "REQUIRED", "QR secretToken is required.");
2371
- if (condition2.qrEntryUrl !== void 0 && typeof condition2.qrEntryUrl !== "string")
2372
- add2(errors, `${path}.qrEntryUrl`, "INVALID_TYPE", "QR entry URL must be a string.");
2373
- return;
2374
- case "passcode":
2375
- if (typeof condition2.code !== "string" || condition2.code.trim() === "")
2376
- add2(errors, `${path}.code`, "REQUIRED", "Passcode is required.");
2377
- return;
2378
- case "gps":
2379
- if (typeof condition2.latitude !== "number" || !Number.isFinite(condition2.latitude) || condition2.latitude < -90 || condition2.latitude > 90 || typeof condition2.longitude !== "number" || !Number.isFinite(condition2.longitude) || condition2.longitude < -180 || condition2.longitude > 180)
2380
- add2(errors, path, "INVALID_COORDINATES", "GPS coordinates are invalid.");
2381
- if (typeof condition2.radiusMeters !== "number" || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters <= 0)
2382
- add2(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "GPS radius must be positive.");
2383
- return;
2384
- case "custom":
2385
- if (typeof condition2.validatorName !== "string" || condition2.validatorName.trim() === "")
2386
- add2(errors, `${path}.validatorName`, "REQUIRED", "Custom validatorName is required.");
2387
- return;
2388
- default:
2389
- add2(errors, `${path}.type`, "INVALID_TYPE", "Unsupported verification condition.");
2390
- }
2391
- }
2392
- function validateDag2(spots, errors) {
2393
- const graph = /* @__PURE__ */ new Map();
2394
- for (const spot of spots) {
2395
- if (!isObject3(spot) || typeof spot.id !== "string") continue;
2396
- const prerequisites = Array.isArray(spot.prerequisites) ? spot.prerequisites.filter((item) => typeof item === "string") : [];
2397
- graph.set(spot.id, prerequisites);
2398
- }
2399
- const visiting = /* @__PURE__ */ new Set();
2400
- const visited = /* @__PURE__ */ new Set();
2401
- const visit = (id) => {
2402
- if (visiting.has(id)) {
2403
- add2(
2404
- errors,
2405
- `spots.${id}.prerequisites`,
2406
- "CYCLE_DETECTED",
2407
- `Dependency cycle detected at '${id}'.`
2408
- );
2409
- return;
2410
- }
2411
- if (visited.has(id)) return;
2412
- visiting.add(id);
2413
- for (const prerequisite of graph.get(id) ?? [])
2414
- if (graph.has(prerequisite)) visit(prerequisite);
2415
- visiting.delete(id);
2416
- visited.add(id);
2417
- };
2418
- for (const id of graph.keys()) visit(id);
2419
- }
2420
- function validateAdminRallyConfig(value) {
2421
- const errors = [];
2422
- if (!isObject3(value))
2423
- return {
2424
- valid: false,
2425
- errors: [{ path: "", code: "INVALID_TYPE", message: "Admin config must be an object." }]
2426
- };
2427
- if (typeof value.id !== "string" || value.id.trim() === "")
2428
- add2(errors, "id", "REQUIRED", "Rally ID is required.");
2429
- if (typeof value.version !== "string" || value.version.trim() === "")
2430
- add2(errors, "version", "INVALID_VERSION", "Version is required.");
2431
- if (!Array.isArray(value.spots)) {
2432
- add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
2433
- } else {
2434
- const ids = [];
2435
- value.spots.forEach((spot, index) => {
2436
- const path = `spots[${index}]`;
2437
- if (!isObject3(spot)) {
2438
- add2(errors, path, "INVALID_TYPE", "Spot must be an object.");
2439
- return;
2440
- }
2441
- if (typeof spot.id !== "string" || spot.id.trim() === "")
2442
- add2(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
2443
- else if (ids.includes(spot.id))
2444
- add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate spot ID '${spot.id}'.`);
2445
- else ids.push(spot.id);
2446
- if (!hasText2(spot.name)) add2(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
2447
- if (typeof spot.orderIndex !== "number" || !Number.isInteger(spot.orderIndex))
2448
- add2(errors, `${path}.orderIndex`, "INVALID_TYPE", "orderIndex must be an integer.");
2449
- if (!Array.isArray(spot.conditions) || spot.conditions.length === 0)
2450
- add2(errors, `${path}.conditions`, "REQUIRED", "At least one condition is required.");
2451
- else {
2452
- spot.conditions.forEach((condition2, conditionIndex) => {
2453
- validateCondition2(condition2, `${path}.conditions[${conditionIndex}]`, errors);
2454
- });
2455
- }
2456
- });
2457
- validateDag2(value.spots, errors);
2458
- }
2459
- if (!Array.isArray(value.rewards))
2460
- add2(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
2461
- else {
2462
- const ids = [];
2463
- value.rewards.forEach((reward, index) => {
2464
- const path = `rewards[${index}]`;
2465
- if (!isObject3(reward)) {
2466
- add2(errors, path, "INVALID_TYPE", "Reward must be an object.");
2467
- return;
2468
- }
2469
- if (typeof reward.id !== "string" || reward.id.trim() === "")
2470
- add2(errors, `${path}.id`, "REQUIRED", "Reward ID is required.");
2471
- else if (ids.includes(reward.id))
2472
- add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate reward ID '${reward.id}'.`);
2473
- else ids.push(reward.id);
2474
- if (!hasText2(reward.title))
2475
- add2(errors, `${path}.title`, "REQUIRED", "Reward title is required.");
2476
- if (typeof reward.requiredStampCount !== "number" || !Number.isInteger(reward.requiredStampCount) || reward.requiredStampCount < 0)
2477
- add2(
2478
- errors,
2479
- `${path}.requiredStampCount`,
2480
- "INVALID_REWARD",
2481
- "requiredStampCount must be a non-negative integer."
2482
- );
2483
- });
2484
- }
2485
- return { valid: errors.length === 0, errors };
2486
- }
2487
- function validatePublicRallyConfig(value) {
2488
- const errors = [];
2489
- if (!isObject3(value))
2490
- return {
2491
- valid: false,
2492
- errors: [{ path: "", code: "INVALID_TYPE", message: "Public config must be an object." }]
2493
- };
2494
- if ("secretKey" in value || "verificationSecrets" in value)
2495
- add2(
2496
- errors,
2497
- "",
2498
- "SECRET_IN_PUBLIC_CONFIG",
2499
- "Public config must not contain server verification secrets."
2500
- );
2501
- if (!Array.isArray(value.spots)) add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
2502
- else
2503
- value.spots.forEach((spot, index) => {
2504
- if (!isObject3(spot) || !Array.isArray(spot.conditions)) return;
2505
- spot.conditions.forEach((condition2, conditionIndex) => {
2506
- if (!isObject3(condition2)) return;
2507
- if ("secretToken" in condition2 || "code" in condition2 || "secretParams" in condition2)
2508
- add2(
2509
- errors,
2510
- `spots[${index}].conditions[${conditionIndex}]`,
2511
- "SECRET_IN_PUBLIC_CONFIG",
2512
- "Public condition contains verification secret material."
2513
- );
2514
- });
2515
- });
2516
- return { valid: errors.length === 0, errors };
2517
- }
2518
- function isAdminRallyConfig(value) {
2519
- return validateAdminRallyConfig(value).valid;
2520
- }
2521
- function isPublicRallyConfigShape(value) {
2522
- return validatePublicRallyConfig(value).valid;
2523
- }
2524
-
2525
1616
  // src/security/snapshotToken.ts
2526
1617
  var encoder2 = new TextEncoder();
2527
1618
  function cryptoApi2() {
@@ -2560,9 +1651,9 @@ async function importAesKey(secret) {
2560
1651
  const digest = await cryptoApi2().subtle.digest("SHA-256", webCryptoBytes(secret));
2561
1652
  return cryptoApi2().subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
2562
1653
  }
2563
- function isExpired(payload, now2) {
2564
- if (typeof payload.exp === "number" && now2 >= payload.exp * 1e3) return true;
2565
- return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now2;
1654
+ function isExpired(payload, now) {
1655
+ if (typeof payload.exp === "number" && now >= payload.exp * 1e3) return true;
1656
+ return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now;
2566
1657
  }
2567
1658
  async function createSignedSnapshotToken(payload, secretKey) {
2568
1659
  const api = cryptoApi2();
@@ -2582,7 +1673,7 @@ async function createSignedSnapshotToken(payload, secretKey) {
2582
1673
  );
2583
1674
  return `sr2.${body}.${base64Url(signature)}`;
2584
1675
  }
2585
- async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
1676
+ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
2586
1677
  try {
2587
1678
  const parts = token.split(".");
2588
1679
  if (parts.length !== 3 || parts[0] !== "sr2") {
@@ -2624,7 +1715,7 @@ async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
2624
1715
  const payload = JSON.parse(new TextDecoder().decode(payloadText));
2625
1716
  if (typeof payload !== "object" || payload === null || Array.isArray(payload))
2626
1717
  throw new Error("Invalid payload.");
2627
- if (isExpired(payload, now2)) {
1718
+ if (isExpired(payload, now)) {
2628
1719
  return {
2629
1720
  ok: false,
2630
1721
  valid: false,
@@ -2641,7 +1732,6 @@ async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
2641
1732
  }
2642
1733
  }
2643
1734
 
2644
- exports.CURRENT_RALLY_CONFIG_VERSION = CURRENT_RALLY_CONFIG_VERSION;
2645
1735
  exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
2646
1736
  exports.InMemoryStorage = InMemoryStorage;
2647
1737
  exports.IndexedDBAdapter = IndexedDBAdapter;
@@ -2649,7 +1739,7 @@ exports.LocalStorageAdapter = LocalStorageAdapter;
2649
1739
  exports.StampRallyClient = StampRallyClient;
2650
1740
  exports.StorageAdapterError = StorageAdapterError;
2651
1741
  exports.THEME_PRESETS = THEME_PRESETS;
2652
- exports.UniversalStampRallyClient = UniversalStampRallyClient;
1742
+ exports.assertPublicConfig = assertPublicConfig;
2653
1743
  exports.calculateDistanceMeters = calculateDistanceMeters;
2654
1744
  exports.calculateProgress = calculateProgress;
2655
1745
  exports.consumeReward = consumeReward;
@@ -2657,34 +1747,28 @@ exports.createClaimTicketNumber = createClaimTicketNumber;
2657
1747
  exports.createSecureToken = createSecureToken;
2658
1748
  exports.createSignedSnapshotToken = createSignedSnapshotToken;
2659
1749
  exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
2660
- exports.evaluateCheckIn = evaluateCheckIn;
2661
1750
  exports.evaluateCondition = evaluateCondition;
2662
1751
  exports.evaluateConditionDetailed = evaluateConditionDetailed;
2663
1752
  exports.exportProgressToken = exportProgressToken;
2664
1753
  exports.getCurrentGeoContext = getCurrentGeoContext;
1754
+ exports.getOrderedSpots = getOrderedSpots;
2665
1755
  exports.importProgressToken = importProgressToken;
2666
- exports.isAdminRallyConfig = isAdminRallyConfig;
2667
1756
  exports.isGeolocationSupported = isGeolocationSupported;
2668
1757
  exports.isNfcSupported = isNfcSupported;
2669
- exports.isPublicRallyConfig = isPublicRallyConfig;
2670
- exports.isPublicRallyConfigShape = isPublicRallyConfigShape;
1758
+ exports.isPublicConfig = isPublicConfig;
2671
1759
  exports.isQrSupported = isQrSupported;
2672
1760
  exports.isRewardState = isRewardState;
2673
1761
  exports.isStampRallyState = isStampRallyState;
2674
1762
  exports.issueClaimTicketNumber = issueClaimTicketNumber;
2675
- exports.migrateRallyConfig = migrateRallyConfig;
2676
1763
  exports.normalizePasscode = normalizePasscode;
2677
1764
  exports.processStamp = processStamp;
2678
1765
  exports.readNfcContext = readNfcContext;
2679
1766
  exports.readQrContext = readQrContext;
2680
1767
  exports.reconcileRewardStates = reconcileRewardStates;
2681
1768
  exports.resolveLocalizedText = resolveLocalizedText;
2682
- exports.stripSensitiveConfig = stripSensitiveConfig;
1769
+ exports.storageKey = storageKey;
2683
1770
  exports.toLocalizedString = toLocalizedString;
2684
- exports.toPublicRallyConfig = toPublicRallyConfig;
2685
- exports.validateAdminRallyConfig = validateAdminRallyConfig;
2686
- exports.validatePublicRallyConfig = validatePublicRallyConfig;
2687
- exports.validateRallyConfig = validateRallyConfig;
1771
+ exports.toPublicConfig = toPublicConfig;
2688
1772
  exports.verifyPasscode = verifyPasscode;
2689
1773
  exports.verifySecureToken = verifySecureToken;
2690
1774
  exports.verifySnapshotToken = verifySnapshotToken;