@stamprally/server 0.13.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.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);
@@ -26,6 +30,39 @@ async function executeClaimRewardTransaction(database, store, params, mutation)
26
30
  return { success: false, error: error instanceof Error ? error.message : String(error) };
27
31
  }
28
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) };
64
+ }
65
+ }
29
66
 
30
67
  // src/persistence.ts
31
68
  var InMemoryServerPersistenceAdapter = class {
@@ -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
@@ -144,6 +216,39 @@ var InMemoryServerPersistenceAdapter = class {
144
216
  return { success: false, error: error instanceof Error ? error.message : String(error) };
145
217
  }
146
218
  }
219
+ async executeCheckInTransaction(params, mutation) {
220
+ try {
221
+ return await this.runTransaction(params.rallyId, async () => {
222
+ const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
223
+ if (userState === void 0)
224
+ return { success: false, error: "A user state is required for this transaction." };
225
+ const mutationResult = mutation({ userState });
226
+ if (mutationResult.error !== void 0) {
227
+ await this.recordAuditLog(mutationResult.auditLog);
228
+ if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
229
+ await this.saveIdempotentResult(
230
+ params.rallyId,
231
+ params.idempotencyKey,
232
+ mutationResult.result,
233
+ params.idempotencyTtlMs ?? 864e5
234
+ );
235
+ return { success: false, error: mutationResult.error };
236
+ }
237
+ await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
238
+ await this.recordAuditLog(mutationResult.auditLog);
239
+ if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
240
+ await this.saveIdempotentResult(
241
+ params.rallyId,
242
+ params.idempotencyKey,
243
+ mutationResult.result,
244
+ params.idempotencyTtlMs ?? 864e5
245
+ );
246
+ return { success: true };
247
+ });
248
+ } catch (error) {
249
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
250
+ }
251
+ }
147
252
  async rollbackUserState(rallyId, userId, previousState) {
148
253
  const key = `${rallyId}:${userId}`;
149
254
  if (previousState === null) this.#states.delete(key);
@@ -195,7 +300,7 @@ var InMemoryServerPersistenceAdapter = class {
195
300
  if (count <= 1) this.#claims.delete(key);
196
301
  else this.#claims.set(key, count - 1);
197
302
  const index = this.#claimRecords.findIndex(
198
- (record) => record.rallyId === rallyId && record.userId === userId && record.rewardId === rewardId && record.ticketNumber === ticketNumber
303
+ (record2) => record2.rallyId === rallyId && record2.userId === userId && record2.rewardId === rewardId && record2.ticketNumber === ticketNumber
199
304
  );
200
305
  if (index >= 0) this.#claimRecords.splice(index, 1);
201
306
  }
@@ -243,6 +348,138 @@ var InMemoryServerPersistenceAdapter = class {
243
348
  }
244
349
  }
245
350
  };
