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