@stamprally/server 0.15.0 → 0.16.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
@@ -3,50 +3,52 @@
3
3
  var core = require('@stamprally/core');
4
4
 
5
5
  // src/examples/transaction.ts
6
- async function executeClaimRewardTransaction(database, store, params, mutation) {
6
+ async function executeClaimRewardTransaction(database, store, params2, mutation2) {
7
7
  try {
8
8
  return await database.transaction(async (transaction) => {
9
- const current = await store.readContext(transaction, params);
10
- const next = mutation(current);
9
+ const current = await store.readContext(transaction, params2);
10
+ const next = mutation2(current);
11
11
  if (next.error !== void 0) {
12
12
  await store.writeAudit(transaction, next.auditLog);
13
- if (params.idempotencyKey !== void 0 && next.result !== void 0)
14
- await store.writeIdempotency(transaction, params, next.result);
13
+ if (params2.idempotencyKey !== void 0 && next.result !== void 0)
14
+ await store.writeIdempotency(transaction, params2, next.result);
15
15
  return { success: false, error: next.error };
16
16
  }
17
- if (next.nextStock !== null) await store.writeStock(transaction, params, next.nextStock);
17
+ if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock === void 0)
18
+ return { success: false, error: "INVENTORY_NOT_SUPPORTED" };
19
+ if (next.nextStock !== null) await store.writeStock(transaction, params2, next.nextStock);
18
20
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
19
21
  if (next.nextSecondaryStock !== null)
20
- await store.writeSecondaryStock(transaction, params, next.nextSecondaryStock);
22
+ await store.writeSecondaryStock(transaction, params2, next.nextSecondaryStock);
21
23
  }
22
- await store.writeUserState(transaction, params, next.nextUserState);
23
- await store.writeClaimRecord(transaction, params, next.nextUserState);
24
+ await store.writeUserState(transaction, params2, next.nextUserState);
25
+ await store.writeClaimRecord(transaction, params2, next.nextUserState);
24
26
  await store.writeAudit(transaction, next.auditLog);
25
- if (params.idempotencyKey !== void 0 && next.result !== void 0)
26
- await store.writeIdempotency(transaction, params, next.result);
27
+ if (params2.idempotencyKey !== void 0 && next.result !== void 0)
28
+ await store.writeIdempotency(transaction, params2, next.result);
27
29
  return { success: true };
28
30
  });
29
31
  } catch (error) {
30
32
  return { success: false, error: error instanceof Error ? error.message : String(error) };
31
33
  }
32
34
  }
33
- async function executeCheckInTransaction(database, store, params, mutation, current) {
35
+ async function executeCheckInTransaction(database, store, params2, mutation2, current) {
34
36
  try {
35
37
  return await database.transaction(async (transaction) => {
36
38
  const userState = current ?? (store.readUserState === void 0 ? (() => {
37
39
  throw new Error("A current user state or readUserState implementation is required.");
38
- })() : await store.readUserState(transaction, params));
39
- const next = mutation({ userState });
40
+ })() : await store.readUserState(transaction, params2));
41
+ const next = mutation2({ userState });
40
42
  if (next.error !== void 0) {
41
43
  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
+ if (params2.idempotencyKey !== void 0 && next.result !== void 0)
45
+ await store.writeIdempotency(transaction, params2, next.result);
44
46
  return { success: false, error: next.error };
45
47
  }
46
- await store.writeUserState(transaction, params, next.nextUserState);
48
+ await store.writeUserState(transaction, params2, next.nextUserState);
47
49
  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
+ if (params2.idempotencyKey !== void 0 && next.result !== void 0)
51
+ await store.writeIdempotency(transaction, params2, next.result);
50
52
  return { success: true };
51
53
  });
