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