@stamprally/core 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +601 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +266 -4
- package/dist/index.d.ts +266 -4
- package/dist/index.js +595 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ 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(condition2, context,
|
|
32
|
+
function evaluateConditionDetailed(condition2, context, now2) {
|
|
33
33
|
switch (condition2.type) {
|
|
34
34
|
case "instant":
|
|
35
35
|
return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
|
|
@@ -59,14 +59,14 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
-
const
|
|
62
|
+
const distanceMeters2 = calculateDistanceMeters(
|
|
63
63
|
condition2.latitude,
|
|
64
64
|
condition2.longitude,
|
|
65
65
|
context.currentLatitude,
|
|
66
66
|
context.currentLongitude
|
|
67
67
|
);
|
|
68
|
-
if (
|
|
69
|
-
return { ok: true, value: { conditionType: "geo", distanceMeters } };
|
|
68
|
+
if (distanceMeters2 <= condition2.radiusMeters) {
|
|
69
|
+
return { ok: true, value: { conditionType: "geo", distanceMeters: distanceMeters2 } };
|
|
70
70
|
}
|
|
71
71
|
return {
|
|
72
72
|
ok: false,
|
|
@@ -74,9 +74,9 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
74
74
|
code: "CONDITION_MISMATCH",
|
|
75
75
|
conditionType: "geo",
|
|
76
76
|
reason: "OUTSIDE_RADIUS",
|
|
77
|
-
distanceMeters,
|
|
77
|
+
distanceMeters: distanceMeters2,
|
|
78
78
|
radiusMeters: condition2.radiusMeters,
|
|
79
|
-
differenceMeters:
|
|
79
|
+
differenceMeters: distanceMeters2 - condition2.radiusMeters
|
|
80
80
|
}
|
|
81
81
|
};
|
|
82
82
|
}
|
|
@@ -101,7 +101,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
101
101
|
for (const [index, childCondition] of condition2.conditions.entries()) {
|
|
102
102
|
const childContext = context.contexts[index];
|
|
103
103
|
if (childContext === void 0) continue;
|
|
104
|
-
const result = evaluateConditionDetailed(childCondition, childContext,
|
|
104
|
+
const result = evaluateConditionDetailed(childCondition, childContext, now2);
|
|
105
105
|
if (result.ok) matchedCount += 1;
|
|
106
106
|
else failures.push({ index, error: result.error });
|
|
107
107
|
}
|
|
@@ -124,7 +124,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
124
124
|
case "time_window": {
|
|
125
125
|
const startsAt = Date.parse(condition2.startsAt);
|
|
126
126
|
const endsAt = Date.parse(condition2.endsAt);
|
|
127
|
-
const currentTime = Date.parse(
|
|
127
|
+
const currentTime = Date.parse(now2);
|
|
128
128
|
if (!Number.isFinite(currentTime)) {
|
|
129
129
|
return {
|
|
130
130
|
ok: false,
|
|
@@ -132,7 +132,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
132
132
|
code: "CONDITION_MISMATCH",
|
|
133
133
|
conditionType: "time_window",
|
|
134
134
|
reason: "INVALID_NOW",
|
|
135
|
-
now,
|
|
135
|
+
now: now2,
|
|
136
136
|
startsAt: condition2.startsAt,
|
|
137
137
|
endsAt: condition2.endsAt
|
|
138
138
|
}
|
|
@@ -145,7 +145,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
145
145
|
code: "CONDITION_MISMATCH",
|
|
146
146
|
conditionType: "time_window",
|
|
147
147
|
reason: "INVALID_TIME_WINDOW",
|
|
148
|
-
now,
|
|
148
|
+
now: now2,
|
|
149
149
|
startsAt: condition2.startsAt,
|
|
150
150
|
endsAt: condition2.endsAt
|
|
151
151
|
}
|
|
@@ -158,21 +158,21 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
158
158
|
code: "CONDITION_MISMATCH",
|
|
159
159
|
conditionType: "time_window",
|
|
160
160
|
reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
|
|
161
|
-
now,
|
|
161
|
+
now: now2,
|
|
162
162
|
startsAt: condition2.startsAt,
|
|
163
163
|
endsAt: condition2.endsAt
|
|
164
164
|
}
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
|
-
const childResult = evaluateConditionDetailed(condition2.condition, context,
|
|
167
|
+
const childResult = evaluateConditionDetailed(condition2.condition, context, now2);
|
|
168
168
|
return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
|
|
169
169
|
}
|
|
170
170
|
default:
|
|
171
171
|
return assertNever(condition2);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
-
function evaluateCondition(condition2, context,
|
|
175
|
-
return evaluateConditionDetailed(condition2, context,
|
|
174
|
+
function evaluateCondition(condition2, context, now2) {
|
|
175
|
+
return evaluateConditionDetailed(condition2, context, now2 ?? "").ok;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// src/engine/checkIn.ts
|
|
@@ -621,14 +621,14 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
621
621
|
}
|
|
622
622
|
|
|
623
623
|
// src/engine/transition.ts
|
|
624
|
-
function reconcileRewardStates(rewards, currentStates, acquiredStampCount,
|
|
624
|
+
function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now2) {
|
|
625
625
|
const statesById = new Map(currentStates.map((state) => [state.rewardId, state]));
|
|
626
626
|
return rewards.map((reward) => {
|
|
627
627
|
const current = statesById.get(reward.id);
|
|
628
628
|
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") {
|
|
629
629
|
return current;
|
|
630
630
|
}
|
|
631
|
-
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(
|
|
631
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now2)) {
|
|
632
632
|
return { rewardId: reward.id, status: "EXPIRED" };
|
|
633
633
|
}
|
|
634
634
|
const stockLimit = reward.stockLimit ?? reward.maxStock;
|
|
@@ -644,7 +644,7 @@ function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now)
|
|
|
644
644
|
return {
|
|
645
645
|
rewardId: reward.id,
|
|
646
646
|
status: "AVAILABLE",
|
|
647
|
-
unlockedAt: current?.unlockedAt ??
|
|
647
|
+
unlockedAt: current?.unlockedAt ?? now2,
|
|
648
648
|
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
649
649
|
};
|
|
650
650
|
}
|
|
@@ -709,7 +709,7 @@ function consumeReward(params) {
|
|
|
709
709
|
}
|
|
710
710
|
};
|
|
711
711
|
}
|
|
712
|
-
function processStamp(state, config, targetStampId, context,
|
|
712
|
+
function processStamp(state, config, targetStampId, context, now2) {
|
|
713
713
|
const targetStamp = config.stamps.find((stamp) => stamp.id === targetStampId);
|
|
714
714
|
if (targetStamp === void 0) {
|
|
715
715
|
return { ok: false, error: { code: "STAMP_NOT_FOUND", stampId: targetStampId } };
|
|
@@ -734,7 +734,7 @@ function processStamp(state, config, targetStampId, context, now) {
|
|
|
734
734
|
};
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
|
-
const conditionResult = evaluateConditionDetailed(targetStamp.condition, context,
|
|
737
|
+
const conditionResult = evaluateConditionDetailed(targetStamp.condition, context, now2);
|
|
738
738
|
if (!conditionResult.ok) {
|
|
739
739
|
return {
|
|
740
740
|
ok: false,
|
|
@@ -745,27 +745,27 @@ function processStamp(state, config, targetStampId, context, now) {
|
|
|
745
745
|
}
|
|
746
746
|
};
|
|
747
747
|
}
|
|
748
|
-
const record = { stampId: targetStampId, acquiredAt:
|
|
748
|
+
const record = { stampId: targetStampId, acquiredAt: now2 };
|
|
749
749
|
const nextRecords = [...state.records, record];
|
|
750
|
-
const nextRewards = config.rewards === void 0 && state.rewards === void 0 ? void 0 : reconcileRewardStates(config.rewards ?? [], state.rewards ?? [], nextRecords.length,
|
|
750
|
+
const nextRewards = config.rewards === void 0 && state.rewards === void 0 ? void 0 : reconcileRewardStates(config.rewards ?? [], state.rewards ?? [], nextRecords.length, now2);
|
|
751
751
|
const nextState = {
|
|
752
752
|
...state,
|
|
753
753
|
records: nextRecords,
|
|
754
754
|
...nextRewards === void 0 ? {} : { rewards: nextRewards },
|
|
755
|
-
updatedAt:
|
|
755
|
+
updatedAt: now2
|
|
756
756
|
};
|
|
757
757
|
const events = [{ type: "stampAcquired", record }];
|
|
758
758
|
if (nextRewards !== void 0) {
|
|
759
759
|
for (const rewardState of nextRewards) {
|
|
760
760
|
const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
|
|
761
761
|
if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
|
|
762
|
-
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt:
|
|
762
|
+
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now2 });
|
|
763
763
|
}
|
|
764
764
|
}
|
|
765
765
|
}
|
|
766
766
|
const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
|
|
767
767
|
if (completed) {
|
|
768
|
-
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt:
|
|
768
|
+
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now2 });
|
|
769
769
|
}
|
|
770
770
|
return { ok: true, value: { nextState, events } };
|
|
771
771
|
}
|
|
@@ -1270,10 +1270,10 @@ var StampRallyClient = class {
|
|
|
1270
1270
|
}
|
|
1271
1271
|
return this.#initialization;
|
|
1272
1272
|
}
|
|
1273
|
-
acquire(stampId, context,
|
|
1273
|
+
acquire(stampId, context, now2 = this.#clock()) {
|
|
1274
1274
|
return this.#enqueue(async () => {
|
|
1275
1275
|
const currentState = await this.initialize();
|
|
1276
|
-
const result = processStamp(currentState, this.#config, stampId, context,
|
|
1276
|
+
const result = processStamp(currentState, this.#config, stampId, context, now2);
|
|
1277
1277
|
if (!result.ok) {
|
|
1278
1278
|
this.#emitEvent({ type: "error", error: result.error });
|
|
1279
1279
|
return result;
|
|
@@ -1285,14 +1285,14 @@ var StampRallyClient = class {
|
|
|
1285
1285
|
return result;
|
|
1286
1286
|
});
|
|
1287
1287
|
}
|
|
1288
|
-
reset(
|
|
1288
|
+
reset(now2 = this.#clock()) {
|
|
1289
1289
|
return this.#enqueue(async () => {
|
|
1290
1290
|
const initialization = this.#initialization;
|
|
1291
1291
|
if (initialization !== null) {
|
|
1292
1292
|
await initialization.catch(() => void 0);
|
|
1293
1293
|
}
|
|
1294
1294
|
await this.#storage.remove(this.#config.id);
|
|
1295
|
-
const nextState = this.#createEmptyState(
|
|
1295
|
+
const nextState = this.#createEmptyState(now2);
|
|
1296
1296
|
this.#currentState = nextState;
|
|
1297
1297
|
this.#initialization = Promise.resolve(nextState);
|
|
1298
1298
|
this.#emit(nextState);
|
|
@@ -1334,18 +1334,18 @@ var StampRallyClient = class {
|
|
|
1334
1334
|
#emitEvent(event) {
|
|
1335
1335
|
for (const listener of this.#eventListeners) listener(event);
|
|
1336
1336
|
}
|
|
1337
|
-
#createEmptyState(
|
|
1337
|
+
#createEmptyState(now2) {
|
|
1338
1338
|
const state = {
|
|
1339
1339
|
rallyId: this.#config.id,
|
|
1340
1340
|
records: [],
|
|
1341
1341
|
...this.#config.rewards === void 0 ? {} : {
|
|
1342
|
-
rewards: reconcileRewardStates(this.#config.rewards, [], 0,
|
|
1342
|
+
rewards: reconcileRewardStates(this.#config.rewards, [], 0, now2)
|
|
1343
1343
|
},
|
|
1344
|
-
updatedAt:
|
|
1344
|
+
updatedAt: now2
|
|
1345
1345
|
};
|
|
1346
1346
|
return state;
|
|
1347
1347
|
}
|
|
1348
|
-
#reconcileState(state,
|
|
1348
|
+
#reconcileState(state, now2) {
|
|
1349
1349
|
const configuredStampIds = new Set(this.#config.stamps.map((stamp) => stamp.id));
|
|
1350
1350
|
const seenStampIds = /* @__PURE__ */ new Set();
|
|
1351
1351
|
const records = state.records.filter((record) => {
|
|
@@ -1363,12 +1363,319 @@ var StampRallyClient = class {
|
|
|
1363
1363
|
this.#config.rewards ?? [],
|
|
1364
1364
|
state.rewards ?? [],
|
|
1365
1365
|
records.length,
|
|
1366
|
-
|
|
1366
|
+
now2
|
|
1367
1367
|
)
|
|
1368
1368
|
};
|
|
1369
1369
|
}
|
|
1370
1370
|
};
|
|
1371
1371
|
|
|
1372
|
+
// src/client/universalClient.ts
|
|
1373
|
+
function isStorage(value) {
|
|
1374
|
+
return "load" in value && "save" in value && "remove" in value;
|
|
1375
|
+
}
|
|
1376
|
+
function randomId(prefix) {
|
|
1377
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
1378
|
+
}
|
|
1379
|
+
function now() {
|
|
1380
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1381
|
+
}
|
|
1382
|
+
function text(value) {
|
|
1383
|
+
if (typeof value === "string") return value;
|
|
1384
|
+
if (typeof value === "object" && value !== null) {
|
|
1385
|
+
const first = Object.values(value).find((item) => typeof item === "string");
|
|
1386
|
+
if (typeof first === "string") return first;
|
|
1387
|
+
}
|
|
1388
|
+
return "";
|
|
1389
|
+
}
|
|
1390
|
+
function proofString(value) {
|
|
1391
|
+
if (typeof value === "string") return value;
|
|
1392
|
+
if (typeof value === "object" && value !== null) {
|
|
1393
|
+
const record = value;
|
|
1394
|
+
for (const key of ["token", "code", "passcode", "value"]) {
|
|
1395
|
+
if (typeof record[key] === "string") return record[key];
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return "";
|
|
1399
|
+
}
|
|
1400
|
+
function distanceMeters(aLat, aLon, bLat, bLon) {
|
|
1401
|
+
const radians = (degrees) => degrees * Math.PI / 180;
|
|
1402
|
+
const dLat = radians(bLat - aLat);
|
|
1403
|
+
const dLon = radians(bLon - aLon);
|
|
1404
|
+
const value = Math.sin(dLat / 2) ** 2 + Math.cos(radians(aLat)) * Math.cos(radians(bLat)) * Math.sin(dLon / 2) ** 2;
|
|
1405
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
|
|
1406
|
+
}
|
|
1407
|
+
function conditionMatches(condition2, proofData) {
|
|
1408
|
+
switch (condition2.type) {
|
|
1409
|
+
case "qr":
|
|
1410
|
+
return proofString(proofData).trim() !== "";
|
|
1411
|
+
case "passcode":
|
|
1412
|
+
return proofString(proofData).trim() !== "";
|
|
1413
|
+
case "gps": {
|
|
1414
|
+
if (typeof proofData !== "object" || proofData === null) return false;
|
|
1415
|
+
const value = proofData;
|
|
1416
|
+
const latitude = typeof value.latitude === "number" ? value.latitude : Number.NaN;
|
|
1417
|
+
const longitude = typeof value.longitude === "number" ? value.longitude : Number.NaN;
|
|
1418
|
+
return Number.isFinite(latitude) && Number.isFinite(longitude) && distanceMeters(condition2.latitude, condition2.longitude, latitude, longitude) <= condition2.radiusMeters;
|
|
1419
|
+
}
|
|
1420
|
+
case "custom":
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function asReward(reward) {
|
|
1425
|
+
return { ...reward, description: text(reward.description) };
|
|
1426
|
+
}
|
|
1427
|
+
function initialState(config, timestamp) {
|
|
1428
|
+
const rewards = config.rewards.map(asReward);
|
|
1429
|
+
return {
|
|
1430
|
+
rallyId: config.id,
|
|
1431
|
+
records: [],
|
|
1432
|
+
rewards: reconcileRewardStates(rewards, [], 0, timestamp),
|
|
1433
|
+
updatedAt: timestamp
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
var UniversalStampRallyClient = class {
|
|
1437
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1438
|
+
#eventListeners = /* @__PURE__ */ new Set();
|
|
1439
|
+
#config;
|
|
1440
|
+
#storage;
|
|
1441
|
+
#options;
|
|
1442
|
+
#state = null;
|
|
1443
|
+
#initialization = null;
|
|
1444
|
+
#queue = Promise.resolve();
|
|
1445
|
+
constructor(config, storageOrOptions = {}, clock) {
|
|
1446
|
+
this.#config = config;
|
|
1447
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions, ...clock === void 0 ? {} : { clock } } : storageOrOptions;
|
|
1448
|
+
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1449
|
+
}
|
|
1450
|
+
getConfig() {
|
|
1451
|
+
return this.#config;
|
|
1452
|
+
}
|
|
1453
|
+
getState() {
|
|
1454
|
+
return this.#state;
|
|
1455
|
+
}
|
|
1456
|
+
subscribe(listener) {
|
|
1457
|
+
this.#listeners.add(listener);
|
|
1458
|
+
return () => this.#listeners.delete(listener);
|
|
1459
|
+
}
|
|
1460
|
+
subscribeEvents(listener) {
|
|
1461
|
+
this.#eventListeners.add(listener);
|
|
1462
|
+
return () => this.#eventListeners.delete(listener);
|
|
1463
|
+
}
|
|
1464
|
+
init() {
|
|
1465
|
+
return this.initialize();
|
|
1466
|
+
}
|
|
1467
|
+
initialize() {
|
|
1468
|
+
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1469
|
+
if (this.#initialization === null) {
|
|
1470
|
+
this.#initialization = this.#storage.load(this.#config.id).then((stored) => {
|
|
1471
|
+
const state = stored === null ? initialState(this.#config, this.#now()) : this.#reconcile(stored);
|
|
1472
|
+
this.#state = state;
|
|
1473
|
+
this.#emit(state);
|
|
1474
|
+
return state;
|
|
1475
|
+
}).catch((error) => {
|
|
1476
|
+
this.#initialization = null;
|
|
1477
|
+
throw error;
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
return this.#initialization;
|
|
1481
|
+
}
|
|
1482
|
+
checkIn(spotId, proofData, options = {}) {
|
|
1483
|
+
return this.#enqueue(async () => {
|
|
1484
|
+
const current = await this.initialize();
|
|
1485
|
+
const spot = this.#config.spots.find((item) => item.id === spotId);
|
|
1486
|
+
if (spot === void 0)
|
|
1487
|
+
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1488
|
+
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1489
|
+
return this.#fail({
|
|
1490
|
+
code: "STAMP_ALREADY_ACQUIRED",
|
|
1491
|
+
spotId,
|
|
1492
|
+
message: "Spot was already claimed."
|
|
1493
|
+
});
|
|
1494
|
+
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1495
|
+
if (spot.prerequisites?.some((id) => !acquired.has(id)))
|
|
1496
|
+
return this.#fail({
|
|
1497
|
+
code: "PREREQUISITES_NOT_MET",
|
|
1498
|
+
spotId,
|
|
1499
|
+
message: "Prerequisite spots are not complete."
|
|
1500
|
+
});
|
|
1501
|
+
for (const condition2 of spot.conditions) {
|
|
1502
|
+
if (condition2.type === "custom") {
|
|
1503
|
+
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1504
|
+
if (validator !== void 0) {
|
|
1505
|
+
try {
|
|
1506
|
+
const validation = await validator({
|
|
1507
|
+
spotId,
|
|
1508
|
+
proofData,
|
|
1509
|
+
config: this.#config,
|
|
1510
|
+
userState: current
|
|
1511
|
+
});
|
|
1512
|
+
if (!validation.success)
|
|
1513
|
+
return this.#fail({
|
|
1514
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1515
|
+
spotId,
|
|
1516
|
+
message: validation.error ?? "Custom validation failed."
|
|
1517
|
+
});
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
return this.#fail({
|
|
1520
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1521
|
+
spotId,
|
|
1522
|
+
message: error instanceof Error ? error.message : "Custom validation failed."
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
} else {
|
|
1526
|
+
return this.#fail({
|
|
1527
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1528
|
+
spotId,
|
|
1529
|
+
message: "No custom validator is registered."
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
} else if (!conditionMatches(condition2, proofData)) {
|
|
1533
|
+
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const timestamp = options.now ?? this.#now();
|
|
1537
|
+
const request = {
|
|
1538
|
+
rallyId: this.#config.id,
|
|
1539
|
+
spotId,
|
|
1540
|
+
proofData,
|
|
1541
|
+
idempotencyKey: options.idempotencyKey ?? randomId("check-in"),
|
|
1542
|
+
now: timestamp,
|
|
1543
|
+
state: current
|
|
1544
|
+
};
|
|
1545
|
+
const remote = this.#options.syncAdapter?.checkIn;
|
|
1546
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1547
|
+
const result = await remote(request);
|
|
1548
|
+
if (!result.ok) return this.#fail(result.error);
|
|
1549
|
+
return this.#commitCheckIn(result.value.state, result);
|
|
1550
|
+
}
|
|
1551
|
+
const record = { stampId: spotId, acquiredAt: timestamp };
|
|
1552
|
+
const next = this.#reconcile({
|
|
1553
|
+
...current,
|
|
1554
|
+
records: [...current.records, record],
|
|
1555
|
+
updatedAt: timestamp
|
|
1556
|
+
});
|
|
1557
|
+
await this.#storage.save(next);
|
|
1558
|
+
return this.#commitCheckIn(next, { ok: true, value: { state: next, record } });
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
claimReward(rewardId, options = {}) {
|
|
1562
|
+
return this.#enqueue(async () => {
|
|
1563
|
+
const current = await this.initialize();
|
|
1564
|
+
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
1565
|
+
if (configured === void 0)
|
|
1566
|
+
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
1567
|
+
const timestamp = options.now ?? this.#now();
|
|
1568
|
+
const state = current.rewards?.find((item) => item.rewardId === rewardId) ?? {
|
|
1569
|
+
rewardId,
|
|
1570
|
+
status: "LOCKED"
|
|
1571
|
+
};
|
|
1572
|
+
const local = consumeReward({
|
|
1573
|
+
reward: asReward(configured),
|
|
1574
|
+
currentState: state,
|
|
1575
|
+
now: timestamp,
|
|
1576
|
+
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
1577
|
+
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
1578
|
+
});
|
|
1579
|
+
if (!local.ok) return this.#fail(local.error);
|
|
1580
|
+
const request = {
|
|
1581
|
+
rallyId: this.#config.id,
|
|
1582
|
+
rewardId,
|
|
1583
|
+
idempotencyKey: options.idempotencyKey ?? randomId("claim"),
|
|
1584
|
+
now: timestamp,
|
|
1585
|
+
options,
|
|
1586
|
+
state: current
|
|
1587
|
+
};
|
|
1588
|
+
const remote = this.#options.syncAdapter?.claimReward;
|
|
1589
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1590
|
+
const result = await remote(request);
|
|
1591
|
+
if (!result.ok) return this.#fail(result.error);
|
|
1592
|
+
return this.#commitClaim(result.value.state, result);
|
|
1593
|
+
}
|
|
1594
|
+
const nextRewards = (current.rewards ?? []).map(
|
|
1595
|
+
(item) => item.rewardId === rewardId ? local.value : item
|
|
1596
|
+
);
|
|
1597
|
+
const next = { ...current, rewards: nextRewards, updatedAt: timestamp };
|
|
1598
|
+
await this.#storage.save(next);
|
|
1599
|
+
return this.#commitClaim(next, { ok: true, value: { state: next, reward: local.value } });
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
sync(adapter = this.#options.syncAdapter ?? {}) {
|
|
1603
|
+
return this.#enqueue(async () => {
|
|
1604
|
+
const current = await this.initialize();
|
|
1605
|
+
if (adapter.sync === void 0) {
|
|
1606
|
+
this.#emitEvent({ type: "sync", state: current });
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
try {
|
|
1610
|
+
const next = this.#reconcile(
|
|
1611
|
+
await adapter.sync({ rallyId: this.#config.id, state: current })
|
|
1612
|
+
);
|
|
1613
|
+
await this.#storage.save(next);
|
|
1614
|
+
this.#state = next;
|
|
1615
|
+
this.#emit(next);
|
|
1616
|
+
this.#emitEvent({ type: "sync", state: next });
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
const failure = {
|
|
1619
|
+
code: "SYNC_FAILED",
|
|
1620
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1621
|
+
};
|
|
1622
|
+
this.#emitEvent({ type: "error", error: failure });
|
|
1623
|
+
throw error;
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
#now() {
|
|
1628
|
+
return this.#options.clock?.() ?? now();
|
|
1629
|
+
}
|
|
1630
|
+
#reconcile(state) {
|
|
1631
|
+
const ids = new Set(this.#config.spots.map((spot) => spot.id));
|
|
1632
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1633
|
+
const records = state.records.filter(
|
|
1634
|
+
(record) => ids.has(record.stampId) && !seen.has(record.stampId) && seen.add(record.stampId)
|
|
1635
|
+
);
|
|
1636
|
+
return {
|
|
1637
|
+
...cloneState(state),
|
|
1638
|
+
records,
|
|
1639
|
+
rewards: reconcileRewardStates(
|
|
1640
|
+
this.#config.rewards.map(asReward),
|
|
1641
|
+
state.rewards ?? [],
|
|
1642
|
+
records.length,
|
|
1643
|
+
state.updatedAt
|
|
1644
|
+
)
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
#enqueue(operation) {
|
|
1648
|
+
const next = this.#queue.then(operation, operation);
|
|
1649
|
+
this.#queue = next.then(
|
|
1650
|
+
() => void 0,
|
|
1651
|
+
() => void 0
|
|
1652
|
+
);
|
|
1653
|
+
return next;
|
|
1654
|
+
}
|
|
1655
|
+
#fail(error) {
|
|
1656
|
+
this.#emitEvent({ type: "error", error });
|
|
1657
|
+
return { ok: false, error };
|
|
1658
|
+
}
|
|
1659
|
+
#commitCheckIn(state, result) {
|
|
1660
|
+
this.#state = state;
|
|
1661
|
+
this.#emit(state);
|
|
1662
|
+
this.#emitEvent({ type: "checkIn", result });
|
|
1663
|
+
return result;
|
|
1664
|
+
}
|
|
1665
|
+
#commitClaim(state, result) {
|
|
1666
|
+
this.#state = state;
|
|
1667
|
+
this.#emit(state);
|
|
1668
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
1669
|
+
return result;
|
|
1670
|
+
}
|
|
1671
|
+
#emit(state) {
|
|
1672
|
+
for (const listener of this.#listeners) listener(state);
|
|
1673
|
+
}
|
|
1674
|
+
#emitEvent(event) {
|
|
1675
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1372
1679
|
// src/crypto/token.ts
|
|
1373
1680
|
var encoder = new TextEncoder();
|
|
1374
1681
|
var decoder = new TextDecoder();
|
|
@@ -1430,7 +1737,7 @@ async function encryptPayload(plaintext, secret, api) {
|
|
|
1430
1737
|
);
|
|
1431
1738
|
return encode(new Uint8Array([...iv, ...encrypted]));
|
|
1432
1739
|
}
|
|
1433
|
-
async function verifySecureToken(token, secretKey,
|
|
1740
|
+
async function verifySecureToken(token, secretKey, now2 = Date.now()) {
|
|
1434
1741
|
try {
|
|
1435
1742
|
const parts = token.split(".");
|
|
1436
1743
|
if (parts.length !== 4 || parts[0] !== "sr3" || parts[1] !== "e" && parts[1] !== "p") {
|
|
@@ -1473,7 +1780,7 @@ async function verifySecureToken(token, secretKey, now = Date.now()) {
|
|
|
1473
1780
|
};
|
|
1474
1781
|
}
|
|
1475
1782
|
const payload = parsed;
|
|
1476
|
-
if (typeof payload.exp === "number" &&
|
|
1783
|
+
if (typeof payload.exp === "number" && now2 >= payload.exp * 1e3) {
|
|
1477
1784
|
return {
|
|
1478
1785
|
ok: false,
|
|
1479
1786
|
valid: false,
|
|
@@ -1502,18 +1809,18 @@ async function decryptPayload(body, secret) {
|
|
|
1502
1809
|
}
|
|
1503
1810
|
|
|
1504
1811
|
// src/domain/i18n.ts
|
|
1505
|
-
function resolveLocalizedText(
|
|
1506
|
-
if (
|
|
1507
|
-
if (typeof
|
|
1508
|
-
const fallback = fallbackLocale === void 0 ? Object.values(
|
|
1509
|
-
return
|
|
1510
|
-
}
|
|
1511
|
-
function toLocalizedString(
|
|
1512
|
-
if (
|
|
1513
|
-
return typeof
|
|
1514
|
-
ja:
|
|
1515
|
-
en:
|
|
1516
|
-
...
|
|
1812
|
+
function resolveLocalizedText(text3, locale, fallbackLocale) {
|
|
1813
|
+
if (text3 === void 0 || text3 === "") return "";
|
|
1814
|
+
if (typeof text3 === "string") return text3;
|
|
1815
|
+
const fallback = fallbackLocale === void 0 ? Object.values(text3).find((value) => typeof value === "string") : text3[fallbackLocale];
|
|
1816
|
+
return text3[locale] || fallback || "";
|
|
1817
|
+
}
|
|
1818
|
+
function toLocalizedString(text3) {
|
|
1819
|
+
if (text3 === void 0) return { ja: "", en: "" };
|
|
1820
|
+
return typeof text3 === "string" ? { ja: text3, en: "" } : {
|
|
1821
|
+
ja: text3["ja"] ?? "",
|
|
1822
|
+
en: text3["en"] ?? "",
|
|
1823
|
+
...text3
|
|
1517
1824
|
};
|
|
1518
1825
|
}
|
|
1519
1826
|
|
|
@@ -1759,7 +2066,7 @@ function validateRallyConfig(config) {
|
|
|
1759
2066
|
function isObject2(value) {
|
|
1760
2067
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1761
2068
|
}
|
|
1762
|
-
function
|
|
2069
|
+
function text2(value) {
|
|
1763
2070
|
if (typeof value === "string") return value;
|
|
1764
2071
|
if (!isObject2(value)) return void 0;
|
|
1765
2072
|
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
|
@@ -1803,11 +2110,11 @@ function condition(value) {
|
|
|
1803
2110
|
function migrateSpot(value, index) {
|
|
1804
2111
|
const source2 = isObject2(value) ? value : {};
|
|
1805
2112
|
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `spot-${index + 1}`;
|
|
1806
|
-
const description =
|
|
1807
|
-
const hint =
|
|
2113
|
+
const description = text2(source2.description);
|
|
2114
|
+
const hint = text2(source2.hint);
|
|
1808
2115
|
return {
|
|
1809
2116
|
id,
|
|
1810
|
-
name:
|
|
2117
|
+
name: text2(source2.name) ?? `Spot ${index + 1}`,
|
|
1811
2118
|
...description === void 0 ? {} : { description },
|
|
1812
2119
|
...hint === void 0 ? {} : { hint },
|
|
1813
2120
|
condition: condition(
|
|
@@ -1829,8 +2136,8 @@ function migrateReward(value, index) {
|
|
|
1829
2136
|
const source2 = isObject2(value) ? value : {};
|
|
1830
2137
|
return {
|
|
1831
2138
|
id: typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `reward-${index + 1}`,
|
|
1832
|
-
title:
|
|
1833
|
-
description:
|
|
2139
|
+
title: text2(source2.title) ?? `Reward ${index + 1}`,
|
|
2140
|
+
description: text2(source2.description) ?? "",
|
|
1834
2141
|
type: source2.type === "digital" ? "digital" : "in_person",
|
|
1835
2142
|
redemptionMethod: source2.redemptionMethod === "staff_passcode" || source2.redemptionMethod === "view_only" || source2.redemptionMethod === "server_claim" ? source2.redemptionMethod : "manual_slide",
|
|
1836
2143
|
requiredStampCount: typeof source2.requiredStampCount === "number" && Number.isFinite(source2.requiredStampCount) ? Math.max(0, Math.trunc(source2.requiredStampCount)) : 0,
|
|
@@ -1849,8 +2156,8 @@ function migrateRallyConfig(raw) {
|
|
|
1849
2156
|
const rawStamps = Array.isArray(source2.stamps) ? source2.stamps : Array.isArray(source2.spots) ? source2.spots : [];
|
|
1850
2157
|
const rawRewards = Array.isArray(source2.rewards) ? source2.rewards : [];
|
|
1851
2158
|
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : "migrated-rally";
|
|
1852
|
-
const title =
|
|
1853
|
-
const description =
|
|
2159
|
+
const title = text2(source2.title);
|
|
2160
|
+
const description = text2(source2.description);
|
|
1854
2161
|
const theme = isObject2(source2.theme) ? source2.theme : void 0;
|
|
1855
2162
|
return {
|
|
1856
2163
|
id,
|
|
@@ -1878,8 +2185,61 @@ var DEFAULT_SHEET_THEME = {
|
|
|
1878
2185
|
fontFamily: "serif"
|
|
1879
2186
|
};
|
|
1880
2187
|
|
|
2188
|
+
// src/domain/universalModel.ts
|
|
2189
|
+
function toPublicCondition(condition2) {
|
|
2190
|
+
switch (condition2.type) {
|
|
2191
|
+
case "qr":
|
|
2192
|
+
return { type: "qr", qrEntryUrl: condition2.qrEntryUrl ?? "" };
|
|
2193
|
+
case "passcode":
|
|
2194
|
+
return { type: "passcode" };
|
|
2195
|
+
case "gps":
|
|
2196
|
+
return {
|
|
2197
|
+
type: "gps",
|
|
2198
|
+
latitude: condition2.latitude,
|
|
2199
|
+
longitude: condition2.longitude,
|
|
2200
|
+
radiusMeters: condition2.radiusMeters
|
|
2201
|
+
};
|
|
2202
|
+
case "custom":
|
|
2203
|
+
return { type: "custom", validatorName: condition2.validatorName };
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
function toPublicRallyConfig(config) {
|
|
2207
|
+
return {
|
|
2208
|
+
...config,
|
|
2209
|
+
spots: config.spots.map((spot) => ({
|
|
2210
|
+
...spot,
|
|
2211
|
+
conditions: spot.conditions.map(toPublicCondition)
|
|
2212
|
+
})),
|
|
2213
|
+
rewards: config.rewards.map(
|
|
2214
|
+
({ staffPasscode: _staffPasscode, digitalContentUrl: _content, ...reward }) => reward
|
|
2215
|
+
)
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
function isPublicRallyConfig(value) {
|
|
2219
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
2220
|
+
const candidate = value;
|
|
2221
|
+
if (candidate.secretKey !== void 0 || candidate.verificationSecrets !== void 0)
|
|
2222
|
+
return false;
|
|
2223
|
+
return Array.isArray(candidate.spots) && Array.isArray(candidate.rewards) && candidate.spots.every((spot) => {
|
|
2224
|
+
if (typeof spot !== "object" || spot === null || Array.isArray(spot)) return false;
|
|
2225
|
+
const conditions = spot.conditions;
|
|
2226
|
+
return Array.isArray(conditions) && conditions.every((condition2) => {
|
|
2227
|
+
if (typeof condition2 !== "object" || condition2 === null) return false;
|
|
2228
|
+
const type = condition2.type;
|
|
2229
|
+
return type === "qr" || type === "passcode" || type === "gps" || type === "custom";
|
|
2230
|
+
});
|
|
2231
|
+
}) && candidate.rewards.every((reward) => {
|
|
2232
|
+
if (typeof reward !== "object" || reward === null || Array.isArray(reward)) return false;
|
|
2233
|
+
const item = reward;
|
|
2234
|
+
return item.staffPasscode === void 0 && item.digitalContentUrl === void 0;
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
|
|
1881
2238
|
// src/domain/publicConfig.ts
|
|
1882
2239
|
function stripSensitiveConfig(config) {
|
|
2240
|
+
if ("spots" in config && !("stamps" in config)) {
|
|
2241
|
+
return toPublicRallyConfig(config);
|
|
2242
|
+
}
|
|
1883
2243
|
const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
|
|
1884
2244
|
return {
|
|
1885
2245
|
...config,
|
|
@@ -1986,6 +2346,180 @@ var THEME_PRESETS = [
|
|
|
1986
2346
|
}
|
|
1987
2347
|
];
|
|
1988
2348
|
|
|
2349
|
+
// src/domain/universalValidation.ts
|
|
2350
|
+
function isObject3(value) {
|
|
2351
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2352
|
+
}
|
|
2353
|
+
function add2(errors, path, code, message) {
|
|
2354
|
+
errors.push({ path, code, message });
|
|
2355
|
+
}
|
|
2356
|
+
function hasText2(value) {
|
|
2357
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
2358
|
+
return isObject3(value) && Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
|
|
2359
|
+
}
|
|
2360
|
+
function validateCondition2(condition2, path, errors) {
|
|
2361
|
+
if (!isObject3(condition2) || typeof condition2.type !== "string") {
|
|
2362
|
+
add2(errors, path, "INVALID_TYPE", "Condition must have a type.");
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
switch (condition2.type) {
|
|
2366
|
+
case "qr":
|
|
2367
|
+
if (typeof condition2.secretToken !== "string" || condition2.secretToken.trim() === "")
|
|
2368
|
+
add2(errors, `${path}.secretToken`, "REQUIRED", "QR secretToken is required.");
|
|
2369
|
+
if (condition2.qrEntryUrl !== void 0 && typeof condition2.qrEntryUrl !== "string")
|
|
2370
|
+
add2(errors, `${path}.qrEntryUrl`, "INVALID_TYPE", "QR entry URL must be a string.");
|
|
2371
|
+
return;
|
|
2372
|
+
case "passcode":
|
|
2373
|
+
if (typeof condition2.code !== "string" || condition2.code.trim() === "")
|
|
2374
|
+
add2(errors, `${path}.code`, "REQUIRED", "Passcode is required.");
|
|
2375
|
+
return;
|
|
2376
|
+
case "gps":
|
|
2377
|
+
if (typeof condition2.latitude !== "number" || !Number.isFinite(condition2.latitude) || condition2.latitude < -90 || condition2.latitude > 90 || typeof condition2.longitude !== "number" || !Number.isFinite(condition2.longitude) || condition2.longitude < -180 || condition2.longitude > 180)
|
|
2378
|
+
add2(errors, path, "INVALID_COORDINATES", "GPS coordinates are invalid.");
|
|
2379
|
+
if (typeof condition2.radiusMeters !== "number" || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters <= 0)
|
|
2380
|
+
add2(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "GPS radius must be positive.");
|
|
2381
|
+
return;
|
|
2382
|
+
case "custom":
|
|
2383
|
+
if (typeof condition2.validatorName !== "string" || condition2.validatorName.trim() === "")
|
|
2384
|
+
add2(errors, `${path}.validatorName`, "REQUIRED", "Custom validatorName is required.");
|
|
2385
|
+
return;
|
|
2386
|
+
default:
|
|
2387
|
+
add2(errors, `${path}.type`, "INVALID_TYPE", "Unsupported verification condition.");
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
function validateDag2(spots, errors) {
|
|
2391
|
+
const graph = /* @__PURE__ */ new Map();
|
|
2392
|
+
for (const spot of spots) {
|
|
2393
|
+
if (!isObject3(spot) || typeof spot.id !== "string") continue;
|
|
2394
|
+
const prerequisites = Array.isArray(spot.prerequisites) ? spot.prerequisites.filter((item) => typeof item === "string") : [];
|
|
2395
|
+
graph.set(spot.id, prerequisites);
|
|
2396
|
+
}
|
|
2397
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2398
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2399
|
+
const visit = (id) => {
|
|
2400
|
+
if (visiting.has(id)) {
|
|
2401
|
+
add2(
|
|
2402
|
+
errors,
|
|
2403
|
+
`spots.${id}.prerequisites`,
|
|
2404
|
+
"CYCLE_DETECTED",
|
|
2405
|
+
`Dependency cycle detected at '${id}'.`
|
|
2406
|
+
);
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
if (visited.has(id)) return;
|
|
2410
|
+
visiting.add(id);
|
|
2411
|
+
for (const prerequisite of graph.get(id) ?? [])
|
|
2412
|
+
if (graph.has(prerequisite)) visit(prerequisite);
|
|
2413
|
+
visiting.delete(id);
|
|
2414
|
+
visited.add(id);
|
|
2415
|
+
};
|
|
2416
|
+
for (const id of graph.keys()) visit(id);
|
|
2417
|
+
}
|
|
2418
|
+
function validateAdminRallyConfig(value) {
|
|
2419
|
+
const errors = [];
|
|
2420
|
+
if (!isObject3(value))
|
|
2421
|
+
return {
|
|
2422
|
+
valid: false,
|
|
2423
|
+
errors: [{ path: "", code: "INVALID_TYPE", message: "Admin config must be an object." }]
|
|
2424
|
+
};
|
|
2425
|
+
if (typeof value.id !== "string" || value.id.trim() === "")
|
|
2426
|
+
add2(errors, "id", "REQUIRED", "Rally ID is required.");
|
|
2427
|
+
if (typeof value.version !== "string" || value.version.trim() === "")
|
|
2428
|
+
add2(errors, "version", "INVALID_VERSION", "Version is required.");
|
|
2429
|
+
if (!Array.isArray(value.spots)) {
|
|
2430
|
+
add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2431
|
+
} else {
|
|
2432
|
+
const ids = [];
|
|
2433
|
+
value.spots.forEach((spot, index) => {
|
|
2434
|
+
const path = `spots[${index}]`;
|
|
2435
|
+
if (!isObject3(spot)) {
|
|
2436
|
+
add2(errors, path, "INVALID_TYPE", "Spot must be an object.");
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
if (typeof spot.id !== "string" || spot.id.trim() === "")
|
|
2440
|
+
add2(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
|
|
2441
|
+
else if (ids.includes(spot.id))
|
|
2442
|
+
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate spot ID '${spot.id}'.`);
|
|
2443
|
+
else ids.push(spot.id);
|
|
2444
|
+
if (!hasText2(spot.name)) add2(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
|
|
2445
|
+
if (typeof spot.orderIndex !== "number" || !Number.isInteger(spot.orderIndex))
|
|
2446
|
+
add2(errors, `${path}.orderIndex`, "INVALID_TYPE", "orderIndex must be an integer.");
|
|
2447
|
+
if (!Array.isArray(spot.conditions) || spot.conditions.length === 0)
|
|
2448
|
+
add2(errors, `${path}.conditions`, "REQUIRED", "At least one condition is required.");
|
|
2449
|
+
else {
|
|
2450
|
+
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2451
|
+
validateCondition2(condition2, `${path}.conditions[${conditionIndex}]`, errors);
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
});
|
|
2455
|
+
validateDag2(value.spots, errors);
|
|
2456
|
+
}
|
|
2457
|
+
if (!Array.isArray(value.rewards))
|
|
2458
|
+
add2(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
|
|
2459
|
+
else {
|
|
2460
|
+
const ids = [];
|
|
2461
|
+
value.rewards.forEach((reward, index) => {
|
|
2462
|
+
const path = `rewards[${index}]`;
|
|
2463
|
+
if (!isObject3(reward)) {
|
|
2464
|
+
add2(errors, path, "INVALID_TYPE", "Reward must be an object.");
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
if (typeof reward.id !== "string" || reward.id.trim() === "")
|
|
2468
|
+
add2(errors, `${path}.id`, "REQUIRED", "Reward ID is required.");
|
|
2469
|
+
else if (ids.includes(reward.id))
|
|
2470
|
+
add2(errors, `${path}.id`, "DUPLICATE_ID", `Duplicate reward ID '${reward.id}'.`);
|
|
2471
|
+
else ids.push(reward.id);
|
|
2472
|
+
if (!hasText2(reward.title))
|
|
2473
|
+
add2(errors, `${path}.title`, "REQUIRED", "Reward title is required.");
|
|
2474
|
+
if (typeof reward.requiredStampCount !== "number" || !Number.isInteger(reward.requiredStampCount) || reward.requiredStampCount < 0)
|
|
2475
|
+
add2(
|
|
2476
|
+
errors,
|
|
2477
|
+
`${path}.requiredStampCount`,
|
|
2478
|
+
"INVALID_REWARD",
|
|
2479
|
+
"requiredStampCount must be a non-negative integer."
|
|
2480
|
+
);
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
return { valid: errors.length === 0, errors };
|
|
2484
|
+
}
|
|
2485
|
+
function validatePublicRallyConfig(value) {
|
|
2486
|
+
const errors = [];
|
|
2487
|
+
if (!isObject3(value))
|
|
2488
|
+
return {
|
|
2489
|
+
valid: false,
|
|
2490
|
+
errors: [{ path: "", code: "INVALID_TYPE", message: "Public config must be an object." }]
|
|
2491
|
+
};
|
|
2492
|
+
if ("secretKey" in value || "verificationSecrets" in value)
|
|
2493
|
+
add2(
|
|
2494
|
+
errors,
|
|
2495
|
+
"",
|
|
2496
|
+
"SECRET_IN_PUBLIC_CONFIG",
|
|
2497
|
+
"Public config must not contain server verification secrets."
|
|
2498
|
+
);
|
|
2499
|
+
if (!Array.isArray(value.spots)) add2(errors, "spots", "INVALID_TYPE", "spots must be an array.");
|
|
2500
|
+
else
|
|
2501
|
+
value.spots.forEach((spot, index) => {
|
|
2502
|
+
if (!isObject3(spot) || !Array.isArray(spot.conditions)) return;
|
|
2503
|
+
spot.conditions.forEach((condition2, conditionIndex) => {
|
|
2504
|
+
if (!isObject3(condition2)) return;
|
|
2505
|
+
if ("secretToken" in condition2 || "code" in condition2 || "secretParams" in condition2)
|
|
2506
|
+
add2(
|
|
2507
|
+
errors,
|
|
2508
|
+
`spots[${index}].conditions[${conditionIndex}]`,
|
|
2509
|
+
"SECRET_IN_PUBLIC_CONFIG",
|
|
2510
|
+
"Public condition contains verification secret material."
|
|
2511
|
+
);
|
|
2512
|
+
});
|
|
2513
|
+
});
|
|
2514
|
+
return { valid: errors.length === 0, errors };
|
|
2515
|
+
}
|
|
2516
|
+
function isAdminRallyConfig(value) {
|
|
2517
|
+
return validateAdminRallyConfig(value).valid;
|
|
2518
|
+
}
|
|
2519
|
+
function isPublicRallyConfigShape(value) {
|
|
2520
|
+
return validatePublicRallyConfig(value).valid;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
1989
2523
|
// src/security/snapshotToken.ts
|
|
1990
2524
|
var encoder2 = new TextEncoder();
|
|
1991
2525
|
function cryptoApi2() {
|
|
@@ -2024,9 +2558,9 @@ async function importAesKey(secret) {
|
|
|
2024
2558
|
const digest = await cryptoApi2().subtle.digest("SHA-256", webCryptoBytes(secret));
|
|
2025
2559
|
return cryptoApi2().subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
2026
2560
|
}
|
|
2027
|
-
function isExpired(payload,
|
|
2028
|
-
if (typeof payload.exp === "number" &&
|
|
2029
|
-
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <=
|
|
2561
|
+
function isExpired(payload, now2) {
|
|
2562
|
+
if (typeof payload.exp === "number" && now2 >= payload.exp * 1e3) return true;
|
|
2563
|
+
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now2;
|
|
2030
2564
|
}
|
|
2031
2565
|
async function createSignedSnapshotToken(payload, secretKey) {
|
|
2032
2566
|
const api = cryptoApi2();
|
|
@@ -2046,7 +2580,7 @@ async function createSignedSnapshotToken(payload, secretKey) {
|
|
|
2046
2580
|
);
|
|
2047
2581
|
return `sr2.${body}.${base64Url(signature)}`;
|
|
2048
2582
|
}
|
|
2049
|
-
async function verifySnapshotToken(token, secretKey,
|
|
2583
|
+
async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
|
|
2050
2584
|
try {
|
|
2051
2585
|
const parts = token.split(".");
|
|
2052
2586
|
if (parts.length !== 3 || parts[0] !== "sr2") {
|
|
@@ -2088,7 +2622,7 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2088
2622
|
const payload = JSON.parse(new TextDecoder().decode(payloadText));
|
|
2089
2623
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
2090
2624
|
throw new Error("Invalid payload.");
|
|
2091
|
-
if (isExpired(payload,
|
|
2625
|
+
if (isExpired(payload, now2)) {
|
|
2092
2626
|
return {
|
|
2093
2627
|
ok: false,
|
|
2094
2628
|
valid: false,
|
|
@@ -2105,6 +2639,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2105
2639
|
}
|
|
2106
2640
|
}
|
|
2107
2641
|
|
|
2108
|
-
export { CURRENT_RALLY_CONFIG_VERSION, DEFAULT_SHEET_THEME, InMemoryStorage, IndexedDBAdapter, LocalStorageAdapter, StampRallyClient, StorageAdapterError, THEME_PRESETS, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCheckIn, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, importProgressToken, isGeolocationSupported, isNfcSupported, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, migrateRallyConfig, normalizePasscode, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, stripSensitiveConfig, toLocalizedString, validateRallyConfig, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2642
|
+
export { CURRENT_RALLY_CONFIG_VERSION, DEFAULT_SHEET_THEME, InMemoryStorage, IndexedDBAdapter, LocalStorageAdapter, StampRallyClient, StorageAdapterError, THEME_PRESETS, UniversalStampRallyClient, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCheckIn, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, importProgressToken, isAdminRallyConfig, isGeolocationSupported, isNfcSupported, isPublicRallyConfig, isPublicRallyConfigShape, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, migrateRallyConfig, normalizePasscode, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, stripSensitiveConfig, toLocalizedString, toPublicRallyConfig, validateAdminRallyConfig, validatePublicRallyConfig, validateRallyConfig, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2109
2643
|
//# sourceMappingURL=index.js.map
|
|
2110
2644
|
//# sourceMappingURL=index.js.map
|