@stamprally/server 0.14.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,32 +3,72 @@
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);
18
- await store.writeUserState(transaction, params, next.nextUserState);
19
- await store.writeClaimRecord(transaction, params, next.nextUserState);
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);
20
+ if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
21
+ if (next.nextSecondaryStock !== null)
22
+ await store.writeSecondaryStock(transaction, params2, next.nextSecondaryStock);
23
+ }
24
+ await store.writeUserState(transaction, params2, next.nextUserState);
25
+ await store.writeClaimRecord(transaction, params2, next.nextUserState);
20
26
  await store.writeAudit(transaction, next.auditLog);
21
- if (params.idempotencyKey !== void 0 && next.result !== void 0)
22
- 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);
23
29
  return { success: true };
24
30
  });
25
- } catch (error2) {
26
- return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
31
+ } catch (error) {
32
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
33
+ }
34
+ }
35
+ async function executeCheckInTransaction(database, store, params2, mutation2, current) {
36
+ try {
37
+ return await database.transaction(async (transaction) => {
38
+ const userState = current ?? (store.readUserState === void 0 ? (() => {
39
+ throw new Error("A current user state or readUserState implementation is required.");
40
+ })() : await store.readUserState(transaction, params2));
41
+ const next = mutation2({ userState });
42
+ if (next.error !== void 0) {
43
+ await store.writeAudit(transaction, next.auditLog);
44
+ if (params2.idempotencyKey !== void 0 && next.result !== void 0)
45
+ await store.writeIdempotency(transaction, params2, next.result);
46
+ return { success: false, error: next.error };
47
+ }
48
+ await store.writeUserState(transaction, params2, next.nextUserState);
49
+ await store.writeAudit(transaction, next.auditLog);
50
+ if (params2.idempotencyKey !== void 0 && next.result !== void 0)
51
+ await store.writeIdempotency(transaction, params2, next.result);
52
+ return { success: true };
53
+ });
54
+ } catch (error) {
55
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
56
+ }
57
+ }
58
+ async function executeRedisTransaction(redis, queue) {
59
+ try {
60
+ const multi = redis.multi();
61
+ queue(multi);
62
+ const result = await redis.exec(multi);
63
+ return result === null ? { success: false, error: "Redis transaction was aborted." } : { success: true };
64
+ } catch (error) {
65
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
27
66
  }
28
67
  }
29
68
 
30
69
  // src/persistence.ts
