@stamprally/core 0.1.0 → 0.2.1

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,15 +31,15 @@ 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(condition, context, now) {
35
- switch (condition.type) {
34
+ function evaluateConditionDetailed(condition2, context, now) {
35
+ switch (condition2.type) {
36
36
  case "instant":
37
37
  return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
38
38
  case "token":
39
39
  if (context.type !== "token") {
40
40
  return contextTypeMismatch("token", "token", context.type);
41
41
  }
42
- return context.token === condition.token ? { ok: true, value: { conditionType: "token" } } : {
42
+ return context.token === condition2.token ? { ok: true, value: { conditionType: "token" } } : {
43
43
  ok: false,
44
44
  error: {
45
45
  code: "CONDITION_MISMATCH",
@@ -51,7 +51,7 @@ function evaluateConditionDetailed(condition, context, now) {
51
51
  if (context.type !== "geo") {
52
52
  return contextTypeMismatch("geo", "geo", context.type);
53
53
  }
54
- if (!isValidCoordinate(condition.latitude, condition.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition.radiusMeters) || condition.radiusMeters < 0) {
54
+ if (!isValidCoordinate(condition2.latitude, condition2.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition2.radiusMeters) || condition2.radiusMeters < 0) {
55
55
  return {
56
56
  ok: false,
57
57
  error: {
@@ -62,12 +62,12 @@ function evaluateConditionDetailed(condition, context, now) {
62
62
  };
63
63
  }
64
64
  const distanceMeters = calculateDistanceMeters(
65
- condition.latitude,
66
- condition.longitude,
65
+ condition2.latitude,
66
+ condition2.longitude,
67
67
  context.currentLatitude,
68
68
  context.currentLongitude
69
69
  );
70
- if (distanceMeters <= condition.radiusMeters) {
70
+ if (distanceMeters <= condition2.radiusMeters) {
71
71
  return { ok: true, value: { conditionType: "geo", distanceMeters } };
72
72
  }
73
73
  return {
@@ -77,8 +77,8 @@ function evaluateConditionDetailed(condition, context, now) {
77
77
  conditionType: "geo",
78
78
  reason: "OUTSIDE_RADIUS",
79
79
  distanceMeters,
80
- radiusMeters: condition.radiusMeters,
81
- differenceMeters: distanceMeters - condition.radiusMeters
80
+ radiusMeters: condition2.radiusMeters,
81
+ differenceMeters: distanceMeters - condition2.radiusMeters
82
82
  }
83
83
  };
84
84
  }
@@ -86,31 +86,31 @@ function evaluateConditionDetailed(condition, context, now) {
86
86
  if (context.type !== "composite") {
87
87
  return contextTypeMismatch("composite", "composite", context.type);
88
88
  }
89
- if (condition.conditions.length !== context.contexts.length) {
89
+ if (condition2.conditions.length !== context.contexts.length) {
90
90
  return {
91
91
  ok: false,
92
92
  error: {
93
93
  code: "CONDITION_MISMATCH",
94
94
  conditionType: "composite",
95
95
  reason: "CONTEXT_LENGTH_MISMATCH",
96
- expectedCount: condition.conditions.length,
96
+ expectedCount: condition2.conditions.length,
97
97
  actualCount: context.contexts.length
98
98
  }
99
99
  };
100
100
  }
101
101
  const failures = [];
102
102
  let matchedCount = 0;
103
- for (const [index, childCondition] of condition.conditions.entries()) {
103
+ for (const [index, childCondition] of condition2.conditions.entries()) {
104
104
  const childContext = context.contexts[index];
105
105
  if (childContext === void 0) continue;
106
106
  const result = evaluateConditionDetailed(childCondition, childContext, now);
107
107
  if (result.ok) matchedCount += 1;
108
108
  else failures.push({ index, error: result.error });
109
109
  }
110
- if (condition.operator === "AND" && failures.length === 0) {
110
+ if (condition2.operator === "AND" && failures.length === 0) {
111
111
  return { ok: true, value: { conditionType: "composite" } };
112
112
  }
113
- if (condition.operator === "OR" && matchedCount > 0) {
113
+ if (condition2.operator === "OR" && matchedCount > 0) {
114
114
  return { ok: true, value: { conditionType: "composite" } };
115
115
  }
116
116
  return {
@@ -118,14 +118,14 @@ function evaluateConditionDetailed(condition, context, now) {
118
118
  error: {
119
119
  code: "CONDITION_MISMATCH",
120
120
  conditionType: "composite",
121
- reason: condition.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
121
+ reason: condition2.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
122
122
  failures
123
123
  }
124
124
  };
125
125
  }
126
126
  case "time_window": {
127
- const startsAt = Date.parse(condition.startsAt);
128
- const endsAt = Date.parse(condition.endsAt);
127
+ const startsAt = Date.parse(condition2.startsAt);
128
+ const endsAt = Date.parse(condition2.endsAt);
129
129
  const currentTime = Date.parse(now);
130
130
  if (!Number.isFinite(currentTime)) {
131
131
  return {
@@ -135,8 +135,8 @@ function evaluateConditionDetailed(condition, context, now) {
135
135
  conditionType: "time_window",
136
136
  reason: "INVALID_NOW",
137
137
  now,
138
- startsAt: condition.startsAt,
139
- endsAt: condition.endsAt
138
+ startsAt: condition2.startsAt,
139
+ endsAt: condition2.endsAt
140
140
  }
141
141
  };
142
142
  }
@@ -148,8 +148,8 @@ function evaluateConditionDetailed(condition, context, now) {
148
148
  conditionType: "time_window",
149
149
  reason: "INVALID_TIME_WINDOW",
150
150
  now,
151
- startsAt: condition.startsAt,
152
- endsAt: condition.endsAt
151
+ startsAt: condition2.startsAt,
152
+ endsAt: condition2.endsAt
153
153
  }
154
154
  };
155
155
  }
@@ -161,20 +161,65 @@ function evaluateConditionDetailed(condition, context, now) {
161
161
  conditionType: "time_window",
162
162
  reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
163
163
  now,
164
- startsAt: condition.startsAt,
165
- endsAt: condition.endsAt
164
+ startsAt: condition2.startsAt,
165
+ endsAt: condition2.endsAt
166
166
  }
167
167
  };
168
168
  }
169
- const childResult = evaluateConditionDetailed(condition.condition, context, now);
169
+ const childResult = evaluateConditionDetailed(condition2.condition, context, now);
170
170
  return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
171
171
  }
172
172
  default:
173
- return assertNever(condition);
173
+ return assertNever(condition2);
174
174
  }
175
175
  }
176
- function evaluateCondition(condition, context, now) {
177
- return evaluateConditionDetailed(condition, context, now ?? "").ok;
176
+ function evaluateCondition(condition2, context, now) {
177
+ return evaluateConditionDetailed(condition2, context, now ?? "").ok;
178
+ }
179
+
180
+ // src/engine/checkIn.ts
181
+ function unwrapContext(input) {
182
+ if ("verificationContext" in input) {
183
+ return {
184
+ context: input.verificationContext,
185
+ now: input.now ?? (/* @__PURE__ */ new Date()).toISOString(),
186
+ ...input.expectedStampId === void 0 ? {} : { expectedStampId: input.expectedStampId },
187
+ alreadyClaimed: input.alreadyClaimed ?? false
188
+ };
189
+ }
190
+ return {
191
+ context: input,
192
+ now: "now" in input && typeof input.now === "string" ? input.now : (/* @__PURE__ */ new Date()).toISOString(),
193
+ ..."expectedStampId" in input && typeof input.expectedStampId === "string" ? { expectedStampId: input.expectedStampId } : {},
194
+ alreadyClaimed: "alreadyClaimed" in input && input.alreadyClaimed === true
195
+ };
196
+ }
197
+ function evaluateCheckIn(spot, input) {
198
+ const context = unwrapContext(input);
199
+ if (context.alreadyClaimed) {
200
+ return {
201
+ ok: false,
202
+ success: false,
203
+ code: "ALREADY_CLAIMED",
204
+ stampId: spot.id,
205
+ message: "This spot has already been claimed."
206
+ };
207
+ }
208
+ if (context.expectedStampId !== void 0 && context.expectedStampId !== spot.id) {
209
+ return {
210
+ ok: false,
211
+ success: false,
212
+ code: "ORDER_VIOLATION",
213
+ stampId: spot.id,
214
+ message: `The next required spot is '${context.expectedStampId}'.`
215
+ };
216
+ }
217
+ const evaluated = evaluateConditionDetailed(spot.condition, context.context, context.now);
218
+ if (evaluated.ok) {
219
+ return { ok: true, success: true, stampId: spot.id, checkedAt: context.now };
220
+ }
221
+ const code = evaluated.error.reason === "OUTSIDE_RADIUS" ? "OUT_OF_RANGE" : evaluated.error.reason === "BEFORE_START" || evaluated.error.reason === "AFTER_END" ? "EXPIRED" : evaluated.error.reason === "TOKEN_MISMATCH" ? "INVALID_PROOF" : "INVALID_CONTEXT";
222
+ return { ok: false, success: false, code, stampId: spot.id, message: evaluated.error.reason };
178
223
  }
179
224
 
180
225
  // src/engine/order.ts
@@ -206,6 +251,26 @@ function calculateProgress(state, config) {
206
251
  };
207
252
  }
208
253
 
254
+ // src/engine/rewards.ts
255
+ function hash(value) {
256
+ let result = 2166136261;
257
+ for (const character of value) {
258
+ result ^= character.codePointAt(0) ?? 0;
259
+ result = Math.imul(result, 16777619) >>> 0;
260
+ }
261
+ return result.toString(36).toUpperCase().padStart(7, "0");
262
+ }
263
+ function createClaimTicketNumber(rewardId, options = {}) {
264
+ const issuedAt = options.issuedAt ?? "";
265
+ const sequence = options.sequence ?? 0;
266
+ return `SR-${hash(`${rewardId}|${issuedAt}|${sequence}`)}`;
267
+ }
268
+ function issueClaimTicketNumber(reward, currentState, options = {}) {
269
+ if (currentState.claimTicketNumber !== void 0) return currentState;
270
+ const claimTicketNumber = reward.claimTicketNumber ?? createClaimTicketNumber(reward.id, options);
271
+ return { ...currentState, claimTicketNumber };
272
+ }
273
+
209
274
  // src/detectors/types.ts
210
275
  function createDetectorError(detector, code, message, cause) {
211
276
  return cause === void 0 ? { detector, code, message } : { detector, code, message, cause };
@@ -410,9 +475,9 @@ function normalizePasscode(input, caseSensitive = false) {
410
475
  const normalized = input.normalize("NFKC").trim();
411
476
  return caseSensitive ? normalized : normalized.toUpperCase();
412
477
  }
413
- function verifyPasscode(inputCode, condition) {
414
- const input = normalizePasscode(inputCode, condition.caseSensitive);
415
- const expected = normalizePasscode(condition.passcode, condition.caseSensitive);
478
+ function verifyPasscode(inputCode, condition2) {
479
+ const input = normalizePasscode(inputCode, condition2.caseSensitive);
480
+ const expected = normalizePasscode(condition2.passcode, condition2.caseSensitive);
416
481
  return input === expected ? { success: true } : {
417
482
  success: false,
418
483
  reason: "INVALID_PASSCODE",
@@ -549,12 +614,23 @@ function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now)
549
614
  if (current?.status === "CONSUMED" || current?.status === "EXPIRED") {
550
615
  return current;
551
616
  }
617
+ if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now)) {
618
+ return { rewardId: reward.id, status: "EXPIRED" };
619
+ }
620
+ if (reward.maxStock !== void 0 && (current?.redeemedCount ?? 0) >= reward.maxStock) {
621
+ return {
622
+ rewardId: reward.id,
623
+ status: "EXPIRED",
624
+ ...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
625
+ };
626
+ }
552
627
  if (acquiredStampCount >= reward.requiredStampCount) {
553
628
  if (current?.status === "AVAILABLE") return current;
554
629
  return {
555
630
  rewardId: reward.id,
556
631
  status: "AVAILABLE",
557
- unlockedAt: current?.unlockedAt ?? now
632
+ unlockedAt: current?.unlockedAt ?? now,
633
+ ...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
558
634
  };
559
635
  }
560
636
  if (current?.status === "LOCKED" && current.unlockedAt === void 0) return current;
@@ -575,6 +651,15 @@ function consumeReward(params) {
575
651
  error: { code: "NOT_AVAILABLE", rewardId: reward.id }
576
652
  };
577
653
  }
654
+ if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(params.now)) {
655
+ return { ok: false, error: { code: "EXPIRED", rewardId: reward.id } };
656
+ }
657
+ if (reward.maxStock !== void 0 && (currentState.redeemedCount ?? 0) >= reward.maxStock) {
658
+ return { ok: false, error: { code: "OUT_OF_STOCK", rewardId: reward.id } };
659
+ }
660
+ if (reward.limitPerUser !== void 0 && (params.userRedemptionCount ?? currentState.userRedemptionCount ?? 0) >= reward.limitPerUser) {
661
+ return { ok: false, error: { code: "USER_LIMIT_REACHED", rewardId: reward.id } };
662
+ }
578
663
  if (reward.redemptionMethod === "staff_passcode") {
579
664
  const passcodeResult = reward.staffPasscode === void 0 ? null : verifyPasscode(params.inputPasscode ?? "", { passcode: reward.staffPasscode });
580
665
  if (passcodeResult === null || !passcodeResult.success) {
@@ -597,6 +682,8 @@ function consumeReward(params) {
597
682
  ...currentState,
598
683
  status: "CONSUMED",
599
684
  consumedAt: params.now,
685
+ ...reward.maxStock !== void 0 || params.userId !== void 0 || params.userRedemptionCount !== void 0 ? { redeemedCount: (currentState.redeemedCount ?? 0) + 1 } : {},
686
+ ...params.userId === void 0 ? {} : { userRedemptionCount: (currentState.userRedemptionCount ?? 0) + 1 },
600
687
  ...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
601
688
  }
602
689
  };
@@ -647,6 +734,14 @@ function processStamp(state, config, targetStampId, context, now) {
647
734
  updatedAt: now
648
735
  };
649
736
  const events = [{ type: "stampAcquired", record }];
737
+ if (nextRewards !== void 0) {
738
+ for (const rewardState of nextRewards) {
739
+ const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
740
+ if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
741
+ events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now });
742
+ }
743
+ }
744
+ }
650
745
  const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
651
746
  if (completed) {
652
747
  events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now });
@@ -1222,14 +1317,361 @@ var StampRallyClient = class {
1222
1317
  };
1223
1318
 
1224
1319
  // src/domain/i18n.ts
1225
- function resolveLocalizedText(text, locale, fallbackLocale = "ja") {
1226
- if (text === void 0 || text === "") return "";
1227
- if (typeof text === "string") return text;
1228
- return text[locale] || text[fallbackLocale] || "";
1320
+ function resolveLocalizedText(text2, locale, fallbackLocale) {
1321
+ if (text2 === void 0 || text2 === "") return "";
1322
+ if (typeof text2 === "string") return text2;
1323
+ const fallback = fallbackLocale ?? "ja";
1324
+ return text2[locale] || text2[fallback] || "";
1325
+ }
1326
+ function toLocalizedString(text2) {
1327
+ if (text2 === void 0) return { ja: "", en: "" };
1328
+ return typeof text2 === "string" ? { ja: text2, en: "" } : {
1329
+ ja: text2["ja"] ?? "",
1330
+ en: text2["en"] ?? "",
1331
+ ...text2
1332
+ };
1333
+ }
1334
+
1335
+ // src/domain/validation.ts
1336
+ var CURRENT_RALLY_CONFIG_VERSION = 2;
1337
+ function isObject(value) {
1338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1339
+ }
1340
+ function add(errors, path, code, message) {
1341
+ errors.push({ path, code, message });
1342
+ }
1343
+ function hasText(value) {
1344
+ if (typeof value === "string") return value.trim() !== "";
1345
+ if (!isObject(value)) return false;
1346
+ return Object.values(value).some((item) => typeof item === "string" && item.trim() !== "");
1347
+ }
1348
+ function validateCondition(value, path, errors) {
1349
+ if (!isObject(value) || typeof value.type !== "string") {
1350
+ add(errors, path, "INVALID_TYPE", "Condition must be an object with a type.");
1351
+ return;
1352
+ }
1353
+ switch (value.type) {
1354
+ case "instant":
1355
+ return;
1356
+ case "token":
1357
+ if (typeof value.token !== "string" || value.token.trim() === "") {
1358
+ add(errors, `${path}.token`, "REQUIRED", "Token must not be empty.");
1359
+ }
1360
+ return;
1361
+ case "geo": {
1362
+ const latitude = value.latitude;
1363
+ const longitude = value.longitude;
1364
+ if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 || typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
1365
+ add(
1366
+ errors,
1367
+ path,
1368
+ "INVALID_COORDINATES",
1369
+ "Latitude must be between -90 and 90 and longitude between -180 and 180."
1370
+ );
1371
+ }
1372
+ if (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0) {
1373
+ add(errors, `${path}.radiusMeters`, "INVALID_RADIUS", "Radius must be greater than zero.");
1374
+ }
1375
+ return;
1376
+ }
1377
+ case "composite":
1378
+ if (value.operator !== "AND" && value.operator !== "OR") {
1379
+ add(errors, `${path}.operator`, "INVALID_TYPE", "Operator must be AND or OR.");
1380
+ }
1381
+ if (!Array.isArray(value.conditions)) {
1382
+ add(errors, `${path}.conditions`, "INVALID_TYPE", "Composite conditions must be an array.");
1383
+ return;
1384
+ }
1385
+ value.conditions.forEach((child, index) => {
1386
+ validateCondition(child, `${path}.conditions[${index}]`, errors);
1387
+ });
1388
+ return;
1389
+ case "time_window":
1390
+ if (typeof value.startsAt !== "string" || Number.isNaN(Date.parse(value.startsAt))) {
1391
+ add(errors, `${path}.startsAt`, "INVALID_DATE", "Start must be a valid ISO date.");
1392
+ }
1393
+ if (typeof value.endsAt !== "string" || Number.isNaN(Date.parse(value.endsAt))) {
1394
+ add(errors, `${path}.endsAt`, "INVALID_DATE", "End must be a valid ISO date.");
1395
+ }
1396
+ if (typeof value.startsAt === "string" && typeof value.endsAt === "string" && !Number.isNaN(Date.parse(value.startsAt)) && !Number.isNaN(Date.parse(value.endsAt)) && Date.parse(value.startsAt) >= Date.parse(value.endsAt)) {
1397
+ add(errors, path, "INVALID_DATE", "Time window start must be before its end.");
1398
+ }
1399
+ validateCondition(value.condition, `${path}.condition`, errors);
1400
+ return;
1401
+ default:
1402
+ add(errors, `${path}.type`, "INVALID_TYPE", "Unsupported condition type.");
1403
+ }
1404
+ }
1405
+ function validateSpot(value, index, errors) {
1406
+ const path = `stamps[${index}]`;
1407
+ if (!isObject(value)) {
1408
+ add(errors, path, "INVALID_TYPE", "Spot must be an object.");
1409
+ return;
1410
+ }
1411
+ if (typeof value.id !== "string") add(errors, `${path}.id`, "REQUIRED", "Spot ID is required.");
1412
+ else if (value.id.trim() === "")
1413
+ add(errors, `${path}.id`, "EMPTY_STRING", "Spot ID must not be empty.");
1414
+ if (value.name === void 0) add(errors, `${path}.name`, "REQUIRED", "Spot name is required.");
1415
+ else if (!hasText(value.name))
1416
+ add(errors, `${path}.name`, "EMPTY_STRING", "Spot name must not be empty.");
1417
+ if (value.order !== void 0 && (typeof value.order !== "number" || !Number.isFinite(value.order))) {
1418
+ add(errors, `${path}.order`, "INVALID_TYPE", "Spot order must be a finite number.");
1419
+ }
1420
+ validateCondition(value.condition, `${path}.condition`, errors);
1421
+ for (const field of ["dependsOn", "requiresStampIds"]) {
1422
+ if (value[field] !== void 0 && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== "string"))) {
1423
+ add(errors, `${path}.${field}`, "INVALID_TYPE", `${field} must be an array of IDs.`);
1424
+ }
1425
+ }
1426
+ }
1427
+ function validateReward(value, index, stampCount, errors) {
1428
+ const path = `rewards[${index}]`;
1429
+ if (!isObject(value)) {
1430
+ add(errors, path, "INVALID_TYPE", "Reward must be an object.");
1431
+ return;
1432
+ }
1433
+ if (typeof value.id !== "string" || value.id.trim() === "") {
1434
+ add(errors, `${path}.id`, "REQUIRED", "Reward ID must not be empty.");
1435
+ }
1436
+ if (!hasText(value.title)) {
1437
+ add(errors, `${path}.title`, "EMPTY_STRING", "Reward title must not be empty.");
1438
+ }
1439
+ if (!hasText(value.description)) {
1440
+ add(errors, `${path}.description`, "EMPTY_STRING", "Reward description must not be empty.");
1441
+ }
1442
+ const required = value.requiredStampCount;
1443
+ if (typeof required !== "number" || !Number.isInteger(required) || required < 0 || required > stampCount) {
1444
+ add(
1445
+ errors,
1446
+ `${path}.requiredStampCount`,
1447
+ "INVALID_REWARD",
1448
+ "Required stamps must be an integer within the rally."
1449
+ );
1450
+ }
1451
+ if (value.validUntil !== void 0 && (typeof value.validUntil !== "string" || Number.isNaN(Date.parse(value.validUntil)))) {
1452
+ add(errors, `${path}.validUntil`, "INVALID_DATE", "Reward expiry must be a valid ISO date.");
1453
+ }
1454
+ for (const field of ["maxStock", "limitPerUser"]) {
1455
+ const count = value[field];
1456
+ if (count !== void 0 && (typeof count !== "number" || !Number.isInteger(count) || count <= 0)) {
1457
+ add(errors, `${path}.${field}`, "INVALID_REWARD", `${field} must be a positive integer.`);
1458
+ }
1459
+ }
1460
+ }
1461
+ function collectDependencies(spot) {
1462
+ const dependencies = /* @__PURE__ */ new Set();
1463
+ for (const field of ["dependsOn", "requiresStampIds"]) {
1464
+ const values = spot[field];
1465
+ if (Array.isArray(values)) {
1466
+ for (const value of values) if (typeof value === "string") dependencies.add(value);
1467
+ }
1468
+ }
1469
+ return [...dependencies];
1470
+ }
1471
+ function validateDag(stamps, errors) {
1472
+ const graph = /* @__PURE__ */ new Map();
1473
+ for (const value of stamps) {
1474
+ if (!isObject(value) || typeof value.id !== "string") continue;
1475
+ graph.set(value.id, [...graph.get(value.id) ?? [], ...collectDependencies(value)]);
1476
+ }
1477
+ const visiting = /* @__PURE__ */ new Set();
1478
+ const visited = /* @__PURE__ */ new Set();
1479
+ const visit = (id, path) => {
1480
+ if (visiting.has(id)) {
1481
+ add(errors, path, "CYCLE_DETECTED", `Dependency cycle detected at '${id}'.`);
1482
+ return;
1483
+ }
1484
+ if (visited.has(id)) return;
1485
+ visiting.add(id);
1486
+ for (const dependency of graph.get(id) ?? [])
1487
+ if (graph.has(dependency)) visit(dependency, path);
1488
+ visiting.delete(id);
1489
+ visited.add(id);
1490
+ };
1491
+ for (const id of graph.keys()) visit(id, `stamps[${id}]`);
1492
+ }
1493
+ function validateRallyConfig(config) {
1494
+ const errors = [];
1495
+ if (!isObject(config))
1496
+ return {
1497
+ valid: false,
1498
+ errors: [{ path: "", code: "INVALID_TYPE", message: "Rally config must be an object." }]
1499
+ };
1500
+ if (typeof config.id !== "string") add(errors, "id", "REQUIRED", "Rally ID is required.");
1501
+ else if (config.id.trim() === "")
1502
+ add(errors, "id", "EMPTY_STRING", "Rally ID must not be empty.");
1503
+ if (config.title !== void 0 && !hasText(config.title))
1504
+ add(errors, "title", "EMPTY_STRING", "Rally title must not be empty.");
1505
+ if (config.version !== void 0 && config.version !== CURRENT_RALLY_CONFIG_VERSION) {
1506
+ add(
1507
+ errors,
1508
+ "version",
1509
+ "INVALID_VERSION",
1510
+ `Config version must be ${CURRENT_RALLY_CONFIG_VERSION}.`
1511
+ );
1512
+ }
1513
+ for (const field of ["startDate", "endDate"]) {
1514
+ if (config[field] !== void 0 && (typeof config[field] !== "string" || Number.isNaN(Date.parse(config[field])))) {
1515
+ add(errors, field, "INVALID_DATE", `${field} must be a valid ISO date.`);
1516
+ }
1517
+ }
1518
+ if (typeof config.startDate === "string" && typeof config.endDate === "string" && !Number.isNaN(Date.parse(config.startDate)) && !Number.isNaN(Date.parse(config.endDate)) && Date.parse(config.startDate) >= Date.parse(config.endDate)) {
1519
+ add(errors, "startDate", "INVALID_DATE", "startDate must be before endDate.");
1520
+ }
1521
+ if (!Array.isArray(config.stamps)) {
1522
+ add(errors, "stamps", "INVALID_TYPE", "stamps must be an array.");
1523
+ } else {
1524
+ config.stamps.forEach((spot, index) => {
1525
+ validateSpot(spot, index, errors);
1526
+ });
1527
+ const ids = config.stamps.map(
1528
+ (spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : ""
1529
+ );
1530
+ ids.forEach((id, index) => {
1531
+ if (id !== "" && ids.indexOf(id) !== index)
1532
+ add(errors, `stamps[${index}].id`, "DUPLICATE_ID", `Duplicate spot ID '${id}'.`);
1533
+ });
1534
+ validateDag(config.stamps, errors);
1535
+ }
1536
+ if (config.rewards !== void 0 && !Array.isArray(config.rewards)) {
1537
+ add(errors, "rewards", "INVALID_TYPE", "rewards must be an array.");
1538
+ } else if (Array.isArray(config.rewards)) {
1539
+ const stampCount = Array.isArray(config.stamps) ? config.stamps.length : 0;
1540
+ config.rewards.forEach((reward, index) => {
1541
+ validateReward(reward, index, stampCount, errors);
1542
+ });
1543
+ const ids = config.rewards.map(
1544
+ (reward) => isObject(reward) && typeof reward.id === "string" ? reward.id : ""
1545
+ );
1546
+ ids.forEach((id, index) => {
1547
+ if (id !== "" && ids.indexOf(id) !== index)
1548
+ add(errors, `rewards[${index}].id`, "DUPLICATE_ID", `Duplicate reward ID '${id}'.`);
1549
+ });
1550
+ if (Array.isArray(config.stamps)) {
1551
+ const stampIds = new Set(
1552
+ config.stamps.map((spot) => isObject(spot) && typeof spot.id === "string" ? spot.id : "")
1553
+ );
1554
+ ids.forEach((id, index) => {
1555
+ if (id !== "" && stampIds.has(id))
1556
+ add(
1557
+ errors,
1558
+ `rewards[${index}].id`,
1559
+ "DUPLICATE_ID",
1560
+ `Reward ID '${id}' is already used by a spot.`
1561
+ );
1562
+ });
1563
+ }
1564
+ }
1565
+ return { valid: errors.length === 0, errors };
1566
+ }
1567
+
1568
+ // src/domain/migration.ts
1569
+ function isObject2(value) {
1570
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1571
+ }
1572
+ function text(value) {
1573
+ if (typeof value === "string") return value;
1574
+ if (!isObject2(value)) return void 0;
1575
+ const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
1576
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
1577
+ }
1578
+ function condition(value) {
1579
+ if (!isObject2(value) || typeof value.type !== "string") return { type: "instant" };
1580
+ switch (value.type) {
1581
+ case "instant":
1582
+ return { type: "instant" };
1583
+ case "token":
1584
+ case "passcode":
1585
+ return {
1586
+ type: "token",
1587
+ token: typeof value.token === "string" ? value.token : typeof value.passcode === "string" ? value.passcode : ""
1588
+ };
1589
+ case "geo":
1590
+ return {
1591
+ type: "geo",
1592
+ latitude: typeof value.latitude === "number" ? value.latitude : 0,
1593
+ longitude: typeof value.longitude === "number" ? value.longitude : 0,
1594
+ radiusMeters: typeof value.radiusMeters === "number" ? value.radiusMeters : typeof value.radius === "number" ? value.radius : 1
1595
+ };
1596
+ case "composite":
1597
+ return {
1598
+ type: "composite",
1599
+ operator: value.operator === "OR" ? "OR" : "AND",
1600
+ conditions: Array.isArray(value.conditions) ? value.conditions.map(condition) : []
1601
+ };
1602
+ case "time_window":
1603
+ return {
1604
+ type: "time_window",
1605
+ startsAt: typeof value.startsAt === "string" ? value.startsAt : "1970-01-01T00:00:00.000Z",
1606
+ endsAt: typeof value.endsAt === "string" ? value.endsAt : "9999-12-31T23:59:59.999Z",
1607
+ condition: condition(value.condition)
1608
+ };
1609
+ default:
1610
+ return { type: "instant" };
1611
+ }
1229
1612
  }
1230
- function toLocalizedString(text) {
1231
- if (text === void 0) return { ja: "", en: "" };
1232
- return typeof text === "string" ? { ja: text, en: "" } : { ...text };
1613
+ function migrateSpot(value, index) {
1614
+ const source = isObject2(value) ? value : {};
1615
+ const id = typeof source.id === "string" && source.id.trim() !== "" ? source.id.trim() : `spot-${index + 1}`;
1616
+ const description = text(source.description);
1617
+ const hint = text(source.hint);
1618
+ return {
1619
+ id,
1620
+ name: text(source.name) ?? `Spot ${index + 1}`,
1621
+ ...description === void 0 ? {} : { description },
1622
+ ...hint === void 0 ? {} : { hint },
1623
+ condition: condition(
1624
+ source.condition ?? (typeof source.token === "string" ? { type: "token", token: source.token } : void 0)
1625
+ ),
1626
+ ...typeof source.order === "number" ? { order: source.order } : {},
1627
+ ...typeof source.deckId === "string" ? { deckId: source.deckId } : {},
1628
+ ...typeof source.groupId === "string" ? { groupId: source.groupId } : {},
1629
+ ...typeof source.guideId === "string" ? { guideId: source.guideId } : {},
1630
+ ...typeof source.iconUrl === "string" ? { iconUrl: source.iconUrl } : {},
1631
+ ...typeof source.imageUrl === "string" ? { imageUrl: source.imageUrl } : {},
1632
+ ...typeof source.externalUrl === "string" ? { externalUrl: source.externalUrl } : {},
1633
+ ...typeof source.redirectUrlAfterClaim === "string" ? { redirectUrlAfterClaim: source.redirectUrlAfterClaim } : {},
1634
+ ...isObject2(source.metadata) ? { metadata: source.metadata } : {},
1635
+ ...Array.isArray(source.dependsOn) ? { dependsOn: source.dependsOn.filter((item) => typeof item === "string") } : {}
1636
+ };
1637
+ }
1638
+ function migrateReward(value, index) {
1639
+ const source = isObject2(value) ? value : {};
1640
+ return {
1641
+ id: typeof source.id === "string" && source.id.trim() !== "" ? source.id.trim() : `reward-${index + 1}`,
1642
+ title: text(source.title) ?? `Reward ${index + 1}`,
1643
+ description: text(source.description) ?? "",
1644
+ type: source.type === "digital" ? "digital" : "in_person",
1645
+ redemptionMethod: source.redemptionMethod === "staff_passcode" || source.redemptionMethod === "view_only" ? source.redemptionMethod : "manual_slide",
1646
+ requiredStampCount: typeof source.requiredStampCount === "number" && Number.isFinite(source.requiredStampCount) ? Math.max(0, Math.trunc(source.requiredStampCount)) : 0,
1647
+ ...typeof source.digitalContentUrl === "string" ? { digitalContentUrl: source.digitalContentUrl } : {},
1648
+ ...typeof source.staffPasscode === "string" ? { staffPasscode: source.staffPasscode } : {},
1649
+ ...typeof source.validUntil === "string" ? { validUntil: source.validUntil } : {},
1650
+ ...typeof source.maxStock === "number" ? { maxStock: source.maxStock } : {},
1651
+ ...typeof source.limitPerUser === "number" ? { limitPerUser: source.limitPerUser } : {},
1652
+ ...typeof source.claimTicketNumber === "string" ? { claimTicketNumber: source.claimTicketNumber } : {}
1653
+ };
1654
+ }
1655
+ function migrateRallyConfig(raw) {
1656
+ const source = isObject2(raw) ? raw : {};
1657
+ const rawStamps = Array.isArray(source.stamps) ? source.stamps : Array.isArray(source.spots) ? source.spots : [];
1658
+ const rawRewards = Array.isArray(source.rewards) ? source.rewards : [];
1659
+ const id = typeof source.id === "string" && source.id.trim() !== "" ? source.id.trim() : "migrated-rally";
1660
+ const title = text(source.title);
1661
+ const description = text(source.description);
1662
+ const theme = isObject2(source.theme) ? source.theme : void 0;
1663
+ return {
1664
+ id,
1665
+ ...title === void 0 ? {} : { title },
1666
+ ...description === void 0 ? {} : { description },
1667
+ stamps: rawStamps.map(migrateSpot),
1668
+ ...rawRewards.length === 0 ? {} : { rewards: rawRewards.map(migrateReward) },
1669
+ ...typeof source.isSequential === "boolean" ? { isSequential: source.isSequential } : {},
1670
+ ...theme === void 0 ? {} : { theme },
1671
+ version: CURRENT_RALLY_CONFIG_VERSION,
1672
+ ...typeof source.startDate === "string" ? { startDate: source.startDate } : typeof source.startsAt === "string" ? { startDate: source.startsAt } : {},
1673
+ ...typeof source.endDate === "string" ? { endDate: source.endDate } : typeof source.endsAt === "string" ? { endDate: source.endsAt } : {}
1674
+ };
1233
1675
  }
1234
1676
 
1235
1677
  // src/domain/models.ts
@@ -1244,6 +1686,15 @@ var DEFAULT_SHEET_THEME = {
1244
1686
  fontFamily: "serif"
1245
1687
  };
1246
1688
 
1689
+ // src/domain/publicConfig.ts
1690
+ function stripSensitiveConfig(config) {
1691
+ const rewards = config.rewards?.map(({ staffPasscode: _staffPasscode, ...reward }) => reward);
1692
+ return {
1693
+ ...config,
1694
+ ...rewards === void 0 ? {} : { rewards }
1695
+ };
1696
+ }
1697
+
1247
1698
  // src/domain/themePresets.ts
1248
1699
  var THEME_PRESETS = [
1249
1700
  {
@@ -1343,6 +1794,126 @@ var THEME_PRESETS = [
1343
1794
  }
1344
1795
  ];
1345
1796
 
1797
+ // src/security/snapshotToken.ts
1798
+ var encoder = new TextEncoder();
1799
+ function cryptoApi() {
1800
+ if (globalThis.crypto === void 0 || globalThis.crypto.subtle === void 0) {
1801
+ throw new Error("Web Crypto API is unavailable in this environment.");
1802
+ }
1803
+ return globalThis.crypto;
1804
+ }
1805
+ function secretBytes(secretKey) {
1806
+ return typeof secretKey === "string" ? encoder.encode(secretKey) : new Uint8Array(secretKey);
1807
+ }
1808
+ function webCryptoBytes(bytes) {
1809
+ return bytes.buffer;
1810
+ }
1811
+ function base64Url(bytes) {
1812
+ let binary = "";
1813
+ for (const byte of bytes) binary += String.fromCharCode(byte);
1814
+ return globalThis.btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
1815
+ }
1816
+ function fromBase64Url(value) {
1817
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
1818
+ const padded = `${base64}${"=".repeat((4 - base64.length % 4) % 4)}`;
1819
+ const binary = globalThis.atob(padded);
1820
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
1821
+ }
1822
+ async function importHmacKey(secret) {
1823
+ return cryptoApi().subtle.importKey(
1824
+ "raw",
1825
+ webCryptoBytes(secret),
1826
+ { name: "HMAC", hash: "SHA-256" },
1827
+ false,
1828
+ ["sign", "verify"]
1829
+ );
1830
+ }
1831
+ async function importAesKey(secret) {
1832
+ const digest = await cryptoApi().subtle.digest("SHA-256", webCryptoBytes(secret));
1833
+ return cryptoApi().subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
1834
+ }
1835
+ function isExpired(payload, now) {
1836
+ if (typeof payload.exp === "number" && now >= payload.exp * 1e3) return true;
1837
+ return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now;
1838
+ }
1839
+ async function createSignedSnapshotToken(payload, secretKey) {
1840
+ const api = cryptoApi();
1841
+ const secret = secretBytes(secretKey);
1842
+ const iv = api.getRandomValues(new Uint8Array(12));
1843
+ const plaintext = encoder.encode(JSON.stringify(payload));
1844
+ const encrypted = new Uint8Array(
1845
+ await api.subtle.encrypt(
1846
+ { name: "AES-GCM", iv: webCryptoBytes(iv) },
1847
+ await importAesKey(secret),
1848
+ webCryptoBytes(plaintext)
1849
+ )
1850
+ );
1851
+ const body = base64Url(new Uint8Array([...iv, ...encrypted]));
1852
+ const signature = new Uint8Array(
1853
+ await api.subtle.sign("HMAC", await importHmacKey(secret), encoder.encode(body))
1854
+ );
1855
+ return `sr2.${body}.${base64Url(signature)}`;
1856
+ }
1857
+ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
1858
+ try {
1859
+ const parts = token.split(".");
1860
+ if (parts.length !== 3 || parts[0] !== "sr2") {
1861
+ return {
1862
+ ok: false,
1863
+ valid: false,
1864
+ error: { code: "MALFORMED", message: "Snapshot token is malformed." }
1865
+ };
1866
+ }
1867
+ const [, body, encodedSignature] = parts;
1868
+ if (body === void 0 || encodedSignature === void 0) {
1869
+ return {
1870
+ ok: false,
1871
+ valid: false,
1872
+ error: { code: "MALFORMED", message: "Snapshot token is malformed." }
1873
+ };
1874
+ }
1875
+ const secret = secretBytes(secretKey);
1876
+ const validSignature = await cryptoApi().subtle.verify(
1877
+ "HMAC",
1878
+ await importHmacKey(secret),
1879
+ webCryptoBytes(fromBase64Url(encodedSignature)),
1880
+ webCryptoBytes(encoder.encode(body))
1881
+ );
1882
+ if (!validSignature) {
1883
+ return {
1884
+ ok: false,
1885
+ valid: false,
1886
+ error: { code: "INVALID_SIGNATURE", message: "Snapshot token signature is invalid." }
1887
+ };
1888
+ }
1889
+ const encrypted = fromBase64Url(body);
1890
+ if (encrypted.length <= 12) throw new Error("Invalid encrypted payload.");
1891
+ const payloadText = await cryptoApi().subtle.decrypt(
1892
+ { name: "AES-GCM", iv: webCryptoBytes(encrypted.slice(0, 12)) },
1893
+ await importAesKey(secret),
1894
+ webCryptoBytes(encrypted.slice(12))
1895
+ );
1896
+ const payload = JSON.parse(new TextDecoder().decode(payloadText));
1897
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
1898
+ throw new Error("Invalid payload.");
1899
+ if (isExpired(payload, now)) {
1900
+ return {
1901
+ ok: false,
1902
+ valid: false,
1903
+ error: { code: "EXPIRED", message: "Snapshot token has expired." }
1904
+ };
1905
+ }
1906
+ return { ok: true, valid: true, payload };
1907
+ } catch {
1908
+ return {
1909
+ ok: false,
1910
+ valid: false,
1911
+ error: { code: "DECRYPTION_FAILED", message: "Snapshot token could not be verified." }
1912
+ };
1913
+ }
1914
+ }
1915
+
1916
+ exports.CURRENT_RALLY_CONFIG_VERSION = CURRENT_RALLY_CONFIG_VERSION;
1346
1917
  exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
1347
1918
  exports.InMemoryStorage = InMemoryStorage;
1348
1919
  exports.IndexedDBAdapter = IndexedDBAdapter;
@@ -1353,6 +1924,9 @@ exports.THEME_PRESETS = THEME_PRESETS;
1353
1924
  exports.calculateDistanceMeters = calculateDistanceMeters;
1354
1925
  exports.calculateProgress = calculateProgress;
1355
1926
  exports.consumeReward = consumeReward;
1927
+ exports.createClaimTicketNumber = createClaimTicketNumber;
1928
+ exports.createSignedSnapshotToken = createSignedSnapshotToken;
1929
+ exports.evaluateCheckIn = evaluateCheckIn;
1356
1930
  exports.evaluateCondition = evaluateCondition;
1357
1931
  exports.evaluateConditionDetailed = evaluateConditionDetailed;
1358
1932
  exports.exportProgressToken = exportProgressToken;
@@ -1363,13 +1937,18 @@ exports.isNfcSupported = isNfcSupported;
1363
1937
  exports.isQrSupported = isQrSupported;
1364
1938
  exports.isRewardState = isRewardState;
1365
1939
  exports.isStampRallyState = isStampRallyState;
1940
+ exports.issueClaimTicketNumber = issueClaimTicketNumber;
1941
+ exports.migrateRallyConfig = migrateRallyConfig;
1366
1942
  exports.normalizePasscode = normalizePasscode;
1367
1943
  exports.processStamp = processStamp;
1368
1944
  exports.readNfcContext = readNfcContext;
1369
1945
  exports.readQrContext = readQrContext;
1370
1946
  exports.reconcileRewardStates = reconcileRewardStates;
1371
1947
  exports.resolveLocalizedText = resolveLocalizedText;
1948
+ exports.stripSensitiveConfig = stripSensitiveConfig;
1372
1949
  exports.toLocalizedString = toLocalizedString;
1950
+ exports.validateRallyConfig = validateRallyConfig;
1373
1951
  exports.verifyPasscode = verifyPasscode;
1952
+ exports.verifySnapshotToken = verifySnapshotToken;
1374
1953
  //# sourceMappingURL=index.cjs.map
1375
1954
  //# sourceMappingURL=index.cjs.map