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