31
70
  var InMemoryServerPersistenceAdapter = class {
71
+ supportsRewardStock = true;
32
72
  #locks = /* @__PURE__ */ new Map();
33
73
  #idempotent = /* @__PURE__ */ new Map();
34
74
  #states = /* @__PURE__ */ new Map();
@@ -37,6 +77,7 @@ var InMemoryServerPersistenceAdapter = class {
37
77
  #claims = /* @__PURE__ */ new Map();
38
78
  #claimRecords = [];
39
79
  #auditLogs = [];
80
+ #transactionTails = /* @__PURE__ */ new Map();
40
81
  constructor(options = {}) {
41
82
  this.#stocks = /* @__PURE__ */ new Map();
42
83
  this.#stockDefaults = /* @__PURE__ */ new Map();
@@ -85,96 +126,140 @@ var InMemoryServerPersistenceAdapter = class {
85
126
  const current = this.#stocks.get(key);
86
127
  if (current !== void 0) this.#stocks.set(key, current + Math.max(0, count));
87
128
  }
88
- async executeClaimRewardTransaction(params, mutation) {
129
+ async executeClaimRewardTransaction(params2, mutation2) {
89
130
  try {
90
- return await this.runTransaction(params.rallyId, async () => {
91
- 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;
92
142
  if (userState === void 0)
93
143
  return { success: false, error: "A user state is required for this transaction." };
94
- const mutationResult = mutation({
95
- stock: await this.getRewardStock(params.rallyId, params.rewardId),
96
- claimCount: await this.getUserClaimCount(params.rallyId, params.userId, params.rewardId),
144
+ const storedStock = await this.getRewardStock(
145
+ params2.rallyId,
146
+ params2.stockKey ?? params2.rewardId
147
+ );
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),
97
153
  userState
98
154
  });
99
- const idempotencyKey = params.idempotencyKey;
155
+ const idempotencyKey = params2.idempotencyKey;
100
156
  if (mutationResult.error !== void 0) {
101
157
  await this.recordAuditLog(mutationResult.auditLog);
102
158
  if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
103
159
  await this.saveIdempotentResult(
104
- params.rallyId,
160
+ params2.rallyId,
105
161
  idempotencyKey,
106
162
  mutationResult.result,
107
- params.idempotencyTtlMs ?? 864e5
163
+ params2.idempotencyTtlMs ?? 864e5
108
164
  );
109
165
  return { success: false, error: mutationResult.error };
110
166
  }
111
- const currentStock = await this.getRewardStock(params.rallyId, params.rewardId);
112
- if (currentStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
167
+ const currentStock = await this.getRewardStock(
168
+ params2.rallyId,
169
+ params2.stockKey ?? params2.rewardId
170
+ );
171
+ const effectiveStock = currentStock ?? initialStock;
172
+ if (currentStock === null && initialStock !== void 0 && initialStock !== null)
173
+ this.#stocks.set(
174
+ this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
175
+ initialStock
176
+ );
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)
180
+ this.#stocks.set(
181
+ this.#stockKey(params2.rallyId, params2.secondaryStockKey),
182
+ initialSecondaryStock
183
+ );
184
+ if (effectiveStock !== null && (mutationResult.nextStock === null || mutationResult.nextStock < 0))
113
185
  throw new Error("The transaction produced an invalid stock value.");
114
- if (currentStock === null && mutationResult.nextStock !== null)
186
+ if (effectiveStock === null && mutationResult.nextStock !== null)
115
187
  throw new Error("The transaction changed an unlimited stock to a limited stock.");
116
188
  if (mutationResult.nextStock !== null)
117
189
  this.#stocks.set(
118
- this.#stockKey(params.rallyId, params.rewardId),
190
+ this.#stockKey(params2.rallyId, params2.stockKey ?? params2.rewardId),
119
191
  mutationResult.nextStock
120
192
  );
121
- await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
193
+ if (params2.secondaryStockKey !== void 0 && mutationResult.nextSecondaryStock !== void 0) {
194
+ if (effectiveSecondaryStock !== null && (mutationResult.nextSecondaryStock === null || mutationResult.nextSecondaryStock < 0))
195
+ throw new Error("The transaction produced an invalid secondary stock value.");
196
+ if (effectiveSecondaryStock === null && mutationResult.nextSecondaryStock !== null)
197
+ throw new Error(
198
+ "The transaction changed an unlimited secondary stock to a limited stock."
199
+ );
200
+ if (mutationResult.nextSecondaryStock !== null)
201
+ this.#stocks.set(
202
+ this.#stockKey(params2.rallyId, params2.secondaryStockKey),
203
+ mutationResult.nextSecondaryStock
204
+ );
205
+ }
206
+ await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
122
207
  const reward = mutationResult.nextUserState.rewards.find(
123
- (item) => item.rewardId === params.rewardId
208
+ (item) => item.rewardId === params2.rewardId
124
209
  );
125
210
  if (reward?.claimTicketNumber !== void 0)
126
211
  await this.recordUserClaim({
127
- rallyId: params.rallyId,
128
- userId: params.userId,
129
- rewardId: params.rewardId,
212
+ rallyId: params2.rallyId,
213
+ userId: params2.userId,
214
+ rewardId: params2.rewardId,
130
215
  ticketNumber: reward.claimTicketNumber,
131
- timestamp: params.timestamp
216
+ timestamp: params2.timestamp
132
217
  });
133
218
  await this.recordAuditLog(mutationResult.auditLog);
134
219
  if (idempotencyKey !== void 0 && mutationResult.result !== void 0)
135
220
  await this.saveIdempotentResult(
136
- params.rallyId,
221
+ params2.rallyId,
137
222
  idempotencyKey,
138
223
  mutationResult.result,
139
- params.idempotencyTtlMs ?? 864e5
224
+ params2.idempotencyTtlMs ?? 864e5
140
225
  );
141
226
  return { success: true };
142
227
  });
143
- } catch (error2) {
144
- return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
228
+ } catch (error) {
229
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
145
230
  }
146
231
  }
