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