@stamprally/server 0.14.0 → 0.15.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
@@ -13,6 +13,10 @@ async function executeClaimRewardTransaction(database, store, params, mutation)
13
13
  return { success: false, error: next.error };
14
14
  }
15
15
  if (next.nextStock !== null) await store.writeStock(transaction, params, next.nextStock);
16
+ if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
17
+ if (next.nextSecondaryStock !== null)
18
+ await store.writeSecondaryStock(transaction, params, next.nextSecondaryStock);
19
+ }
16
20
  await store.writeUserState(transaction, params, next.nextUserState);
17
21
  await store.writeClaimRecord(transaction, params, next.nextUserState);
18
22
  await store.writeAudit(transaction, next.auditLog);
@@ -20,8 +24,41 @@ async function executeClaimRewardTransaction(database, store, params, mutation)
20
24
  await store.writeIdempotency(transaction, params, next.result);
21
25
  return { success: true };
22
26
  });
23
- } catch (error2) {
24
- return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
27
+ } catch (error) {
28
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
29
+ }
30
+ }
31
+ async function executeCheckInTransaction(database, store, params, mutation, current) {
32
+ try {
33
+ return await database.transaction(async (transaction) => {
34
+ const userState = current ?? (store.readUserState === void 0 ? (() => {
35
+ throw new Error("A current user state or readUserState implementation is required.");
36
+ })() : await store.readUserState(transaction, params));
37
+ const next = mutation({ userState });
38
+ if (next.error !== void 0) {
39
+ await store.writeAudit(transaction, next.auditLog);
40
+ if (params.idempotencyKey !== void 0 && next.result !== void 0)
41
+ await store.writeIdempotency(transaction, params, next.result);
42
+ return { success: false, error: next.error };
43
+ }
44
+ await store.writeUserState(transaction, params, next.nextUserState);
45
+ await store.writeAudit(transaction, next.auditLog);
46
+ if (params.idempotencyKey !== void 0 && next.result !== void 0)
47
+ await store.writeIdempotency(transaction, params, next.result);
48
+ return { success: true };
49
+ });
50
+ } catch (error) {
51
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
52
+ }
53
+ }
54
+ async function executeRedisTransaction(redis, queue) {
55
+ try {
56
+ const multi = redis.multi();
57
+ queue(multi);
58
+ const result = await redis.exec(multi);
59
+ return result === null ? { success: false, error: "Redis transaction was aborted." } : { success: true };
60
+ } catch (error) {
61
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
25
62
  }
26
63
  }
27
64
 
@@ -89,8 +126,14 @@ var InMemoryServerPersistenceAdapter = class {
89
126
  const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
90
127
  if (userState === void 0)
91
128
  return { success: false, error: "A user state is required for this transaction." };
129
+ const storedStock = await this.getRewardStock(
130
+ params.rallyId,
131
+ params.stockKey ?? params.rewardId
132
+ );
133
+ const storedSecondaryStock = params.secondaryStockKey === void 0 ? null : await this.getRewardStock(params.rallyId, params.secondaryStockKey);
92
134
  const mutationResult = mutation({
93
- stock: await this.getRewardStock(params.rallyId, params.rewardId),
135
+ stock: storedStock ?? params.initialStock ?? null,
136
+ secondaryStock: storedSecondaryStock ?? params.initialSecondaryStock ?? null,
94
137
  claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
95
138
  userState
96
139
  });
@@ -106,16 +149,45 @@ var InMemoryServerPersistenceAdapter = class {
106
149
  );
107
150
  return { success: false, error: mutationResult.error };
108
151
  }