351
+
352
+ // src/security.ts
353
+ function errors(...items) {
354
+ return { success: false, errors: items };
355
+ }
356
+ function record(value) {
357
+ return typeof value === "object" && value !== null && !Array.isArray(value);
358
+ }
359
+ function nonEmpty(value) {
360
+ return typeof value === "string" && value.trim().length > 0;
361
+ }
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));
425
+ }
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
+ }));
433
+ }
434
+ function validateCheckInRequest(value) {
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);
441
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
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
+ });
449
+ return { success: true, data: value };
450
+ }
451
+ function validateClaimRewardRequest(value) {
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" });
456
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
457
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
458
+ if (value.staffPasscode !== void 0 && !nonEmpty(value.staffPasscode))
459
+ return errors({
460
+ path: "staffPasscode",
461
+ message: "staffPasscode must be non-empty.",
462
+ code: "INVALID_TYPE"
463
+ });
464
+ if (value.staffId !== void 0 && !nonEmpty(value.staffId))
465
+ return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
466
+ if (value.now !== void 0 && !nonEmpty(value.now))
467
+ return errors({
468
+ path: "now",
469
+ message: "now must be an ISO 8601 date or positive timestamp.",
470
+ code: "INVALID_DATE"
471
+ });
472
+ return { success: true, data: value };
473
+ }
474
+ function validateSyncRequest(value) {
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" });
479
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
480
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
481
+ return { success: true, data: value };
482
+ }
246
483
  function json(body, status = 200) {
247
484
  return new Response(JSON.stringify(body), {
248
485
  status,
@@ -252,11 +489,27 @@ function json(body, status = 200) {
252
489
  function isObject(value) {
253
490
  return typeof value === "object" && value !== null && !Array.isArray(value);
254
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
+ }
255
495
  function requestId(prefix) {
256
496
  return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
257
497
  }
258
- function now(options, requested) {
259
- return requested ?? options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
498
+ function now(options) {
499
+ return options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
500
+ }
501
+ function timestampMillis(timestamp) {
502
+ const value = Date.parse(timestamp);
503
+ return Number.isFinite(value) ? value : Date.now();
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
+ );
260
513
  }
261
514
  function initialState(config, userId, timestamp) {
262
515
  return {
@@ -267,6 +520,27 @@ function initialState(config, userId, timestamp) {
267
520
  updatedAt: timestamp
268
521
  };
269
522
  }
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
+ }
270
544
  function getProof(context) {
271
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;
272
546
  }
@@ -319,47 +593,69 @@ var StampRallyServer = class {
319
593
  return json({ ok: false, code: "NOT_FOUND", message: "Route not found." }, 404);
320
594
  }
321
595
  async handleCheckIn(request) {
322
- const body = await this.#body(request);
323
- const userId = await this.#user(request, body?.userId);
324
- if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "" || body.context === void 0)
325
- return json(
596
+ const body = validateCheckInRequest(await this.#body(request));
597
+ if (!body.success) return validationResponse(body.errors);
598
+ if (body.data.rallyId !== this.#config.id)
599
+ return validationResponse([
326
600
  {
327
- ok: false,
328
- code: "INVALID_REQUEST",
329
- message: "rallyId, spotId, context, and idempotencyKey are required."
330
- },
331
- 400
601
+ path: "rallyId",
602
+ message: "The rally does not match this server.",
603
+ code: "INVALID_VALUE"
604
+ }
605
+ ]);
606
+ const userId = await this.#user(request);
607
+ if (userId === null)
608
+ return json(
609
+ { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
610
+ 401
332
611
  );
333
- const result = await this.checkIn({ ...body, userId });
612
+ const result = await this.checkIn({ ...body.data, userId });
334
613
  return json(
335
614
  { ...result, status: operationStatus(result) },
336
615
  result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
337
616
  );
338
617
  }
339
618
  async handleClaimReward(request) {
340
- const body = await this.#body(request);
341
- const userId = await this.#user(request, body?.userId);
342
- if (body === null || userId === null || body.rallyId !== this.#config.id || body.rewardId === "" || body.idempotencyKey === "")
343
- return json(
619
+ const body = validateClaimRewardRequest(await this.#body(request));
620
+ if (!body.success) return validationResponse(body.errors);
621
+ if (body.data.rallyId !== this.#config.id)
622
+ return validationResponse([
344
623
  {
345
- ok: false,
346
- code: "INVALID_REQUEST",
347
- message: "rallyId, rewardId, and idempotencyKey are required."
348
- },
349
- 400
624
+ path: "rallyId",
625
+ message: "The rally does not match this server.",
626
+ code: "INVALID_VALUE"
627
+ }
628
+ ]);
629
+ const userId = await this.#user(request);
630
+ if (userId === null)
631
+ return json(
632
+ { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
633
+ 401
350
634
  );
351
- const result = await this.claimReward({ ...body, userId });
635
+ const result = await this.claimReward({ ...body.data, userId });
352
636
  return json(
353
637
  { ...result, status: operationStatus(result) },
354
638
  result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
355
639
  );
356
640
  }
357
641
  async handleSync(request) {
358
- const body = await this.#body(request);
359
- const userId = await this.#user(request, body?.userId);
360
- if (body === null || userId === null || body.rallyId !== this.#config.id)
361
- return json({ ok: false, code: "INVALID_REQUEST", message: "rallyId is required." }, 400);
362
- return json({ ok: true, state: await this.sync(body.rallyId, userId) });
642
+ const body = validateSyncRequest(await this.#body(request));
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
+ ]);
652
+ const userId = await this.#user(request);
653
+ if (userId === null)
654
+ return json(
655
+ { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
656
+ 401
657
+ );
658
+ return json({ ok: true, state: await this.sync(body.data.rallyId, userId) });
363
659
  }
364
660
  async checkIn(request) {
365
661
  const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
@@ -375,47 +671,68 @@ var StampRallyServer = class {
375
671
  this.#options.lockTtlMs ?? 5e3
376
672
  ))
377
673
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
378
- const timestamp = now(this.#options, request.now);
674
+ const timestamp = now(this.#options);
379
675
  try {
676
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
677
+ const responseHolder = { value: null };
678
+ const makeAudit = (status, code) => audit(
679
+ request.rallyId,
680
+ request.userId,
681
+ "CHECK_IN",
682
+ request.spotId,
683
+ request.idempotencyKey,
684
+ status,
685
+ timestamp,
686
+ code
687
+ );
380
688
  const spot = this.#config.spots.find((item) => item.id === request.spotId);
381
- if (spot === void 0)
382
- return this.#remember(
689
+ if (spot === void 0) {
690
+ responseHolder.value = {
691
+ ok: false,
692
+ code: "SPOT_NOT_FOUND",
693
+ message: "Spot was not found."
694
+ };
695
+ return await this.#rememberCheckInTransaction(
696
+ request,
697
+ timestamp,
383
698
  key,
384
- { ok: false, code: "SPOT_NOT_FOUND", message: "Spot was not found." },
385
- request.rallyId,
386
- request.userId,
387
- "CHECK_IN",
388
- request.spotId,
389
- request.idempotencyKey,
390
- timestamp
699
+ current,
700
+ {
701
+ nextUserState: current,
702
+ auditLog: makeAudit("REJECTED", "SPOT_NOT_FOUND"),
703
+ result: responseHolder.value,
704
+ error: "SPOT_NOT_FOUND"
705
+ },
706
+ responseHolder
391
707
  );
392
- const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
393
- if (current.records.some((record) => record.stampId === request.spotId))
394
- return this.#remember(
708
+ }
709
+ const rejected = (code, message) => {
710
+ responseHolder.value = { ok: false, code, message };
711
+ return {
712
+ nextUserState: current,
713
+ auditLog: makeAudit("REJECTED", code),
714
+ result: responseHolder.value,
715
+ error: code
716
+ };
717
+ };
718
+ if (current.records.some((record2) => record2.stampId === request.spotId))
719
+ return await this.#rememberCheckInTransaction(
720
+ request,
721
+ timestamp,
395
722
  key,
396
- { ok: false, code: "STAMP_ALREADY_ACQUIRED", message: "Spot was already claimed." },
397
- request.rallyId,
398
- request.userId,
399
- "CHECK_IN",
400
- request.spotId,
401
- request.idempotencyKey,
402
- timestamp
723
+ current,
724
+ rejected("STAMP_ALREADY_ACQUIRED", "Spot was already claimed."),
725
+ responseHolder
403
726
  );
404
- const acquired = new Set(current.records.map((record) => record.stampId));
727
+ const acquired = new Set(current.records.map((record2) => record2.stampId));
405
728
  if (spot.prerequisites?.some((id) => !acquired.has(id)))
406
- return this.#remember(
729
+ return await this.#rememberCheckInTransaction(
730
+ request,
731
+ timestamp,
407
732
  key,
408
- {
409
- ok: false,
410
- code: "PREREQUISITES_NOT_MET",
411
- message: "Prerequisite spots are not complete."
412
- },
413
- request.rallyId,
414
- request.userId,
415
- "CHECK_IN",
416
- request.spotId,
417
- request.idempotencyKey,
418
- timestamp
733
+ current,
734
+ rejected("PREREQUISITES_NOT_MET", "Prerequisite spots are not complete."),
735
+ responseHolder
419
736
  );
420
737
  for (const condition of spot.conditions)
421
738
  if (!await evaluate(
@@ -424,15 +741,13 @@ var StampRallyServer = class {
424
741
  condition.type === "custom" ? this.#options.customValidators?.[condition.validatorName] : void 0,
425
742
  { rallyId: request.rallyId, spotId: request.spotId, state: current }
426
743
  ))
427
- return this.#remember(
744
+ return await this.#rememberCheckInTransaction(
745
+ request,
746
+ timestamp,
428
747
  key,
429
- { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
430
- request.rallyId,
431
- request.userId,
432
- "CHECK_IN",
433
- request.spotId,
434
- request.idempotencyKey,
435
- timestamp
748
+ current,
749
+ rejected("INVALID_PROOF", "Verification failed."),
750
+ responseHolder
436
751
  );
437
752
  const next = {
438
753
  ...current,
@@ -445,17 +760,30 @@ var StampRallyServer = class {
445
760
  ),
446
761
  updatedAt: timestamp
447
762
  };
448
- await this.#persistence.saveUserState(request.rallyId, request.userId, next);
449
- return this.#remember(
450
- key,
451
- { ok: true, state: next },
452
- request.rallyId,
453
- request.userId,
454
- "CHECK_IN",
455
- request.spotId,
456
- request.idempotencyKey,
457
- timestamp
763
+ responseHolder.value = { ok: true, state: next };
764
+ const transaction = await this.#persistence.executeCheckInTransaction(
765
+ {
766
+ rallyId: request.rallyId,
767
+ userId: request.userId,
768
+ spotId: request.spotId,
769
+ timestamp: timestampMillis(timestamp),
770
+ idempotencyKey: key,
771
+ proofData: request.context,
772
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
773
+ initialUserState: current
774
+ },
775
+ () => ({
776
+ nextUserState: next,
777
+ auditLog: makeAudit("SUCCESS"),
778
+ result: responseHolder.value
779
+ })
458
780
  );
781
+ if (transaction.success && responseHolder.value !== null) return responseHolder.value;
782
+ return {
783
+ ok: false,
784
+ code: "PERSISTENCE_FAILED",
785
+ message: transaction.error ?? "Check-in failed."
786
+ };
459
787
  } finally {
460
788
  await this.#persistence.releaseLock(request.rallyId, lockKey);
461
789
  }
@@ -473,16 +801,17 @@ var StampRallyServer = class {
473
801
  key,
474
802
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
475
803
  request,
476
- now(this.#options, request.now)
804
+ now(this.#options)
477
805
  );
478
- const lockKey = `reward:${request.rallyId}:${reward.id}`;
806
+ const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
479
807
  if (!await this.#persistence.acquireLock(
480
808
  request.rallyId,
481
809
  lockKey,
482
810
  this.#options.lockTtlMs ?? 5e3
483
811
  ))
484
812
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
485
- const timestamp = now(this.#options, request.now);
813
+ const timestamp = now(this.#options);
814
+ const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
486
815
  try {
487
816
  const checked = await this.#persistence.getIdempotentResult(
488
817
  request.rallyId,
@@ -495,6 +824,10 @@ var StampRallyServer = class {
495
824
  rallyId: request.rallyId,
496
825
  userId: request.userId,
497
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 },
498
831
  ticketNumber: request.idempotencyKey,
499
832
  timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
500
833
  idempotencyKey: key,
@@ -502,7 +835,7 @@ var StampRallyServer = class {
502
835
  ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
503
836
  initialUserState: initialState(this.#config, request.userId, timestamp)
504
837
  },
505
- ({ stock, claimCount, userState }) => {
838
+ ({ stock, secondaryStock, claimCount, userState }) => {
506
839
  const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
507
840
  rewardId: reward.id,
508
841
  status: "LOCKED"
@@ -518,7 +851,7 @@ var StampRallyServer = class {
518
851
  timestamp,
519
852
  code
520
853
  );
521
- if (stock !== null && stock <= 0) {
854
+ if (stock !== null && stock <= 0 || secondaryStock !== null && secondaryStock <= 0) {
522
855
  responseHolder.value = {
523
856
  ok: false,
524
857
  code: "OUT_OF_STOCK",
@@ -526,14 +859,16 @@ var StampRallyServer = class {
526
859
  };
527
860
  return {
528
861
  nextStock: stock,
862
+ ...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock: secondaryStock },
529
863
  nextUserState: userState,
530
864
  auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
531
865
  result: responseHolder.value,
532
866
  error: "OUT_OF_STOCK"
533
867
  };
534
868
  }
869
+ const effectiveReward = reward.staffPasscode === void 0 && this.#config.staffPasscode !== void 0 ? { ...reward, staffPasscode: this.#config.staffPasscode } : reward;
535
870
  const local = core.consumeReward({
536
- reward,
871
+ reward: effectiveReward,
537
872
  currentState: currentReward,
538
873
  now: timestamp,
539
874
  userRedemptionCount: claimCount,
@@ -555,14 +890,35 @@ var StampRallyServer = class {
555
890
  };
556
891
  }
557
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;
558
896
  const next = {
559
897
  ...userState,
560
898
  rewards: nextRewards,
561
- 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 }
562
918
  };
563
- responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
564
919
  return {
565
- nextStock: stock === null ? null : stock - 1,
920
+ nextStock,
921
+ ...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock },
566
922
  nextUserState: next,
567
923
  auditLog: makeAudit("SUCCESS"),
568
924
  result: responseHolder.value
@@ -595,7 +951,35 @@ var StampRallyServer = class {
595
951
  }
596
952
  }
597
953
  async sync(rallyId, userId) {
598
- 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
+ };
599
983
  }
600
984
  async #body(request) {
601
985
  try {
@@ -605,31 +989,40 @@ var StampRallyServer = class {
605
989
  return null;
606
990
  }
607
991
  }
608
- async #user(request, requested) {
609
- if (this.#options.authenticate !== void 0)
610
- return await this.#options.authenticate(request) ?? null;
611
- return requested ?? null;
992
+ async #user(request) {
993
+ if (this.#options.authenticate !== void 0) {
994
+ const identity = await this.#options.authenticate(request);
995
+ if (typeof identity === "string") return identity.length > 0 ? identity : null;
996
+ if (identity === null) return null;
997
+ const authenticatedUserId = identity.authenticatedUserId;
998
+ return authenticatedUserId.length > 0 ? authenticatedUserId : null;
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;
1004
+ return "anonymous";
612
1005
  }
613
- async #remember(key, result, rallyId, userId, action, resourceId, idempotencyKey, timestamp) {
614
- await this.#persistence.recordAuditLog(
615
- audit(
616
- rallyId,
617
- userId,
618
- action,
619
- resourceId,
620
- idempotencyKey,
621
- result.ok ? "SUCCESS" : "REJECTED",
622
- timestamp,
623
- result.ok ? void 0 : result.code
624
- )
625
- );
626
- await this.#persistence.saveIdempotentResult(
627
- rallyId,
628
- key,
629
- result,
630
- this.#options.idempotencyTtlMs ?? 864e5
1006
+ async #rememberCheckInTransaction(request, timestamp, key, current, mutation, responseHolder) {
1007
+ const transaction = await this.#persistence.executeCheckInTransaction(
1008
+ {
1009
+ rallyId: request.rallyId,
1010
+ userId: request.userId,
1011
+ spotId: request.spotId,
1012
+ timestamp: timestampMillis(timestamp),
1013
+ idempotencyKey: key,
1014
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
1015
+ initialUserState: current
1016
+ },
1017
+ () => mutation
631
1018
  );
632
- return result;
1019
+ if (responseHolder.value !== null && (transaction.success || transaction.error === mutation.error))
1020
+ return responseHolder.value;
1021
+ return {
1022
+ ok: false,
1023
+ code: "PERSISTENCE_FAILED",
1024
+ message: transaction.error ?? "Check-in failed."
1025
+ };
633
1026
  }
634
1027
  async #rememberClaim(key, result, request, timestamp) {
635
1028
  await this.#persistence.recordAuditLog(
@@ -656,6 +1049,11 @@ var StampRallyServer = class {
656
1049
 
657
1050
  exports.InMemoryServerPersistenceAdapter = InMemoryServerPersistenceAdapter;
658
1051
  exports.StampRallyServer = StampRallyServer;
1052
+ exports.executeCheckInTransaction = executeCheckInTransaction;
659
1053
  exports.executeClaimRewardTransaction = executeClaimRewardTransaction;
1054
+ exports.executeRedisTransaction = executeRedisTransaction;
1055
+ exports.validateCheckInRequest = validateCheckInRequest;
1056
+ exports.validateClaimRewardRequest = validateClaimRewardRequest;
1057
+ exports.validateSyncRequest = validateSyncRequest;
660
1058
  //# sourceMappingURL=index.cjs.map
661
1059
  //# sourceMappingURL=index.cjs.map