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