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