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