@stamprally/core 0.1.0 → 0.2.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/README.md +161 -0
- package/dist/index.cjs +617 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +140 -20
- package/dist/index.d.ts +140 -20
- package/dist/index.js +609 -39
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -31,15 +31,15 @@ function contextTypeMismatch(conditionType, expectedContextType, actualContextTy
|
|
|
31
31
|
function assertNever(value) {
|
|
32
32
|
throw new Error(`Unexpected condition: ${JSON.stringify(value)}`);
|
|
33
33
|
}
|
|
34
|
-
function evaluateConditionDetailed(
|
|
35
|
-
switch (
|
|
34
|
+
function evaluateConditionDetailed(condition2, context, now) {
|
|
35
|
+
switch (condition2.type) {
|
|
36
36
|
case "instant":
|
|
37
37
|
return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
|
|
38
38
|
case "token":
|
|
39
39
|
if (context.type !== "token") {
|
|
40
40
|
return contextTypeMismatch("token", "token", context.type);
|
|
41
41
|
}
|
|
42
|
-
return context.token ===
|
|
42
|
+
return context.token === condition2.token ? { ok: true, value: { conditionType: "token" } } : {
|
|
43
43
|
ok: false,
|
|
44
44
|
error: {
|
|
45
45
|
code: "CONDITION_MISMATCH",
|
|
@@ -51,7 +51,7 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
51
51
|
if (context.type !== "geo") {
|
|
52
52
|
return contextTypeMismatch("geo", "geo", context.type);
|
|
53
53
|
}
|
|
54
|
-
if (!isValidCoordinate(
|
|
54
|
+
if (!isValidCoordinate(condition2.latitude, condition2.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0) {
|
|
55
55
|
return {
|
|
56
56
|
ok: false,
|
|
57
57
|
error: {
|
|
@@ -62,12 +62,12 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
const distanceMeters = calculateDistanceMeters(
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
condition2.latitude,
|
|
66
|
+
condition2.longitude,
|
|
67
67
|
context.currentLatitude,
|
|
68
68
|
context.currentLongitude
|
|
69
69
|
);
|
|
70
|
-
if (distanceMeters <=
|
|
70
|
+
if (distanceMeters <= condition2.radiusMeters) {
|
|
71
71
|
return { ok: true, value: { conditionType: "geo", distanceMeters } };
|
|
72
72
|
}
|
|
73
73
|
return {
|
|
@@ -77,8 +77,8 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
77
77
|
conditionType: "geo",
|
|
78
78
|
reason: "OUTSIDE_RADIUS",
|
|
79
79
|
distanceMeters,
|
|
80
|
-
radiusMeters:
|
|
81
|
-
differenceMeters: distanceMeters -
|
|
80
|
+
radiusMeters: condition2.radiusMeters,
|
|
81
|
+
differenceMeters: distanceMeters - condition2.radiusMeters
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
84
|
}
|
|
@@ -86,31 +86,31 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
86
86
|
if (context.type !== "composite") {
|
|
87
87
|
return contextTypeMismatch("composite", "composite", context.type);
|
|
88
88
|
}
|
|
89
|
-
if (
|
|
89
|
+
if (condition2.conditions.length !== context.contexts.length) {
|
|
90
90
|
return {
|
|
91
91
|
ok: false,
|
|
92
92
|
error: {
|
|
93
93
|
code: "CONDITION_MISMATCH",
|
|
94
94
|
conditionType: "composite",
|
|
95
95
|
reason: "CONTEXT_LENGTH_MISMATCH",
|
|
96
|
-
expectedCount:
|
|
96
|
+
expectedCount: condition2.conditions.length,
|
|
97
97
|
actualCount: context.contexts.length
|
|
98
98
|
}
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
const failures = [];
|
|
102
102
|
let matchedCount = 0;
|
|
103
|
-
for (const [index, childCondition] of
|
|
103
|
+
for (const [index, childCondition] of condition2.conditions.entries()) {
|
|
104
104
|
const childContext = context.contexts[index];
|
|
105
105
|
if (childContext === void 0) continue;
|
|
106
106
|
const result = evaluateConditionDetailed(childCondition, childContext, now);
|
|
107
107
|
if (result.ok) matchedCount += 1;
|
|
108
108
|
else failures.push({ index, error: result.error });
|
|
109
109
|
}
|
|
110
|
-
if (
|
|
110
|
+
if (condition2.operator === "AND" && failures.length === 0) {
|
|
111
111
|
return { ok: true, value: { conditionType: "composite" } };
|
|
112
112
|
}
|
|
113
|
-
if (
|
|
113
|
+
if (condition2.operator === "OR" && matchedCount > 0) {
|
|
114
114
|
return { ok: true, value: { conditionType: "composite" } };
|
|
115
115
|
}
|
|
116
116
|
return {
|
|
@@ -118,14 +118,14 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
118
118
|
error: {
|
|
119
119
|
code: "CONDITION_MISMATCH",
|
|
120
120
|
conditionType: "composite",
|
|
121
|
-
reason:
|
|
121
|
+
reason: condition2.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
|
|
122
122
|
failures
|
|
123
123
|
}
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
case "time_window": {
|
|
127
|
-
const startsAt = Date.parse(
|
|
128
|
-
const endsAt = Date.parse(
|
|
127
|
+
const startsAt = Date.parse(condition2.startsAt);
|
|
128
|
+
const endsAt = Date.parse(condition2.endsAt);
|
|
129
129
|
const currentTime = Date.parse(now);
|
|
130
130
|
if (!Number.isFinite(currentTime)) {
|
|
131
131
|
return {
|
|
@@ -135,8 +135,8 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
135
135
|
conditionType: "time_window",
|
|
136
136
|
reason: "INVALID_NOW",
|
|
137
137
|
now,
|
|
138
|
-
startsAt:
|
|
139
|
-
endsAt:
|
|
138
|
+
startsAt: condition2.startsAt,
|
|
139
|
+
endsAt: condition2.endsAt
|
|
140
140
|
}
|
|
141
141
|
};
|
|
142
142
|
}
|
|
@@ -148,8 +148,8 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
148
148
|
conditionType: "time_window",
|
|
149
149
|
reason: "INVALID_TIME_WINDOW",
|
|
150
150
|
now,
|
|
151
|
-
startsAt:
|
|
152
|
-
endsAt:
|
|
151
|
+
startsAt: condition2.startsAt,
|
|
152
|
+
endsAt: condition2.endsAt
|
|
153
153
|
}
|
|
154
154
|
};
|
|
155
155
|
}
|
|
@@ -161,20 +161,65 @@ function evaluateConditionDetailed(condition, context, now) {
|
|
|
161
161
|
conditionType: "time_window",
|
|
162
162
|
reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
|
|
163
163
|
now,
|
|
164
|
-
startsAt:
|
|
165
|
-
endsAt:
|
|
164
|
+
startsAt: condition2.startsAt,
|
|
165
|
+
endsAt: condition2.endsAt
|
|
166
166
|
}
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
-
const childResult = evaluateConditionDetailed(
|
|
169
|
+
const childResult = evaluateConditionDetailed(condition2.condition, context, now);
|
|
170
170
|
return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
|
|
171
171
|
}
|
|
172
172
|
default:
|
|
173
|
-
return assertNever(
|
|
173
|
+
return assertNever(condition2);
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
|
-
function evaluateCondition(
|
|
177
|
-
return evaluateConditionDetailed(
|
|
176
|
+
function evaluateCondition(condition2, context, now) {
|
|
177
|
+
return evaluateConditionDetailed(condition2, context, now ?? "").ok;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/engine/checkIn.ts
|
|
181
|
+
function unwrapContext(input) {
|
|
182
|
+
if ("verificationContext" in input) {
|
|
183
|
+
return {
|
|
184
|
+
context: input.verificationContext,
|
|
185
|
+
now: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
186
|
+
...input.expectedStampId === void 0 ? {} : { expectedStampId: input.expectedStampId },
|
|
187
|
+
alreadyClaimed: input.alreadyClaimed ?? false
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
context: input,
|
|
192
|
+
now: "now" in input && typeof input.now === "string" ? input.now : (/* @__PURE__ */ new Date()).toISOString(),
|
|
193
|
+
..."expectedStampId" in input && typeof input.expectedStampId === "string" ? { expectedStampId: input.expectedStampId } : {},
|
|
194
|
+
alreadyClaimed: "alreadyClaimed" in input && input.alreadyClaimed === true
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function evaluateCheckIn(spot, input) {
|
|
198
|
+
const context = unwrapContext(input);
|
|
199
|
+
if (context.alreadyClaimed) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
success: false,
|
|
203
|
+
code: "ALREADY_CLAIMED",
|
|
204
|
+
stampId: spot.id,
|
|
205
|
+
message: "This spot has already been claimed."
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (context.expectedStampId !== void 0 && context.expectedStampId !== spot.id) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
success: false,
|
|
212
|
+
code: "ORDER_VIOLATION",
|
|
213
|
+
stampId: spot.id,
|
|
214
|
+
message: `The next required spot is '${context.expectedStampId}'.`
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const evaluated = evaluateConditionDetailed(spot.condition, context.context, context.now);
|
|
218
|
+
if (evaluated.ok) {
|
|
219
|
+
return { ok: true, success: true, stampId: spot.id, checkedAt: context.now };
|
|
220
|
+
}
|
|
221
|
+
const code = evaluated.error.reason === "OUTSIDE_RADIUS" ? "OUT_OF_RANGE" : evaluated.error.reason === "BEFORE_START" || evaluated.error.reason === "AFTER_END" ? "EXPIRED" : evaluated.error.reason === "TOKEN_MISMATCH" ? "INVALID_PROOF" : "INVALID_CONTEXT";
|
|
222
|
+
return { ok: false, success: false, code, stampId: spot.id, message: evaluated.error.reason };
|
|
178
223
|
}
|
|
179
224
|
|
|
180
225
|
// src/engine/order.ts
|
|
@@ -206,6 +251,26 @@ function calculateProgress(state, config) {
|
|
|
206
251
|
};
|
|
207
252
|
}
|
|
208
253
|
|
|
254
|
+
// src/engine/rewards.ts
|
|
255
|
+
function hash(value) {
|
|
256
|
+
let result = 2166136261;
|
|
257
|
+
for (const character of value) {
|
|
258
|
+
result ^= character.codePointAt(0) ?? 0;
|
|
259
|
+
result = Math.imul(result, 16777619) >>> 0;
|
|
260
|
+
}
|
|
261
|
+
return result.toString(36).toUpperCase().padStart(7, "0");
|
|
262
|
+
}
|
|
263
|
+
function createClaimTicketNumber(rewardId, options = {}) {
|
|
264
|
+
const issuedAt = options.issuedAt ?? "";
|
|
265
|
+
const sequence = options.sequence ?? 0;
|
|
266
|
+
return `SR-${hash(`${rewardId}|${issuedAt}|${sequence}`)}`;
|
|
267
|
+
}
|
|
268
|
+
function issueClaimTicketNumber(reward, currentState, options = {}) {
|
|
269
|
+
if (currentState.claimTicketNumber !== void 0) return currentState;
|
|
270
|
+
const claimTicketNumber = reward.claimTicketNumber ?? createClaimTicketNumber(reward.id, options);
|
|
271
|
+
return { ...currentState, claimTicketNumber };
|
|
272
|
+
}
|
|
273
|
+
|
|
209
274
|
// src/detectors/types.ts
|
|
210
275
|
function createDetectorError(detector, code, message, cause) {
|
|
211
276
|
return cause === void 0 ? { detector, code, message } : { detector, code, message, cause };
|
|
@@ -410,9 +475,9 @@ function normalizePasscode(input, caseSensitive = false) {
|
|
|
410
475
|
const normalized = input.normalize("NFKC").trim();
|
|
411
476
|
return caseSensitive ? normalized : normalized.toUpperCase();
|
|
412
477
|
}
|
|
413
|
-
function verifyPasscode(inputCode,
|
|
414
|
-
const input = normalizePasscode(inputCode,
|
|
415
|
-
const expected = normalizePasscode(
|
|
478
|
+
function verifyPasscode(inputCode, condition2) {
|
|
479
|
+
const input = normalizePasscode(inputCode, condition2.caseSensitive);
|
|
480
|
+
const expected = normalizePasscode(condition2.passcode, condition2.caseSensitive);
|
|
416
481
|
return input === expected ? { success: true } : {
|
|
417
482
|
success: false,
|
|
418
483
|
reason: "INVALID_PASSCODE",
|
|
@@ -549,12 +614,23 @@ function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now)
|
|
|
549
614
|
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") {
|
|
550
615
|
return current;
|
|
551
616
|
}
|
|
617
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now)) {
|
|
618
|
+
return { rewardId: reward.id, status: "EXPIRED" };
|
|
619
|
+
}
|
|
620
|
+
if (reward.maxStock !== void 0 && (current?.redeemedCount ?? 0) >= reward.maxStock) {
|
|
621
|
+
return {
|
|
622
|
+
rewardId: reward.id,
|
|
623
|
+
status: "EXPIRED",
|
|
624
|
+
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
625
|
+
};
|
|
626
|
+
}
|
|
552
627
|
if (acquiredStampCount >= reward.requiredStampCount) {
|
|
553
628
|
if (current?.status === "AVAILABLE") return current;
|
|
554
629
|
return {
|
|
555
630
|
rewardId: reward.id,
|
|
556
631
|
status: "AVAILABLE",
|
|
557
|
-
unlockedAt: current?.unlockedAt ?? now
|
|
632
|
+
unlockedAt: current?.unlockedAt ?? now,
|
|
633
|
+
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
558
634
|
};
|
|
559
635
|
}
|
|
560
636
|
if (current?.status === "LOCKED" && current.unlockedAt === void 0) return current;
|
|
@@ -575,6 +651,15 @@ function consumeReward(params) {
|
|
|
575
651
|
error: { code: "NOT_AVAILABLE", rewardId: reward.id }
|
|
576
652
|
};
|
|
577
653
|
}
|
|
654
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now)) {
|
|
655
|
+
return { ok: false, error: { code: "EXPIRED", rewardId: reward.id } };
|
|
656
|
+
}
|
|
657
|
+
if (reward.maxStock !== void 0 && (currentState.redeemedCount ?? 0) >= reward.maxStock) {
|
|
658
|
+
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
|
|
659
|
+
}
|
|
660
|
+
if (reward.limitPerUser !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward.limitPerUser) {
|
|
661
|
+
return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward.id } };
|
|
662
|
+
}
|
|
578
663
|
if (reward.redemptionMethod === "staff_passcode") {
|
|
579
664
|
const passcodeResult = reward.staffPasscode === void 0 ? null : verifyPasscode(params.inputPasscode ?? "", { passcode: reward.staffPasscode });
|
|
580
665
|
if (passcodeResult === null || !passcodeResult.success) {
|
|
@@ -597,6 +682,8 @@ function consumeReward(params) {
|
|
|
597
682
|
...currentState,
|
|
598
683
|
status: "CONSUMED",
|
|
599
684
|
consumedAt: params.now,
|
|
685
|
+
...reward.maxStock !== void 0 || params.userId !== void 0 || params.userRedemptionCount !== void 0 ? { redeemedCount: (currentState.redeemedCount ?? 0) + 1 } : {},
|
|
686
|
+
...params.userId === void 0 ? {} : { userRedemptionCount: (currentState.userRedemptionCount ?? 0) + 1 },
|
|
600
687
|
...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
|
|
601
688
|
}
|
|
602
689
|
};
|
|
@@ -647,6 +734,14 @@ function processStamp(state, config, targetStampId, context, now) {
|
|
|
647
734
|
updatedAt: now
|
|
648
735
|
};
|
|
649
736
|
const events = [{ type: "stampAcquired", record }];
|
|
737
|
+
if (nextRewards !== void 0) {
|
|
738
|
+
for (const rewardState of nextRewards) {
|
|
739
|
+
const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
|
|
740
|
+
if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
|
|
741
|
+
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now });
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
650
745
|
const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
|
|
651
746
|
if (completed) {
|
|
652
747
|
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now });
|
|
@@ -1222,14 +1317,361 @@ var StampRallyClient = class {
|
|
|
1222
1317
|
};
|
|
1223
1318
|
|
|
1224
1319
|
// src/domain/i18n.ts
|
|
1225
|
-
function resolveLocalizedText(
|
|
1226
|
-
if (
|
|
1227
|
-
if (typeof
|
|
1228
|
-
|
|
1320
|
+
function resolveLocalizedText(text2, locale, fallbackLocale) {
|
|
1321
|
+
if (text2 === void 0 || text2 === "") return "";
|
|
1322
|
+
if (typeof text2 === "string") return text2;
|
|
1323
|
+
const fallback = fallbackLocale ?? "ja";
|
|
1324
|
+
return text2[locale] || text2[fallback] || "";
|
|
1325
|
+
}
|
|
1326
|
+
function toLocalizedString(text2) {
|
|
1327
|
+
if (text2 === void 0) return { ja: "", en: "" };
|
|
1328
|
+
return typeof text2 === "string" ? { ja: text2, en: "" } : {
|
|
1329
|
+
ja: text2["ja"] ?? "",
|
|
1330
|
+
en: text2["en"] ?? "",
|
|
1331
|
+
...text2
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// src/domain/validation.ts
|
|
1336
|
+
var CURRENT_RALLY_CONFIG_VERSION = 2;
|
|
1337
|
+
function isObject(value) {
|
|
1338
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1339
|
+
}
|
|
1340
|
+
function add(errors, path, code, message) {
|
|
1341
|
+
errors.push({ path, code, message });
|
|
1342
|
+
}
|
|
1343
|
+
function hasText(value) {
|
|
1344
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
1345
|
+
if (!isObject(value)) return false;
|
|
1346
|
+
return Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
1347
|
+
}
|
|
1348
|
+
function validateCondition(value, path, errors) {
|
|
1349
|
+
if (!isObject(value) || typeof value.type !== "string") {
|
|
1350
|
+
add(errors, path, "INVALID_TYPE", "Condition must be an object with a type.");
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
switch (value.type) {
|
|
1354
|
+
case "instant":
|
|
1355
|
+
return;
|
|
1356
|
+
case "token":
|
|
1357
|
+
if (typeof value.token !== "string" || value.token.trim() === "") {
|
|
1358
|
+
add(errors, `${path}.token`, "REQUIRED", "Token must not be empty.");
|
|
1359
|
+
}
|
|
1360
|
+
return;
|
|
1361
|
+
case "geo": {
|
|
1362
|
+
const latitude = value.latitude;
|
|
1363
|
+
const longitude = value.longitude;
|
|
1364
|
+
if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
|
1365
|
+
add(
|
|
1366
|
+
errors,
|
|
1367
|
+
path,
|
|
1368
|
+
"INVALID_COORDINATES",
|
|
1369
|
+
"Latitude must be between -90 and 90 and longitude between -180 and 180."
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
if (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0) {
|
|
1373
|
+
add(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "Radius must be greater than zero.");
|
|
1374
|
+
}
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
case "composite":
|
|
1378
|
+
if (value.operator !== "AND" && value.operator !== "OR") {
|
|
1379
|
+
add(errors, `${path}.operator`, "INVALID_TYPE", "Operator must be AND or OR.");
|
|
1380
|
+
}
|
|
1381
|
+
if (!Array.isArray(value.conditions)) {
|
|
1382
|
+
add(errors, `${path}.conditions`, "INVALID_TYPE", "Composite conditions must be an array.");
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
value.conditions.forEach((child, index) => {
|
|
1386
|
+
validateCondition(child, `${path}.conditions[${index}]`, errors);
|
|
1387
|
+
});
|
|
1388
|
+
return;
|
|
1389
|
+
case "time_window":
|
|
1390
|
+
if (typeof value.startsAt !== "string" || Number.isNaN(Date.parse(value.startsAt))) {
|
|
1391
|
+
add(errors, `${path}.startsAt`, "INVALID_DATE", "Start must be a valid ISO date.");
|
|
1392
|
+
}
|
|
1393
|
+
if (typeof value.endsAt !== "string" || Number.isNaN(Date.parse(value.endsAt))) {
|
|
1394
|
+
add(errors, `${path}.endsAt`, "INVALID_DATE", "End must be a valid ISO date.");
|
|
1395
|
+
}
|
|
1396
|
+
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)) {
|
|
1397
|
+
add(errors, path, "INVALID_DATE", "Time window start must be before its end.");
|
|
1398
|
+
}
|
|
1399
|
+
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1400
|
+
return;
|
|
1401
|
+
default:
|
|
1402
|
+
add(errors, `${path}.type`, "INVALID_TYPE", "Unsupported condition type.");
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
function validateSpot(value, index, errors) {
|
|
1406
|
+
const path = `stamps[${index}]`;
|
|
1407
|
+
if (!isObject(value)) {
|
|
1408
|
+
add(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (typeof value.id !== "string") add(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
1412
|
+
else if (value.id.trim() === "")
|
|
1413
|
+
add(errors, `${path}.id`, "EMPTY_STRING", "Spot ID must not be empty.");
|
|
1414
|
+
if (value.name === void 0) add(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
1415
|
+
else if (!hasText(value.name))
|
|
1416
|
+
add(errors, `${path}.name`, "EMPTY_STRING", "Spot name must not be empty.");
|
|
1417
|
+
if (value.order !== void 0 && (typeof value.order !== "number" || !Number.isFinite(value.order))) {
|
|
1418
|
+
add(errors, `${path}.order`, "INVALID_TYPE", "Spot order must be a finite number.");
|
|
1419
|
+
}
|
|
1420
|
+
validateCondition(value.condition, `${path}.condition`, errors);
|
|
1421
|
+
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1422
|
+
if (value[field] !== void 0 && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== "string"))) {
|
|
1423
|
+
add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be an array of IDs.`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
function validateReward(value, index, stampCount, errors) {
|
|
1428
|
+
const path = `rewards[${index}]`;
|
|
1429
|
+
if (!isObject(value)) {
|
|
1430
|
+
add(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (typeof value.id !== "string" || value.id.trim() === "") {
|
|
1434
|
+
add(errors, `${path}.id`, "REQUIRED", "Reward ID must not be empty.");
|
|
1435
|
+
}
|
|
1436
|
+
if (!hasText(value.title)) {
|
|
1437
|
+
add(errors, `${path}.title`, "EMPTY_STRING", "Reward title must not be empty.");
|
|
1438
|
+
}
|
|
1439
|
+
if (!hasText(value.description)) {
|
|
1440
|
+
add(errors, `${path}.description`, "EMPTY_STRING", "Reward description must not be empty.");
|
|
1441
|
+
}
|
|
1442
|
+
const required = value.requiredStampCount;
|
|
1443
|
+
if (typeof required !== "number" || !Number.isInteger(required) || required < 0 || required > stampCount) {
|
|
1444
|
+
add(
|
|
1445
|
+
errors,
|
|
1446
|
+
`${path}.requiredStampCount`,
|
|
1447
|
+
"INVALID_REWARD",
|
|
1448
|
+
"Required stamps must be an integer within the rally."
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
if (value.validUntil !== void 0 && (typeof value.validUntil !== "string" || Number.isNaN(Date.parse(value.validUntil)))) {
|
|
1452
|
+
add(errors, `${path}.validUntil`, "INVALID_DATE", "Reward expiry must be a valid ISO date.");
|
|
1453
|
+
}
|
|
1454
|
+
for (const field of ["maxStock", "limitPerUser"]) {
|
|
1455
|
+
const count = value[field];
|
|
1456
|
+
if (count !== void 0 && (typeof count !== "number" || !Number.isInteger(count) || count <= 0)) {
|
|
1457
|
+
add(errors, `${path}.${field}`, "INVALID_REWARD", `${field} must be a positive integer.`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
function collectDependencies(spot) {
|
|
1462
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
1463
|
+
for (const field of ["dependsOn", "requiresStampIds"]) {
|
|
1464
|
+
const values = spot[field];
|
|
1465
|
+
if (Array.isArray(values)) {
|
|
1466
|
+
for (const value of values) if (typeof value === "string") dependencies.add(value);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return [...dependencies];
|
|
1470
|
+
}
|
|
1471
|
+
function validateDag(stamps, errors) {
|
|
1472
|
+
const graph = /* @__PURE__ */ new Map();
|
|
1473
|
+
for (const value of stamps) {
|
|
1474
|
+
if (!isObject(value) || typeof value.id !== "string") continue;
|
|
1475
|
+
graph.set(value.id, [...graph.get(value.id) ?? [], ...collectDependencies(value)]);
|
|
1476
|
+
}
|
|
1477
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1478
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1479
|
+
const visit = (id, path) => {
|
|
1480
|
+
if (visiting.has(id)) {
|
|
1481
|
+
add(errors, path, "CYCLE_DETECTED", `Dependency cycle detected at '${id}'.`);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (visited.has(id)) return;
|
|
1485
|
+
visiting.add(id);
|
|
1486
|
+
for (const dependency of graph.get(id) ?? [])
|
|
1487
|
+
if (graph.has(dependency)) visit(dependency, path);
|
|
1488
|
+
visiting.delete(id);
|
|
1489
|
+
visited.add(id);
|
|
1490
|
+
};
|
|
1491
|
+
for (const id of graph.keys()) visit(id, `stamps[${id}]`);
|
|
1492
|
+
}
|
|
1493
|
+
function validateRallyConfig(config) {
|
|
1494
|
+
const errors = [];
|
|
1495
|
+
if (!isObject(config))
|
|
1496
|
+
return {
|
|
1497
|
+
valid: false,
|
|
1498
|
+
errors: [{ path: "", code: "INVALID_TYPE", message: "Rally config must be an object." }]
|
|
1499
|
+
};
|
|
1500
|
+
if (typeof config.id !== "string") add(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
1501
|
+
else if (config.id.trim() === "")
|
|
1502
|
+
add(errors, "id", "EMPTY_STRING", "Rally ID must not be empty.");
|
|
1503
|
+
if (config.title !== void 0 && !hasText(config.title))
|
|
1504
|
+
add(errors, "title", "EMPTY_STRING", "Rally title must not be empty.");
|
|
1505
|
+
if (config.version !== void 0 && config.version !== CURRENT_RALLY_CONFIG_VERSION) {
|
|
1506
|
+
add(
|
|
1507
|
+
errors,
|
|
1508
|
+
"version",
|
|
1509
|
+
"INVALID_VERSION",
|
|
1510
|
+
`Config version must be ${CURRENT_RALLY_CONFIG_VERSION}.`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
for (const field of ["startDate", "endDate"]) {
|
|
1514
|
+
if (config[field] !== void 0 && (typeof config[field] !== "string" || Number.isNaN(Date.parse(config[field])))) {
|
|
1515
|
+
add(errors, field, "INVALID_DATE", `${field} must be a valid ISO date.`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
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)) {
|
|
1519
|
+
add(errors, "startDate", "INVALID_DATE", "startDate must be before endDate.");
|
|
1520
|
+
}
|
|
1521
|
+
if (!Array.isArray(config.stamps)) {
|
|
1522
|
+
add(errors, "stamps", "INVALID_TYPE", "stamps must be an array.");
|
|
1523
|
+
} else {
|
|
1524
|
+
config.stamps.forEach((spot, index) => {
|
|
1525
|
+
validateSpot(spot, index, errors);
|
|
1526
|
+
});
|
|
1527
|
+
const ids = config.stamps.map(
|
|
1528
|
+
(spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : ""
|
|
1529
|
+
);
|
|
1530
|
+
ids.forEach((id, index) => {
|
|
1531
|
+
if (id !== "" && ids.indexOf(id) !== index)
|
|
1532
|
+
add(errors, `stamps[${index}].id`, "DUPLICATE_ID", `Duplicate spot ID '${id}'.`);
|
|
1533
|
+
});
|
|
1534
|
+
validateDag(config.stamps, errors);
|
|
1535
|
+
}
|
|
1536
|
+
if (config.rewards !== void 0 && !Array.isArray(config.rewards)) {
|
|
1537
|
+
add(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
1538
|
+
} else if (Array.isArray(config.rewards)) {
|
|
1539
|
+
const stampCount = Array.isArray(config.stamps) ? config.stamps.length : 0;
|
|
1540
|
+
config.rewards.forEach((reward, index) => {
|
|
1541
|
+
validateReward(reward, index, stampCount, errors);
|
|
1542
|
+
});
|
|
1543
|
+
const ids = config.rewards.map(
|
|
1544
|
+
(reward) => isObject(reward) && typeof reward.id === "string" ? reward.id : ""
|
|
1545
|
+
);
|
|
1546
|
+
ids.forEach((id, index) => {
|
|
1547
|
+
if (id !== "" && ids.indexOf(id) !== index)
|
|
1548
|
+
add(errors, `rewards[${index}].id`, "DUPLICATE_ID", `Duplicate reward ID '${id}'.`);
|
|
1549
|
+
});
|
|
1550
|
+
if (Array.isArray(config.stamps)) {
|
|
1551
|
+
const stampIds = new Set(
|
|
1552
|
+
config.stamps.map((spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : "")
|
|
1553
|
+
);
|
|
1554
|
+
ids.forEach((id, index) => {
|
|
1555
|
+
if (id !== "" && stampIds.has(id))
|
|
1556
|
+
add(
|
|
1557
|
+
errors,
|
|
1558
|
+
`rewards[${index}].id`,
|
|
1559
|
+
"DUPLICATE_ID",
|
|
1560
|
+
`Reward ID '${id}' is already used by a spot.`
|
|
1561
|
+
);
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return { valid: errors.length === 0, errors };
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// src/domain/migration.ts
|
|
1569
|
+
function isObject2(value) {
|
|
1570
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1571
|
+
}
|
|
1572
|
+
function text(value) {
|
|
1573
|
+
if (typeof value === "string") return value;
|
|
1574
|
+
if (!isObject2(value)) return void 0;
|
|
1575
|
+
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
|
1576
|
+
return entries.length === 0 ? void 0 : Object.fromEntries(entries);
|
|
1577
|
+
}
|
|
1578
|
+
function condition(value) {
|
|
1579
|
+
if (!isObject2(value) || typeof value.type !== "string") return { type: "instant" };
|
|
1580
|
+
switch (value.type) {
|
|
1581
|
+
case "instant":
|
|
1582
|
+
return { type: "instant" };
|
|
1583
|
+
case "token":
|
|
1584
|
+
case "passcode":
|
|
1585
|
+
return {
|
|
1586
|
+
type: "token",
|
|
1587
|
+
token: typeof value.token === "string" ? value.token : typeof value.passcode === "string" ? value.passcode : ""
|
|
1588
|
+
};
|
|
1589
|
+
case "geo":
|
|
1590
|
+
return {
|
|
1591
|
+
type: "geo",
|
|
1592
|
+
latitude: typeof value.latitude === "number" ? value.latitude : 0,
|
|
1593
|
+
longitude: typeof value.longitude === "number" ? value.longitude : 0,
|
|
1594
|
+
radiusMeters: typeof value.radiusMeters === "number" ? value.radiusMeters : typeof value.radius === "number" ? value.radius : 1
|
|
1595
|
+
};
|
|
1596
|
+
case "composite":
|
|
1597
|
+
return {
|
|
1598
|
+
type: "composite",
|
|
1599
|
+
operator: value.operator === "OR" ? "OR" : "AND",
|
|
1600
|
+
conditions: Array.isArray(value.conditions) ? value.conditions.map(condition) : []
|
|
1601
|
+
};
|
|
1602
|
+
case "time_window":
|
|
1603
|
+
return {
|
|
1604
|
+
type: "time_window",
|
|
1605
|
+
startsAt: typeof value.startsAt === "string" ? value.startsAt : "1970-01-01T00:00:00.000Z",
|
|
1606
|
+
endsAt: typeof value.endsAt === "string" ? value.endsAt : "9999-12-31T23:59:59.999Z",
|
|
1607
|
+
condition: condition(value.condition)
|
|
1608
|
+
};
|
|
1609
|
+
default:
|
|
1610
|
+
return { type: "instant" };
|
|
1611
|
+
}
|
|
1229
1612
|
}
|
|
1230
|
-
function
|
|
1231
|
-
|
|
1232
|
-
|
|
1613
|
+
function migrateSpot(value, index) {
|
|
1614
|
+
const source = isObject2(value) ? value : {};
|
|
1615
|
+
const id = typeof source.id === "string" && source.id.trim() !== "" ? source.id.trim() : `spot-${index + 1}`;
|
|
1616
|
+
const description = text(source.description);
|
|
1617
|
+
const hint = text(source.hint);
|
|
1618
|
+
return {
|
|
1619
|
+
id,
|
|
1620
|
+
name: text(source.name) ?? `Spot ${index + 1}`,
|
|
1621
|
+
...description === void 0 ? {} : { description },
|
|
1622
|
+
...hint === void 0 ? {} : { hint },
|
|
1623
|
+
condition: condition(
|
|
1624
|
+
source.condition ?? (typeof source.token === "string" ? { type: "token", token: source.token } : void 0)
|
|
1625
|
+
),
|
|
1626
|
+
...typeof source.order === "number" ? { order: source.order } : {},
|
|
1627
|
+
...typeof source.deckId === "string" ? { deckId: source.deckId } : {},
|
|
1628
|
+
...typeof source.groupId === "string" ? { groupId: source.groupId } : {},
|
|
1629
|
+
...typeof source.guideId === "string" ? { guideId: source.guideId } : {},
|
|
1630
|
+
...typeof source.iconUrl === "string" ? { iconUrl: source.iconUrl } : {},
|
|
1631
|
+
...typeof source.imageUrl === "string" ? { imageUrl: source.imageUrl } : {},
|
|
1632
|
+
...typeof source.externalUrl === "string" ? { externalUrl: source.externalUrl } : {},
|
|
1633
|
+
...typeof source.redirectUrlAfterClaim === "string" ? { redirectUrlAfterClaim: source.redirectUrlAfterClaim } : {},
|
|
1634
|
+
...isObject2(source.metadata) ? { metadata: source.metadata } : {},
|
|
1635
|
+
...Array.isArray(source.dependsOn) ? { dependsOn: source.dependsOn.filter((item) => typeof item === "string") } : {}
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
function migrateReward(value, index) {
|
|
1639
|
+
const source = isObject2(value) ? value : {};
|
|
1640
|
+
return {
|
|
1641
|
+
id: typeof source.id === "string" && source.id.trim() !== "" ? source.id.trim() : `reward-${index + 1}`,
|
|
1642
|
+
title: text(source.title) ?? `Reward ${index + 1}`,
|
|
1643
|
+
description: text(source.description) ?? "",
|
|
1644
|
+
type: source.type === "digital" ? "digital" : "in_person",
|
|
1645
|
+
redemptionMethod: source.redemptionMethod === "staff_passcode" || source.redemptionMethod === "view_only" ? source.redemptionMethod : "manual_slide",
|
|
1646
|
+
requiredStampCount: typeof source.requiredStampCount === "number" && Number.isFinite(source.requiredStampCount) ? Math.max(0, Math.trunc(source.requiredStampCount)) : 0,
|
|
1647
|
+
...typeof source.digitalContentUrl === "string" ? { digitalContentUrl: source.digitalContentUrl } : {},
|
|
1648
|
+
...typeof source.staffPasscode === "string" ? { staffPasscode: source.staffPasscode } : {},
|
|
1649
|
+
...typeof source.validUntil === "string" ? { validUntil: source.validUntil } : {},
|
|
1650
|
+
...typeof source.maxStock === "number" ? { maxStock: source.maxStock } : {},
|
|
1651
|
+
...typeof source.limitPerUser === "number" ? { limitPerUser: source.limitPerUser } : {},
|
|
1652
|
+
...typeof source.claimTicketNumber === "string" ? { claimTicketNumber: source.claimTicketNumber } : {}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
function migrateRallyConfig(raw) {
|
|
1656
|
+
const source = isObject2(raw) ? raw : {};
|
|
1657
|
+
const rawStamps = Array.isArray(source.stamps) ? source.stamps : Array.isArray(source.spots) ? source.spots : [];
|
|
1658
|
+
const rawRewards = Array.isArray(source.rewards) ? source.rewards : [];
|
|
1659
|
+
const id = typeof source.id === "string" && source.id.trim() !== "" ? source.id.trim() : "migrated-rally";
|
|
1660
|
+
const title = text(source.title);
|
|
1661
|
+
const description = text(source.description);
|
|
1662
|
+
const theme = isObject2(source.theme) ? source.theme : void 0;
|
|
1663
|
+
return {
|
|
1664
|
+
id,
|
|
1665
|
+
...title === void 0 ? {} : { title },
|
|
1666
|
+
...description === void 0 ? {} : { description },
|
|
1667
|
+
stamps: rawStamps.map(migrateSpot),
|
|
1668
|
+
...rawRewards.length === 0 ? {} : { rewards: rawRewards.map(migrateReward) },
|
|
1669
|
+
...typeof source.isSequential === "boolean" ? { isSequential: source.isSequential } : {},
|
|
1670
|
+
...theme === void 0 ? {} : { theme },
|
|
1671
|
+
version: CURRENT_RALLY_CONFIG_VERSION,
|
|
1672
|
+
...typeof source.startDate === "string" ? { startDate: source.startDate } : typeof source.startsAt === "string" ? { startDate: source.startsAt } : {},
|
|
1673
|
+
...typeof source.endDate === "string" ? { endDate: source.endDate } : typeof source.endsAt === "string" ? { endDate: source.endsAt } : {}
|
|
1674
|
+
};
|
|
1233
1675
|
}
|
|
1234
1676
|
|
|
1235
1677
|
// src/domain/models.ts
|
|
@@ -1244,6 +1686,15 @@ var DEFAULT_SHEET_THEME = {
|
|
|
1244
1686
|
fontFamily: "serif"
|
|
1245
1687
|
};
|
|
1246
1688
|
|
|
1689
|
+
// src/domain/publicConfig.ts
|
|
1690
|
+
function stripSensitiveConfig(config) {
|
|
1691
|
+
const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
|
|
1692
|
+
return {
|
|
1693
|
+
...config,
|
|
1694
|
+
...rewards === void 0 ? {} : { rewards }
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1247
1698
|
// src/domain/themePresets.ts
|
|
1248
1699
|
var THEME_PRESETS = [
|
|
1249
1700
|
{
|
|
@@ -1343,6 +1794,126 @@ var THEME_PRESETS = [
|
|
|
1343
1794
|
}
|
|
1344
1795
|
];
|
|
1345
1796
|
|
|
1797
|
+
// src/security/snapshotToken.ts
|
|
1798
|
+
var encoder = new TextEncoder();
|
|
1799
|
+
function cryptoApi() {
|
|
1800
|
+
if (globalThis.crypto === void 0 || globalThis.crypto.subtle === void 0) {
|
|
1801
|
+
throw new Error("Web Crypto API is unavailable in this environment.");
|
|
1802
|
+
}
|
|
1803
|
+
return globalThis.crypto;
|
|
1804
|
+
}
|
|
1805
|
+
function secretBytes(secretKey) {
|
|
1806
|
+
return typeof secretKey === "string" ? encoder.encode(secretKey) : new Uint8Array(secretKey);
|
|
1807
|
+
}
|
|
1808
|
+
function webCryptoBytes(bytes) {
|
|
1809
|
+
return bytes.buffer;
|
|
1810
|
+
}
|
|
1811
|
+
function base64Url(bytes) {
|
|
1812
|
+
let binary = "";
|
|
1813
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
1814
|
+
return globalThis.btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
1815
|
+
}
|
|
1816
|
+
function fromBase64Url(value) {
|
|
1817
|
+
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
1818
|
+
const padded = `${base64}${"=".repeat((4 - base64.length % 4) % 4)}`;
|
|
1819
|
+
const binary = globalThis.atob(padded);
|
|
1820
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
1821
|
+
}
|
|
1822
|
+
async function importHmacKey(secret) {
|
|
1823
|
+
return cryptoApi().subtle.importKey(
|
|
1824
|
+
"raw",
|
|
1825
|
+
webCryptoBytes(secret),
|
|
1826
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1827
|
+
false,
|
|
1828
|
+
["sign", "verify"]
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
async function importAesKey(secret) {
|
|
1832
|
+
const digest = await cryptoApi().subtle.digest("SHA-256", webCryptoBytes(secret));
|
|
1833
|
+
return cryptoApi().subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
1834
|
+
}
|
|
1835
|
+
function isExpired(payload, now) {
|
|
1836
|
+
if (typeof payload.exp === "number" && now >= payload.exp * 1e3) return true;
|
|
1837
|
+
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now;
|
|
1838
|
+
}
|
|
1839
|
+
async function createSignedSnapshotToken(payload, secretKey) {
|
|
1840
|
+
const api = cryptoApi();
|
|
1841
|
+
const secret = secretBytes(secretKey);
|
|
1842
|
+
const iv = api.getRandomValues(new Uint8Array(12));
|
|
1843
|
+
const plaintext = encoder.encode(JSON.stringify(payload));
|
|
1844
|
+
const encrypted = new Uint8Array(
|
|
1845
|
+
await api.subtle.encrypt(
|
|
1846
|
+
{ name: "AES-GCM", iv: webCryptoBytes(iv) },
|
|
1847
|
+
await importAesKey(secret),
|
|
1848
|
+
webCryptoBytes(plaintext)
|
|
1849
|
+
)
|
|
1850
|
+
);
|
|
1851
|
+
const body = base64Url(new Uint8Array([...iv, ...encrypted]));
|
|
1852
|
+
const signature = new Uint8Array(
|
|
1853
|
+
await api.subtle.sign("HMAC", await importHmacKey(secret), encoder.encode(body))
|
|
1854
|
+
);
|
|
1855
|
+
return `sr2.${body}.${base64Url(signature)}`;
|
|
1856
|
+
}
|
|
1857
|
+
async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
1858
|
+
try {
|
|
1859
|
+
const parts = token.split(".");
|
|
1860
|
+
if (parts.length !== 3 || parts[0] !== "sr2") {
|
|
1861
|
+
return {
|
|
1862
|
+
ok: false,
|
|
1863
|
+
valid: false,
|
|
1864
|
+
error: { code: "MALFORMED", message: "Snapshot token is malformed." }
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
const [, body, encodedSignature] = parts;
|
|
1868
|
+
if (body === void 0 || encodedSignature === void 0) {
|
|
1869
|
+
return {
|
|
1870
|
+
ok: false,
|
|
1871
|
+
valid: false,
|
|
1872
|
+
error: { code: "MALFORMED", message: "Snapshot token is malformed." }
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
const secret = secretBytes(secretKey);
|
|
1876
|
+
const validSignature = await cryptoApi().subtle.verify(
|
|
1877
|
+
"HMAC",
|
|
1878
|
+
await importHmacKey(secret),
|
|
1879
|
+
webCryptoBytes(fromBase64Url(encodedSignature)),
|
|
1880
|
+
webCryptoBytes(encoder.encode(body))
|
|
1881
|
+
);
|
|
1882
|
+
if (!validSignature) {
|
|
1883
|
+
return {
|
|
1884
|
+
ok: false,
|
|
1885
|
+
valid: false,
|
|
1886
|
+
error: { code: "INVALID_SIGNATURE", message: "Snapshot token signature is invalid." }
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
const encrypted = fromBase64Url(body);
|
|
1890
|
+
if (encrypted.length <= 12) throw new Error("Invalid encrypted payload.");
|
|
1891
|
+
const payloadText = await cryptoApi().subtle.decrypt(
|
|
1892
|
+
{ name: "AES-GCM", iv: webCryptoBytes(encrypted.slice(0, 12)) },
|
|
1893
|
+
await importAesKey(secret),
|
|
1894
|
+
webCryptoBytes(encrypted.slice(12))
|
|
1895
|
+
);
|
|
1896
|
+
const payload = JSON.parse(new TextDecoder().decode(payloadText));
|
|
1897
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
1898
|
+
throw new Error("Invalid payload.");
|
|
1899
|
+
if (isExpired(payload, now)) {
|
|
1900
|
+
return {
|
|
1901
|
+
ok: false,
|
|
1902
|
+
valid: false,
|
|
1903
|
+
error: { code: "EXPIRED", message: "Snapshot token has expired." }
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
return { ok: true, valid: true, payload };
|
|
1907
|
+
} catch {
|
|
1908
|
+
return {
|
|
1909
|
+
ok: false,
|
|
1910
|
+
valid: false,
|
|
1911
|
+
error: { code: "DECRYPTION_FAILED", message: "Snapshot token could not be verified." }
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
exports.CURRENT_RALLY_CONFIG_VERSION = CURRENT_RALLY_CONFIG_VERSION;
|
|
1346
1917
|
exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
|
|
1347
1918
|
exports.InMemoryStorage = InMemoryStorage;
|
|
1348
1919
|
exports.IndexedDBAdapter = IndexedDBAdapter;
|
|
@@ -1353,6 +1924,9 @@ exports.THEME_PRESETS = THEME_PRESETS;
|
|
|
1353
1924
|
exports.calculateDistanceMeters = calculateDistanceMeters;
|
|
1354
1925
|
exports.calculateProgress = calculateProgress;
|
|
1355
1926
|
exports.consumeReward = consumeReward;
|
|
1927
|
+
exports.createClaimTicketNumber = createClaimTicketNumber;
|
|
1928
|
+
exports.createSignedSnapshotToken = createSignedSnapshotToken;
|
|
1929
|
+
exports.evaluateCheckIn = evaluateCheckIn;
|
|
1356
1930
|
exports.evaluateCondition = evaluateCondition;
|
|
1357
1931
|
exports.evaluateConditionDetailed = evaluateConditionDetailed;
|
|
1358
1932
|
exports.exportProgressToken = exportProgressToken;
|
|
@@ -1363,13 +1937,18 @@ exports.isNfcSupported = isNfcSupported;
|
|
|
1363
1937
|
exports.isQrSupported = isQrSupported;
|
|
1364
1938
|
exports.isRewardState = isRewardState;
|
|
1365
1939
|
exports.isStampRallyState = isStampRallyState;
|
|
1940
|
+
exports.issueClaimTicketNumber = issueClaimTicketNumber;
|
|
1941
|
+
exports.migrateRallyConfig = migrateRallyConfig;
|
|
1366
1942
|
exports.normalizePasscode = normalizePasscode;
|
|
1367
1943
|
exports.processStamp = processStamp;
|
|
1368
1944
|
exports.readNfcContext = readNfcContext;
|
|
1369
1945
|
exports.readQrContext = readQrContext;
|
|
1370
1946
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
1371
1947
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
1948
|
+
exports.stripSensitiveConfig = stripSensitiveConfig;
|
|
1372
1949
|
exports.toLocalizedString = toLocalizedString;
|
|
1950
|
+
exports.validateRallyConfig = validateRallyConfig;
|
|
1373
1951
|
exports.verifyPasscode = verifyPasscode;
|
|
1952
|
+
exports.verifySnapshotToken = verifySnapshotToken;
|
|
1374
1953
|
//# sourceMappingURL=index.cjs.map
|
|
1375
1954
|
//# sourceMappingURL=index.cjs.map
|