@stamprally/core 0.9.0 → 0.10.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 +10 -143
- package/dist/index.cjs +411 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +406 -83
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -12,34 +12,34 @@ function calculateDistanceMeters(aLat, aLon, bLat, bLon) {
|
|
|
12
12
|
function mismatch(conditionType, reason, extra = {}) {
|
|
13
13
|
return { ok: false, error: { code: "CONDITION_MISMATCH", conditionType, reason, ...extra } };
|
|
14
14
|
}
|
|
15
|
-
function evaluateConditionDetailed(
|
|
16
|
-
switch (
|
|
15
|
+
function evaluateConditionDetailed(condition2, context) {
|
|
16
|
+
switch (condition2.type) {
|
|
17
17
|
case "qr":
|
|
18
|
-
return context.type === "qr" && context.token ===
|
|
18
|
+
return context.type === "qr" && context.token === condition2.secretToken ? { ok: true, value: { conditionType: "qr" } } : mismatch("qr", "INVALID_PROOF");
|
|
19
19
|
case "passcode":
|
|
20
|
-
return context.type === "passcode" && (
|
|
20
|
+
return context.type === "passcode" && (condition2.caseSensitive === false ? context.code.toLocaleLowerCase() === condition2.code.toLocaleLowerCase() : context.code === condition2.code) ? { ok: true, value: { conditionType: "passcode" } } : mismatch("passcode", "INVALID_PROOF");
|
|
21
21
|
case "nfc":
|
|
22
|
-
return context.type === "nfc" && context.tagId ===
|
|
22
|
+
return context.type === "nfc" && context.tagId === condition2.tagId ? { ok: true, value: { conditionType: "nfc" } } : mismatch("nfc", "INVALID_PROOF");
|
|
23
23
|
case "custom":
|
|
24
24
|
return mismatch("custom", "VALIDATOR_FAILED");
|
|
25
25
|
case "gps": {
|
|
26
|
-
if (!Number.isFinite(
|
|
26
|
+
if (!Number.isFinite(condition2.latitude) || !Number.isFinite(condition2.longitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0 || context.type !== "gps" || !Number.isFinite(context.latitude) || !Number.isFinite(context.longitude))
|
|
27
27
|
return mismatch("gps", "INVALID_GEO_INPUT");
|
|
28
28
|
const distanceMeters = calculateDistanceMeters(
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
condition2.latitude,
|
|
30
|
+
condition2.longitude,
|
|
31
31
|
context.latitude,
|
|
32
32
|
context.longitude
|
|
33
33
|
);
|
|
34
|
-
return distanceMeters <=
|
|
34
|
+
return distanceMeters <= condition2.radiusMeters ? { ok: true, value: { conditionType: "gps", distanceMeters } } : mismatch("gps", "OUTSIDE_RADIUS", {
|
|
35
35
|
distanceMeters,
|
|
36
|
-
radiusMeters:
|
|
36
|
+
radiusMeters: condition2.radiusMeters
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
function evaluateCondition(
|
|
42
|
-
return evaluateConditionDetailed(
|
|
41
|
+
function evaluateCondition(condition2, context) {
|
|
42
|
+
return evaluateConditionDetailed(condition2, context).ok;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// src/engine/order.ts
|
|
@@ -49,11 +49,11 @@ function getOrderedSpots(spots) {
|
|
|
49
49
|
|
|
50
50
|
// src/engine/progress.ts
|
|
51
51
|
function calculateProgress(state, config) {
|
|
52
|
-
const ids = new Set(config.spots.map((
|
|
52
|
+
const ids = new Set(config.spots.map((spot2) => spot2.id));
|
|
53
53
|
const acquired = new Set(
|
|
54
54
|
state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
|
|
55
55
|
);
|
|
56
|
-
const remaining = config.spots.filter((
|
|
56
|
+
const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
|
|
57
57
|
return {
|
|
58
58
|
acquired: acquired.size,
|
|
59
59
|
total: config.spots.length,
|
|
@@ -93,9 +93,9 @@ function createUniqueClaimTicketNumber(rewardId, issuedAt) {
|
|
|
93
93
|
const timestampPart = Number.isNaN(timestamp) ? Date.now() : timestamp;
|
|
94
94
|
return `CLAIM-${rewardId}-${timestampPart}-${createRandomHash()}`;
|
|
95
95
|
}
|
|
96
|
-
function issueClaimTicketNumber(
|
|
96
|
+
function issueClaimTicketNumber(reward2, currentState, options = {}) {
|
|
97
97
|
if (currentState.claimTicketNumber !== void 0) return currentState;
|
|
98
|
-
const claimTicketNumber = createClaimTicketNumber(
|
|
98
|
+
const claimTicketNumber = createClaimTicketNumber(reward2.id, options);
|
|
99
99
|
return { ...currentState, claimTicketNumber };
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -303,8 +303,8 @@ function normalizePasscode(input, caseSensitive = false) {
|
|
|
303
303
|
const normalized = input.normalize("NFKC").trim();
|
|
304
304
|
return caseSensitive ? normalized : normalized.toUpperCase();
|
|
305
305
|
}
|
|
306
|
-
function verifyPasscode(inputCode,
|
|
307
|
-
return normalizePasscode(inputCode,
|
|
306
|
+
function verifyPasscode(inputCode, condition2) {
|
|
307
|
+
return normalizePasscode(inputCode, condition2.caseSensitive) === normalizePasscode(condition2.code, condition2.caseSensitive) ? { success: true } : { success: false, message: "The passcode is invalid." };
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
// src/detectors/qr.ts
|
|
@@ -431,71 +431,71 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
431
431
|
// src/engine/transition.ts
|
|
432
432
|
function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
|
|
433
433
|
const states = new Map(currentStates.map((state) => [state.rewardId, state]));
|
|
434
|
-
return rewards.map((
|
|
435
|
-
const current = states.get(
|
|
434
|
+
return rewards.map((reward2) => {
|
|
435
|
+
const current = states.get(reward2.id);
|
|
436
436
|
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") return current;
|
|
437
|
-
if (
|
|
438
|
-
return { rewardId:
|
|
439
|
-
if (
|
|
440
|
-
return { rewardId:
|
|
441
|
-
if (acquiredStampCount >=
|
|
437
|
+
if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(now))
|
|
438
|
+
return { rewardId: reward2.id, status: "EXPIRED" };
|
|
439
|
+
if (reward2.stockLimit !== void 0 && (current?.redeemedCount ?? 0) >= reward2.stockLimit)
|
|
440
|
+
return { rewardId: reward2.id, status: "EXPIRED" };
|
|
441
|
+
if (acquiredStampCount >= reward2.requiredStampCount)
|
|
442
442
|
return {
|
|
443
|
-
rewardId:
|
|
443
|
+
rewardId: reward2.id,
|
|
444
444
|
status: "AVAILABLE",
|
|
445
445
|
...current?.unlockedAt === void 0 ? { unlockedAt: now } : { unlockedAt: current.unlockedAt }
|
|
446
446
|
};
|
|
447
|
-
return { rewardId:
|
|
447
|
+
return { rewardId: reward2.id, status: "LOCKED" };
|
|
448
448
|
});
|
|
449
449
|
}
|
|
450
450
|
function consumeReward(params) {
|
|
451
|
-
const { reward, currentState } = params;
|
|
451
|
+
const { reward: reward2, currentState } = params;
|
|
452
452
|
if (currentState.status === "CONSUMED")
|
|
453
|
-
return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId:
|
|
453
|
+
return { ok: false, error: { code: "ALREADY_CONSUMED", rewardId: reward2.id } };
|
|
454
454
|
if (currentState.status !== "AVAILABLE")
|
|
455
|
-
return { ok: false, error: { code: "NOT_AVAILABLE", rewardId:
|
|
456
|
-
if (
|
|
457
|
-
return { ok: false, error: { code: "EXPIRED", rewardId:
|
|
458
|
-
if (
|
|
459
|
-
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId:
|
|
460
|
-
if (
|
|
461
|
-
return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId:
|
|
462
|
-
if (
|
|
463
|
-
if (
|
|
455
|
+
return { ok: false, error: { code: "NOT_AVAILABLE", rewardId: reward2.id } };
|
|
456
|
+
if (reward2.validUntil !== void 0 && Date.parse(reward2.validUntil) <= Date.parse(params.now))
|
|
457
|
+
return { ok: false, error: { code: "EXPIRED", rewardId: reward2.id } };
|
|
458
|
+
if (reward2.stockLimit !== void 0 && (currentState.redeemedCount ?? 0) >= reward2.stockLimit)
|
|
459
|
+
return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward2.id } };
|
|
460
|
+
if (reward2.userClaimLimit !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward2.userClaimLimit)
|
|
461
|
+
return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward2.id } };
|
|
462
|
+
if (reward2.redemptionMethod === "staff_passcode") {
|
|
463
|
+
if (reward2.staffPasscode === void 0 || !verifyPasscode(params.inputPasscode ?? "", { code: reward2.staffPasscode }).success)
|
|
464
464
|
return {
|
|
465
465
|
ok: false,
|
|
466
466
|
error: {
|
|
467
467
|
code: "INVALID_PASSCODE",
|
|
468
|
-
rewardId:
|
|
468
|
+
rewardId: reward2.id,
|
|
469
469
|
message: "The passcode is invalid."
|
|
470
470
|
}
|
|
471
471
|
};
|
|
472
472
|
}
|
|
473
|
-
if (
|
|
473
|
+
if (reward2.redemptionMethod === "view_only") return { ok: true, value: currentState };
|
|
474
474
|
return {
|
|
475
475
|
ok: true,
|
|
476
476
|
value: {
|
|
477
477
|
...currentState,
|
|
478
478
|
status: "CONSUMED",
|
|
479
479
|
consumedAt: params.now,
|
|
480
|
-
claimTicketNumber: createUniqueClaimTicketNumber(
|
|
480
|
+
claimTicketNumber: createUniqueClaimTicketNumber(reward2.id, params.now),
|
|
481
481
|
redeemedCount: (currentState.redeemedCount ?? 0) + 1,
|
|
482
482
|
...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
|
|
483
483
|
}
|
|
484
484
|
};
|
|
485
485
|
}
|
|
486
486
|
function processStamp(state, config, spotId, context, now) {
|
|
487
|
-
const
|
|
488
|
-
if (
|
|
487
|
+
const spot2 = config.spots.find((item) => item.id === spotId);
|
|
488
|
+
if (spot2 === void 0) return { ok: false, error: { code: "SPOT_NOT_FOUND", spotId } };
|
|
489
489
|
if (state.records.some((record2) => record2.stampId === spotId))
|
|
490
490
|
return { ok: false, error: { code: "STAMP_ALREADY_ACQUIRED", spotId } };
|
|
491
491
|
const acquired = new Set(state.records.map((record2) => record2.stampId));
|
|
492
|
-
if (
|
|
492
|
+
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
493
493
|
return { ok: false, error: { code: "PREREQUISITES_NOT_MET", spotId } };
|
|
494
|
-
for (const
|
|
495
|
-
if (
|
|
494
|
+
for (const condition2 of spot2.conditions) {
|
|
495
|
+
if (condition2.type === "custom" || !evaluateConditionDetailed(condition2, context).ok)
|
|
496
496
|
return {
|
|
497
497
|
ok: false,
|
|
498
|
-
error:
|
|
498
|
+
error: condition2.type === "custom" ? {
|
|
499
499
|
code: "CUSTOM_VALIDATION_FAILED",
|
|
500
500
|
spotId,
|
|
501
501
|
message: "Custom validation requires an async validator."
|
|
@@ -959,18 +959,18 @@ function proof(value) {
|
|
|
959
959
|
}
|
|
960
960
|
return "";
|
|
961
961
|
}
|
|
962
|
-
function matches(
|
|
963
|
-
if (
|
|
962
|
+
function matches(condition2, value) {
|
|
963
|
+
if (condition2.type === "gps") {
|
|
964
964
|
if (typeof value !== "object" || value === null) return false;
|
|
965
965
|
const item = value;
|
|
966
966
|
const latitude = item.latitude;
|
|
967
967
|
const longitude = item.longitude;
|
|
968
968
|
if (typeof latitude !== "number" || typeof longitude !== "number") return false;
|
|
969
969
|
const radians = (v) => v * Math.PI / 180;
|
|
970
|
-
const dLat = radians(latitude -
|
|
971
|
-
const dLon = radians(longitude -
|
|
972
|
-
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(
|
|
973
|
-
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <=
|
|
970
|
+
const dLat = radians(latitude - condition2.latitude);
|
|
971
|
+
const dLon = radians(longitude - condition2.longitude);
|
|
972
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(radians(condition2.latitude)) * Math.cos(radians(latitude)) * Math.sin(dLon / 2) ** 2;
|
|
973
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, h))) <= condition2.radiusMeters;
|
|
974
974
|
}
|
|
975
975
|
return proof(value).trim() !== "";
|
|
976
976
|
}
|
|
@@ -1057,8 +1057,8 @@ var StampRallyClient = class {
|
|
|
1057
1057
|
checkIn(spotId, proofData, options = {}) {
|
|
1058
1058
|
return this.#enqueue(async () => {
|
|
1059
1059
|
const current = await this.initialize();
|
|
1060
|
-
const
|
|
1061
|
-
if (
|
|
1060
|
+
const spot2 = this.#config.spots.find((item) => item.id === spotId);
|
|
1061
|
+
if (spot2 === void 0)
|
|
1062
1062
|
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1063
1063
|
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1064
1064
|
return this.#fail({
|
|
@@ -1067,15 +1067,15 @@ var StampRallyClient = class {
|
|
|
1067
1067
|
message: "Spot was already claimed."
|
|
1068
1068
|
});
|
|
1069
1069
|
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1070
|
-
if (
|
|
1070
|
+
if (spot2.prerequisites?.some((id2) => !acquired.has(id2)))
|
|
1071
1071
|
return this.#fail({
|
|
1072
1072
|
code: "PREREQUISITES_NOT_MET",
|
|
1073
1073
|
spotId,
|
|
1074
1074
|
message: "Prerequisite spots are not complete."
|
|
1075
1075
|
});
|
|
1076
|
-
for (const
|
|
1077
|
-
if (
|
|
1078
|
-
const validator = this.#options.customValidators?.[
|
|
1076
|
+
for (const condition2 of spot2.conditions) {
|
|
1077
|
+
if (condition2.type === "custom") {
|
|
1078
|
+
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1079
1079
|
if (validator === void 0)
|
|
1080
1080
|
return this.#fail({
|
|
1081
1081
|
code: "CUSTOM_VALIDATION_FAILED",
|
|
@@ -1086,7 +1086,7 @@ var StampRallyClient = class {
|
|
|
1086
1086
|
rallyId: this.#config.id,
|
|
1087
1087
|
spotId,
|
|
1088
1088
|
proofData,
|
|
1089
|
-
condition: { type: "custom", validatorName:
|
|
1089
|
+
condition: { type: "custom", validatorName: condition2.validatorName },
|
|
1090
1090
|
userState: current
|
|
1091
1091
|
};
|
|
1092
1092
|
const result = typeof validator === "function" ? await validator(context) : await validator.validate(context);
|
|
@@ -1096,7 +1096,7 @@ var StampRallyClient = class {
|
|
|
1096
1096
|
spotId,
|
|
1097
1097
|
message: typeof result === "object" && result.message !== void 0 ? result.message : "Custom validation failed."
|
|
1098
1098
|
});
|
|
1099
|
-
} else if (!matches(
|
|
1099
|
+
} else if (!matches(condition2, proofData))
|
|
1100
1100
|
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1101
1101
|
}
|
|
1102
1102
|
const now = options.now ?? this.#now();
|
|
@@ -1216,7 +1216,7 @@ var StampRallyClient = class {
|
|
|
1216
1216
|
return Promise.resolve(next);
|
|
1217
1217
|
}
|
|
1218
1218
|
#reconcile(state) {
|
|
1219
|
-
const ids = new Set(this.#config.spots.map((
|
|
1219
|
+
const ids = new Set(this.#config.spots.map((spot2) => spot2.id));
|
|
1220
1220
|
const records = state.records.filter(
|
|
1221
1221
|
(record, index, all) => ids.has(record.stampId) && all.findIndex((candidate) => candidate.stampId === record.stampId) === index
|
|
1222
1222
|
);
|
|
@@ -1396,6 +1396,11 @@ async function decryptPayload(body, secret) {
|
|
|
1396
1396
|
}
|
|
1397
1397
|
|
|
1398
1398
|
// src/domain/i18n.ts
|
|
1399
|
+
function updateLocalizedField(current, locale, newValue) {
|
|
1400
|
+
if (typeof current === "string" || current === void 0)
|
|
1401
|
+
return { [locale]: newValue };
|
|
1402
|
+
return { ...current, [locale]: newValue };
|
|
1403
|
+
}
|
|
1399
1404
|
function resolveLocalizedText(text, locale, fallbackLocale) {
|
|
1400
1405
|
if (text === void 0 || text === "") return "";
|
|
1401
1406
|
if (typeof text === "string") return text;
|
|
@@ -1422,23 +1427,23 @@ var DEFAULT_SHEET_THEME = {
|
|
|
1422
1427
|
unclaimedOpacity: 1,
|
|
1423
1428
|
fontFamily: "serif"
|
|
1424
1429
|
};
|
|
1425
|
-
function publicCondition(
|
|
1426
|
-
switch (
|
|
1430
|
+
function publicCondition(condition2) {
|
|
1431
|
+
switch (condition2.type) {
|
|
1427
1432
|
case "qr":
|
|
1428
|
-
return
|
|
1433
|
+
return condition2.qrEntryUrl === void 0 ? { type: "qr" } : { type: "qr", qrEntryUrl: condition2.qrEntryUrl };
|
|
1429
1434
|
case "passcode":
|
|
1430
1435
|
return { type: "passcode" };
|
|
1431
1436
|
case "gps":
|
|
1432
1437
|
return {
|
|
1433
1438
|
type: "gps",
|
|
1434
|
-
latitude:
|
|
1435
|
-
longitude:
|
|
1436
|
-
radiusMeters:
|
|
1439
|
+
latitude: condition2.latitude,
|
|
1440
|
+
longitude: condition2.longitude,
|
|
1441
|
+
radiusMeters: condition2.radiusMeters
|
|
1437
1442
|
};
|
|
1438
1443
|
case "nfc":
|
|
1439
1444
|
return { type: "nfc" };
|
|
1440
1445
|
case "custom":
|
|
1441
|
-
return { type: "custom", validatorName:
|
|
1446
|
+
return { type: "custom", validatorName: condition2.validatorName };
|
|
1442
1447
|
}
|
|
1443
1448
|
}
|
|
1444
1449
|
function toPublicConfig(config) {
|
|
@@ -1448,12 +1453,12 @@ function toPublicConfig(config) {
|
|
|
1448
1453
|
title: config.title,
|
|
1449
1454
|
...config.description === void 0 ? {} : { description: config.description },
|
|
1450
1455
|
...config.theme === void 0 ? {} : { theme: config.theme },
|
|
1451
|
-
spots: config.spots.map((
|
|
1452
|
-
...
|
|
1453
|
-
conditions:
|
|
1456
|
+
spots: config.spots.map((spot2) => ({
|
|
1457
|
+
...spot2,
|
|
1458
|
+
conditions: spot2.conditions.map(publicCondition)
|
|
1454
1459
|
})),
|
|
1455
1460
|
rewards: config.rewards.map(
|
|
1456
|
-
({ digitalContentUrl: _content, staffPasscode: _passcode, ...
|
|
1461
|
+
({ digitalContentUrl: _content, staffPasscode: _passcode, ...reward2 }) => reward2
|
|
1457
1462
|
),
|
|
1458
1463
|
...config.metadata === void 0 ? {} : { metadata: config.metadata },
|
|
1459
1464
|
...config.serverEndpoint === void 0 ? {} : { serverEndpoint: config.serverEndpoint }
|
|
@@ -1491,15 +1496,15 @@ function isPublicConfig(value) {
|
|
|
1491
1496
|
}
|
|
1492
1497
|
if (typeof candidate.id !== "string" || typeof candidate.version !== "string" || typeof candidate.title !== "string" && (typeof candidate.title !== "object" || candidate.title === null || Array.isArray(candidate.title)) || !Array.isArray(candidate.spots) || !Array.isArray(candidate.rewards))
|
|
1493
1498
|
return false;
|
|
1494
|
-
return candidate.spots.every((
|
|
1495
|
-
if (typeof
|
|
1496
|
-
const item =
|
|
1499
|
+
return candidate.spots.every((spot2) => {
|
|
1500
|
+
if (typeof spot2 !== "object" || spot2 === null || Array.isArray(spot2)) return false;
|
|
1501
|
+
const item = spot2;
|
|
1497
1502
|
if (typeof item.id !== "string" || typeof item.orderIndex !== "number" || item.name === void 0 || "secretToken" in item || "code" in item || "tagId" in item || "secretParams" in item)
|
|
1498
1503
|
return false;
|
|
1499
|
-
return Array.isArray(item.conditions) && item.conditions.every((
|
|
1500
|
-
if (typeof
|
|
1504
|
+
return Array.isArray(item.conditions) && item.conditions.every((condition2) => {
|
|
1505
|
+
if (typeof condition2 !== "object" || condition2 === null || Array.isArray(condition2))
|
|
1501
1506
|
return false;
|
|
1502
|
-
const value2 =
|
|
1507
|
+
const value2 = condition2;
|
|
1503
1508
|
if ("secretToken" in value2 || "code" in value2 || "tagId" in value2 || "secretParams" in value2)
|
|
1504
1509
|
return false;
|
|
1505
1510
|
const type = value2.type;
|
|
@@ -1507,9 +1512,9 @@ function isPublicConfig(value) {
|
|
|
1507
1512
|
if (type === "custom") return typeof value2.validatorName === "string";
|
|
1508
1513
|
return type === "gps" && typeof value2.latitude === "number" && typeof value2.longitude === "number" && typeof value2.radiusMeters === "number";
|
|
1509
1514
|
});
|
|
1510
|
-
}) && candidate.rewards.every((
|
|
1511
|
-
if (typeof
|
|
1512
|
-
const item =
|
|
1515
|
+
}) && candidate.rewards.every((reward2) => {
|
|
1516
|
+
if (typeof reward2 !== "object" || reward2 === null || Array.isArray(reward2)) return false;
|
|
1517
|
+
const item = reward2;
|
|
1513
1518
|
return typeof item.id === "string" && typeof item.requiredStampCount === "number" && (item.type === "digital" || item.type === "in_person") && (item.redemptionMethod === "manual_slide" || item.redemptionMethod === "staff_passcode" || item.redemptionMethod === "view_only" || item.redemptionMethod === "server_claim") && !("staffPasscode" in item || "digitalContentUrl" in item);
|
|
1514
1519
|
});
|
|
1515
1520
|
}
|
|
@@ -1613,6 +1618,324 @@ var THEME_PRESETS = [
|
|
|
1613
1618
|
}
|
|
1614
1619
|
];
|
|
1615
1620
|
|
|
1621
|
+
// src/domain/validation.ts
|
|
1622
|
+
var ConfigValidationError = class extends Error {
|
|
1623
|
+
constructor(errors) {
|
|
1624
|
+
super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
|
|
1625
|
+
this.errors = errors;
|
|
1626
|
+
}
|
|
1627
|
+
errors;
|
|
1628
|
+
name = "ConfigValidationError";
|
|
1629
|
+
};
|
|
1630
|
+
function isRecord2(value) {
|
|
1631
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1632
|
+
}
|
|
1633
|
+
function hasOwn(value, key) {
|
|
1634
|
+
return Object.hasOwn(value, key);
|
|
1635
|
+
}
|
|
1636
|
+
function add(errors, path, message, code) {
|
|
1637
|
+
errors.push({ path, message, code });
|
|
1638
|
+
}
|
|
1639
|
+
function requiredString(value, key, path, errors) {
|
|
1640
|
+
if (typeof value[key] !== "string" || value[key].length === 0) {
|
|
1641
|
+
add(errors, `${path}.${key}`, "A non-empty string is required.", "required_string");
|
|
1642
|
+
return false;
|
|
1643
|
+
}
|
|
1644
|
+
return true;
|
|
1645
|
+
}
|
|
1646
|
+
function optionalString(value, key, path, errors) {
|
|
1647
|
+
if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "string")
|
|
1648
|
+
add(errors, `${path}.${key}`, "Expected a string.", "invalid_type");
|
|
1649
|
+
}
|
|
1650
|
+
function optionalBoolean(value, key, path, errors) {
|
|
1651
|
+
if (hasOwn(value, key) && value[key] !== void 0 && typeof value[key] !== "boolean")
|
|
1652
|
+
add(errors, `${path}.${key}`, "Expected a boolean.", "invalid_type");
|
|
1653
|
+
}
|
|
1654
|
+
function finiteNumber(value, key, path, errors, minimum) {
|
|
1655
|
+
const item = value[key];
|
|
1656
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
1657
|
+
add(errors, `${path}.${key}`, "Expected a finite number.", "invalid_number");
|
|
1658
|
+
} else if (minimum !== void 0 && item < minimum) {
|
|
1659
|
+
add(
|
|
1660
|
+
errors,
|
|
1661
|
+
`${path}.${key}`,
|
|
1662
|
+
`Expected a number greater than or equal to ${minimum}.`,
|
|
1663
|
+
"out_of_range"
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
function localizedText(value, path, errors) {
|
|
1668
|
+
if (typeof value === "string") return;
|
|
1669
|
+
if (!isRecord2(value)) {
|
|
1670
|
+
add(errors, path, "Expected a string or a locale map.", "invalid_localized_text");
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
for (const [locale, text] of Object.entries(value)) {
|
|
1674
|
+
if (typeof text !== "string")
|
|
1675
|
+
add(errors, `${path}.${locale}`, "Expected a string.", "invalid_type");
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
function theme(value, path, errors) {
|
|
1679
|
+
if (!isRecord2(value)) {
|
|
1680
|
+
add(errors, path, "Expected a theme object.", "invalid_type");
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
for (const key of ["primaryColor", "cardBackgroundColor", "textColor"])
|
|
1684
|
+
requiredString(value, key, path, errors);
|
|
1685
|
+
optionalString(value, "backgroundColor", path, errors);
|
|
1686
|
+
optionalString(value, "backgroundImageUrl", path, errors);
|
|
1687
|
+
optionalString(value, "completedStampColor", path, errors);
|
|
1688
|
+
optionalString(value, "fontFamily", path, errors);
|
|
1689
|
+
const slotShape = value.slotShape;
|
|
1690
|
+
if (slotShape !== "circle" && slotShape !== "square" && slotShape !== "rounded")
|
|
1691
|
+
add(errors, `${path}.slotShape`, "Expected circle, square, or rounded.", "invalid_enum");
|
|
1692
|
+
finiteNumber(value, "gridColumns", path, errors, 1);
|
|
1693
|
+
if (typeof value.gridColumns === "number" && !Number.isInteger(value.gridColumns))
|
|
1694
|
+
add(errors, `${path}.gridColumns`, "Expected an integer.", "invalid_integer");
|
|
1695
|
+
if (hasOwn(value, "unclaimedOpacity")) finiteNumber(value, "unclaimedOpacity", path, errors, 0);
|
|
1696
|
+
}
|
|
1697
|
+
function externalReferences(value, path, errors) {
|
|
1698
|
+
if (!Array.isArray(value)) {
|
|
1699
|
+
add(errors, path, "Expected an array.", "invalid_type");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
value.forEach((item, index) => {
|
|
1703
|
+
const itemPath = `${path}[${index}]`;
|
|
1704
|
+
if (!isRecord2(item)) {
|
|
1705
|
+
add(errors, itemPath, "Expected an object.", "invalid_type");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
requiredString(item, "type", itemPath, errors);
|
|
1709
|
+
requiredString(item, "id", itemPath, errors);
|
|
1710
|
+
optionalString(item, "url", itemPath, errors);
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
function condition(value, path, errors, isPublic) {
|
|
1714
|
+
if (!isRecord2(value)) {
|
|
1715
|
+
add(errors, path, "Expected a condition object.", "invalid_type");
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const type = value.type;
|
|
1719
|
+
if (type === "qr") {
|
|
1720
|
+
if (isPublic) {
|
|
1721
|
+
if (hasOwn(value, "secretToken"))
|
|
1722
|
+
add(errors, `${path}.secretToken`, "Private field is not allowed.", "private_field");
|
|
1723
|
+
} else requiredString(value, "secretToken", path, errors);
|
|
1724
|
+
optionalString(value, "qrEntryUrl", path, errors);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (type === "passcode") {
|
|
1728
|
+
if (isPublic) {
|
|
1729
|
+
for (const key of ["code", "caseSensitive"])
|
|
1730
|
+
if (hasOwn(value, key))
|
|
1731
|
+
add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
|
|
1732
|
+
} else {
|
|
1733
|
+
requiredString(value, "code", path, errors);
|
|
1734
|
+
optionalBoolean(value, "caseSensitive", path, errors);
|
|
1735
|
+
}
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (type === "gps") {
|
|
1739
|
+
finiteNumber(value, "latitude", path, errors);
|
|
1740
|
+
finiteNumber(value, "longitude", path, errors);
|
|
1741
|
+
finiteNumber(value, "radiusMeters", path, errors, 0);
|
|
1742
|
+
if (typeof value.latitude === "number" && (value.latitude < -90 || value.latitude > 90))
|
|
1743
|
+
add(errors, `${path}.latitude`, "Expected a latitude between -90 and 90.", "out_of_range");
|
|
1744
|
+
if (typeof value.longitude === "number" && (value.longitude < -180 || value.longitude > 180))
|
|
1745
|
+
add(
|
|
1746
|
+
errors,
|
|
1747
|
+
`${path}.longitude`,
|
|
1748
|
+
"Expected a longitude between -180 and 180.",
|
|
1749
|
+
"out_of_range"
|
|
1750
|
+
);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
if (type === "nfc") {
|
|
1754
|
+
if (isPublic) {
|
|
1755
|
+
if (hasOwn(value, "tagId"))
|
|
1756
|
+
add(errors, `${path}.tagId`, "Private field is not allowed.", "private_field");
|
|
1757
|
+
} else requiredString(value, "tagId", path, errors);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (type === "custom") {
|
|
1761
|
+
requiredString(value, "validatorName", path, errors);
|
|
1762
|
+
if (isPublic && hasOwn(value, "secretParams"))
|
|
1763
|
+
add(errors, `${path}.secretParams`, "Private field is not allowed.", "private_field");
|
|
1764
|
+
if (!isPublic && hasOwn(value, "secretParams") && !isRecord2(value.secretParams))
|
|
1765
|
+
add(errors, `${path}.secretParams`, "Expected an object.", "invalid_type");
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
add(errors, `${path}.type`, "Unknown condition type.", "invalid_enum");
|
|
1769
|
+
}
|
|
1770
|
+
function unlockCondition(value, path, errors) {
|
|
1771
|
+
if (!isRecord2(value)) {
|
|
1772
|
+
add(errors, path, "Expected an unlock condition object.", "invalid_type");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (value.type === "stamp_count") {
|
|
1776
|
+
finiteNumber(value, "count", path, errors, 0);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (value.type === "stamps") {
|
|
1780
|
+
if (!Array.isArray(value.stampIds))
|
|
1781
|
+
add(errors, `${path}.stampIds`, "Expected an array.", "invalid_type");
|
|
1782
|
+
else
|
|
1783
|
+
value.stampIds.forEach((item, index) => {
|
|
1784
|
+
if (typeof item !== "string" || item.length === 0)
|
|
1785
|
+
add(
|
|
1786
|
+
errors,
|
|
1787
|
+
`${path}.stampIds[${index}]`,
|
|
1788
|
+
"Expected a non-empty string.",
|
|
1789
|
+
"required_string"
|
|
1790
|
+
);
|
|
1791
|
+
});
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
if (value.type === "all" || value.type === "any") {
|
|
1795
|
+
if (!Array.isArray(value.conditions))
|
|
1796
|
+
add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
|
|
1797
|
+
else
|
|
1798
|
+
value.conditions.forEach((item, index) => {
|
|
1799
|
+
unlockCondition(item, `${path}.conditions[${index}]`, errors);
|
|
1800
|
+
});
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
add(errors, `${path}.type`, "Unknown unlock condition type.", "invalid_enum");
|
|
1804
|
+
}
|
|
1805
|
+
function spot(value, path, errors, isPublic) {
|
|
1806
|
+
if (!isRecord2(value)) {
|
|
1807
|
+
add(errors, path, "Expected a spot object.", "invalid_type");
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
requiredString(value, "id", path, errors);
|
|
1811
|
+
finiteNumber(value, "orderIndex", path, errors, 0);
|
|
1812
|
+
if (typeof value.orderIndex === "number" && !Number.isInteger(value.orderIndex))
|
|
1813
|
+
add(errors, `${path}.orderIndex`, "Expected an integer.", "invalid_integer");
|
|
1814
|
+
localizedText(value.name, `${path}.name`, errors);
|
|
1815
|
+
for (const key of ["description", "hint"])
|
|
1816
|
+
if (hasOwn(value, key)) localizedText(value[key], `${path}.${key}`, errors);
|
|
1817
|
+
for (const key of ["imageUrl", "iconUrl", "redirectUrlAfterClaim"])
|
|
1818
|
+
optionalString(value, key, path, errors);
|
|
1819
|
+
if (hasOwn(value, "externalReferences") && value.externalReferences !== void 0)
|
|
1820
|
+
externalReferences(value.externalReferences, `${path}.externalReferences`, errors);
|
|
1821
|
+
if (hasOwn(value, "prerequisites") && value.prerequisites !== void 0) {
|
|
1822
|
+
if (!Array.isArray(value.prerequisites))
|
|
1823
|
+
add(errors, `${path}.prerequisites`, "Expected an array.", "invalid_type");
|
|
1824
|
+
else
|
|
1825
|
+
value.prerequisites.forEach((item, index) => {
|
|
1826
|
+
if (typeof item !== "string" || item.length === 0)
|
|
1827
|
+
add(
|
|
1828
|
+
errors,
|
|
1829
|
+
`${path}.prerequisites[${index}]`,
|
|
1830
|
+
"Expected a non-empty string.",
|
|
1831
|
+
"required_string"
|
|
1832
|
+
);
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
if (!Array.isArray(value.conditions))
|
|
1836
|
+
add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
|
|
1837
|
+
else
|
|
1838
|
+
value.conditions.forEach((item, index) => {
|
|
1839
|
+
condition(item, `${path}.conditions[${index}]`, errors, isPublic);
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
function reward(value, path, errors, isPublic) {
|
|
1843
|
+
if (!isRecord2(value)) {
|
|
1844
|
+
add(errors, path, "Expected a reward object.", "invalid_type");
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
requiredString(value, "id", path, errors);
|
|
1848
|
+
localizedText(value.title, `${path}.title`, errors);
|
|
1849
|
+
if (hasOwn(value, "description")) localizedText(value.description, `${path}.description`, errors);
|
|
1850
|
+
if (value.type !== "digital" && value.type !== "in_person")
|
|
1851
|
+
add(errors, `${path}.type`, "Unknown reward type.", "invalid_enum");
|
|
1852
|
+
if (!["manual_slide", "staff_passcode", "view_only", "server_claim"].includes(
|
|
1853
|
+
String(value.redemptionMethod)
|
|
1854
|
+
))
|
|
1855
|
+
add(errors, `${path}.redemptionMethod`, "Unknown redemption method.", "invalid_enum");
|
|
1856
|
+
finiteNumber(value, "requiredStampCount", path, errors, 0);
|
|
1857
|
+
for (const key of ["stockLimit", "userClaimLimit"]) {
|
|
1858
|
+
if (hasOwn(value, key) && value[key] !== void 0) {
|
|
1859
|
+
finiteNumber(value, key, path, errors, 0);
|
|
1860
|
+
if (typeof value[key] === "number" && !Number.isInteger(value[key]))
|
|
1861
|
+
add(errors, `${path}.${key}`, "Expected an integer.", "invalid_integer");
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
optionalString(value, "validUntil", path, errors);
|
|
1865
|
+
if (typeof value.validUntil === "string" && Number.isNaN(Date.parse(value.validUntil)))
|
|
1866
|
+
add(errors, `${path}.validUntil`, "Expected a valid date string.", "invalid_date");
|
|
1867
|
+
if (isPublic) {
|
|
1868
|
+
for (const key of ["staffPasscode", "digitalContentUrl"])
|
|
1869
|
+
if (hasOwn(value, key))
|
|
1870
|
+
add(errors, `${path}.${key}`, "Private field is not allowed.", "private_field");
|
|
1871
|
+
} else {
|
|
1872
|
+
optionalString(value, "staffPasscode", path, errors);
|
|
1873
|
+
optionalString(value, "digitalContentUrl", path, errors);
|
|
1874
|
+
}
|
|
1875
|
+
if (hasOwn(value, "conditions") && value.conditions !== void 0) {
|
|
1876
|
+
if (!Array.isArray(value.conditions))
|
|
1877
|
+
add(errors, `${path}.conditions`, "Expected an array.", "invalid_type");
|
|
1878
|
+
else
|
|
1879
|
+
value.conditions.forEach((item, index) => {
|
|
1880
|
+
unlockCondition(item, `${path}.conditions[${index}]`, errors);
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
function validate(value, isPublic) {
|
|
1885
|
+
const errors = [];
|
|
1886
|
+
if (!isRecord2(value)) {
|
|
1887
|
+
add(errors, "$", "Expected a configuration object.", "invalid_type");
|
|
1888
|
+
return errors;
|
|
1889
|
+
}
|
|
1890
|
+
requiredString(value, "id", "$", errors);
|
|
1891
|
+
requiredString(value, "version", "$", errors);
|
|
1892
|
+
localizedText(value.title, "$.title", errors);
|
|
1893
|
+
if (hasOwn(value, "description")) localizedText(value.description, "$.description", errors);
|
|
1894
|
+
if (hasOwn(value, "theme") && value.theme !== void 0) theme(value.theme, "$.theme", errors);
|
|
1895
|
+
if (!Array.isArray(value.spots)) add(errors, "spots", "Expected an array.", "invalid_type");
|
|
1896
|
+
else
|
|
1897
|
+
value.spots.forEach((item, index) => {
|
|
1898
|
+
spot(item, `spots[${index}]`, errors, isPublic);
|
|
1899
|
+
});
|
|
1900
|
+
if (!Array.isArray(value.rewards)) add(errors, "rewards", "Expected an array.", "invalid_type");
|
|
1901
|
+
else
|
|
1902
|
+
value.rewards.forEach((item, index) => {
|
|
1903
|
+
reward(item, `rewards[${index}]`, errors, isPublic);
|
|
1904
|
+
});
|
|
1905
|
+
if (!isPublic) {
|
|
1906
|
+
optionalString(value, "staffPasscode", "$", errors);
|
|
1907
|
+
if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord2(value.inventory))
|
|
1908
|
+
add(errors, "$.inventory", "Expected an object.", "invalid_type");
|
|
1909
|
+
if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord2(value.serverMetadata))
|
|
1910
|
+
add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
|
|
1911
|
+
optionalString(value, "serverEndpoint", "$", errors);
|
|
1912
|
+
} else {
|
|
1913
|
+
for (const key of ["staffPasscode", "serverMetadata", "inventory"])
|
|
1914
|
+
if (hasOwn(value, key))
|
|
1915
|
+
add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
|
|
1916
|
+
optionalString(value, "serverEndpoint", "$", errors);
|
|
1917
|
+
}
|
|
1918
|
+
return errors;
|
|
1919
|
+
}
|
|
1920
|
+
function safeParseAdminConfig(input) {
|
|
1921
|
+
const errors = validate(input, false);
|
|
1922
|
+
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
1923
|
+
}
|
|
1924
|
+
function parseAdminConfig(input) {
|
|
1925
|
+
const result = safeParseAdminConfig(input);
|
|
1926
|
+
if (!result.success) throw new ConfigValidationError(result.errors);
|
|
1927
|
+
return result.data;
|
|
1928
|
+
}
|
|
1929
|
+
function safeParsePublicConfig(input) {
|
|
1930
|
+
const errors = validate(input, true);
|
|
1931
|
+
return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
|
|
1932
|
+
}
|
|
1933
|
+
function parsePublicConfig(input) {
|
|
1934
|
+
const result = safeParsePublicConfig(input);
|
|
1935
|
+
if (!result.success) throw new ConfigValidationError(result.errors);
|
|
1936
|
+
return result.data;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1616
1939
|
// src/security/snapshotToken.ts
|
|
1617
1940
|
var encoder2 = new TextEncoder();
|
|
1618
1941
|
function cryptoApi2() {
|
|
@@ -1732,6 +2055,7 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
1732
2055
|
}
|
|
1733
2056
|
}
|
|
1734
2057
|
|
|
2058
|
+
exports.ConfigValidationError = ConfigValidationError;
|
|
1735
2059
|
exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
|
|
1736
2060
|
exports.InMemoryStorage = InMemoryStorage;
|
|
1737
2061
|
exports.IndexedDBAdapter = IndexedDBAdapter;
|
|
@@ -1761,14 +2085,19 @@ exports.isRewardState = isRewardState;
|
|
|
1761
2085
|
exports.isStampRallyState = isStampRallyState;
|
|
1762
2086
|
exports.issueClaimTicketNumber = issueClaimTicketNumber;
|
|
1763
2087
|
exports.normalizePasscode = normalizePasscode;
|
|
2088
|
+
exports.parseAdminConfig = parseAdminConfig;
|
|
2089
|
+
exports.parsePublicConfig = parsePublicConfig;
|
|
1764
2090
|
exports.processStamp = processStamp;
|
|
1765
2091
|
exports.readNfcContext = readNfcContext;
|
|
1766
2092
|
exports.readQrContext = readQrContext;
|
|
1767
2093
|
exports.reconcileRewardStates = reconcileRewardStates;
|
|
1768
2094
|
exports.resolveLocalizedText = resolveLocalizedText;
|
|
2095
|
+
exports.safeParseAdminConfig = safeParseAdminConfig;
|
|
2096
|
+
exports.safeParsePublicConfig = safeParsePublicConfig;
|
|
1769
2097
|
exports.storageKey = storageKey;
|
|
1770
2098
|
exports.toLocalizedString = toLocalizedString;
|
|
1771
2099
|
exports.toPublicConfig = toPublicConfig;
|
|
2100
|
+
exports.updateLocalizedField = updateLocalizedField;
|
|
1772
2101
|
exports.verifyPasscode = verifyPasscode;
|
|
1773
2102
|
exports.verifySecureToken = verifySecureToken;
|
|
1774
2103
|
exports.verifySnapshotToken = verifySnapshotToken;
|