@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 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, now) {
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 distanceMeters = calculateDistanceMeters(
64
+ const distanceMeters2 = calculateDistanceMeters(
65
65
  condition2.latitude,
66
66
  condition2.longitude,
67
67
  context.currentLatitude,
68
68
  context.currentLongitude
69
69
  );
70
- if (distanceMeters <= condition2.radiusMeters) {
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: distanceMeters - condition2.radiusMeters
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, now);
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(now);
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, now);
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, now) {
177
- return evaluateConditionDetailed(condition2, context, now ?? "").ok;
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, now) {
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(now)) {
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 ?? now,
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, now) {
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, now);
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: now };
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, now);
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: now
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: now });
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: now });
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, now = this.#clock()) {
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, now);
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(now = this.#clock()) {
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(now);
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(now) {
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, now)
1344
+ rewards: reconcileRewardStates(this.#config.rewards, [], 0, now2)
1345
1345
  },
1346
- updatedAt: now
1346
+ updatedAt: now2
1347
1347
  };
1348
1348
  return state;
1349
1349
  }
1350
- #reconcileState(state, now) {
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
- now
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, now = Date.now()) {
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" && now >= payload.exp * 1e3) {
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(text2, locale, fallbackLocale) {
1508
- if (text2 === void 0 || text2 === "") return "";
1509
- if (typeof text2 === "string") return text2;
1510
- const fallback = fallbackLocale === void 0 ? Object.values(text2).find((value) => typeof value === "string") : text2[fallbackLocale];
1511
- return text2[locale] || fallback || "";
1512
- }
1513
- function toLocalizedString(text2) {
1514
- if (text2 === void 0) return { ja: "", en: "" };
1515
- return typeof text2 === "string" ? { ja: text2, en: "" } : {
1516
- ja: text2["ja"] ?? "",
1517
- en: text2["en"] ?? "",
1518
- ...text2
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 text(value) {
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 = text(source2.description);
1809
- const hint = text(source2.hint);
2115
+ const description = text2(source2.description);
2116
+ const hint = text2(source2.hint);
1810
2117
  return {
1811
2118
  id,
1812
- name: text(source2.name) ?? `Spot ${index + 1}`,
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: text(source2.title) ?? `Reward ${index + 1}`,
1835
- description: text(source2.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 = text(source2.title);
1855
- const description = text(source2.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, now) {
2030
- if (typeof payload.exp === "number" && now >= payload.exp * 1e3) return true;
2031
- return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now;
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, now = Date.now()) {
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, now)) {
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;