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