147
- async executeCheckInTransaction(params, mutation) {
232
+ async executeCheckInTransaction(params2, mutation2) {
148
233
  try {
149
- return await this.runTransaction(params.rallyId, async () => {
150
- 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;
151
236
  if (userState === void 0)
152
237
  return { success: false, error: "A user state is required for this transaction." };
153
- const mutationResult = mutation({ userState });
238
+ const mutationResult = mutation2({ userState });
154
239
  if (mutationResult.error !== void 0) {
155
240
  await this.recordAuditLog(mutationResult.auditLog);
156
- if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
241
+ if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
157
242
  await this.saveIdempotentResult(
158
- params.rallyId,
159
- params.idempotencyKey,
243
+ params2.rallyId,
244
+ params2.idempotencyKey,
160
245
  mutationResult.result,
161
- params.idempotencyTtlMs ?? 864e5
246
+ params2.idempotencyTtlMs ?? 864e5
162
247
  );
163
248
  return { success: false, error: mutationResult.error };
164
249
  }
165
- await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
250
+ await this.saveUserState(params2.rallyId, params2.userId, mutationResult.nextUserState);
166
251
  await this.recordAuditLog(mutationResult.auditLog);
167
- if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
252
+ if (params2.idempotencyKey !== void 0 && mutationResult.result !== void 0)
168
253
  await this.saveIdempotentResult(
169
- params.rallyId,
170
- params.idempotencyKey,
254
+ params2.rallyId,
255
+ params2.idempotencyKey,
171
256
  mutationResult.result,
172
- params.idempotencyTtlMs ?? 864e5
257
+ params2.idempotencyTtlMs ?? 864e5
173
258
  );
174
259
  return { success: true };
175
260
  });
176
- } catch (error2) {
177
- return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
261
+ } catch (error) {
262
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
178
263
  }
179
264
  }
180
265
  async rollbackUserState(rallyId, userId, previousState) {
@@ -203,8 +288,8 @@ var InMemoryServerPersistenceAdapter = class {
203
288
  const value = this.#states.get(`${rallyId}:${userId}`);
204
289
  return value === void 0 ? null : structuredClone(value);
205
290
  }
206
- async saveUserState(rallyId, userId, state) {
207
- this.#states.set(`${rallyId}:${userId}`, structuredClone(state));
291
+ async saveUserState(rallyId, userId, state2) {
292
+ this.#states.set(`${rallyId}:${userId}`, structuredClone(state2));
208
293
  }
209
294
  async recordAuditLog(log) {
210
295
  this.#auditLogs.push(structuredClone(log));
@@ -216,11 +301,11 @@ var InMemoryServerPersistenceAdapter = class {
216
301
  getAuditLogs() {
217
302
  return structuredClone(this.#auditLogs);
218
303
  }
219
- async recordUserClaim(params) {
220
- const { rallyId, userId, rewardId } = params;
304
+ async recordUserClaim(params2) {
305
+ const { rallyId, userId, rewardId } = params2;
221
306
  const key = `${rallyId}:${userId}:${rewardId}`;
222
307
  this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
223
- this.#claimRecords.push(structuredClone(params));
308
+ this.#claimRecords.push(structuredClone(params2));
224
309
  }
225
310
  async rollbackUserClaim(rallyId, userId, rewardId, ticketNumber) {
226
311
  const key = `${rallyId}:${userId}:${rewardId}`;
@@ -240,46 +325,66 @@ var InMemoryServerPersistenceAdapter = class {
240
325
  return structuredClone(this.#claimRecords);
241
326
  }
242
327
  recordClaim(paramsOrRallyId, userId, rewardId) {
243
- const params = typeof paramsOrRallyId === "string" ? {
328
+ const params2 = typeof paramsOrRallyId === "string" ? {
244
329
  rallyId: paramsOrRallyId,
245
330
  userId: userId ?? "",
246
331
  rewardId: rewardId ?? "",
247
332
  ticketNumber: "",
248
333
  timestamp: Date.now()
249
334
  } : paramsOrRallyId;
250
- return this.recordUserClaim(params);
251
- }
252
- async runTransaction(_rallyId, operation) {
253
- const snapshot = {
254
- stocks: new Map(this.#stocks),
255
- idempotent: new Map(this.#idempotent),
256
- states: new Map(this.#states),
257
- claims: new Map(this.#claims),
258
- claimRecords: structuredClone(this.#claimRecords),
259
- auditLogs: structuredClone(this.#auditLogs)
260
- };
261
- try {
262
- return await operation(this);
263
- } catch (error2) {
264
- this.#stocks.clear();
265
- for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
266
- this.#idempotent.clear();
267
- for (const [key, value] of snapshot.idempotent)
268
- this.#idempotent.set(key, structuredClone(value));
269
- this.#states.clear();
270
- for (const [key, value] of snapshot.states) this.#states.set(key, structuredClone(value));
271
- this.#claims.clear();
272
- for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
273
- this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
274
- this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
275
- throw error2;
276
- }
335
+ return this.recordUserClaim(params2);
336
+ }
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;
277
373
  }
278
374
  };
279
375
 
280
376
  // src/security.ts
281
- function error(path, message) {
282
- return { success: false, errors: [{ path, message, code: "invalid_request" }] };
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
+ };
386
+ function errors(...items) {
387
+ return { success: false, errors: items };
283
388
  }
284
389
  function record(value) {
285
390
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -287,47 +392,199 @@ function record(value) {
287
392
  function nonEmpty(value) {
288
393
  return typeof value === "string" && value.trim().length > 0;
289
394
  }
290
- function context(value) {
291
- if (!record(value) || typeof value.type !== "string") return false;
292
- if (value.type === "qr") return nonEmpty(value.token);
293
- if (value.type === "passcode") return nonEmpty(value.code);
294
- if (value.type === "nfc") return nonEmpty(value.tagId);
295
- if (value.type === "custom") return "value" in value;
296
- return value.type === "gps" && typeof value.latitude === "number" && Number.isFinite(value.latitude) && typeof value.longitude === "number" && Number.isFinite(value.longitude);
395
+ function contextErrors(value) {
396
+ if (!record(value) || typeof value.type !== "string")
397
+ return [
398
+ {
399
+ path: "proof",
400
+ message: "proof is not a valid verification context.",
401
+ code: "INVALID_TYPE"
402
+ }
403
+ ];
404
+ if (value.type === "qr" && !nonEmpty(value.token))
405
+ return [
406
+ { path: "proof.token", message: "token must be a non-empty string.", code: "INVALID_TYPE" }
407
+ ];
408
+ if (value.type === "passcode" && !nonEmpty(value.code))
409
+ return [
410
+ { path: "proof.code", message: "code must be a non-empty string.", code: "INVALID_TYPE" }
411
+ ];
412
+ if (value.type === "nfc" && !nonEmpty(value.tagId))
413
+ return [
414
+ { path: "proof.tagId", message: "tagId must be a non-empty string.", code: "INVALID_TYPE" }
415
+ ];
416
+ if (value.type === "custom")
417
+ return "value" in value ? [] : [{ path: "proof.value", message: "value is required.", code: "REQUIRED" }];
418
+ if (value.type === "qr" || value.type === "passcode" || value.type === "nfc") return [];
419
+ if (value.type !== "gps")
420
+ return [{ path: "proof.type", message: "Unknown verification type.", code: "INVALID_ENUM" }];
421
+ const result = [];
422
+ if (typeof value.latitude !== "number" || !Number.isFinite(value.latitude))
423
+ result.push({
424
+ path: "proof.latitude",
425
+ message: "Latitude must be a finite number.",
426
+ code: "INVALID_TYPE"
427
+ });
428
+ else if (value.latitude < -90 || value.latitude > 90)
429
+ result.push({
430
+ path: "proof.latitude",
431
+ message: "Latitude must be between -90 and 90.",
432
+ code: "INVALID_RANGE"
433
+ });
434
+ if (typeof value.longitude !== "number" || !Number.isFinite(value.longitude))
435
+ result.push({
436
+ path: "proof.longitude",
437
+ message: "Longitude must be a finite number.",
438
+ code: "INVALID_TYPE"
439
+ });
440
+ else if (value.longitude < -180 || value.longitude > 180)
441
+ result.push({
442
+ path: "proof.longitude",
443
+ message: "Longitude must be between -180 and 180.",
444
+ code: "INVALID_RANGE"
445
+ });
446
+ if ("radiusMeters" in value && (typeof value.radiusMeters !== "number" || !Number.isFinite(value.radiusMeters) || value.radiusMeters <= 0))
447
+ result.push({
448
+ path: "proof.radiusMeters",
449
+ message: "Radius must be greater than zero.",
450
+ code: "INVALID_RANGE"
451
+ });
452
+ return result;
453
+ }
454
+ function dateInput(value) {
455
+ if (typeof value === "number") return Number.isInteger(value) && value > 0;
456
+ if (typeof value !== "string" || value.trim() === "") return false;
457
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
297
458
  }
298
- function common(value, fields) {
299
- if (!record(value)) return false;
300
- return fields.every((field) => nonEmpty(value[field]));
459
+ function requiredErrors(value, fields) {
460
+ if (record(value) && fields.every((field) => nonEmpty(value[field]))) return [];
461
+ return fields.filter((field) => !record(value) || !nonEmpty(value[field])).map((field) => ({
462
+ path: field,
463
+ message: `${field} must be a non-empty string.`,
464
+ code: "REQUIRED"
465
+ }));
301
466
  }
302
467
  function validateCheckInRequest(value) {
303
- if (!common(value, ["rallyId", "spotId", "idempotencyKey"]))
304
- return error("$", "rallyId, spotId, and idempotencyKey must be non-empty strings.");
305
- if (!context(value.context))
306
- return error("context", "context is not a valid verification context.");
468
+ const required = requiredErrors(value, ["rallyId", "spotId", "idempotencyKey"]);
469
+ if (required.length > 0) return errors(...required);
470
+ if (!record(value))
471
+ return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
472
+ const proof = contextErrors(value.context);
473
+ if (proof.length > 0) return errors(...proof);
307
474
  if (value.userId !== void 0 && !nonEmpty(value.userId))
308
- return error("userId", "userId must be non-empty.");
309
- if (value.now !== void 0 && !nonEmpty(value.now))
310
- return error("now", "now must be non-empty when provided.");
475
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
476
+ if (value.now !== void 0 && !dateInput(value.now))
477
+ return errors({
478
+ path: "now",
479
+ message: "now must be an ISO 8601 date or positive timestamp.",
480
+ code: "INVALID_DATE"
481
+ });
311
482
  return { success: true, data: value };
312
483
  }
313
484
  function validateClaimRewardRequest(value) {
314
- if (!common(value, ["rallyId", "rewardId", "idempotencyKey"]))
315
- return error("$", "rallyId, rewardId, and idempotencyKey must be non-empty strings.");
485
+ const required = requiredErrors(value, ["rallyId", "rewardId", "idempotencyKey"]);
486
+ if (required.length > 0) return errors(...required);
487
+ if (!record(value))
488
+ return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
316
489
  if (value.userId !== void 0 && !nonEmpty(value.userId))
317
- return error("userId", "userId must be non-empty.");
490
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
318
491
  if (value.staffPasscode !== void 0 && !nonEmpty(value.staffPasscode))
319
- return error("staffPasscode", "staffPasscode must be non-empty.");
492
+ return errors({
493
+ path: "staffPasscode",
494
+ message: "staffPasscode must be non-empty.",
495
+ code: "INVALID_TYPE"
496
+ });
320
497
  if (value.staffId !== void 0 && !nonEmpty(value.staffId))
321
- return error("staffId", "staffId must be non-empty.");
322
- if (value.now !== void 0 && !nonEmpty(value.now))
323
- return error("now", "now must be non-empty when provided.");
498
+ return errors({ path: "staffId", message: "staffId must be non-empty.", code: "INVALID_TYPE" });
499
+ if (value.now !== void 0 && !dateInput(value.now))
500
+ return errors({
501
+ path: "now",
502
+ message: "now must be an ISO 8601 date or positive timestamp.",
503
+ code: "INVALID_DATE"
504
+ });
324
505
  return { success: true, data: value };
325
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
+ }
326
577
  function validateSyncRequest(value) {
327
- if (!common(value, ["rallyId"])) return error("rallyId", "rallyId must be a non-empty string.");
578
+ const required = requiredErrors(value, ["rallyId"]);
579
+ if (required.length > 0) return errors(...required);
580
+ if (!record(value))
581
+ return errors({ path: "$", message: "Expected an object.", code: "INVALID_TYPE" });
328
582
  if (value.userId !== void 0 && !nonEmpty(value.userId))
329
- return error("userId", "userId must be non-empty.");
330
- return { success: true, data: value };
583
+ return errors({ path: "userId", message: "userId must be non-empty.", code: "INVALID_TYPE" });
584
+ return {
585
+ success: true,
586
+ data: value
587
+ };
331
588
  }
332
589
  function json(body, status = 200) {
333
590
  return new Response(JSON.stringify(body), {
@@ -338,6 +595,9 @@ function json(body, status = 200) {
338
595
  function isObject(value) {
339
596
  return typeof value === "object" && value !== null && !Array.isArray(value);
340
597
  }
598
+ function isUuidV4(value) {
599
+ 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);
600
+ }
341
601
  function requestId(prefix) {
342
602
  return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
343
603
  }
@@ -348,6 +608,15 @@ function timestampMillis(timestamp) {
348
608
  const value = Date.parse(timestamp);
349
609
  return Number.isFinite(value) ? value : Date.now();
350
610
  }
611
+ function validationResponse(errors2) {
612
+ return json(
613
+ {
614
+ error: "VALIDATION_FAILED",
615
+ details: errors2.map(({ path, message, code }) => ({ path, message, code }))
616
+ },
617
+ 400
618
+ );
619
+ }
351
620
  function initialState(config, userId, timestamp) {
352
621
  return {
353
622
  rallyId: config.id,
@@ -357,16 +626,37 @@ function initialState(config, userId, timestamp) {
357
626
  updatedAt: timestamp
358
627
  };
359
628
  }
360
- function getProof(context2) {
361
- return context2.type === "qr" ? context2.token : context2.type === "passcode" ? context2.code : context2.type === "gps" ? { latitude: context2.latitude, longitude: context2.longitude } : context2.type === "nfc" ? context2.tagId : context2.value;
629
+ function rewardStock(config, rewardId, stockLimit) {
630
+ const configured = config.inventory?.[rewardId];
631
+ if (stockLimit === void 0) return configured ?? null;
632
+ if (configured === void 0) return stockLimit;
633
+ return Math.min(stockLimit, configured);
634
+ }
635
+ function sharedStock(config) {
636
+ return config.inventory?.sharedStock ?? config.inventory?.global ?? null;
362
637
  }
363
- async function evaluate(condition, context2, validator, base) {
364
- if (condition.type !== "custom") return core.evaluateConditionDetailed(condition, context2).ok;
638
+ function inventoryPlan(config, rewardId, stockLimit) {
639
+ const individual = rewardStock(config, rewardId, stockLimit);
640
+ const shared = sharedStock(config);
641
+ if (config.inventoryMode === "shared" && shared !== null) {
642
+ return {
643
+ primaryKey: "__shared__",
644
+ primaryInitial: shared,
645
+ ...individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
646
+ };
647
+ }
648
+ return { primaryKey: rewardId, primaryInitial: individual };
649
+ }
650
+ function getProof(context) {
651
+ 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;
652
+ }
653
+ async function evaluate(condition, context, validator, base) {
654
+ if (condition.type !== "custom") return core.evaluateConditionDetailed(condition, context).ok;
365
655
  if (validator === void 0) return false;
366
656
  const validationContext = {
367
657
  rallyId: base.rallyId,
368
658
  spotId: base.spotId,
369
- proofData: getProof(context2),
659
+ proofData: getProof(context),
370
660
  condition,
371
661
  userState: base.state
372
662
  };
@@ -390,6 +680,18 @@ function operationStatus(result) {
390
680
  if (result.ok) return "ACCEPTED";
391
681
  return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
392
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
+ }
393
695
  var StampRallyServer = class {
394
696
  #config;
395
697
  #persistence;
@@ -410,18 +712,33 @@ var StampRallyServer = class {
410
712
  }
411
713
  async handleCheckIn(request) {
412
714
  const body = validateCheckInRequest(await this.#body(request));
413
- if (!body.success || body.data.rallyId !== this.#config.id)
414
- return json(
415
- { ok: false, code: "INVALID_REQUEST", message: "Invalid check-in request." },
416
- 400
417
- );
715
+ if (!body.success) return validationResponse(body.errors);
716
+ if (body.data.rallyId !== this.#config.id)
717
+ return validationResponse([
718
+ {
719
+ path: "rallyId",
720
+ message: "The rally does not match this server.",
721
+ code: "INVALID_VALUE"
722
+ }
723
+ ]);
418
724
  const userId = await this.#user(request);
419
725
  if (userId === null)
420
726
  return json(
421
727
  { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
422
728
  401
423
729
  );
424
- 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
+ }
425
742
  return json(
426
743
  { ...result, status: operationStatus(result) },
427
744
  result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
@@ -429,15 +746,33 @@ var StampRallyServer = class {
429
746
  }
430
747
  async handleClaimReward(request) {
431
748
  const body = validateClaimRewardRequest(await this.#body(request));
432
- if (!body.success || body.data.rallyId !== this.#config.id)
433
- return json({ ok: false, code: "INVALID_REQUEST", message: "Invalid reward request." }, 400);
749
+ if (!body.success) return validationResponse(body.errors);
750
+ if (body.data.rallyId !== this.#config.id)
751
+ return validationResponse([
752
+ {
753
+ path: "rallyId",
754
+ message: "The rally does not match this server.",
755
+ code: "INVALID_VALUE"
756
+ }
757
+ ]);
434
758
  const userId = await this.#user(request);
435
759
  if (userId === null)
436
760
  return json(
437
761
  { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
438
762
  401
439
763
  );
440
- 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
+ }
441
776
  return json(
442
777
  { ...result, status: operationStatus(result) },
443
778
  result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
@@ -445,24 +780,47 @@ var StampRallyServer = class {
445
780
  }
446
781
  async handleSync(request) {
447
782
  const body = validateSyncRequest(await this.#body(request));
448
- if (!body.success || body.data.rallyId !== this.#config.id)
449
- return json({ ok: false, code: "INVALID_REQUEST", message: "Invalid sync request." }, 400);
783
+ if (!body.success) return validationResponse(body.errors);
784
+ if (body.data.rallyId !== this.#config.id)
785
+ return validationResponse([
786
+ {
787
+ path: "rallyId",
788
+ message: "The rally does not match this server.",
789
+ code: "INVALID_VALUE"
790
+ }
791
+ ]);
450
792
  const userId = await this.#user(request);
451
793
  if (userId === null)
452
794
  return json(
453
795
  { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
454
796
  401
455
797
  );
456
- 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
+ }
457
812
  }
458
813
  async checkIn(request) {
459
- 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}`;
460
818
  const previous = await this.#persistence.getIdempotentResult(
461
819
  request.rallyId,
462
820
  key
463
821
  );
464
822
  if (previous !== null) return previous;
465
- const lockKey = `state:${request.rallyId}:${request.userId}`;
823
+ const lockKey = `state:${request.rallyId}:${userId}`;
466
824
  if (!await this.#persistence.acquireLock(
467
825
  request.rallyId,
468
826
  lockKey,
@@ -471,11 +829,11 @@ var StampRallyServer = class {
471
829
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
472
830
  const timestamp = now(this.#options);
473
831
  try {
474
- 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);
475
833
  const responseHolder = { value: null };
476
834
  const makeAudit = (status, code) => audit(
477
835
  request.rallyId,
478
- request.userId,
836
+ userId,
479
837
  "CHECK_IN",
480
838
  request.spotId,
481
839
  request.idempotencyKey,
@@ -491,7 +849,7 @@ var StampRallyServer = class {
491
849
  message: "Spot was not found."
492
850
  };
493
851
  return await this.#rememberCheckInTransaction(
494
- request,
852
+ directRequest,
495
853
  timestamp,
496
854
  key,
497
855
  current,
@@ -515,7 +873,7 @@ var StampRallyServer = class {
515
873
  };
516
874
  if (current.records.some((record2) => record2.stampId === request.spotId))
517
875
  return await this.#rememberCheckInTransaction(
518
- request,
876
+ directRequest,
519
877
  timestamp,
520
878
  key,
521
879
  current,
@@ -525,7 +883,7 @@ var StampRallyServer = class {
525
883
  const acquired = new Set(current.records.map((record2) => record2.stampId));
526
884
  if (spot.prerequisites?.some((id) => !acquired.has(id)))
527
885
  return await this.#rememberCheckInTransaction(
528
- request,
886
+ directRequest,
529
887
  timestamp,
530
888
  key,
531
889
  current,
@@ -540,7 +898,7 @@ var StampRallyServer = class {
540
898
  { rallyId: request.rallyId, spotId: request.spotId, state: current }
541
899
  ))
542
900
  return await this.#rememberCheckInTransaction(
543
- request,
901
+ directRequest,
544
902
  timestamp,
545
903
  key,
546
904
  current,
@@ -562,7 +920,7 @@ var StampRallyServer = class {
562
920
  const transaction = await this.#persistence.executeCheckInTransaction(
563
921
  {
564
922
  rallyId: request.rallyId,
565
- userId: request.userId,
923
+ userId,
566
924
  spotId: request.spotId,
567
925
  timestamp: timestampMillis(timestamp),
568
926
  idempotencyKey: key,
@@ -587,7 +945,10 @@ var StampRallyServer = class {
587
945
  }
588
946
  }
589
947
  async claimReward(request) {
590
- 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}`;
591
952
  const previous = await this.#persistence.getIdempotentResult(
592
953
  request.rallyId,
593
954
  key
@@ -598,10 +959,22 @@ var StampRallyServer = class {
598
959
  return this.#rememberClaim(
599
960
  key,
600
961
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
601
- request,
962
+ directRequest,
602
963
  now(this.#options)
603
964
  );
604
- const lockKey = `reward:${request.rallyId}:${reward.id}`;
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,
975
+ now(this.#options)
976
+ );
977
+ const lockKey = this.#config.inventoryMode === "shared" ? "inventory:shared" : `reward:${request.rallyId}:${reward.id}`;
605
978
  if (!await this.#persistence.acquireLock(
606
979
  request.rallyId,
607
980
  lockKey,
@@ -619,16 +992,22 @@ var StampRallyServer = class {
619
992
  const result = await this.#persistence.executeClaimRewardTransaction(
620
993
  {
621
994
  rallyId: request.rallyId,
622
- userId: request.userId,
995
+ userId,
623
996
  rewardId: reward.id,
997
+ stockKey: plan.primaryKey,
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,
1001
+ initialStock: plan.primaryInitial,
1002
+ ...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
624
1003
  ticketNumber: request.idempotencyKey,
625
1004
  timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp),
626
1005
  idempotencyKey: key,
627
1006
  ...request.staffPasscode === void 0 ? {} : { proofData: request.staffPasscode },
628
1007
  ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
629
- initialUserState: initialState(this.#config, request.userId, timestamp)
1008
+ initialUserState: initialState(this.#config, userId, timestamp)
630
1009
  },
631
- ({ stock, claimCount, userState }) => {
1010
+ ({ stock, secondaryStock, claimCount, userState }) => {
632
1011
  const storedReward = userState.rewards.find((item) => item.rewardId === reward.id) ?? {
633
1012
  rewardId: reward.id,
634
1013
  status: "LOCKED"
@@ -636,7 +1015,7 @@ var StampRallyServer = class {
636
1015
  const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
637
1016
  const makeAudit = (status, code) => audit(
638
1017
  request.rallyId,
639
- request.userId,
1018
+ userId,
640
1019
  "CLAIM_REWARD",
641
1020
  reward.id,
642
1021
  request.idempotencyKey,
@@ -644,7 +1023,7 @@ var StampRallyServer = class {
644
1023
  timestamp,
645
1024
  code
646
1025
  );
647
- if (stock !== null && stock <= 0) {
1026
+ if (stock !== null && stock <= 0 || secondaryStock !== null && secondaryStock <= 0) {
648
1027
  responseHolder.value = {
649
1028
  ok: false,
650
1029
  code: "OUT_OF_STOCK",
@@ -652,6 +1031,7 @@ var StampRallyServer = class {
652
1031
  };
653
1032
  return {
654
1033
  nextStock: stock,
1034
+ ...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock: secondaryStock },
655
1035
  nextUserState: userState,
656
1036
  auditLog: makeAudit("REJECTED", "OUT_OF_STOCK"),
657
1037
  result: responseHolder.value,
@@ -682,14 +1062,35 @@ var StampRallyServer = class {
682
1062
  };
683
1063
  }
684
1064
  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];
1065
+ const consumed = local.value.claimTicketNumber !== void 0;
1066
+ const nextStock = consumed && stock !== null ? Math.max(0, stock - 1) : stock;
1067
+ const nextSecondaryStock = consumed && secondaryStock !== null ? Math.max(0, secondaryStock - 1) : secondaryStock;
685
1068
  const next = {
686
1069
  ...userState,
687
1070
  rewards: nextRewards,
688
- updatedAt: timestamp
1071
+ updatedAt: timestamp,
1072
+ inventory: {
1073
+ ...plan.primaryKey === "__shared__" && nextStock !== null ? { sharedRemaining: nextStock } : {},
1074
+ ...plan.secondaryKey !== void 0 && nextSecondaryStock !== null ? { rewardRemaining: { [reward.id]: nextSecondaryStock } } : plan.primaryKey === reward.id && nextStock !== null ? { rewardRemaining: { [reward.id]: nextStock } } : {}
1075
+ }
1076
+ };
1077
+ const inventory = {
1078
+ ...plan.primaryKey === "__shared__" && nextStock !== null ? { sharedRemaining: nextStock } : {},
1079
+ ...plan.secondaryKey !== void 0 && nextSecondaryStock !== null ? { rewardRemaining: nextSecondaryStock } : plan.primaryKey === reward.id && nextStock !== null ? { rewardRemaining: nextStock } : {}
1080
+ };
1081
+ responseHolder.value = local.value.claimTicketNumber === void 0 ? {
1082
+ ok: true,
1083
+ state: next,
1084
+ ...Object.keys(inventory).length === 0 ? {} : { inventory }
1085
+ } : {
1086
+ ok: true,
1087
+ state: next,
1088
+ claimTicketNumber: local.value.claimTicketNumber,
1089
+ ...Object.keys(inventory).length === 0 ? {} : { inventory }
689
1090
  };
690
- responseHolder.value = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
691
1091
  return {
692
- nextStock: stock === null ? null : stock - 1,
1092
+ nextStock,
1093
+ ...plan.secondaryKey === void 0 ? {} : { nextSecondaryStock },
693
1094
  nextUserState: next,
694
1095
  auditLog: makeAudit("SUCCESS"),
695
1096
  result: responseHolder.value
@@ -699,6 +1100,12 @@ var StampRallyServer = class {
699
1100
  const response = responseHolder.value;
700
1101
  if (!result.success) {
701
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
+ };
702
1109
  return {
703
1110
  ok: false,
704
1111
  code: "PERSISTENCE_FAILED",
@@ -711,18 +1118,52 @@ var StampRallyServer = class {
711
1118
  code: "PERSISTENCE_FAILED",
712
1119
  message: result.error ?? "Reward claim failed."
713
1120
  };
714
- } catch (error2) {
1121
+ } catch (error) {
715
1122
  return {
716
1123
  ok: false,
717
1124
  code: "PERSISTENCE_FAILED",
718
- message: error2 instanceof Error ? error2.message : "Reward claim failed."
1125
+ message: error instanceof Error ? error.message : "Reward claim failed."
719
1126
  };
720
1127
  } finally {
721
1128
  await this.#persistence.releaseLock(request.rallyId, lockKey);
722
1129
  }
723
1130
  }
724
1131
  async sync(rallyId, userId) {
725
- return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
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);
1140
+ }
1141
+ async #attachInventory(state2) {
1142
+ const rewardRemaining = {};
1143
+ for (const reward of this.#config.rewards) {
1144
+ const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
1145
+ if (plan.secondaryKey !== void 0) {
1146
+ const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
1147
+ const remaining = stock ?? plan.secondaryInitial ?? null;
1148
+ if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
1149
+ } else if (plan.primaryKey !== "__shared__") {
1150
+ const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.primaryKey);
1151
+ const remaining = stock ?? plan.primaryInitial;
1152
+ if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
1153
+ }
1154
+ }
1155
+ const shared = sharedStock(this.#config);
1156
+ const storedShared = await this.#persistence.getRewardStock(state2.rallyId, "__shared__");
1157
+ const sharedRemaining = this.#config.inventoryMode === "shared" && shared !== null ? Math.max(0, storedShared ?? shared) : void 0;
1158
+ return {
1159
+ ...state2,
1160
+ ...Object.keys(rewardRemaining).length === 0 && sharedRemaining === void 0 ? {} : {
1161
+ inventory: {
1162
+ ...sharedRemaining === void 0 ? {} : { sharedRemaining },
1163
+ ...Object.keys(rewardRemaining).length === 0 ? {} : { rewardRemaining }
1164
+ }
1165
+ }
1166
+ };
726
1167
  }
727
1168
  async #body(request) {
728
1169
  try {
@@ -740,9 +1181,14 @@ var StampRallyServer = class {
740
1181
  const authenticatedUserId = identity.authenticatedUserId;
741
1182
  return authenticatedUserId.length > 0 ? authenticatedUserId : null;
742
1183
  }
1184
+ const policy = this.#options.anonymousPolicy ?? "session_scoped";
1185
+ if (policy === "reject") return null;
1186
+ const sessionId = request.headers.get("X-Anonymous-Session-Id");
1187
+ if (sessionId !== null) return isUuidV4(sessionId) ? sessionId : null;
1188
+ if (policy === "session_scoped") return null;
743
1189
  return "anonymous";
744
1190
  }
745
- async #rememberCheckInTransaction(request, timestamp, key, current, mutation, responseHolder) {
1191
+ async #rememberCheckInTransaction(request, timestamp, key, current, mutation2, responseHolder) {
746
1192
  const transaction = await this.#persistence.executeCheckInTransaction(
747
1193
  {
748
1194
  rallyId: request.rallyId,
@@ -753,9 +1199,9 @@ var StampRallyServer = class {
753
1199
  ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
754
1200
  initialUserState: current
755
1201
  },
756
- () => mutation
1202
+ () => mutation2
757
1203
  );
758
- if (responseHolder.value !== null && (transaction.success || transaction.error === mutation.error))
1204
+ if (responseHolder.value !== null && (transaction.success || transaction.error === mutation2.error))
759
1205
  return responseHolder.value;
760
1206
  return {
761
1207
  ok: false,
@@ -786,9 +1232,127 @@ var StampRallyServer = class {
786
1232
  }
787
1233
  };
788
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
+
789
1346
  exports.InMemoryServerPersistenceAdapter = InMemoryServerPersistenceAdapter;
1347
+ exports.RequestValidationException = RequestValidationException;
790
1348
  exports.StampRallyServer = StampRallyServer;
1349
+ exports.assertValidCheckInParams = assertValidCheckInParams;
1350
+ exports.assertValidClaimParams = assertValidClaimParams;
1351
+ exports.assertValidSyncParams = assertValidSyncParams;
1352
+ exports.executeCheckInTransaction = executeCheckInTransaction;
791
1353
  exports.executeClaimRewardTransaction = executeClaimRewardTransaction;
1354
+ exports.executeRedisTransaction = executeRedisTransaction;
1355
+ exports.runPersistenceAdapterComplianceTests = runPersistenceAdapterComplianceTests;
792
1356
  exports.validateCheckInRequest = validateCheckInRequest;
793
1357
  exports.validateClaimRewardRequest = validateClaimRewardRequest;
794
1358
  exports.validateSyncRequest = validateSyncRequest;