@stamprally/server 0.8.0 → 0.10.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,6 +1,4 @@
1
- import { consumeReward, processStamp, createSecureToken, reconcileRewardStates } from '@stamprally/core';
2
-
3
- // src/index.ts
1
+ import { reconcileRewardStates, consumeReward, evaluateConditionDetailed } from '@stamprally/core';
4
2
 
5
3
  // src/persistence.ts
6
4
  var InMemoryServerPersistenceAdapter = class {
@@ -8,46 +6,78 @@ var InMemoryServerPersistenceAdapter = class {
8
6
  #idempotent = /* @__PURE__ */ new Map();
9
7
  #states = /* @__PURE__ */ new Map();
10
8
  #stocks;
9
+ #stockDefaults;
10
+ #claims = /* @__PURE__ */ new Map();
11
+ #claimRecords = [];
11
12
  #auditLogs = [];
12
13
  constructor(options = {}) {
13
- this.#stocks = new Map(Object.entries(options.stocks ?? {}));
14
+ this.#stocks = /* @__PURE__ */ new Map();
15
+ this.#stockDefaults = /* @__PURE__ */ new Map();
16
+ for (const [key, stock] of Object.entries(options.stocks ?? {})) {
17
+ if (key.includes(":")) this.#stocks.set(key, stock);
18
+ else this.#stockDefaults.set(key, stock);
19
+ }
20
+ }
21
+ #key(rallyId, key) {
22
+ return `${rallyId}:${key}`;
14
23
  }
15
- async acquireLock(lockKey, ttlMs) {
24
+ #stockKey(rallyId, rewardId) {
25
+ const scopedKey = `${rallyId}:${rewardId}`;
26
+ if (this.#stocks.has(scopedKey)) return scopedKey;
27
+ const defaultStock = this.#stockDefaults.get(rewardId);
28
+ if (defaultStock !== void 0) {
29
+ this.#stocks.set(scopedKey, defaultStock);
30
+ return scopedKey;
31
+ }
32
+ return scopedKey;
33
+ }
34
+ async acquireLock(rallyId, key, ttlMs) {
35
+ const scopedKey = this.#key(rallyId, key);
16
36
  const now2 = Date.now();
17
- const expiresAt = this.#locks.get(lockKey);
18
- if (expiresAt !== void 0 && expiresAt > now2) return false;
19
- this.#locks.set(lockKey, now2 + Math.max(1, ttlMs));
37
+ const until = this.#locks.get(scopedKey);
38
+ if (until !== void 0 && until > now2) return false;
39
+ this.#locks.set(scopedKey, now2 + Math.max(1, ttlMs));
20
40
  return true;
21
41
  }
22
- async releaseLock(lockKey) {
23
- this.#locks.delete(lockKey);
24
- }
25
- async decrementRewardStock(rewardId) {
26
- const stock = this.#stocks.get(rewardId);
27
- if (stock === void 0) return { success: true, remainingStock: Number.POSITIVE_INFINITY };
28
- if (stock <= 0) return { success: false, remainingStock: 0 };
29
- const remainingStock = stock - 1;
30
- this.#stocks.set(rewardId, remainingStock);
31
- return { success: true, remainingStock };
32
- }
33
- async getIdempotentResult(idempotencyKey) {
34
- const entry = this.#idempotent.get(idempotencyKey);
35
- if (entry === void 0) return null;
36
- if (entry.expiresAt <= Date.now()) {
37
- this.#idempotent.delete(idempotencyKey);
42
+ async releaseLock(rallyId, key) {
43
+ this.#locks.delete(this.#key(rallyId, key));
44
+ }
45
+ async getRewardStock(rallyId, rewardId) {
46
+ return this.#stocks.get(this.#stockKey(rallyId, rewardId)) ?? null;
47
+ }
48
+ async decrementRewardStock(rallyId, rewardId) {
49
+ const key = this.#stockKey(rallyId, rewardId);
50
+ const current = this.#stocks.get(key);
51
+ if (current === void 0) return { success: true, remainingStock: Number.POSITIVE_INFINITY };
52
+ if (current <= 0) return { success: false, remainingStock: 0 };
53
+ this.#stocks.set(key, current - 1);
54
+ return { success: true, remainingStock: current - 1 };
55
+ }
56
+ async restoreRewardStock(rallyId, rewardId) {
57
+ const key = this.#stockKey(rallyId, rewardId);
58
+ const current = this.#stocks.get(key);
59
+ if (current !== void 0) this.#stocks.set(key, current + 1);
60
+ }
61
+ async getIdempotentResult(rallyId, key) {
62
+ const value = this.#idempotent.get(this.#key(rallyId, key));
63
+ if (value === void 0 || value.expiresAt <= Date.now()) {
64
+ this.#idempotent.delete(this.#key(rallyId, key));
38
65
  return null;
39
66
  }
40
- return structuredClone(entry.value);
67
+ return structuredClone(value.value);
41
68
  }
42
- async saveIdempotentResult(idempotencyKey, result, ttlMs) {
43
- this.#idempotent.set(idempotencyKey, {
69
+ async saveIdempotentResult(rallyId, key, result, ttlMs) {
70
+ this.#idempotent.set(this.#key(rallyId, key), {
44
71
  value: structuredClone(result),
45
72
  expiresAt: Date.now() + Math.max(1, ttlMs)
46
73
  });
47
74
  }
75
+ async getUserClaimCount(rallyId, userId, rewardId) {
76
+ return this.#claims.get(`${rallyId}:${userId}:${rewardId}`) ?? 0;
77
+ }
48
78
  async getUserState(rallyId, userId) {
49
- const state = this.#states.get(`${rallyId}:${userId}`);
50
- return state === void 0 ? null : structuredClone(state);
79
+ const value = this.#states.get(`${rallyId}:${userId}`);
80
+ return value === void 0 ? null : structuredClone(value);
51
81
  }
52
82
  async saveUserState(rallyId, userId, state) {
53
83
  this.#states.set(`${rallyId}:${userId}`, structuredClone(state));
@@ -58,78 +88,84 @@ var InMemoryServerPersistenceAdapter = class {
58
88
  getAuditLogs() {
59
89
  return structuredClone(this.#auditLogs);
60
90
  }
91
+ async recordUserClaim(params) {
92
+ const { rallyId, userId, rewardId } = params;
93
+ const key = `${rallyId}:${userId}:${rewardId}`;
94
+ this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
95
+ this.#claimRecords.push(structuredClone(params));
96
+ }
97
+ async incrementUserClaimCount(rallyId, userId, rewardId) {
98
+ const key = `${rallyId}:${userId}:${rewardId}`;
99
+ this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
100
+ }
101
+ getClaimRecords() {
102
+ return structuredClone(this.#claimRecords);
103
+ }
104
+ recordClaim(paramsOrRallyId, userId, rewardId) {
105
+ const params = typeof paramsOrRallyId === "string" ? {
106
+ rallyId: paramsOrRallyId,
107
+ userId: userId ?? "",
108
+ rewardId: rewardId ?? "",
109
+ ticketNumber: "",
110
+ timestamp: Date.now()
111
+ } : paramsOrRallyId;
112
+ return this.recordUserClaim(params);
113
+ }
61
114
  };
62
- function distanceMeters(aLat, aLon, bLat, bLon) {
63
- const radians = (degrees) => degrees * Math.PI / 180;
64
- const dLat = radians(bLat - aLat);
65
- const dLon = radians(bLon - aLon);
66
- const latA = radians(aLat);
67
- const latB = radians(bLat);
68
- const value = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLon / 2) ** 2;
69
- return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
70
- }
71
- function initialState(config, now2) {
72
- return {
73
- rallyId: config.id,
74
- records: [],
75
- rewards: reconcileRewardStates(
76
- config.rewards.map((reward) => ({ ...reward, description: reward.description ?? "" })),
77
- [],
78
- 0,
79
- now2
80
- ),
81
- updatedAt: now2
82
- };
83
- }
84
115
  function json(body, status = 200) {
85
116
  return new Response(JSON.stringify(body), {
86
117
  status,
87
118
  headers: { "content-type": "application/json; charset=utf-8" }
88
119
  });
89
120
  }
90
- function requestBody(request) {
91
- return request.json().then((value) => {
92
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
93
- return value;
94
- }).catch(() => null);
121
+ function isObject(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value);
123
+ }
124
+ function requestId(prefix) {
125
+ return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
95
126
  }
96
- function adminReward(reward) {
97
- return { ...reward, description: reward.description ?? "" };
127
+ function now(options, requested) {
128
+ return requested ?? options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
98
129
  }
99
- function matchesCondition(condition, context, customValidators) {
100
- switch (condition.type) {
101
- case "qr":
102
- return context.type === "qr" && context.token === condition.secretToken;
103
- case "passcode":
104
- return context.type === "passcode" && (condition.caseSensitive === false ? context.code.toLocaleLowerCase() === condition.code.toLocaleLowerCase() : context.code === condition.code);
105
- case "gps":
106
- return context.type === "gps" && distanceMeters(
107
- condition.latitude,
108
- condition.longitude,
109
- context.latitude,
110
- context.longitude
111
- ) <= condition.radiusMeters;
112
- case "custom":
113
- return customValidators?.[condition.validatorName]?.(
114
- context.type === "custom" ? context.value : void 0,
115
- condition
116
- ) ?? false;
117
- }
130
+ function initialState(config, userId, timestamp) {
131
+ return {
132
+ rallyId: config.id,
133
+ userId,
134
+ records: [],
135
+ rewards: reconcileRewardStates(config.rewards, [], 0, timestamp),
136
+ updatedAt: timestamp
137
+ };
138
+ }
139
+ function getProof(context) {
140
+ 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;
141
+ }
142
+ async function evaluate(condition, context, validator, base) {
143
+ if (condition.type !== "custom") return evaluateConditionDetailed(condition, context).ok;
144
+ if (validator === void 0) return false;
145
+ const validationContext = {
146
+ rallyId: base.rallyId,
147
+ spotId: base.spotId,
148
+ proofData: getProof(context),
149
+ condition,
150
+ userState: base.state
151
+ };
152
+ const result = typeof validator === "function" ? await validator(validationContext) : await validator.validate(validationContext);
153
+ return result === true || typeof result === "object" && result.valid;
118
154
  }
119
- function audit(request, status, now2, errorCode) {
155
+ function audit(rallyId, userId, action, resourceId, key, status, timestamp, code) {
120
156
  return {
121
- id: `audit-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`,
122
- timestamp: now2,
123
- rallyId: request.rallyId,
124
- userId: request.userId,
125
- action: "CHECK_IN",
126
- resourceId: request.spotId,
157
+ id: requestId("audit"),
158
+ timestamp,
159
+ rallyId,
160
+ userId,
161
+ action,
162
+ resourceId,
127
163
  status,
128
- idempotencyKey: request.idempotencyKey,
129
- ...errorCode === void 0 ? {} : { metadata: { errorCode } }
164
+ idempotencyKey: key,
165
+ ...code === void 0 ? {} : { metadata: { errorCode: code } }
130
166
  };
131
167
  }
132
- var UniversalRallyServer = class {
168
+ var StampRallyServer = class {
133
169
  #config;
134
170
  #persistence;
135
171
  #options;
@@ -138,185 +174,94 @@ var UniversalRallyServer = class {
138
174
  this.#persistence = persistence;
139
175
  this.#options = options;
140
176
  }
141
- /** Web Standard endpoint handler. Authentication, when configured, supplies the user identity. */
177
+ async handle(request) {
178
+ if (request.method !== "POST")
179
+ return json({ ok: false, code: "METHOD_NOT_ALLOWED", message: "POST is required." }, 405);
180
+ const path = new URL(request.url).pathname;
181
+ if (path.endsWith("/check-in")) return this.handleCheckIn(request);
182
+ if (path.endsWith("/claim-reward")) return this.handleClaimReward(request);
183
+ if (path.endsWith("/sync")) return this.handleSync(request);
184
+ return json({ ok: false, code: "NOT_FOUND", message: "Route not found." }, 404);
185
+ }
142
186
  async handleCheckIn(request) {
143
- const body = await requestBody(request);
144
- const userId = await this.#authenticatedUser(request, body?.userId);
145
- if (this.#options.authenticate !== void 0 && userId === null)
146
- return json(
147
- { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
148
- 401
149
- );
150
- if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "")
187
+ const body = await this.#body(request);
188
+ const userId = await this.#user(request, body?.userId);
189
+ if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "" || body.context === void 0)
151
190
  return json(
152
191
  {
153
192
  ok: false,
154
- error: {
155
- code: "INVALID_REQUEST",
156
- message: "rallyId, spotId, and idempotencyKey are required."
157
- }
193
+ code: "INVALID_REQUEST",
194
+ message: "rallyId, spotId, context, and idempotencyKey are required."
158
195
  },
159
196
  400
160
197
  );
161
- if (body.context === void 0)
162
- return json(
163
- { ok: false, error: { code: "INVALID_REQUEST", message: "context is required." } },
164
- 400
165
- );
166
198
  const result = await this.checkIn({ ...body, userId });
167
- return result.ok ? json(result) : json(result, result.code === "SPOT_NOT_FOUND" ? 404 : 422);
199
+ return json(result, result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422);
168
200
  }
169
201
  async handleClaimReward(request) {
170
- const body = await requestBody(request);
171
- const userId = await this.#authenticatedUser(request, body?.userId);
172
- if (this.#options.authenticate !== void 0 && userId === null)
173
- return json(
174
- { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
175
- 401
176
- );
202
+ const body = await this.#body(request);
203
+ const userId = await this.#user(request, body?.userId);
177
204
  if (body === null || userId === null || body.rallyId !== this.#config.id || body.rewardId === "" || body.idempotencyKey === "")
178
205
  return json(
179
206
  {
180
207
  ok: false,
181
- error: {
182
- code: "INVALID_REQUEST",
183
- message: "rallyId, rewardId, and idempotencyKey are required."
184
- }
208
+ code: "INVALID_REQUEST",
209
+ message: "rallyId, rewardId, and idempotencyKey are required."
185
210
  },
186
211
  400
187
212
  );
188
- const key = `claim-reward:${body.rallyId}:${userId}:${body.rewardId}:${body.idempotencyKey}`;
189
- const previous = await this.#persistence.getIdempotentResult(key);
190
- if (previous !== null) return json(previous, previous.ok ? 200 : 422);
191
- const reward = this.#config.rewards.find((item) => item.id === body.rewardId);
192
- if (reward === void 0) {
193
- const failure = {
194
- ok: false,
195
- code: "REWARD_NOT_FOUND",
196
- message: "Reward was not found."
197
- };
198
- return json(await this.#rememberClaim(key, failure, body, userId), 404);
199
- }
200
- const timestamp = body.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
201
- const current = await this.sync(body.rallyId, userId);
202
- const currentReward = current.rewards?.find((item) => item.rewardId === reward.id) ?? {
203
- rewardId: reward.id,
204
- status: "LOCKED"
205
- };
206
- const userClaims = await this.#getUserClaimCount(userId, reward.id);
207
- const local = consumeReward({
208
- reward: adminReward(reward),
209
- currentState: currentReward,
210
- now: timestamp,
211
- userId,
212
- userRedemptionCount: userClaims,
213
- ...body.staffPasscode === void 0 ? {} : { inputPasscode: body.staffPasscode },
214
- ...body.staffId === void 0 ? {} : { staffId: body.staffId }
215
- });
216
- if (!local.ok)
217
- return json(
218
- await this.#rememberClaim(
219
- key,
220
- {
221
- ok: false,
222
- code: local.error.code,
223
- message: "Reward cannot be claimed.",
224
- error: local.error
225
- },
226
- body,
227
- userId
228
- ),
229
- 422
230
- );
231
- const stock = await this.#persistence.decrementRewardStock(reward.id);
232
- if (!stock.success)
233
- return json(
234
- await this.#rememberClaim(
235
- key,
236
- { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
237
- body,
238
- userId
239
- ),
240
- 422
241
- );
242
- const next = {
243
- ...current,
244
- rewards: (current.rewards ?? []).map(
245
- (item) => item.rewardId === reward.id ? local.value : item
246
- ),
247
- updatedAt: timestamp
248
- };
249
- await this.#persistence.saveUserState(body.rallyId, userId, next);
250
- const success = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
251
- await this.#persistence.saveIdempotentResult(
252
- key,
253
- success,
254
- this.#options.idempotencyTtlMs ?? 864e5
255
- );
256
- await this.#persistence.recordAuditLog({
257
- id: `audit-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
258
- timestamp,
259
- rallyId: body.rallyId,
260
- userId,
261
- action: "CLAIM_REWARD",
262
- resourceId: reward.id,
263
- status: "SUCCESS",
264
- idempotencyKey: body.idempotencyKey
265
- });
266
- return json(success);
213
+ const result = await this.claimReward({ ...body, userId });
214
+ return json(result, result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422);
267
215
  }
268
216
  async handleSync(request) {
269
- const body = await requestBody(request);
270
- const userId = await this.#authenticatedUser(request, body?.userId);
271
- if (this.#options.authenticate !== void 0 && userId === null)
272
- return json(
273
- { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
274
- 401
275
- );
217
+ const body = await this.#body(request);
218
+ const userId = await this.#user(request, body?.userId);
276
219
  if (body === null || userId === null || body.rallyId !== this.#config.id)
277
- return json(
278
- { ok: false, error: { code: "INVALID_REQUEST", message: "rallyId is required." } },
279
- 400
280
- );
220
+ return json({ ok: false, code: "INVALID_REQUEST", message: "rallyId is required." }, 400);
281
221
  return json({ ok: true, state: await this.sync(body.rallyId, userId) });
282
222
  }
283
- async handle(request) {
284
- if (request.method !== "POST")
285
- return json({ ok: false, error: { code: "METHOD_NOT_ALLOWED" } }, 405);
286
- const path = new URL(request.url).pathname;
287
- if (path.endsWith("/check-in")) return this.handleCheckIn(request);
288
- if (path.endsWith("/claim-reward")) return this.handleClaimReward(request);
289
- if (path.endsWith("/sync")) return this.handleSync(request);
290
- return json({ ok: false, error: { code: "NOT_FOUND" } }, 404);
291
- }
292
223
  async checkIn(request) {
293
224
  const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
294
- const previous = await this.#persistence.getIdempotentResult(key);
225
+ const previous = await this.#persistence.getIdempotentResult(
226
+ request.rallyId,
227
+ key
228
+ );
295
229
  if (previous !== null) return previous;
296
230
  const lockKey = `state:${request.rallyId}:${request.userId}`;
297
- const locked = await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3);
298
- if (!locked)
231
+ if (!await this.#persistence.acquireLock(
232
+ request.rallyId,
233
+ lockKey,
234
+ this.#options.lockTtlMs ?? 5e3
235
+ ))
299
236
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
300
- const now2 = request.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
237
+ const timestamp = now(this.#options, request.now);
301
238
  try {
302
239
  const spot = this.#config.spots.find((item) => item.id === request.spotId);
303
240
  if (spot === void 0)
304
241
  return this.#remember(
305
242
  key,
306
243
  { ok: false, code: "SPOT_NOT_FOUND", message: "Spot was not found." },
307
- request,
308
- now2
244
+ request.rallyId,
245
+ request.userId,
246
+ "CHECK_IN",
247
+ request.spotId,
248
+ request.idempotencyKey,
249
+ timestamp
309
250
  );
310
- const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, now2);
251
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
311
252
  if (current.records.some((record) => record.stampId === request.spotId))
312
253
  return this.#remember(
313
254
  key,
314
- { ok: false, code: "ALREADY_CLAIMED", message: "Spot was already claimed." },
315
- request,
316
- now2
255
+ { ok: false, code: "STAMP_ALREADY_ACQUIRED", message: "Spot was already claimed." },
256
+ request.rallyId,
257
+ request.userId,
258
+ "CHECK_IN",
259
+ request.spotId,
260
+ request.idempotencyKey,
261
+ timestamp
317
262
  );
318
263
  const acquired = new Set(current.records.map((record) => record.stampId));
319
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
264
+ if (spot.prerequisites?.some((id) => !acquired.has(id)))
320
265
  return this.#remember(
321
266
  key,
322
267
  {
@@ -324,440 +269,234 @@ var UniversalRallyServer = class {
324
269
  code: "PREREQUISITES_NOT_MET",
325
270
  message: "Prerequisite spots are not complete."
326
271
  },
272
+ request.rallyId,
273
+ request.userId,
274
+ "CHECK_IN",
275
+ request.spotId,
276
+ request.idempotencyKey,
277
+ timestamp
278
+ );
279
+ for (const condition of spot.conditions)
280
+ if (!await evaluate(
281
+ condition,
282
+ request.context,
283
+ condition.type === "custom" ? this.#options.customValidators?.[condition.validatorName] : void 0,
284
+ { rallyId: request.rallyId, spotId: request.spotId, state: current }
285
+ ))
286
+ return this.#remember(
287
+ key,
288
+ { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
289
+ request.rallyId,
290
+ request.userId,
291
+ "CHECK_IN",
292
+ request.spotId,
293
+ request.idempotencyKey,
294
+ timestamp
295
+ );
296
+ const next = {
297
+ ...current,
298
+ records: [...current.records, { stampId: request.spotId, acquiredAt: timestamp }],
299
+ rewards: reconcileRewardStates(
300
+ this.#config.rewards,
301
+ current.rewards,
302
+ current.records.length + 1,
303
+ timestamp
304
+ ),
305
+ updatedAt: timestamp
306
+ };
307
+ await this.#persistence.saveUserState(request.rallyId, request.userId, next);
308
+ return this.#remember(
309
+ key,
310
+ { ok: true, state: next },
311
+ request.rallyId,
312
+ request.userId,
313
+ "CHECK_IN",
314
+ request.spotId,
315
+ request.idempotencyKey,
316
+ timestamp
317
+ );
318
+ } finally {
319
+ await this.#persistence.releaseLock(request.rallyId, lockKey);
320
+ }
321
+ }
322
+ async claimReward(request) {
323
+ const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
324
+ const previous = await this.#persistence.getIdempotentResult(
325
+ request.rallyId,
326
+ key
327
+ );
328
+ if (previous !== null) return previous;
329
+ const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
330
+ if (reward === void 0)
331
+ return this.#rememberClaim(
332
+ key,
333
+ { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
334
+ request,
335
+ now(this.#options, request.now)
336
+ );
337
+ const lockKey = `reward:${request.rallyId}:${reward.id}`;
338
+ if (!await this.#persistence.acquireLock(
339
+ request.rallyId,
340
+ lockKey,
341
+ this.#options.lockTtlMs ?? 5e3
342
+ ))
343
+ return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
344
+ const timestamp = now(this.#options, request.now);
345
+ let decremented = false;
346
+ try {
347
+ const checked = await this.#persistence.getIdempotentResult(
348
+ request.rallyId,
349
+ key
350
+ );
351
+ if (checked !== null) return checked;
352
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
353
+ const claimCount = await this.#persistence.getUserClaimCount(
354
+ request.rallyId,
355
+ request.userId,
356
+ reward.id
357
+ );
358
+ const storedReward = current.rewards.find((item) => item.rewardId === reward.id) ?? {
359
+ rewardId: reward.id,
360
+ status: "LOCKED"
361
+ };
362
+ const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
363
+ const local = consumeReward({
364
+ reward,
365
+ currentState: currentReward,
366
+ now: timestamp,
367
+ userRedemptionCount: claimCount,
368
+ ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
369
+ ...request.staffId === void 0 ? {} : { staffId: request.staffId }
370
+ });
371
+ if (!local.ok)
372
+ return this.#rememberClaim(
373
+ key,
374
+ { ok: false, code: local.error.code, message: "Reward cannot be claimed." },
327
375
  request,
328
- now2
376
+ timestamp
329
377
  );
330
- if (!spot.conditions.every(
331
- (condition) => matchesCondition(condition, request.context, this.#options.customValidators)
332
- ))
333
- return this.#remember(
378
+ const stock = await this.#persistence.decrementRewardStock(request.rallyId, reward.id);
379
+ if (!stock.success)
380
+ return this.#rememberClaim(
334
381
  key,
335
- { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
382
+ { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
336
383
  request,
337
- now2
384
+ timestamp
338
385
  );
339
- const state = {
386
+ decremented = true;
387
+ const next = {
340
388
  ...current,
341
- records: [...current.records, { stampId: request.spotId, acquiredAt: now2 }],
342
- updatedAt: now2
389
+ rewards: current.rewards.map((item) => item.rewardId === reward.id ? local.value : item),
390
+ updatedAt: timestamp
391
+ };
392
+ try {
393
+ await this.#persistence.saveUserState(request.rallyId, request.userId, next);
394
+ const response = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
395
+ await this.#persistence.recordUserClaim({
396
+ rallyId: request.rallyId,
397
+ userId: request.userId,
398
+ rewardId: reward.id,
399
+ ticketNumber: local.value.claimTicketNumber ?? "",
400
+ timestamp: Number.isNaN(Date.parse(timestamp)) ? Date.now() : Date.parse(timestamp)
401
+ });
402
+ await this.#persistence.recordAuditLog(
403
+ audit(
404
+ request.rallyId,
405
+ request.userId,
406
+ "CLAIM_REWARD",
407
+ reward.id,
408
+ request.idempotencyKey,
409
+ "SUCCESS",
410
+ timestamp
411
+ )
412
+ );
413
+ await this.#persistence.saveIdempotentResult(
414
+ request.rallyId,
415
+ key,
416
+ response,
417
+ this.#options.idempotencyTtlMs ?? 864e5
418
+ );
419
+ return response;
420
+ } catch (error) {
421
+ await this.#restoreRewardStock(request.rallyId, reward.id);
422
+ decremented = false;
423
+ throw error;
424
+ }
425
+ } catch (error) {
426
+ if (decremented) await this.#restoreRewardStock(request.rallyId, reward.id);
427
+ return {
428
+ ok: false,
429
+ code: "PERSISTENCE_FAILED",
430
+ message: error instanceof Error ? error.message : "Reward claim failed."
343
431
  };
344
- await this.#persistence.saveUserState(request.rallyId, request.userId, state);
345
- return this.#remember(key, { ok: true, state }, request, now2);
346
432
  } finally {
347
- await this.#persistence.releaseLock(lockKey);
433
+ await this.#persistence.releaseLock(request.rallyId, lockKey);
348
434
  }
349
435
  }
350
436
  async sync(rallyId, userId) {
351
- const now2 = this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
352
- return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, now2);
437
+ return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
353
438
  }
354
- async #authenticatedUser(request, requestedUserId) {
355
- const authenticated = await this.#options.authenticate?.(request);
356
- if (this.#options.authenticate !== void 0) return authenticated ?? null;
357
- return requestedUserId ?? null;
439
+ async #body(request) {
440
+ try {
441
+ const value = await request.json();
442
+ return isObject(value) ? value : null;
443
+ } catch {
444
+ return null;
445
+ }
358
446
  }
359
- async #getUserClaimCount(userId, rewardId) {
360
- const state = await this.#persistence.getUserState(this.#config.id, userId);
361
- return state?.rewards?.find((item) => item.rewardId === rewardId)?.userRedemptionCount ?? 0;
447
+ async #user(request, requested) {
448
+ if (this.#options.authenticate !== void 0)
449
+ return await this.#options.authenticate(request) ?? null;
450
+ return requested ?? null;
362
451
  }
363
- async #rememberClaim(key, result, request, userId) {
452
+ async #remember(key, result, rallyId, userId, action, resourceId, idempotencyKey, timestamp) {
453
+ await this.#persistence.recordAuditLog(
454
+ audit(
455
+ rallyId,
456
+ userId,
457
+ action,
458
+ resourceId,
459
+ idempotencyKey,
460
+ result.ok ? "SUCCESS" : "REJECTED",
461
+ timestamp,
462
+ result.ok ? void 0 : result.code
463
+ )
464
+ );
364
465
  await this.#persistence.saveIdempotentResult(
466
+ rallyId,
365
467
  key,
366
468
  result,
367
469
  this.#options.idempotencyTtlMs ?? 864e5
368
470
  );
369
- await this.#persistence.recordAuditLog({
370
- id: `audit-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
371
- timestamp: request.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString(),
372
- rallyId: request.rallyId,
373
- userId,
374
- action: "CLAIM_REWARD",
375
- resourceId: request.rewardId,
376
- status: "REJECTED",
377
- idempotencyKey: request.idempotencyKey
378
- });
379
471
  return result;
380
472
  }
381
- async #remember(key, result, request, now2) {
473
+ async #rememberClaim(key, result, request, timestamp) {
382
474
  await this.#persistence.recordAuditLog(
383
- audit(request, result.ok ? "SUCCESS" : "REJECTED", now2, result.ok ? void 0 : result.code)
475
+ audit(
476
+ request.rallyId,
477
+ request.userId,
478
+ "CLAIM_REWARD",
479
+ request.rewardId,
480
+ request.idempotencyKey,
481
+ "REJECTED",
482
+ timestamp,
483
+ result.ok ? void 0 : result.code
484
+ )
384
485
  );
385
486
  await this.#persistence.saveIdempotentResult(
487
+ request.rallyId,
386
488
  key,
387
489
  result,
388
490
  this.#options.idempotencyTtlMs ?? 864e5
389
491
  );
390
492
  return result;
391
493
  }
392
- };
393
-
394
- // src/index.ts
395
- var InMemoryServerStorage = class {
396
- #states = /* @__PURE__ */ new Map();
397
- #stocks;
398
- #claims = /* @__PURE__ */ new Map();
399
- #auditLogs = [];
400
- constructor(options = {}) {
401
- this.#stocks = new Map(Object.entries(options.stocks ?? {}));
402
- }
403
- async getRewardStock(rewardId) {
404
- return this.#stocks.get(rewardId) ?? null;
405
- }
406
- async decrementRewardStock(rewardId) {
407
- const stock = this.#stocks.get(rewardId);
408
- if (stock === void 0) return true;
409
- if (stock <= 0) return false;
410
- this.#stocks.set(rewardId, stock - 1);
411
- return true;
412
- }
413
- async getUserClaims(userId, rewardId) {
414
- return this.#claims.get(`${userId}:${rewardId}`) ?? 0;
415
- }
416
- async recordAuditLog(log) {
417
- this.#auditLogs.push({ ...log });
418
- }
419
- async saveUserState(userId, state) {
420
- this.#states.set(userId, cloneUserState(state));
421
- }
422
- async getUserState(userId) {
423
- const state = this.#states.get(userId);
424
- return state === void 0 ? null : cloneUserState(state);
425
- }
426
- getAuditLogs() {
427
- return this.#auditLogs.map((log) => ({ ...log }));
428
- }
429
- recordClaim(userId, rewardId) {
430
- const key = `${userId}:${rewardId}`;
431
- this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
432
- }
433
- };
434
- function cloneUserState(state) {
435
- return {
436
- ...state,
437
- records: state.records.map((record) => ({
438
- ...record,
439
- ...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
440
- })),
441
- ...state.rewards === void 0 ? {} : { rewards: state.rewards.map((reward) => ({ ...reward })) }
442
- };
443
- }
444
- function jsonResponse(body, status = 200) {
445
- return new Response(JSON.stringify(body), {
446
- status,
447
- headers: { "content-type": "application/json; charset=utf-8" }
448
- });
449
- }
450
- function errorResponse(code, message, status) {
451
- return jsonResponse({ ok: false, error: { code, message } }, status);
452
- }
453
- function isObject(value) {
454
- return typeof value === "object" && value !== null && !Array.isArray(value);
455
- }
456
- function contextForClaim(method, proofData) {
457
- if (method === "token" || method === "qr" || method === "passcode") {
458
- return {
459
- type: "token",
460
- token: isObject(proofData) && typeof proofData.token === "string" ? proofData.token : String(proofData ?? "")
461
- };
462
- }
463
- if (method === "geo" || method === "geolocation") {
464
- const value = isObject(proofData) ? proofData : {};
465
- return {
466
- type: "geo",
467
- currentLatitude: typeof value.latitude === "number" ? value.latitude : typeof value.currentLatitude === "number" ? value.currentLatitude : Number.NaN,
468
- currentLongitude: typeof value.longitude === "number" ? value.longitude : typeof value.currentLongitude === "number" ? value.currentLongitude : Number.NaN
469
- };
470
- }
471
- return { type: "instant" };
472
- }
473
- function now() {
474
- return (/* @__PURE__ */ new Date()).toISOString();
475
- }
476
- function hasText(value) {
477
- return typeof value === "string" && value.trim() !== "";
478
- }
479
- function safeProofData(value) {
480
- return isObject(value) && typeof value.token === "string" ? { type: "token" } : value;
481
- }
482
- function proofFromContext(context) {
483
- if (context?.type === "token") return { token: context.token };
484
- if (context?.type === "geo") {
485
- return { latitude: context.currentLatitude, longitude: context.currentLongitude };
486
- }
487
- return void 0;
488
- }
489
- function id(prefix) {
490
- return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
491
- }
492
- var StampRallyServer = class {
493
- #config;
494
- #storage;
495
- #idempotent = /* @__PURE__ */ new Map();
496
- #claims = /* @__PURE__ */ new Map();
497
- #queue = Promise.resolve();
498
- constructor(config, storage) {
499
- this.#config = config;
500
- this.#storage = storage;
501
- }
502
- handle(request) {
503
- const path = new URL(request.url).pathname;
504
- if (request.method !== "POST")
505
- return Promise.resolve(errorResponse("METHOD_NOT_ALLOWED", "POST is required.", 405));
506
- if (path.endsWith("/check-in")) return this.verifyCheckIn(request);
507
- if (path.endsWith("/claim-reward")) return this.claimReward(request);
508
- if (path.endsWith("/sync")) return this.syncProgress(request);
509
- return Promise.resolve(errorResponse("NOT_FOUND", "Route not found.", 404));
510
- }
511
- verifyCheckIn(request) {
512
- return this.#enqueue(() => this.#verifyCheckIn(request));
513
- }
514
- claimReward(request) {
515
- return this.#enqueue(() => this.#claimReward(request));
516
- }
517
- syncProgress(request) {
518
- return this.#enqueue(() => this.#syncProgress(request));
519
- }
520
- #enqueue(operation) {
521
- const next = this.#queue.then(operation, operation);
522
- this.#queue = next.then(
523
- () => void 0,
524
- () => void 0
525
- );
526
- return next;
527
- }
528
- async #authenticate(request) {
529
- return this.#config.authenticate === void 0 ? null : this.#config.authenticate(request);
530
- }
531
- async #parse(request) {
532
- try {
533
- const value = await request.json();
534
- return isObject(value) ? value : null;
535
- } catch {
536
- return null;
537
- }
538
- }
539
- async #verifyCheckIn(request) {
540
- const authenticatedUser = await this.#authenticate(request);
541
- const body = await this.#parse(request);
542
- const userId = body?.userId ?? authenticatedUser;
543
- if (userId === null || userId === void 0 || body === null || !hasText(userId) || !hasText(body.spotId) || !hasText(body.claimMethod) || !hasText(body.idempotencyKey)) {
544
- return errorResponse(
545
- "INVALID_REQUEST",
546
- "userId, spotId, and idempotencyKey are required.",
547
- 400
548
- );
549
- }
550
- if (authenticatedUser !== null && authenticatedUser !== userId)
551
- return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
552
- const key = `check-in:${userId}:${body.idempotencyKey}`;
553
- const previous = this.#idempotent.get(key);
554
- if (previous !== void 0) return previous.clone();
555
- const timestamp = now();
556
- const current = await this.#storage.getUserState(userId) ?? emptyState(this.#config, timestamp);
557
- const result = processStamp(
558
- current,
559
- this.#config,
560
- body.spotId,
561
- contextForClaim(body.claimMethod, body.proofData),
562
- timestamp
563
- );
564
- if (!result.ok) {
565
- await this.#audit(
566
- userId,
567
- "CHECK_IN",
568
- body.spotId,
569
- body.idempotencyKey,
570
- "REJECTED",
571
- safeProofData(body.proofData),
572
- result.error
573
- );
574
- return this.#remember(key, jsonResponse({ ok: false, error: result.error }, 422));
575
- }
576
- const ttl = this.#config.proofTtlSeconds ?? 3600;
577
- const token = await createSecureToken(
578
- {
579
- type: "stamp_claim",
580
- rallyId: this.#config.id,
581
- userId,
582
- spotId: body.spotId,
583
- acquiredAt: timestamp,
584
- exp: Math.floor(Date.now() / 1e3) + ttl
585
- },
586
- this.#config.secretKey,
587
- { encrypt: true }
588
- );
589
- await this.#storage.saveUserState(userId, result.value.nextState);
590
- await this.#audit(
591
- userId,
592
- "CHECK_IN",
593
- body.spotId,
594
- body.idempotencyKey,
595
- "SUCCESS",
596
- safeProofData(body.proofData)
597
- );
598
- return this.#remember(
599
- key,
600
- jsonResponse({
601
- ok: true,
602
- state: result.value.nextState,
603
- proof: {
604
- token,
605
- rallyId: this.#config.id,
606
- userId,
607
- spotId: body.spotId,
608
- acquiredAt: timestamp
609
- }
610
- })
611
- );
612
- }
613
- async #claimReward(request) {
614
- const authenticatedUser = await this.#authenticate(request);
615
- const body = await this.#parse(request);
616
- const userId = body?.userId ?? authenticatedUser;
617
- if (userId === null || userId === void 0 || body === null || !hasText(userId) || !hasText(body.rewardId) || !hasText(body.idempotencyKey))
618
- return errorResponse(
619
- "INVALID_REQUEST",
620
- "userId, rewardId, and idempotencyKey are required.",
621
- 400
622
- );
623
- if (authenticatedUser !== null && authenticatedUser !== userId)
624
- return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
625
- const key = `claim-reward:${userId}:${body.rewardId}:${body.idempotencyKey}`;
626
- const previous = this.#idempotent.get(key);
627
- if (previous !== void 0) return previous.clone();
628
- const reward = this.#config.rewards?.find((item) => item.id === body.rewardId);
629
- if (reward === void 0) {
630
- await this.#audit(userId, "CLAIM_REWARD", body.rewardId, body.idempotencyKey, "REJECTED");
631
- return this.#remember(key, errorResponse("REWARD_NOT_FOUND", "Reward was not found.", 404));
632
- }
633
- const timestamp = now();
634
- const current = await this.#storage.getUserState(userId) ?? emptyState(this.#config, timestamp);
635
- const rewardState = current.rewards?.find((item) => item.rewardId === reward.id);
636
- if (rewardState === void 0) {
637
- await this.#audit(userId, "CLAIM_REWARD", reward.id, body.idempotencyKey, "REJECTED");
638
- return this.#remember(key, errorResponse("NOT_AVAILABLE", "Reward is not available.", 422));
639
- }
640
- const userClaims = Math.max(
641
- await this.#storage.getUserClaims(userId, reward.id),
642
- this.#claims.get(`${userId}:${reward.id}`) ?? 0
643
- );
644
- const stock = await this.#storage.getRewardStock(reward.id);
645
- const userLimit = reward.userClaimLimit ?? reward.limitPerUser;
646
- const canReclaimServerReward = reward.redemptionMethod === "server_claim" && (userLimit === void 0 || userClaims < userLimit) && (stock === null || stock > 0);
647
- const claimableState = canReclaimServerReward && rewardState.status === "CONSUMED" ? { ...rewardState, status: "AVAILABLE" } : rewardState;
648
- const local = consumeReward({
649
- reward,
650
- currentState: claimableState,
651
- now: timestamp,
652
- ...body.staffPasscode === void 0 ? {} : { inputPasscode: body.staffPasscode },
653
- ...body.staffId === void 0 ? {} : { staffId: body.staffId },
654
- userId,
655
- userRedemptionCount: userClaims
656
- });
657
- if (!local.ok)
658
- return this.#remember(
659
- key,
660
- await this.#rewardError(userId, reward.id, body.idempotencyKey, local.error)
661
- );
662
- if (stock !== null && !await this.#storage.decrementRewardStock(reward.id))
663
- return this.#remember(
664
- key,
665
- await this.#rewardError(userId, reward.id, body.idempotencyKey, {
666
- code: "OUT_OF_STOCK",
667
- rewardId: reward.id
668
- })
669
- );
670
- const nextState = {
671
- ...current,
672
- rewards: (current.rewards ?? []).map(
673
- (item) => item.rewardId === reward.id ? local.value : item
674
- ),
675
- updatedAt: timestamp
676
- };
677
- await this.#storage.saveUserState(userId, nextState);
678
- const claimKey = `${userId}:${reward.id}`;
679
- this.#claims.set(claimKey, userClaims + 1);
680
- if (this.#storage instanceof InMemoryServerStorage)
681
- this.#storage.recordClaim(userId, reward.id);
682
- await this.#audit(userId, "CLAIM_REWARD", reward.id, body.idempotencyKey, "SUCCESS", {
683
- staffId: body.staffId
684
- });
685
- return this.#remember(
686
- key,
687
- jsonResponse({
688
- ok: true,
689
- state: nextState,
690
- claimTicketNumber: local.value.claimTicketNumber
691
- })
692
- );
693
- }
694
- async #rewardError(userId, rewardId, key, error) {
695
- await this.#audit(userId, "CLAIM_REWARD", rewardId, key, "REJECTED", void 0, error);
696
- return jsonResponse({ ok: false, error }, 422);
697
- }
698
- async #syncProgress(request) {
699
- const authenticatedUser = await this.#authenticate(request);
700
- const body = await this.#parse(request);
701
- if (body === null || body.userId === void 0)
702
- return errorResponse("INVALID_REQUEST", "userId is required.", 400);
703
- if (authenticatedUser !== null && authenticatedUser !== body.userId)
704
- return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
705
- const queue = body.queue ?? body.operations ?? [];
706
- for (const operation of queue) {
707
- const userId = operation.userId ?? body.userId;
708
- const spotId = operation.spotId ?? operation.stampId;
709
- const claimMethod = operation.claimMethod ?? operation.context?.type;
710
- if (!hasText(userId) || !hasText(spotId) || !hasText(claimMethod)) continue;
711
- const synthetic = new Request(new URL("/api/check-in", request.url), {
712
- method: "POST",
713
- body: JSON.stringify({
714
- userId,
715
- spotId,
716
- claimMethod,
717
- proofData: operation.proofData ?? proofFromContext(operation.context),
718
- idempotencyKey: operation.idempotencyKey
719
- }),
720
- headers: { "content-type": "application/json" }
721
- });
722
- await this.#verifyCheckIn(synthetic);
723
- }
724
- const timestamp = now();
725
- const state = await this.#storage.getUserState(body.userId) ?? emptyState(this.#config, timestamp);
726
- await this.#storage.saveUserState(body.userId, state);
727
- return jsonResponse({ ok: true, state, accepted: queue.length });
728
- }
729
- async #audit(userId, action, resourceId, idempotencyKey, status, proofData, error) {
730
- await this.#storage.recordAuditLog({
731
- id: id("audit"),
732
- timestamp: now(),
733
- rallyId: this.#config.id,
734
- userId,
735
- action,
736
- resourceId,
737
- status,
738
- idempotencyKey,
739
- ...proofData === void 0 ? {} : { proofData },
740
- ...error === void 0 ? {} : {
741
- metadata: {
742
- errorCode: isObject(error) && typeof error.code === "string" ? error.code : "UNKNOWN"
743
- }
744
- }
745
- });
746
- }
747
- #remember(key, response) {
748
- this.#idempotent.set(key, response.clone());
749
- return response;
494
+ async #restoreRewardStock(rallyId, rewardId) {
495
+ if (this.#persistence.restoreRewardStock !== void 0)
496
+ await this.#persistence.restoreRewardStock(rallyId, rewardId);
750
497
  }
751
498
  };
752
- function emptyState(config, timestamp) {
753
- return {
754
- rallyId: config.id,
755
- records: [],
756
- ...config.rewards === void 0 ? {} : { rewards: reconcileRewardStates(config.rewards, [], 0, timestamp) },
757
- updatedAt: timestamp
758
- };
759
- }
760
499
 
761
- export { InMemoryServerPersistenceAdapter, InMemoryServerStorage, StampRallyServer, UniversalRallyServer };
500
+ export { InMemoryServerPersistenceAdapter, StampRallyServer };
762
501
  //# sourceMappingURL=index.js.map
763
502
  //# sourceMappingURL=index.js.map