52
54
  } catch (error) {
@@ -66,6 +68,7 @@ async function executeRedisTransaction(redis, queue) {
66
68
 
67
69
  // src/persistence.ts
68
70
  var InMemoryServerPersistenceAdapter = class {
71
+ supportsRewardStock = true;
69
72
  #locks = /* @__PURE__ */ new Map();
70
73
  #idempotent = /* @__PURE__ */ new Map();
71
74
  #states = /* @__PURE__ */ new Map();
@@ -74,6 +77,7 @@ var InMemoryServerPersistenceAdapter = class {
74
77
  #claims = /* @__PURE__ */ new Map();
75
78
  #claimRecords = [];
76
79
  #auditLogs = [];
80
+ #transactionTails = /* @__PURE__ */ new Map();
77
81
  constructor(options = {}) {
78
82
  this.#stocks = /* @__PURE__ */ new Map();
79
83
  this.#stockDefaults = /* @__PURE__ */ new Map();
@@ -122,51 +126,60 @@ var InMemoryServerPersistenceAdapter = class {
122
126
  const current = this.#stocks.get(key);
123
127
  if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
124
128
  }
125
- async executeClaimRewardTransaction(params, mutation) {
129
+ async executeClaimRewardTransaction(params2, mutation2) {
126
130
  try {
127
- return await this.runTransaction(params.rallyId, async () => {
128
- const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
131
+ const initialStock = params2.initialStock !== void 0 ? params2.initialStock : params2.stockKey === "__shared__" ? params2.sharedStockLimit : params2.rewardStockLimit;
132
+ const initialSecondaryStock = params2.initialSecondaryStock !== void 0 ? params2.initialSecondaryStock : params2.rewardStockLimit;
133
+ return await this.runTransaction(params2.rallyId, async () => {
134
+ if (params2.idempotencyKey !== void 0) {
135
+ const previous = await this.getIdempotentResult(
136
+ params2.rallyId,
137
+ params2.idempotencyKey
138
+ );
139
+ if (previous !== null) return { success: true };
140
+ }
141
+ const userState = await this.getUserState(params2.rallyId, params2.userId) ?? params2.initialUserState;
129
142
  if (userState === void 0)
130
143
  return { success: false, error: "A user state is required for this transaction." };
131
144
  const storedStock = await this.getRewardStock(
132
- params.rallyId,
133
- params.stockKey ?? params.rewardId
145
+ params2.rallyId,
146
+ params2.stockKey ?? params2.rewardId
134
147
  );
135
- const storedSecondaryStock = params.secondaryStockKey === void 0 ? null : await this.getRewardStock(params.rallyId, params.secondaryStockKey);
136
- const mutationResult = mutation({
137
- stock: storedStock ?? params.initialStock ?? null,
138
- secondaryStock: storedSecondaryStock ?? params.initialSecondaryStock ?? null,
139
- claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
148
+ const storedSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
149
+ const mutationResult = mutation2({
150
+ stock: storedStock ?? initialStock,
151
+ secondaryStock: storedSecondaryStock ?? initialSecondaryStock,
152
+ claimCount: await this.getUserClaimCount(params2.rallyId, params2.userId, params2.rewardId),
140
153
  userState
141
154
  });
142
- const idempotencyKey = params.idempotencyKey;
155
+ const idempotencyKey = params2.idempotencyKey;
143
156
  if (mutationResult.error !== void 0) {
144
157
  await this.recordAuditLog(mutationResult.auditLog);
145
158
  if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
146
159
  await this.saveIdempotentResult(
147
- params.rallyId,
160
+ params2.rallyId,
148
161
  idempotencyKey,
149
162
  mutationResult.result,
150
- params.idempotencyTtlMs ?? 864e5
163
+ params2.idempotencyTtlMs ?? 864e5
151
164
  );
152
165
  return { success: false, error: mutationResult.error };
153
166
  }
154
167
  const currentStock = await this.getRewardStock(
155
- params.rallyId,
156
- params.stockKey ?? params.rewardId
168
+ params2.rallyId,
169
+ params2.stockKey ?? params2.rewardId
157
170
  );
158
- const effectiveStock = currentStock ?? params.initialStock ?? null;
159
- if (currentStock === null && params.initialStock !== void 0 && params.initialStock !== null)
171
+ const effectiveStock = currentStock ?? initialStock;
172
+ if (currentStock === null && initialStock !== void 0 && initialStock !== null)
160
173
  this.#stocks.set(
161
- this.#stockKey(params.rallyId, params.stockKey ?? params.rewardId),
162
- params.initialStock
174
+ this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
175
+ initialStock
163
176
  );
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)
177
+ const currentSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
178
+ const effectiveSecondaryStock = currentSecondaryStock ?? initialSecondaryStock;
179
+ if (currentSecondaryStock === null && params2.secondaryStockKey !== void 0 && initialSecondaryStock !== void 0 && initialSecondaryStock !== null)
167
180
  this.#stocks.set(
168
- this.#stockKey(params.rallyId, params.secondaryStockKey),
169
- params.initialSecondaryStock
181
+ this.#stockKey(params2.rallyId, params2.secondaryStockKey),
182
+ initialSecondaryStock
170
183
  );
171
184
  if (effectiveStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
172
185
  throw new Error("The transaction produced an invalid stock value.");
@@ -174,10 +187,10 @@ var InMemoryServerPersistenceAdapter = class {
174
187
  throw new Error("The transaction changed an unlimited stock to a limited stock.");
175
188
  if (mutationResult.nextStock !== null)
176
189
  this.#stocks.set(
177
- this.#stockKey(params.rallyId, params.stockKey ?? params.rewardId),
190
+ this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
178
191
  mutationResult.nextStock
179
192
  );
180
- if (params.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
193
+ if (params2.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
181
194
  if (effectiveSecondaryStock !== null && (mutationResult.nextSecondaryStock === null || mutationResult.nextSecondaryStock < 0))
182
195
  throw new Error("The transaction produced an invalid secondary stock value.");
183
196
  if (effectiveSecondaryStock === null && mutationResult.nextSecondaryStock !== null)
@@ -186,29 +199,29 @@ var InMemoryServerPersistenceAdapter = class {
186
199
  );
187
200
  if (mutationResult.nextSecondaryStock !== null)
188
201
  this.#stocks.set(
189
- this.#stockKey(params.rallyId, params.secondaryStockKey),
202
+ this.#stockKey(params2.rallyId, params2.secondaryStockKey),
190
203
  mutationResult.nextSecondaryStock
191
204
  );
192
205
  }
193
- await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
206
+ await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
194
207
  const reward = mutationResult.nextUserState.rewards.find(
195
- (item) => item.rewardId === params.rewardId
208
+ (item) => item.rewardId === params2.rewardId
196
209
  );
197
210
  if (reward?.claimTicketNumber !== void 0)
198
211
  await this.recordUserClaim({
199
- rallyId: params.rallyId,
200
- userId: params.userId,
201
- rewardId: params.rewardId,
212
+ rallyId: params2.rallyId,
213
+ userId: params2.userId,
214
+ rewardId: params2.rewardId,
202
215
  ticketNumber: reward.claimTicketNumber,
203
- timestamp: params.timestamp
216
+ timestamp: params2.timestamp
204
217
  });
205
218
  await this.recordAuditLog(mutationResult.auditLog);
206
219
  if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
207
220
  await this.saveIdempotentResult(
208
- params.rallyId,
221
+ params2.rallyId,
209
222
  idempotencyKey,
210
223
  mutationResult.result,
211
- params.idempotencyTtlMs ?? 864e5
224
+ params2.idempotencyTtlMs ?? 864e5
212
225
  );
213
226
  return { success: true };
214
227
  });
@@ -216,32 +229,32 @@ var InMemoryServerPersistenceAdapter = class {
216
229
  return { success: false, error: error instanceof Error ? error.message : String(error) };
217
230
  }
218
231
  }
219
- async executeCheckInTransaction(params, mutation) {
232
+ async executeCheckInTransaction(params2, mutation2) {
220
233
  try {
221
- return await this.runTransaction(params.rallyId, async () => {
222
- const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
234
+ return await this.runTransaction(params2.rallyId, async () => {
235
+ const userState = await this.getUserState(params2.rallyId, params2.userId) ?? params2.initialUserState;
223
236
  if (userState === void 0)
224
237
  return { success: false, error: "A user state is required for this transaction." };
225
- const mutationResult = mutation({ userState });
238
+ const mutationResult = mutation2({ userState });
226
239
  if (mutationResult.error !== void 0) {
227
240
  await this.recordAuditLog(mutationResult.auditLog);
228
- if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
241
+ if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
229
242
  await this.saveIdempotentResult(
230
- params.rallyId,
231
- params.idempotencyKey,
243
+ params2.rallyId,
244
+ params2.idempotencyKey,
232
245
  mutationResult.result,
233
- params.idempotencyTtlMs ?? 864e5
246
+ params2.idempotencyTtlMs ?? 864e5
234
247
  );
235
248
  return { success: false, error: mutationResult.error };
236
249
  }
237
- await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
250
+ await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
238
251
  await this.recordAuditLog(mutationResult.auditLog);
239
- if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
252
+ if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
240
253
  await this.saveIdempotentResult(
241
- params.rallyId,
242
- params.idempotencyKey,
254
+ params2.rallyId,
255
+ params2.idempotencyKey,
243
256
  mutationResult.result,
244
- params.idempotencyTtlMs ?? 864e5
257
+ params2.idempotencyTtlMs ?? 864e5
245
258
  );
246
259
  return { success: true };
247
260
  });
@@ -275,8 +288,8 @@ var InMemoryServerPersistenceAdapter = class {
275
288
  const value = this.#states.get(`${rallyId}:${userId}`);
276
289
  return value === void 0 ? null : structuredClone(value);
277
290
  }
278
- async saveUserState(rallyId, userId, state) {
279
- this.#states.set(`${rallyId}:${userId}`, structuredClone(state));
291
+ async saveUserState(rallyId, userId, state2) {
292
+ this.#states.set(`${rallyId}:${userId}`, structuredClone(state2));
280
293
  }
281
294
  async recordAuditLog(log) {
282
295
  this.#auditLogs.push(structuredClone(log));
@@ -288,11 +301,11 @@ var InMemoryServerPersistenceAdapter = class {
288
301
  getAuditLogs() {
289
302
  return structuredClone(this.#auditLogs);
290
303
  }
291
- async recordUserClaim(params) {
292
- const { rallyId, userId, rewardId } = params;
304
+ async recordUserClaim(params2) {
305
+ const { rallyId, userId, rewardId } = params2;
293
306
  const key = `${rallyId}:${userId}:${rewardId}`;
294
307
  this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
295
- this.#claimRecords.push(structuredClone(params));
308
+ this.#claimRecords.push(structuredClone(params2));
296
309
  }
297
310
  async rollbackUserClaim(rallyId, userId, rewardId, ticketNumber) {
298
311
  const key = `${rallyId}:${userId}:${rewardId}`;
@@ -312,44 +325,64 @@ var InMemoryServerPersistenceAdapter = class {
312
325
  return structuredClone(this.#claimRecords);
313
326
  }
314
327
  recordClaim(paramsOrRallyId, userId, rewardId) {
315
- const params = typeof paramsOrRallyId === "string" ? {
328
+ const params2 = typeof paramsOrRallyId === "string" ? {
316
329
  rallyId: paramsOrRallyId,
317
330
  userId: userId ?? "",
318
331
  rewardId: rewardId ?? "",
319
332
  ticketNumber: "",
320
333
  timestamp: Date.now()
321
334
  } : paramsOrRallyId;
322
- return this.recordUserClaim(params);
335
+ return this.recordUserClaim(params2);
323
336
  }
324
- async runTransaction(_rallyId, operation) {
325
- const snapshot = {
326
- stocks: new Map(this.#stocks),
327
- idempotent: new Map(this.#idempotent),
328
- states: new Map(this.#states),
329
- claims: new Map(this.#claims),
330
- claimRecords: structuredClone(this.#claimRecords),
331
- auditLogs: structuredClone(this.#auditLogs)
332
- };
333
- try {
334
- return await operation(this);
335
- } catch (error) {
336
- this.#stocks.clear();
337
- for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
338
- this.#idempotent.clear();
339
- for (const [key, value] of snapshot.idempotent)
340
- this.#idempotent.set(key, structuredClone(value));
341
- this.#states.clear();
342
- for (const [key, value] of snapshot.states) this.#states.set(key, structuredClone(value));
343
- this.#claims.clear();
344
- for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
345
- this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
346
- this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
347
- throw error;
348
- }
337
+ async runTransaction(rallyId, operation) {
338
+ const previous = this.#transactionTails.get(rallyId) ?? Promise.resolve();
339
+ const current = previous.then(async () => {
340
+ const snapshot = {
341
+ stocks: new Map(this.#stocks),
342
+ idempotent: new Map(this.#idempotent),
343
+ states: new Map(this.#states),
344
+ claims: new Map(this.#claims),
345
+ claimRecords: structuredClone(this.#claimRecords),
346
+ auditLogs: structuredClone(this.#auditLogs)
347
+ };
348
+ try {
349
+ return await operation(this);
350
+ } catch (error) {
351
+ this.#stocks.clear();
352
+ for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
353
+ this.#idempotent.clear();
354
+ for (const [key, value] of snapshot.idempotent)
355
+ this.#idempotent.set(key, structuredClone(value));
356
+ this.#states.clear();
357
+ for (const [key, value] of snapshot.states) this.#states.set(key, structuredClone(value));
358
+ this.#claims.clear();
359
+ for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
360
+ this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
361
+ this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
362
+ throw error;
363
+ }
364
+ });
365
+ this.#transactionTails.set(
366
+ rallyId,
367
+ current.then(
368
+ () => void 0,
369
+ () => void 0
370
+ )
371
+ );
372
+ return current;
349
373
  }
350
374
  };
351
375
 
352
376
  // src/security.ts
377
+ var RequestValidationException = class extends Error {
378
+ code = "VALIDATION_FAILED";
379
+ errors;
380
+ constructor(errors2) {
381
+ super("Request validation failed.");
382
+ this.name = "RequestValidationException";
383
+ this.errors = errors2;
384
+ }
385
+ };
353
386
  function errors(...items) {
354
387
  return { success: false, errors: items };
355
388
  }
@@ -463,7 +496,7 @@ function validateClaimRewardRequest(value) {
463
496
  });
464
497
  if (value.staffId !== void 0 && !nonEmpty(value.staffId))
465
498
  return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
466
- if (value.now !== void 0 && !nonEmpty(value.now))
499
+ if (value.now !== void 0 && !dateInput(value.now))
467
500
  return errors({
468
501
  path: "now",
469
502
  message: "now must be an ISO 8601 date or positive timestamp.",
@@ -471,6 +504,76 @@ function validateClaimRewardRequest(value) {
471
504
  });
472
505
  return { success: true, data: value };
473
506
  }
507
+ function uuid(value) {
508
+ return typeof value === "string" && /^[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);
509
+ }
510
+ function identityErrors(value) {
511
+ const result = [];
512
+ if (value.userId === void 0 && value.anonymousSessionId === void 0)
513
+ result.push({
514
+ path: "userId",
515
+ message: "An authenticated userId or anonymousSessionId is required.",
516
+ code: "REQUIRED"
517
+ });
518
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
519
+ result.push({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
520
+ if (value.anonymousSessionId !== void 0 && !uuid(value.anonymousSessionId))
521
+ result.push({
522
+ path: "anonymousSessionId",
523
+ message: "anonymousSessionId must be a UUID v4.",
524
+ code: "INVALID_FORMAT"
525
+ });
526
+ if (value.userId !== void 0 && value.anonymousSessionId !== void 0 && value.userId !== value.anonymousSessionId)
527
+ result.push({
528
+ path: "anonymousSessionId",
529
+ message: "userId and anonymousSessionId must identify the same session.",
530
+ code: "IDENTITY_MISMATCH"
531
+ });
532
+ return result;
533
+ }
534
+ function directErrors(value, config, kind) {
535
+ const validated = kind === "check-in" ? validateCheckInRequest(value) : validateClaimRewardRequest(value);
536
+ if (!validated.success) return validated.errors;
537
+ const errors2 = [];
538
+ if (validated.data.rallyId !== config.id)
539
+ errors2.push({
540
+ path: "rallyId",
541
+ message: "The rally does not match this server.",
542
+ code: "INVALID_VALUE"
543
+ });
544
+ const resourceId = kind === "check-in" ? validated.data.spotId : validated.data.rewardId;
545
+ const exists = kind === "check-in" ? config.spots.some((spot) => spot.id === resourceId) : config.rewards.some((reward) => reward.id === resourceId);
546
+ if (!exists)
547
+ errors2.push({
548
+ path: kind === "check-in" ? "spotId" : "rewardId",
549
+ message: `${kind === "check-in" ? "Spot" : "Reward"} was not found.`,
550
+ code: kind === "check-in" ? "SPOT_NOT_FOUND" : "REWARD_NOT_FOUND"
551
+ });
552
+ errors2.push(...identityErrors(validated.data));
553
+ return errors2;
554
+ }
555
+ function assertValidCheckInParams(value, config) {
556
+ const errors2 = directErrors(value, config, "check-in");
557
+ if (errors2.length > 0) throw new RequestValidationException(errors2);
558
+ }
559
+ function assertValidClaimParams(value, config) {
560
+ const errors2 = directErrors(value, config, "claim");
561
+ if (errors2.length > 0) throw new RequestValidationException(errors2);
562
+ }
563
+ function assertValidSyncParams(value, config) {
564
+ const validated = validateSyncRequest(value);
565
+ const errors2 = validated.success ? [
566
+ ...validated.data.rallyId !== config.id ? [
567
+ {
568
+ path: "rallyId",
569
+ message: "The rally does not match this server.",
570
+ code: "INVALID_VALUE"
571
+ }
572
+ ] : []
573
+ ] : [...validated.errors];
574
+ if (validated.success) errors2.push(...identityErrors(validated.data));
575
+ if (errors2.length > 0) throw new RequestValidationException(errors2);
576
+ }
474
577
  function validateSyncRequest(value) {
475
578
  const required = requiredErrors(value, ["rallyId"]);
476
579
  if (required.length > 0) return errors(...required);
@@ -478,7 +581,10 @@ function validateSyncRequest(value) {
478
581
  return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
479
582
  if (value.userId !== void 0 && !nonEmpty(value.userId))
480
583
  return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
481
- return { success: true, data: value };
584
+ return {
585
+ success: true,
586
+ data: value
587
+ };
482
588
  }
483
589
  function json(body, status = 200) {
484
590
  return new Response(JSON.stringify(body), {
@@ -574,6 +680,18 @@ function operationStatus(result) {
574
680
  if (result.ok) return "ACCEPTED";
575
681
  return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
576
682
  }
683
+ function withDirectIdentity(request) {
684
+ if (request.userId !== void 0) return { ...request, userId: request.userId };
685
+ if (request.anonymousSessionId !== void 0 && isUuidV4(request.anonymousSessionId))
686
+ return { ...request, userId: request.anonymousSessionId };
687
+ throw new RequestValidationException([
688
+ {
689
+ path: "userId",
690
+ message: "An authenticated userId or anonymousSessionId is required.",
691
+ code: "REQUIRED"
692
+ }
693
+ ]);
694
+ }
577
695
  var StampRallyServer = class {
578
696
  #config;
579
697
  #persistence;
@@ -609,7 +727,18 @@ var StampRallyServer = class {
609
727
  { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
610
728
  401
611
729
  );
612
- const result = await this.checkIn({ ...body.data, userId });
730
+ const sessionId = request.headers.get("x-anonymous-session-id");
731
+ let result;
732
+ try {
733
+ result = await this.checkIn({
734
+ ...body.data,
735
+ userId,
736
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
737
+ });
738
+ } catch (error) {
739
+ if (error instanceof RequestValidationException) return validationResponse(error.errors);
740
+ throw error;
741
+ }
613
742
  return json(
614
743
  { ...result, status: operationStatus(result) },
615
744
  result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
@@ -632,7 +761,18 @@ var StampRallyServer = class {
632
761
  { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
633
762
  401
634
763
  );
635
- const result = await this.claimReward({ ...body.data, userId });
764
+ const sessionId = request.headers.get("x-anonymous-session-id");
765
+ let result;
766
+ try {
767
+ result = await this.claimReward({
768
+ ...body.data,
769
+ userId,
770
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
771
+ });
772
+ } catch (error) {
773
+ if (error instanceof RequestValidationException) return validationResponse(error.errors);
774
+ throw error;
775
+ }
636
776
  return json(
637
777
  { ...result, status: operationStatus(result) },
638
778
  result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
@@ -655,16 +795,32 @@ var StampRallyServer = class {
655
795
  { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
656
796
  401
657
797
  );
658
- return json({ ok: true, state: await this.sync(body.data.rallyId, userId) });
798
+ const sessionId = request.headers.get("x-anonymous-session-id");
799
+ try {
800
+ return json({
801
+ ok: true,
802
+ state: await this.syncProgress({
803
+ rallyId: body.data.rallyId,
804
+ userId,
805
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
806
+ })
807
+ });
808
+ } catch (error) {
809
+ if (error instanceof RequestValidationException) return validationResponse(error.errors);
810
+ throw error;
811
+ }
659
812
  }
660
813
  async checkIn(request) {
661
- const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
814
+ const directRequest = withDirectIdentity(request);
815
+ assertValidCheckInParams(directRequest, this.#config);
816
+ const { userId } = directRequest;
817
+ const key = `check-in:${request.rallyId}:${userId}:${request.idempotencyKey}`;
662
818
  const previous = await this.#persistence.getIdempotentResult(
663
819
  request.rallyId,
664
820
  key
665
821
  );
666
822
  if (previous !== null) return previous;
667
- const lockKey = `state:${request.rallyId}:${request.userId}`;
823
+ const lockKey = `state:${request.rallyId}:${userId}`;
668
824
  if (!await this.#persistence.acquireLock(
669
825
  request.rallyId,
670
826
  lockKey,
@@ -673,11 +829,11 @@ var StampRallyServer = class {
673
829
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
674
830
  const timestamp = now(this.#options);
675
831
  try {
676
- const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
832
+ const current = await this.#persistence.getUserState(request.rallyId, userId) ?? initialState(this.#config, userId, timestamp);
677
833
  const responseHolder = { value: null };
678
834
  const makeAudit = (status, code) => audit(
679
835
  request.rallyId,
680
- request.userId,
836
+ userId,
681
837
  "CHECK_IN",
682
838
  request.spotId,
683
839
  request.idempotencyKey,
@@ -693,7 +849,7 @@ var StampRallyServer = class {
693
849
  message: "Spot was not found."
694
850
  };
695
851
  return await this.#rememberCheckInTransaction(
696
- request,
852
+ directRequest,
697
853
  timestamp,
698
854
  key,
699
855
  current,
@@ -717,7 +873,7 @@ var StampRallyServer = class {
717
873
  };
718
874
  if (current.records.some((record2) => record2.stampId === request.spotId))
719
875
  return await this.#rememberCheckInTransaction(
720
- request,
876
+ directRequest,
721
877
  timestamp,
722
878
  key,
723
879
  current,
@@ -727,7 +883,7 @@ var StampRallyServer = class {
727
883
  const acquired = new Set(current.records.map((record2) => record2.stampId));
728
884
  if (spot.prerequisites?.some((id) => !acquired.has(id)))
729
885
  return await this.#rememberCheckInTransaction(
730
- request,
886
+ directRequest,
731
887
  timestamp,
732
888
  key,
733
889
  current,
@@ -742,7 +898,7 @@ var StampRallyServer = class {
742
898
  { rallyId: request.rallyId, spotId: request.spotId, state: current }
743
899
  ))
744
900
  return await this.#rememberCheckInTransaction(
745
- request,
901
+ directRequest,
746
902
  timestamp,
747
903
  key,
748
904
  current,
@@ -764,7 +920,7 @@ var StampRallyServer = class {
764
920
  const transaction = await this.#persistence.executeCheckInTransaction(
765
921
  {
766
922
  rallyId: request.rallyId,
767
- userId: request.userId,
923
+ userId,
768
924
  spotId: request.spotId,
769
925
  timestamp: timestampMillis(timestamp),
770
926
  idempotencyKey: key,
@@ -789,7 +945,10 @@ var StampRallyServer = class {
789
945
  }
790
946
  }
791
947
  async claimReward(request) {
792
- const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
948
+ const directRequest = withDirectIdentity(request);
949
+ assertValidClaimParams(directRequest, this.#config);
950
+ const { userId } = directRequest;
951
+ const key = `claim:${request.rallyId}:${userId}:${request.rewardId}:${request.idempotencyKey}`;
793
952
  const previous = await this.#persistence.getIdempotentResult(
794
953
  request.rallyId,
795
954
  key
@@ -800,7 +959,19 @@ var StampRallyServer = class {
800
959
  return this.#rememberClaim(
801
960
  key,
802
961
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
803
- request,
962
+ directRequest,
963
+ now(this.#options)
964
+ );
965
+ const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
966
+ if (rewardStock(this.#config, reward.id, reward.stockLimit) !== null && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function"))
967
+ return this.#rememberClaim(
968
+ key,
969
+ {
970
+ ok: false,
971
+ code: "INVENTORY_NOT_SUPPORTED",
972
+ message: "This persistence adapter cannot store per-reward inventory."
973
+ },
974
+ directRequest,
804
975
  now(this.#options)
805
976
  );
806
977
  const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
@@ -811,7 +982,6 @@ var StampRallyServer = class {
811
982
  ))
812
983
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
813
984
  const timestamp = now(this.#options);
814
- const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
815
985
  try {
816
986
  const checked = await this.#persistence.getIdempotentResult(
817
987
  request.rallyId,
@@ -822,10 +992,12 @@ var StampRallyServer = class {
822
992
  const result = await this.#persistence.executeClaimRewardTransaction(
823
993
  {
824
994
  rallyId: request.rallyId,
825
- userId: request.userId,
995
+ userId,
826
996
  rewardId: reward.id,
827
997
  stockKey: plan.primaryKey,
828
998
  ...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
999
+ rewardStockLimit: rewardStock(this.#config, reward.id, reward.stockLimit),
1000
+ sharedStockLimit: this.#config.inventoryMode === "shared" ? sharedStock(this.#config) : null,
829
1001
  initialStock: plan.primaryInitial,
830
1002
  ...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
831
1003
  ticketNumber: request.idempotencyKey,
@@ -833,7 +1005,7 @@ var StampRallyServer = class {
833
1005
  idempotencyKey: key,
834
1006
  ...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
835
1007
  ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
836
- initialUserState: initialState(this.#config, request.userId, timestamp)
1008
+ initialUserState: initialState(this.#config, userId, timestamp)
837
1009
  },
838
1010
  ({ stock, secondaryStock, claimCount, userState }) => {
839
1011
  const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
@@ -843,7 +1015,7 @@ var StampRallyServer = class {
843
1015
  const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
844
1016
  const makeAudit = (status, code) => audit(
845
1017
  request.rallyId,
846
- request.userId,
1018
+ userId,
847
1019
  "CLAIM_REWARD",
848
1020
  reward.id,
849
1021
  request.idempotencyKey,
@@ -928,6 +1100,12 @@ var StampRallyServer = class {
928
1100
  const response = responseHolder.value;
929
1101
  if (!result.success) {
930
1102
  if (response !== null && !response.ok && response.code === result.error) return response;
1103
+ if (result.error === "INVENTORY_NOT_SUPPORTED")
1104
+ return {
1105
+ ok: false,
1106
+ code: "INVENTORY_NOT_SUPPORTED",
1107
+ message: "This persistence adapter cannot store per-reward inventory."
1108
+ };
931
1109
  return {
932
1110
  ok: false,
933
1111
  code: "PERSISTENCE_FAILED",
@@ -951,28 +1129,34 @@ var StampRallyServer = class {
951
1129
  }
952
1130
  }
953
1131
  async sync(rallyId, userId) {
954
- const state = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
955
- return this.#attachInventory(state);
1132
+ assertValidSyncParams({ rallyId, userId }, this.#config);
1133
+ const state2 = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
1134
+ return this.#attachInventory(state2);
1135
+ }
1136
+ async syncProgress(request) {
1137
+ const directRequest = withDirectIdentity(request);
1138
+ assertValidSyncParams(directRequest, this.#config);
1139
+ return this.sync(directRequest.rallyId, directRequest.userId);
956
1140
  }
957
- async #attachInventory(state) {
1141
+ async #attachInventory(state2) {
958
1142
  const rewardRemaining = {};
959
1143
  for (const reward of this.#config.rewards) {
960
1144
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
961
1145
  if (plan.secondaryKey !== void 0) {
962
- const stock = await this.#persistence.getRewardStock(state.rallyId, plan.secondaryKey);
1146
+ const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
963
1147
  const remaining = stock ?? plan.secondaryInitial ?? null;
964
1148
  if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
965
1149
  } else if (plan.primaryKey !== "__shared__") {
966
- const stock = await this.#persistence.getRewardStock(state.rallyId, plan.primaryKey);
1150
+ const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.primaryKey);
967
1151
  const remaining = stock ?? plan.primaryInitial;
968
1152
  if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
969
1153
  }
970
1154
  }
971
1155
  const shared = sharedStock(this.#config);
972
- const storedShared = await this.#persistence.getRewardStock(state.rallyId, "__shared__");
1156
+ const storedShared = await this.#persistence.getRewardStock(state2.rallyId, "__shared__");
973
1157
  const sharedRemaining = this.#config.inventoryMode === "shared" && shared !== null ? Math.max(0, storedShared ?? shared) : void 0;
974
1158
  return {
975
- ...state,
1159
+ ...state2,
976
1160
  ...Object.keys(rewardRemaining).length === 0 && sharedRemaining === void 0 ? {} : {
977
1161
  inventory: {
978
1162
  ...sharedRemaining === void 0 ? {} : { sharedRemaining },
@@ -997,13 +1181,14 @@ var StampRallyServer = class {
997
1181
  const authenticatedUserId = identity.authenticatedUserId;
998
1182
  return authenticatedUserId.length > 0 ? authenticatedUserId : null;
999
1183
  }
1000
- if (this.#options.anonymousPolicy === "reject") return null;
1184
+ const policy = this.#options.anonymousPolicy ?? "session_scoped";
1185
+ if (policy === "reject") return null;
1001
1186
  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;
1187
+ if (sessionId !== null) return isUuidV4(sessionId) ? sessionId : null;
1188
+ if (policy === "session_scoped") return null;
1004
1189
  return "anonymous";
1005
1190
  }
1006
- async #rememberCheckInTransaction(request, timestamp, key, current, mutation, responseHolder) {
1191
+ async #rememberCheckInTransaction(request, timestamp, key, current, mutation2, responseHolder) {
1007
1192
  const transaction = await this.#persistence.executeCheckInTransaction(
1008
1193
  {
1009
1194
  rallyId: request.rallyId,
@@ -1014,9 +1199,9 @@ var StampRallyServer = class {
1014
1199
  ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
1015
1200
  initialUserState: current
1016
1201
  },
1017
- () => mutation
1202
+ () => mutation2
1018
1203
  );
1019
- if (responseHolder.value !== null && (transaction.success || transaction.error === mutation.error))
1204
+ if (responseHolder.value !== null && (transaction.success || transaction.error === mutation2.error))
1020
1205
  return responseHolder.value;
1021
1206
  return {
1022
1207
  ok: false,
@@ -1047,11 +1232,127 @@ var StampRallyServer = class {
1047
1232
  }
1048
1233
  };
1049
1234
 
1235
+ // src/testing/compliance.ts
1236
+ var state = (userId) => ({
1237
+ rallyId: "compliance-rally",
1238
+ userId,
1239
+ records: [],
1240
+ rewards: [{ rewardId: "reward", status: "AVAILABLE" }],
1241
+ updatedAt: "2026-01-01T00:00:00.000Z"
1242
+ });
1243
+ function audit2(idempotencyKey, userId) {
1244
+ return {
1245
+ id: `audit-${idempotencyKey}`,
1246
+ timestamp: "2026-01-01T00:00:00.000Z",
1247
+ rallyId: "compliance-rally",
1248
+ userId,
1249
+ action: "CLAIM_REWARD",
1250
+ resourceId: "reward",
1251
+ status: "SUCCESS",
1252
+ idempotencyKey
1253
+ };
1254
+ }
1255
+ function params(userId, idempotencyKey) {
1256
+ return {
1257
+ rallyId: "compliance-rally",
1258
+ userId,
1259
+ rewardId: "reward",
1260
+ ticketNumber: `ticket-${idempotencyKey}`,
1261
+ timestamp: Date.parse("2026-01-01T00:00:00.000Z"),
1262
+ idempotencyKey,
1263
+ rewardStockLimit: 1,
1264
+ sharedStockLimit: 1,
1265
+ stockKey: "__shared__",
1266
+ secondaryStockKey: "reward",
1267
+ initialStock: 1,
1268
+ initialSecondaryStock: 1,
1269
+ initialUserState: state(userId)
1270
+ };
1271
+ }
1272
+ function mutation(current) {
1273
+ if (current.stock === 0 || current.secondaryStock === 0)
1274
+ return {
1275
+ nextStock: current.stock,
1276
+ nextSecondaryStock: current.secondaryStock,
1277
+ nextUserState: current.userState,
1278
+ auditLog: audit2("rejected", current.userState.userId ?? "unknown"),
1279
+ error: "OUT_OF_STOCK"
1280
+ };
1281
+ return {
1282
+ nextStock: current.stock === null ? null : current.stock - 1,
1283
+ nextSecondaryStock: current.secondaryStock === null ? null : current.secondaryStock - 1,
1284
+ nextUserState: {
1285
+ ...current.userState,
1286
+ rewards: [{ rewardId: "reward", status: "CONSUMED" }],
1287
+ updatedAt: "2026-01-01T00:00:00.000Z"
1288
+ },
1289
+ auditLog: audit2("success", current.userState.userId ?? "unknown"),
1290
+ result: { ok: true }
1291
+ };
1292
+ }
1293
+ function assert(condition, message) {
1294
+ if (!condition) throw new Error(`Persistence adapter compliance failed: ${message}`);
1295
+ }
1296
+ async function runPersistenceAdapterComplianceTests(createAdapter) {
1297
+ const adapter = await createAdapter();
1298
+ assert(
1299
+ adapter.supportsRewardStock !== false,
1300
+ "the adapter must explicitly support reward stock for this suite"
1301
+ );
1302
+ const [first, second] = await Promise.all([
1303
+ adapter.executeClaimRewardTransaction(params("alice", "race-a"), mutation),
1304
+ adapter.executeClaimRewardTransaction(params("bob", "race-b"), mutation)
1305
+ ]);
1306
+ assert([first.success, second.success].filter(Boolean).length === 1, "race was not serialized");
1307
+ assert(
1308
+ await adapter.getRewardStock("compliance-rally", "__shared__") === 0,
1309
+ "shared stock was not decremented atomically"
1310
+ );
1311
+ assert(
1312
+ await adapter.getRewardStock("compliance-rally", "reward") === 0,
1313
+ "per-reward stock was not decremented atomically"
1314
+ );
1315
+ const idempotentAdapter = await createAdapter();
1316
+ const idempotentParams = params("alice", "same-key");
1317
+ const firstClaim = await idempotentAdapter.executeClaimRewardTransaction(
1318
+ idempotentParams,
1319
+ mutation
1320
+ );
1321
+ const secondClaim = await idempotentAdapter.executeClaimRewardTransaction(
1322
+ idempotentParams,
1323
+ mutation
1324
+ );
1325
+ assert(firstClaim.success && secondClaim.success, "idempotent claim did not remain successful");
1326
+ assert(
1327
+ await idempotentAdapter.getRewardStock("compliance-rally", "__shared__") === 0,
1328
+ "idempotent retry decremented shared stock twice"
1329
+ );
1330
+ const rollbackAdapter = await createAdapter();
1331
+ const rollbackParams = params("alice", "rollback");
1332
+ const rollback = await rollbackAdapter.executeClaimRewardTransaction(rollbackParams, () => {
1333
+ throw new Error("forced rollback");
1334
+ });
1335
+ assert(!rollback.success, "a failed mutation was committed");
1336
+ assert(
1337
+ await rollbackAdapter.getRewardStock("compliance-rally", "__shared__") === null,
1338
+ "rollback changed shared stock"
1339
+ );
1340
+ assert(
1341
+ await rollbackAdapter.getRewardStock("compliance-rally", "reward") === null,
1342
+ "rollback changed per-reward stock"
1343
+ );
1344
+ }
1345
+
1050
1346
  exports.InMemoryServerPersistenceAdapter = InMemoryServerPersistenceAdapter;
1347
+ exports.RequestValidationException = RequestValidationException;
1051
1348
  exports.StampRallyServer = StampRallyServer;
1349
+ exports.assertValidCheckInParams = assertValidCheckInParams;
1350
+ exports.assertValidClaimParams = assertValidClaimParams;
1351
+ exports.assertValidSyncParams = assertValidSyncParams;
1052
1352
  exports.executeCheckInTransaction = executeCheckInTransaction;
1053
1353
  exports.executeClaimRewardTransaction = executeClaimRewardTransaction;
1054
1354
  exports.executeRedisTransaction = executeRedisTransaction;
1355
+ exports.runPersistenceAdapterComplianceTests = runPersistenceAdapterComplianceTests;
1055
1356
  exports.validateCheckInRequest = validateCheckInRequest;
1056
1357
  exports.validateClaimRewardRequest = validateClaimRewardRequest;
1057
1358
  exports.validateSyncRequest = validateSyncRequest;