109
- const currentStock = await this.getRewardStock(params.rallyId, params.rewardId);
110
- if (currentStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
152
+ const currentStock = await this.getRewardStock(
153
+ params.rallyId,
154
+ params.stockKey ?? params.rewardId
155
+ );
156
+ const effectiveStock = currentStock ?? params.initialStock ?? null;
157
+ if (currentStock === null && params.initialStock !== void 0 && params.initialStock !== null)
158
+ this.#stocks.set(
159
+ this.#stockKey(params.rallyId, params.stockKey ?? params.rewardId),
160
+ params.initialStock
161
+ );
162
+ const currentSecondaryStock = params.secondaryStockKey === void 0 ? null : await this.getRewardStock(params.rallyId, params.secondaryStockKey);
163
+ const effectiveSecondaryStock = currentSecondaryStock ?? params.initialSecondaryStock ?? null;
164
+ if (currentSecondaryStock === null && params.secondaryStockKey !== void 0 && params.initialSecondaryStock !== void 0 && params.initialSecondaryStock !== null)
165
+ this.#stocks.set(
166
+ this.#stockKey(params.rallyId, params.secondaryStockKey),
167
+ params.initialSecondaryStock
168
+ );
169
+ if (effectiveStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
111
170
  throw new Error("The transaction produced an invalid stock value.");
112
- if (currentStock === null && mutationResult.nextStock !== null)
171
+ if (effectiveStock === null && mutationResult.nextStock !== null)
113
172
  throw new Error("The transaction changed an unlimited stock to a limited stock.");
114
173
  if (mutationResult.nextStock !== null)
115
174
  this.#stocks.set(
116
- this.#stockKey(params.rallyId, params.rewardId),
175
+ this.#stockKey(params.rallyId, params.stockKey ?? params.rewardId),
117
176
  mutationResult.nextStock
118
177
  );
178
+ if (params.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
179
+ if (effectiveSecondaryStock !== null && (mutationResult.nextSecondaryStock === null || mutationResult.nextSecondaryStock < 0))
180
+ throw new Error("The transaction produced an invalid secondary stock value.");
181
+ if (effectiveSecondaryStock === null && mutationResult.nextSecondaryStock !== null)
182
+ throw new Error(
183
+ "The transaction changed an unlimited secondary stock to a limited stock."
184
+ );
185
+ if (mutationResult.nextSecondaryStock !== null)
186
+ this.#stocks.set(
187
+ this.#stockKey(params.rallyId, params.secondaryStockKey),
188
+ mutationResult.nextSecondaryStock
189
+ );
190
+ }
119
191
  await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
120
192
  const reward = mutationResult.nextUserState.rewards.find(
121
193
  (item) => item.rewardId === params.rewardId
@@ -138,8 +210,8 @@ var InMemoryServerPersistenceAdapter = class {
138
210
  );
139
211
  return { success: true };
140
212
  });
141
- } catch (error2) {
142
- return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
213
+ } catch (error) {
214
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
143
215
  }
144
216
  }
145
217
  async executeCheckInTransaction(params, mutation) {
@@ -171,8 +243,8 @@ var InMemoryServerPersistenceAdapter = class {
171
243
  );
172
244
  return { success: true };
173
245
  });
174
- } catch (error2) {
175
- return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
246
+ } catch (error) {
247
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
176
248
  }
177
249
  }
178
250
  async rollbackUserState(rallyId, userId, previousState) {
@@ -258,7 +330,7 @@ var InMemoryServerPersistenceAdapter = class {
258
330
  };
259
331
  try {
260
332
  return await operation(this);
261
- } catch (error2) {
333
+ } catch (error) {
262
334
  this.#stocks.clear();
263
335
  for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
264
336
  this.#idempotent.clear();
@@ -270,14 +342,14 @@ var InMemoryServerPersistenceAdapter = class {
270
342
  for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
271
343
  this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
272
344
  this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
273
- throw error2;
345
+ throw error;
274
346
  }
275
347
  }
276
348
  };
277
349
 
278
350
  // src/security.ts
