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