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