279
- function error(path, message) {
280
- return { success: false, errors: [{ path, message, code: "invalid_request" }] };
351
+ function errors(...items) {
352
+ return { success: false, errors: items };
281
353
  }
282
354
  function record(value) {
283
355
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -285,46 +357,125 @@ function record(value) {
285
357
  function nonEmpty(value) {
286
358
  return typeof value === "string" && value.trim().length > 0;
287
359
  }
288
- function context(value) {
289
- if (!record(value) || typeof value.type !== "string") return false;
290
- if (value.type === "qr") return nonEmpty(value.token);
291
- if (value.type === "passcode") return nonEmpty(value.code);
292
- if (value.type === "nfc") return nonEmpty(value.tagId);
293
- if (value.type === "custom") return "value" in value;
294
- return value.type === "gps" && typeof value.latitude === "number" && Number.isFinite(value.latitude) && typeof value.longitude === "number" && Number.isFinite(value.longitude);
360
+ function contextErrors(value) {
361
+ if (!record(value) || typeof value.type !== "string")
362
+ return [
363
+ {
364
+ path: "proof",
365
+ message: "proof is not a valid verification context.",
366
+ code: "INVALID_TYPE"
367
+ }
368
+ ];
369
+ if (value.type === "qr" && !nonEmpty(value.token))
370
+ return [
371
+ { path: "proof.token", message: "token must be a non-empty string.", code: "INVALID_TYPE" }
372
+ ];
373
+ if (value.type === "passcode" && !nonEmpty(value.code))
374
+ return [
375
+ { path: "proof.code", message: "code must be a non-empty string.", code: "INVALID_TYPE" }
376
+ ];
377
+ if (value.type === "nfc" && !nonEmpty(value.tagId))
378
+ return [
379
+ { path: "proof.tagId", message: "tagId must be a non-empty string.", code: "INVALID_TYPE" }
380
+ ];
381
+ if (value.type === "custom")
382
+ return "value" in value ? [] : [{ path: "proof.value", message: "value is required.", code: "REQUIRED" }];
383
+ if (value.type === "qr" || value.type === "passcode" || value.type === "nfc") return [];
384
+ if (value.type !== "gps")
385
+ return [{ path: "proof.type", message: "Unknown verification type.", code: "INVALID_ENUM" }];
386
+ const result = [];
387
+ if (typeof value.latitude !== "number" || !Number.isFinite(value.latitude))
388
+ result.push({
389
+ path: "proof.latitude",
390
+ message: "Latitude must be a finite number.",
391
+ code: "INVALID_TYPE"
392
+ });
393
+ else if (value.latitude < -90 || value.latitude > 90)
394
+ result.push({
395
+ path: "proof.latitude",
396
+ message: "Latitude must be between -90 and 90.",
397
+ code: "INVALID_RANGE"
398
+ });
399
+ if (typeof value.longitude !== "number" || !Number.isFinite(value.longitude))
400
+ result.push({
401
+ path: "proof.longitude",
402
+ message: "Longitude must be a finite number.",
403
+ code: "INVALID_TYPE"
404
+ });
405
+ else if (value.longitude < -180 || value.longitude > 180)
406
+ result.push({
407
+ path: "proof.longitude",
408
+ message: "Longitude must be between -180 and 180.",
409
+ code: "INVALID_RANGE"
410
+ });
411
+ if ("radiusMeters" in value && (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0))
412
+ result.push({
413
+ path: "proof.radiusMeters",
414
+ message: "Radius must be greater than zero.",
415
+ code: "INVALID_RANGE"
416
+ });
417
+ return result;
418
+ }
419
+ function dateInput(value) {
420
+ if (typeof value === "number") return Number.isInteger(value) && value > 0;
421
+ if (typeof value !== "string" || value.trim() === "") return false;
422
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
295
423
  }
296
- function common(value, fields) {
297
- if (!record(value)) return false;
298
- return fields.every((field) => nonEmpty(value[field]));
424
+ function requiredErrors(value, fields) {
425
+ if (record(value) && fields.every((field) => nonEmpty(value[field]))) return [];
426
+ return fields.filter((field) => !record(value) || !nonEmpty(value[field])).map((field) => ({
427
+ path: field,
428
+ message: `${field} must be a non-empty string.`,
429
+ code: "REQUIRED"
430
+ }));
299
431
  }
300
432
  function validateCheckInRequest(value) {
301
- if (!common(value, ["rallyId", "spotId", "idempotencyKey"]))
302
- return error("$", "rallyId, spotId, and idempotencyKey must be non-empty strings.");
303
- if (!context(value.context))
304
- return error("context", "context is not a valid verification context.");
433
+ const required = requiredErrors(value, ["rallyId", "spotId", "idempotencyKey"]);
434
+ if (required.length > 0) return errors(...required);
435
+ if (!record(value))
436
+ return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
437
+ const proof = contextErrors(value.context);
438
+ if (proof.length > 0) return errors(...proof);
305
439
  if (value.userId !== void 0 && !nonEmpty(value.userId))
306
- return error("userId", "userId must be non-empty.");
307
- if (value.now !== void 0 && !nonEmpty(value.now))
308
- return error("now", "now must be non-empty when provided.");
440
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
441
+ if (value.now !== void 0 && !dateInput(value.now))
442
+ return errors({
443
+ path: "now",
444
+ message: "now must be an ISO 8601 date or positive timestamp.",
445
+ code: "INVALID_DATE"
446
+ });
309
447
  return { success: true, data: value };
310
448
  }
311
449
  function validateClaimRewardRequest(value) {
312
- if (!common(value, ["rallyId", "rewardId", "idempotencyKey"]))
313
- return error("$", "rallyId, rewardId, and idempotencyKey must be non-empty strings.");
450
+ const required = requiredErrors(value, ["rallyId", "rewardId", "idempotencyKey"]);
451
+ if (required.length > 0) return errors(...required);
452
+ if (!record(value))
453
+ return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
314
454
  if (value.userId !== void 0 && !nonEmpty(value.userId))
315
- return error("userId", "userId must be non-empty.");
455
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
316
456
  if (value.staffPasscode !== void 0 && !nonEmpty(value.staffPasscode))
317
- return error("staffPasscode", "staffPasscode must be non-empty.");
457
+ return errors({
458
+ path: "staffPasscode",
459
+ message: "staffPasscode must be non-empty.",
460
+ code: "INVALID_TYPE"
461
+ });
318
462
  if (value.staffId !== void 0 && !nonEmpty(value.staffId))
319
- return error("staffId", "staffId must be non-empty.");
463
+ return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
320
464
  if (value.now !== void 0 && !nonEmpty(value.now))
321
- return error("now", "now must be non-empty when provided.");
465
+ return errors({
466
+ path: "now",
467
+ message: "now must be an ISO 8601 date or positive timestamp.",
468
+ code: "INVALID_DATE"
469
+ });
322
470
  return { success: true, data: value };
323
471
  }
324
472
  function validateSyncRequest(value) {
325
- if (!common(value, ["rallyId"])) return error("rallyId", "rallyId must be a non-empty string.");
473
+ const required = requiredErrors(value, ["rallyId"]);
474
+ if (required.length > 0) return errors(...required);
475
+ if (!record(value))
476
+ return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
326
477
  if (value.userId !== void 0 && !nonEmpty(value.userId))
327
- return error("userId", "userId must be non-empty.");
478
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
328
479
  return { success: true, data: value };
329
480
  }
330
481
  function json(body, status = 200) {
@@ -336,6 +487,9 @@ function json(body, status = 200) {
336
487
  function isObject(value) {
337
488
  return typeof value === "object" && value !== null && !Array.isArray(value);
338
489
  }
490
+ function isUuidV4(value) {
491
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
492
+ }
339
493
  function requestId(prefix) {
340
494
  return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
341
495
  }
@@ -346,6 +500,15 @@ function timestampMillis(timestamp) {
346
500
  const value = Date.parse(timestamp);
347
501
  return Number.isFinite(value) ? value : Date.now();
348
502
  }
503
+ function validationResponse(errors2) {
504
+ return json(
505
+ {
506
+ error: "VALIDATION_FAILED",
507
+ details: errors2.map(({ path, message, code }) => ({ path, message, code }))
508
+ },
509
+ 400
510
+ );
511
+ }
349
512
  function initialState(config, userId, timestamp) {
350
513
  return {
351
514
  rallyId: config.id,
@@ -355,16 +518,37 @@ function initialState(config, userId, timestamp) {
355
518
  updatedAt: timestamp
356
519
  };
357
520
  }
358
- function getProof(context2) {
359
- return context2.type === "qr" ? context2.token : context2.type === "passcode" ? context2.code : context2.type === "gps" ? { latitude: context2.latitude, longitude: context2.longitude } : context2.type === "nfc" ? context2.tagId : context2.value;
521
+ function rewardStock(config, rewardId, stockLimit) {
522
+ const configured = config.inventory?.[rewardId];
523
+ if (stockLimit === void 0) return configured ?? null;
524
+ if (configured === void 0) return stockLimit;
525
+ return Math.min(stockLimit, configured);
526
+ }
527
+ function sharedStock(config) {
528
+ return config.inventory?.sharedStock ?? config.inventory?.global ?? null;
529
+ }
530
+ function inventoryPlan(config, rewardId, stockLimit) {
531
+ const individual = rewardStock(config, rewardId, stockLimit);
532
+ const shared = sharedStock(config);
533
+ if (config.inventoryMode === "shared" && shared !== null) {
534
+ return {
535
+ primaryKey: "__shared__",
536
+ primaryInitial: shared,
537
+ ...individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
538
+ };
539
+ }
540
+ return { primaryKey: rewardId, primaryInitial: individual };
541
+ }
542
+ function getProof(context) {
543
+ return context.type === "qr" ? context.token : context.type === "passcode" ? context.code : context.type === "gps" ? { latitude: context.latitude, longitude: context.longitude } : context.type === "nfc" ? context.tagId : context.value;
360
544
  }
361
- async function evaluate(condition, context2, validator, base) {
362
- if (condition.type !== "custom") return evaluateConditionDetailed(condition, context2).ok;
545
+ async function evaluate(condition, context, validator, base) {
546
+ if (condition.type !== "custom") return evaluateConditionDetailed(condition, context).ok;
363
547
  if (validator === void 0) return false;
364
548
  const validationContext = {
365
549
  rallyId: base.rallyId,
366
550
  spotId: base.spotId,
367
- proofData: getProof(context2),
551
+ proofData: getProof(context),
368
552
  condition,
369
553
  userState: base.state
370
554
  };
@@ -408,11 +592,15 @@ var StampRallyServer = class {
408
592
  }
409
593
  async handleCheckIn(request) {
410
594
  const body = validateCheckInRequest(await this.#body(request));
411
- if (!body.success || body.data.rallyId !== this.#config.id)
412
- return json(
413
- { ok: false, code: "INVALID_REQUEST", message: "Invalid check-in request." },
414
- 400
415
- );
595
+ if (!body.success) return validationResponse(body.errors);
596
+ if (body.data.rallyId !== this.#config.id)
597
+ return validationResponse([
598
+ {
599
+ path: "rallyId",
600
+ message: "The rally does not match this server.",
601
+ code: "INVALID_VALUE"
602
+ }
603
+ ]);
416
604
  const userId = await this.#user(request);
417
605
  if (userId === null)
418
606
  return json(
@@ -427,8 +615,15 @@ var StampRallyServer = class {
427
615
  }
428
616
  async handleClaimReward(request) {
429
617
  const body = validateClaimRewardRequest(await this.#body(request));
430
- if (!body.success || body.data.rallyId !== this.#config.id)
431
- return json({ ok: false, code: "INVALID_REQUEST", message: "Invalid reward request." }, 400);
618
+ if (!body.success) return validationResponse(body.errors);
619
+ if (body.data.rallyId !== this.#config.id)
620
+ return validationResponse([
621
+ {
622
+ path: "rallyId",
623
+ message: "The rally does not match this server.",
624
+ code: "INVALID_VALUE"
625
+ }
626
+ ]);
432
627
  const userId = await this.#user(request);
433
628
  if (userId === null)
434
629
  return json(
@@ -443,8 +638,15 @@ var StampRallyServer = class {
443
638
  }
444
639
  async handleSync(request) {
445
640
  const body = validateSyncRequest(await this.#body(request));
446
- if (!body.success || body.data.rallyId !== this.#config.id)
447
- return json({ ok: false, code: "INVALID_REQUEST", message: "Invalid sync request." }, 400);
641
+ if (!body.success) return validationResponse(body.errors);
642
+ if (body.data.rallyId !== this.#config.id)
643
+ return validationResponse([
644
+ {
645
+ path: "rallyId",
646
+ message: "The rally does not match this server.",
647
+ code: "INVALID_VALUE"
648
+ }
649
+ ]);
448
650
  const userId = await this.#user(request);
449
651
  if (userId === null)
450
652
  return json(
@@ -599,7 +801,7 @@ var StampRallyServer = class {
599
801
  request,
600
802
  now(this.#options)
601
803
  );
602
- const lockKey = `reward:${request.rallyId}:${reward.id}`;
804
+ const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
603
805
  if (!await this.#persistence.acquireLock(
604
806
  request.rallyId,
605
807
  lockKey,
@@ -607,6 +809,7 @@ var StampRallyServer = class {
607
809
  ))
608
810
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
609
811
  const timestamp = now(this.#options);
812
+ const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
610
813
  try {
611
814
  const checked = await this.#persistence.getIdempotentResult(
612
815
  request.rallyId,
@@ -619,6 +822,10 @@ var StampRallyServer = class {
619
822
  rallyId: request.rallyId,
620
823
  userId: request.userId,
621
824
  rewardId: reward.id,
825
+ stockKey: plan.primaryKey,
826
+ ...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
827
+ initialStock: plan.primaryInitial,
828
+ ...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
622
829
  ticketNumber: request.idempotencyKey,
623
830
  timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
624
831
  idempotencyKey: key,
@@ -626,7 +833,7 @@ var StampRallyServer = class {
626
833
  ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
627
834
  initialUserState: initialState(this.#config, request.userId, timestamp)
628
835
  },
629
- ({ stock, claimCount, userState }) => {
836
+ ({ stock, secondaryStock, claimCount, userState }) => {
630
837
  const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
631
838
  rewardId: reward.id,
632
839
  status: "LOCKED"
@@ -642,7 +849,7 @@ var StampRallyServer = class {
642
849
  timestamp,
643
850
  code
644
851
  );
645
- if (stock !== null && stock <= 0) {
852
+ if (stock !== null && stock <= 0 || secondaryStock !== null && secondaryStock <= 0) {
646
853
  responseHolder.value = {
647
854
  ok: false,
648
855
  code: "OUT_OF_STOCK",
@@ -650,6 +857,7 @@ var StampRallyServer = class {
650
857
  };
651
858
  return {
652
859
  nextStock: stock,
860
+ ...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock: secondaryStock },
653
861
  nextUserState: userState,
654
862
  auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
655
863
  result: responseHolder.value,
@@ -680,14 +888,35 @@ var StampRallyServer = class {
680
888
  };
681
889
  }
682
890
  const nextRewards = userState.rewards.some((item) => item.rewardId === reward.id) ? userState.rewards.map((item) => item.rewardId === reward.id ? local.value : item) : [...userState.rewards, local.value];
891
+ const consumed = local.value.claimTicketNumber !== void 0;
892
+ const nextStock = consumed && stock !== null ? Math.max(0, stock - 1) : stock;
893
+ const nextSecondaryStock = consumed && secondaryStock !== null ? Math.max(0, secondaryStock - 1) : secondaryStock;
683
894
  const next = {
684
895
  ...userState,
685
896
  rewards: nextRewards,
686
- updatedAt: timestamp
897
+ updatedAt: timestamp,
898
+ inventory: {
899
+ ...plan.primaryKey === "__shared__" && nextStock !== null ? { sharedRemaining: nextStock } : {},
900
+ ...plan.secondaryKey !== void 0 && nextSecondaryStock !== null ? { rewardRemaining: { [reward.id]: nextSecondaryStock } } : plan.primaryKey === reward.id && nextStock !== null ? { rewardRemaining: { [reward.id]: nextStock } } : {}
901
+ }
902
+ };
903
+ const inventory = {
904
+ ...plan.primaryKey === "__shared__" && nextStock !== null ? { sharedRemaining: nextStock } : {},
905
+ ...plan.secondaryKey !== void 0 && nextSecondaryStock !== null ? { rewardRemaining: nextSecondaryStock } : plan.primaryKey === reward.id && nextStock !== null ? { rewardRemaining: nextStock } : {}
906
+ };
907
+ responseHolder.value = local.value.claimTicketNumber === void 0 ? {
908
+ ok: true,
909
+ state: next,
910
+ ...Object.keys(inventory).length === 0 ? {} : { inventory }
911
+ } : {
912
+ ok: true,
913
+ state: next,
914
+ claimTicketNumber: local.value.claimTicketNumber,
915
+ ...Object.keys(inventory).length === 0 ? {} : { inventory }
687
916
  };
688
- responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
689
917
  return {
690
- nextStock: stock === null ? null : stock - 1,
918
+ nextStock,
919
+ ...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock },
691
920
  nextUserState: next,
692
921
  auditLog: makeAudit("SUCCESS"),
693
922
  result: responseHolder.value
@@ -709,18 +938,46 @@ var StampRallyServer = class {
709
938
  code: "PERSISTENCE_FAILED",
710
939
  message: result.error ?? "Reward claim failed."
711
940
  };
712
- } catch (error2) {
941
+ } catch (error) {
713
942
  return {
714
943
  ok: false,
715
944
  code: "PERSISTENCE_FAILED",
716
- message: error2 instanceof Error ? error2.message : "Reward claim failed."
945
+ message: error instanceof Error ? error.message : "Reward claim failed."
717
946
  };
718
947
  } finally {
719
948
  await this.#persistence.releaseLock(request.rallyId, lockKey);
720
949
  }
721
950
  }
722
951
  async sync(rallyId, userId) {
723
- return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
952
+ const state = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
953
+ return this.#attachInventory(state);
954
+ }
955
+ async #attachInventory(state) {
956
+ const rewardRemaining = {};
957
+ for (const reward of this.#config.rewards) {
958
+ const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
959
+ if (plan.secondaryKey !== void 0) {
960
+ const stock = await this.#persistence.getRewardStock(state.rallyId, plan.secondaryKey);
961
+ const remaining = stock ?? plan.secondaryInitial ?? null;
962
+ if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
963
+ } else if (plan.primaryKey !== "__shared__") {
964
+ const stock = await this.#persistence.getRewardStock(state.rallyId, plan.primaryKey);
965
+ const remaining = stock ?? plan.primaryInitial;
966
+ if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
967
+ }
968
+ }
969
+ const shared = sharedStock(this.#config);
970
+ const storedShared = await this.#persistence.getRewardStock(state.rallyId, "__shared__");
971
+ const sharedRemaining = this.#config.inventoryMode === "shared" && shared !== null ? Math.max(0, storedShared ?? shared) : void 0;
972
+ return {
973
+ ...state,
974
+ ...Object.keys(rewardRemaining).length === 0 && sharedRemaining === void 0 ? {} : {
975
+ inventory: {
976
+ ...sharedRemaining === void 0 ? {} : { sharedRemaining },
977
+ ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
978
+ }
979
+ }
980
+ };
724
981
  }
725
982
  async #body(request) {
726
983
  try {
@@ -738,6 +995,10 @@ var StampRallyServer = class {
738
995
  const authenticatedUserId = identity.authenticatedUserId;
739
996
  return authenticatedUserId.length > 0 ? authenticatedUserId : null;
740
997
  }
998
+ if (this.#options.anonymousPolicy === "reject") return null;
999
+ const sessionId = request.headers.get("X-Anonymous-Session-Id");
1000
+ if (sessionId !== null && isUuidV4(sessionId)) return sessionId;
1001
+ if (this.#options.anonymousPolicy === "session_scoped") return null;
741
1002
  return "anonymous";
742
1003
  }
743
1004
  async #rememberCheckInTransaction(request, timestamp, key, current, mutation, responseHolder) {
@@ -784,6 +1045,6 @@ var StampRallyServer = class {
784
1045
  }
785
1046
  };
786
1047
 
787
- export { InMemoryServerPersistenceAdapter, StampRallyServer, executeClaimRewardTransaction, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
1048
+ export { InMemoryServerPersistenceAdapter, StampRallyServer, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
788
1049
  //# sourceMappingURL=index.js.map
789
1050
  //# sourceMappingURL=index.js.map