@stamprally/server 0.7.0 → 0.9.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,58 @@
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
+ #claims = /* @__PURE__ */ new Map();
13
12
  #auditLogs = [];
14
13
  constructor(options = {}) {
15
14
  this.#stocks = new Map(Object.entries(options.stocks ?? {}));
16
15
  }
17
- async acquireLock(lockKey, ttlMs) {
16
+ async acquireLock(key, ttlMs) {
18
17
  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));
18
+ const until = this.#locks.get(key);
19
+ if (until !== void 0 && until > now2) return false;
20
+ this.#locks.set(key, now2 + Math.max(1, ttlMs));
22
21
  return true;
23
22
  }
24
- async releaseLock(lockKey) {
25
- this.#locks.delete(lockKey);
23
+ async releaseLock(key) {
24
+ this.#locks.delete(key);
26
25
  }
27
26
  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);
27
+ const current = this.#stocks.get(rewardId);
28
+ if (current === void 0) return { success: true, remainingStock: Number.POSITIVE_INFINITY };
29
+ if (current <= 0) return { success: false, remainingStock: 0 };
30
+ this.#stocks.set(rewardId, current - 1);
31
+ return { success: true, remainingStock: current - 1 };
32
+ }
33
+ async incrementRewardStock(rewardId) {
34
+ const current = this.#stocks.get(rewardId);
35
+ if (current !== void 0) this.#stocks.set(rewardId, current + 1);
36
+ }
37
+ async getIdempotentResult(key) {
38
+ const value = this.#idempotent.get(key);
39
+ if (value === void 0 || value.expiresAt <= Date.now()) {
40
+ this.#idempotent.delete(key);
40
41
  return null;
41
42
  }
42
- return structuredClone(entry.value);
43
+ return structuredClone(value.value);
43
44
  }
44
- async saveIdempotentResult(idempotencyKey, result, ttlMs) {
45
- this.#idempotent.set(idempotencyKey, {
45
+ async saveIdempotentResult(key, result, ttlMs) {
46
+ this.#idempotent.set(key, {
46
47
  value: structuredClone(result),
47
48
  expiresAt: Date.now() + Math.max(1, ttlMs)
48
49
  });
49
50
  }
51
+ async getUserClaimCount(rallyId, userId, rewardId) {
52
+ return this.#claims.get(`${rallyId}:${userId}:${rewardId}`) ?? 0;
53
+ }
50
54
  async getUserState(rallyId, userId) {
51
- const state = this.#states.get(`${rallyId}:${userId}`);
52
- return state === void 0 ? null : structuredClone(state);
55
+ const value = this.#states.get(`${rallyId}:${userId}`);
56
+ return value === void 0 ? null : structuredClone(value);
53
57
  }
54
58
  async saveUserState(rallyId, userId, state) {
55
59
  this.#states.set(`${rallyId}:${userId}`, structuredClone(state));
@@ -60,55 +64,65 @@ var InMemoryServerPersistenceAdapter = class {
60
64
  getAuditLogs() {
61
65
  return structuredClone(this.#auditLogs);
62
66
  }
67
+ recordClaim(rallyId, userId, rewardId) {
68
+ const key = `${rallyId}:${userId}:${rewardId}`;
69
+ this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
70
+ }
63
71
  };
64
-
65
- // src/universalServer.ts
66
- function distanceMeters(aLat, aLon, bLat, bLon) {
67
- const radians = (degrees) => degrees * Math.PI / 180;
68
- const dLat = radians(bLat - aLat);
69
- const dLon = radians(bLon - aLon);
70
- const latA = radians(aLat);
71
- const latB = radians(bLat);
72
- const value = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLon / 2) ** 2;
73
- return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
72
+ function json(body, status = 200) {
73
+ return new Response(JSON.stringify(body), {
74
+ status,
75
+ headers: { "content-type": "application/json; charset=utf-8" }
76
+ });
74
77
  }
75
- function initialState(config, now2) {
76
- return { rallyId: config.id, records: [], updatedAt: now2 };
78
+ function isObject(value) {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
80
  }
78
- function matchesCondition(condition, context, customValidators) {
79
- switch (condition.type) {
80
- case "qr":
81
- return context.type === "qr" && context.token === condition.secretToken;
82
- case "passcode":
83
- return context.type === "passcode" && (condition.caseSensitive === false ? context.code.toLocaleLowerCase() === condition.code.toLocaleLowerCase() : context.code === condition.code);
84
- case "gps":
85
- return context.type === "gps" && distanceMeters(
86
- condition.latitude,
87
- condition.longitude,
88
- context.latitude,
89
- context.longitude
90
- ) <= condition.radiusMeters;
91
- case "custom":
92
- return customValidators?.[condition.validatorName]?.(
93
- context.type === "custom" ? context.value : void 0,
94
- condition
95
- ) ?? false;
96
- }
81
+ function requestId(prefix) {
82
+ return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
97
83
  }
98
- function audit(request, status, now2, errorCode) {
84
+ function now(options, requested) {
85
+ return requested ?? options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
86
+ }
87
+ function initialState(config, userId, timestamp) {
99
88
  return {
100
- id: `audit-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`,
101
- timestamp: now2,
102
- rallyId: request.rallyId,
103
- userId: request.userId,
104
- action: "CHECK_IN",
105
- resourceId: request.spotId,
89
+ rallyId: config.id,
90
+ userId,
91
+ records: [],
92
+ rewards: core.reconcileRewardStates(config.rewards, [], 0, timestamp),
93
+ updatedAt: timestamp
94
+ };
95
+ }
96
+ function getProof(context) {
97
+ 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;
98
+ }
99
+ async function evaluate(condition, context, validator, base) {
100
+ if (condition.type !== "custom") return core.evaluateConditionDetailed(condition, context).ok;
101
+ if (validator === void 0) return false;
102
+ const validationContext = {
103
+ rallyId: base.rallyId,
104
+ spotId: base.spotId,
105
+ proofData: getProof(context),
106
+ condition,
107
+ userState: base.state
108
+ };
109
+ const result = typeof validator === "function" ? await validator(validationContext) : await validator.validate(validationContext);
110
+ return result === true || typeof result === "object" && result.valid;
111
+ }
112
+ function audit(rallyId, userId, action, resourceId, key, status, timestamp, code) {
113
+ return {
114
+ id: requestId("audit"),
115
+ timestamp,
116
+ rallyId,
117
+ userId,
118
+ action,
119
+ resourceId,
106
120
  status,
107
- idempotencyKey: request.idempotencyKey,
108
- ...errorCode === void 0 ? {} : { metadata: { errorCode } }
121
+ idempotencyKey: key,
122
+ ...code === void 0 ? {} : { metadata: { errorCode: code } }
109
123
  };
110
124
  }
111
- var UniversalRallyServer = class {
125
+ var StampRallyServer = class {
112
126
  #config;
113
127
  #persistence;
114
128
  #options;
@@ -117,34 +131,87 @@ var UniversalRallyServer = class {
117
131
  this.#persistence = persistence;
118
132
  this.#options = options;
119
133
  }
134
+ async handle(request) {
135
+ if (request.method !== "POST")
136
+ return json({ ok: false, code: "METHOD_NOT_ALLOWED", message: "POST is required." }, 405);
137
+ const path = new URL(request.url).pathname;
138
+ if (path.endsWith("/check-in")) return this.handleCheckIn(request);
139
+ if (path.endsWith("/claim-reward")) return this.handleClaimReward(request);
140
+ if (path.endsWith("/sync")) return this.handleSync(request);
141
+ return json({ ok: false, code: "NOT_FOUND", message: "Route not found." }, 404);
142
+ }
143
+ async handleCheckIn(request) {
144
+ const body = await this.#body(request);
145
+ const userId = await this.#user(request, body?.userId);
146
+ if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "" || body.context === void 0)
147
+ return json(
148
+ {
149
+ ok: false,
150
+ code: "INVALID_REQUEST",
151
+ message: "rallyId, spotId, context, and idempotencyKey are required."
152
+ },
153
+ 400
154
+ );
155
+ const result = await this.checkIn({ ...body, userId });
156
+ return json(result, result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422);
157
+ }
158
+ async handleClaimReward(request) {
159
+ const body = await this.#body(request);
160
+ const userId = await this.#user(request, body?.userId);
161
+ if (body === null || userId === null || body.rallyId !== this.#config.id || body.rewardId === "" || body.idempotencyKey === "")
162
+ return json(
163
+ {
164
+ ok: false,
165
+ code: "INVALID_REQUEST",
166
+ message: "rallyId, rewardId, and idempotencyKey are required."
167
+ },
168
+ 400
169
+ );
170
+ const result = await this.claimReward({ ...body, userId });
171
+ return json(result, result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422);
172
+ }
173
+ async handleSync(request) {
174
+ const body = await this.#body(request);
175
+ const userId = await this.#user(request, body?.userId);
176
+ if (body === null || userId === null || body.rallyId !== this.#config.id)
177
+ return json({ ok: false, code: "INVALID_REQUEST", message: "rallyId is required." }, 400);
178
+ return json({ ok: true, state: await this.sync(body.rallyId, userId) });
179
+ }
120
180
  async checkIn(request) {
121
181
  const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
122
182
  const previous = await this.#persistence.getIdempotentResult(key);
123
183
  if (previous !== null) return previous;
124
184
  const lockKey = `state:${request.rallyId}:${request.userId}`;
125
- const locked = await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3);
126
- if (!locked)
185
+ if (!await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3))
127
186
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
128
- const now2 = request.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
187
+ const timestamp = now(this.#options, request.now);
129
188
  try {
130
189
  const spot = this.#config.spots.find((item) => item.id === request.spotId);
131
190
  if (spot === void 0)
132
191
  return this.#remember(
133
192
  key,
134
193
  { ok: false, code: "SPOT_NOT_FOUND", message: "Spot was not found." },
135
- request,
136
- now2
194
+ request.rallyId,
195
+ request.userId,
196
+ "CHECK_IN",
197
+ request.spotId,
198
+ request.idempotencyKey,
199
+ timestamp
137
200
  );
138
- const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, now2);
201
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
139
202
  if (current.records.some((record) => record.stampId === request.spotId))
140
203
  return this.#remember(
141
204
  key,
142
- { ok: false, code: "ALREADY_CLAIMED", message: "Spot was already claimed." },
143
- request,
144
- now2
205
+ { ok: false, code: "STAMP_ALREADY_ACQUIRED", message: "Spot was already claimed." },
206
+ request.rallyId,
207
+ request.userId,
208
+ "CHECK_IN",
209
+ request.spotId,
210
+ request.idempotencyKey,
211
+ timestamp
145
212
  );
146
213
  const acquired = new Set(current.records.map((record) => record.stampId));
147
- if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
214
+ if (spot.prerequisites?.some((id) => !acquired.has(id)))
148
215
  return this.#remember(
149
216
  key,
150
217
  {
@@ -152,185 +219,158 @@ var UniversalRallyServer = class {
152
219
  code: "PREREQUISITES_NOT_MET",
153
220
  message: "Prerequisite spots are not complete."
154
221
  },
222
+ request.rallyId,
223
+ request.userId,
224
+ "CHECK_IN",
225
+ request.spotId,
226
+ request.idempotencyKey,
227
+ timestamp
228
+ );
229
+ for (const condition of spot.conditions)
230
+ if (!await evaluate(
231
+ condition,
232
+ request.context,
233
+ condition.type === "custom" ? this.#options.customValidators?.[condition.validatorName] : void 0,
234
+ { rallyId: request.rallyId, spotId: request.spotId, state: current }
235
+ ))
236
+ return this.#remember(
237
+ key,
238
+ { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
239
+ request.rallyId,
240
+ request.userId,
241
+ "CHECK_IN",
242
+ request.spotId,
243
+ request.idempotencyKey,
244
+ timestamp
245
+ );
246
+ const next = {
247
+ ...current,
248
+ records: [...current.records, { stampId: request.spotId, acquiredAt: timestamp }],
249
+ rewards: core.reconcileRewardStates(
250
+ this.#config.rewards,
251
+ current.rewards,
252
+ current.records.length + 1,
253
+ timestamp
254
+ ),
255
+ updatedAt: timestamp
256
+ };
257
+ await this.#persistence.saveUserState(request.rallyId, request.userId, next);
258
+ return this.#remember(
259
+ key,
260
+ { ok: true, state: next },
261
+ request.rallyId,
262
+ request.userId,
263
+ "CHECK_IN",
264
+ request.spotId,
265
+ request.idempotencyKey,
266
+ timestamp
267
+ );
268
+ } finally {
269
+ await this.#persistence.releaseLock(lockKey);
270
+ }
271
+ }
272
+ async claimReward(request) {
273
+ const key = `claim:${request.rallyId}:${request.userId}:${request.rewardId}:${request.idempotencyKey}`;
274
+ const previous = await this.#persistence.getIdempotentResult(key);
275
+ if (previous !== null) return previous;
276
+ const reward = this.#config.rewards.find((item) => item.id === request.rewardId);
277
+ if (reward === void 0)
278
+ return this.#rememberClaim(
279
+ key,
280
+ { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
281
+ request,
282
+ now(this.#options, request.now)
283
+ );
284
+ const lockKey = `reward:${request.rallyId}:${reward.id}`;
285
+ if (!await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3))
286
+ return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
287
+ const timestamp = now(this.#options, request.now);
288
+ let decremented = false;
289
+ try {
290
+ const checked = await this.#persistence.getIdempotentResult(key);
291
+ if (checked !== null) return checked;
292
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
293
+ const claimCount = await this.#persistence.getUserClaimCount(
294
+ request.rallyId,
295
+ request.userId,
296
+ reward.id
297
+ );
298
+ const storedReward = current.rewards.find((item) => item.rewardId === reward.id) ?? {
299
+ rewardId: reward.id,
300
+ status: "LOCKED"
301
+ };
302
+ const currentReward = reward.redemptionMethod === "server_claim" && storedReward.status === "CONSUMED" && (reward.userClaimLimit === void 0 || claimCount < reward.userClaimLimit) ? { ...storedReward, status: "AVAILABLE" } : storedReward;
303
+ const local = core.consumeReward({
304
+ reward,
305
+ currentState: currentReward,
306
+ now: timestamp,
307
+ userRedemptionCount: claimCount,
308
+ ...request.staffPasscode === void 0 ? {} : { inputPasscode: request.staffPasscode },
309
+ ...request.staffId === void 0 ? {} : { staffId: request.staffId }
310
+ });
311
+ if (!local.ok)
312
+ return this.#rememberClaim(
313
+ key,
314
+ { ok: false, code: local.error.code, message: "Reward cannot be claimed." },
155
315
  request,
156
- now2
316
+ timestamp
157
317
  );
158
- if (!spot.conditions.every(
159
- (condition) => matchesCondition(condition, request.context, this.#options.customValidators)
160
- ))
161
- return this.#remember(
318
+ const stock = await this.#persistence.decrementRewardStock(reward.id);
319
+ if (!stock.success)
320
+ return this.#rememberClaim(
162
321
  key,
163
- { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
322
+ { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
164
323
  request,
165
- now2
324
+ timestamp
166
325
  );
167
- const state = {
326
+ decremented = true;
327
+ const next = {
168
328
  ...current,
169
- records: [...current.records, { stampId: request.spotId, acquiredAt: now2 }],
170
- updatedAt: now2
329
+ rewards: current.rewards.map((item) => item.rewardId === reward.id ? local.value : item),
330
+ updatedAt: timestamp
331
+ };
332
+ try {
333
+ await this.#persistence.saveUserState(request.rallyId, request.userId, next);
334
+ const response = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
335
+ await this.#persistence.recordAuditLog(
336
+ audit(
337
+ request.rallyId,
338
+ request.userId,
339
+ "CLAIM_REWARD",
340
+ reward.id,
341
+ request.idempotencyKey,
342
+ "SUCCESS",
343
+ timestamp
344
+ )
345
+ );
346
+ await this.#persistence.saveIdempotentResult(
347
+ key,
348
+ response,
349
+ this.#options.idempotencyTtlMs ?? 864e5
350
+ );
351
+ if (this.#persistence instanceof Object && "recordClaim" in this.#persistence && typeof this.#persistence.recordClaim === "function")
352
+ this.#persistence.recordClaim(request.rallyId, request.userId, reward.id);
353
+ return response;
354
+ } catch (error) {
355
+ await this.#persistence.incrementRewardStock(reward.id);
356
+ decremented = false;
357
+ throw error;
358
+ }
359
+ } catch (error) {
360
+ if (decremented) await this.#persistence.incrementRewardStock(reward.id);
361
+ return {
362
+ ok: false,
363
+ code: "PERSISTENCE_FAILED",
364
+ message: error instanceof Error ? error.message : "Reward claim failed."
171
365
  };
172
- await this.#persistence.saveUserState(request.rallyId, request.userId, state);
173
- await this.#persistence.recordAuditLog(audit(request, "SUCCESS", now2));
174
- return this.#remember(key, { ok: true, state }, request, now2);
175
366
  } finally {
176
367
  await this.#persistence.releaseLock(lockKey);
177
368
  }
178
369
  }
179
370
  async sync(rallyId, userId) {
180
- const now2 = this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
181
- return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, now2);
371
+ return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
182
372
  }
183
- async #remember(key, result, request, now2) {
184
- await this.#persistence.recordAuditLog(
185
- audit(request, "REJECTED", now2, result.ok ? void 0 : result.code)
186
- );
187
- await this.#persistence.saveIdempotentResult(
188
- key,
189
- result,
190
- this.#options.idempotencyTtlMs ?? 864e5
191
- );
192
- return result;
193
- }
194
- };
195
-
196
- // src/index.ts
197
- var InMemoryServerStorage = class {
198
- #states = /* @__PURE__ */ new Map();
199
- #stocks;
200
- #claims = /* @__PURE__ */ new Map();
201
- #auditLogs = [];
202
- constructor(options = {}) {
203
- this.#stocks = new Map(Object.entries(options.stocks ?? {}));
204
- }
205
- async getRewardStock(rewardId) {
206
- return this.#stocks.get(rewardId) ?? null;
207
- }
208
- async decrementRewardStock(rewardId) {
209
- const stock = this.#stocks.get(rewardId);
210
- if (stock === void 0) return true;
211
- if (stock <= 0) return false;
212
- this.#stocks.set(rewardId, stock - 1);
213
- return true;
214
- }
215
- async getUserClaims(userId, rewardId) {
216
- return this.#claims.get(`${userId}:${rewardId}`) ?? 0;
217
- }
218
- async recordAuditLog(log) {
219
- this.#auditLogs.push({ ...log });
220
- }
221
- async saveUserState(userId, state) {
222
- this.#states.set(userId, cloneUserState(state));
223
- }
224
- async getUserState(userId) {
225
- const state = this.#states.get(userId);
226
- return state === void 0 ? null : cloneUserState(state);
227
- }
228
- getAuditLogs() {
229
- return this.#auditLogs.map((log) => ({ ...log }));
230
- }
231
- recordClaim(userId, rewardId) {
232
- const key = `${userId}:${rewardId}`;
233
- this.#claims.set(key, (this.#claims.get(key) ?? 0) + 1);
234
- }
235
- };
236
- function cloneUserState(state) {
237
- return {
238
- ...state,
239
- records: state.records.map((record) => ({
240
- ...record,
241
- ...record.metadata === void 0 ? {} : { metadata: { ...record.metadata } }
242
- })),
243
- ...state.rewards === void 0 ? {} : { rewards: state.rewards.map((reward) => ({ ...reward })) }
244
- };
245
- }
246
- function jsonResponse(body, status = 200) {
247
- return new Response(JSON.stringify(body), {
248
- status,
249
- headers: { "content-type": "application/json; charset=utf-8" }
250
- });
251
- }
252
- function errorResponse(code, message, status) {
253
- return jsonResponse({ ok: false, error: { code, message } }, status);
254
- }
255
- function isObject(value) {
256
- return typeof value === "object" && value !== null && !Array.isArray(value);
257
- }
258
- function contextForClaim(method, proofData) {
259
- if (method === "token" || method === "qr" || method === "passcode") {
260
- return {
261
- type: "token",
262
- token: isObject(proofData) && typeof proofData.token === "string" ? proofData.token : String(proofData ?? "")
263
- };
264
- }
265
- if (method === "geo" || method === "geolocation") {
266
- const value = isObject(proofData) ? proofData : {};
267
- return {
268
- type: "geo",
269
- currentLatitude: typeof value.latitude === "number" ? value.latitude : typeof value.currentLatitude === "number" ? value.currentLatitude : Number.NaN,
270
- currentLongitude: typeof value.longitude === "number" ? value.longitude : typeof value.currentLongitude === "number" ? value.currentLongitude : Number.NaN
271
- };
272
- }
273
- return { type: "instant" };
274
- }
275
- function now() {
276
- return (/* @__PURE__ */ new Date()).toISOString();
277
- }
278
- function hasText(value) {
279
- return typeof value === "string" && value.trim() !== "";
280
- }
281
- function safeProofData(value) {
282
- return isObject(value) && typeof value.token === "string" ? { type: "token" } : value;
283
- }
284
- function proofFromContext(context) {
285
- if (context?.type === "token") return { token: context.token };
286
- if (context?.type === "geo") {
287
- return { latitude: context.currentLatitude, longitude: context.currentLongitude };
288
- }
289
- return void 0;
290
- }
291
- function id(prefix) {
292
- return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
293
- }
294
- var StampRallyServer = class {
295
- #config;
296
- #storage;
297
- #idempotent = /* @__PURE__ */ new Map();
298
- #claims = /* @__PURE__ */ new Map();
299
- #queue = Promise.resolve();
300
- constructor(config, storage) {
301
- this.#config = config;
302
- this.#storage = storage;
303
- }
304
- handle(request) {
305
- const path = new URL(request.url).pathname;
306
- if (request.method !== "POST")
307
- return Promise.resolve(errorResponse("METHOD_NOT_ALLOWED", "POST is required.", 405));
308
- if (path.endsWith("/check-in")) return this.verifyCheckIn(request);
309
- if (path.endsWith("/claim-reward")) return this.claimReward(request);
310
- if (path.endsWith("/sync")) return this.syncProgress(request);
311
- return Promise.resolve(errorResponse("NOT_FOUND", "Route not found.", 404));
312
- }
313
- verifyCheckIn(request) {
314
- return this.#enqueue(() => this.#verifyCheckIn(request));
315
- }
316
- claimReward(request) {
317
- return this.#enqueue(() => this.#claimReward(request));
318
- }
319
- syncProgress(request) {
320
- return this.#enqueue(() => this.#syncProgress(request));
321
- }
322
- #enqueue(operation) {
323
- const next = this.#queue.then(operation, operation);
324
- this.#queue = next.then(
325
- () => void 0,
326
- () => void 0
327
- );
328
- return next;
329
- }
330
- async #authenticate(request) {
331
- return this.#config.authenticate === void 0 ? null : this.#config.authenticate(request);
332
- }
333
- async #parse(request) {
373
+ async #body(request) {
334
374
  try {
335
375
  const value = await request.json();
336
376
  return isObject(value) ? value : null;
@@ -338,231 +378,54 @@ var StampRallyServer = class {
338
378
  return null;
339
379
  }
340
380
  }
341
- async #verifyCheckIn(request) {
342
- const authenticatedUser = await this.#authenticate(request);
343
- const body = await this.#parse(request);
344
- const userId = body?.userId ?? authenticatedUser;
345
- if (userId === null || userId === void 0 || body === null || !hasText(userId) || !hasText(body.spotId) || !hasText(body.claimMethod) || !hasText(body.idempotencyKey)) {
346
- return errorResponse(
347
- "INVALID_REQUEST",
348
- "userId, spotId, and idempotencyKey are required.",
349
- 400
350
- );
351
- }
352
- if (authenticatedUser !== null && authenticatedUser !== userId)
353
- return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
354
- const key = `check-in:${userId}:${body.idempotencyKey}`;
355
- const previous = this.#idempotent.get(key);
356
- if (previous !== void 0) return previous.clone();
357
- const timestamp = now();
358
- const current = await this.#storage.getUserState(userId) ?? emptyState(this.#config, timestamp);
359
- const result = core.processStamp(
360
- current,
361
- this.#config,
362
- body.spotId,
363
- contextForClaim(body.claimMethod, body.proofData),
364
- timestamp
365
- );
366
- if (!result.ok) {
367
- await this.#audit(
368
- userId,
369
- "CHECK_IN",
370
- body.spotId,
371
- body.idempotencyKey,
372
- "REJECTED",
373
- safeProofData(body.proofData),
374
- result.error
375
- );
376
- return this.#remember(key, jsonResponse({ ok: false, error: result.error }, 422));
377
- }
378
- const ttl = this.#config.proofTtlSeconds ?? 3600;
379
- const token = await core.createSecureToken(
380
- {
381
- type: "stamp_claim",
382
- rallyId: this.#config.id,
381
+ async #user(request, requested) {
382
+ if (this.#options.authenticate !== void 0)
383
+ return await this.#options.authenticate(request) ?? null;
384
+ return requested ?? null;
385
+ }
386
+ async #remember(key, result, rallyId, userId, action, resourceId, idempotencyKey, timestamp) {
387
+ await this.#persistence.recordAuditLog(
388
+ audit(
389
+ rallyId,
383
390
  userId,
384
- spotId: body.spotId,
385
- acquiredAt: timestamp,
386
- exp: Math.floor(Date.now() / 1e3) + ttl
387
- },
388
- this.#config.secretKey,
389
- { encrypt: true }
390
- );
391
- await this.#storage.saveUserState(userId, result.value.nextState);
392
- await this.#audit(
393
- userId,
394
- "CHECK_IN",
395
- body.spotId,
396
- body.idempotencyKey,
397
- "SUCCESS",
398
- safeProofData(body.proofData)
391
+ action,
392
+ resourceId,
393
+ idempotencyKey,
394
+ result.ok ? "SUCCESS" : "REJECTED",
395
+ timestamp,
396
+ result.ok ? void 0 : result.code
397
+ )
399
398
  );
400
- return this.#remember(
399
+ await this.#persistence.saveIdempotentResult(
401
400
  key,
402
- jsonResponse({
403
- ok: true,
404
- state: result.value.nextState,
405
- proof: {
406
- token,
407
- rallyId: this.#config.id,
408
- userId,
409
- spotId: body.spotId,
410
- acquiredAt: timestamp
411
- }
412
- })
401
+ result,
402
+ this.#options.idempotencyTtlMs ?? 864e5
413
403
  );
404
+ return result;
414
405
  }
415
- async #claimReward(request) {
416
- const authenticatedUser = await this.#authenticate(request);
417
- const body = await this.#parse(request);
418
- const userId = body?.userId ?? authenticatedUser;
419
- if (userId === null || userId === void 0 || body === null || !hasText(userId) || !hasText(body.rewardId) || !hasText(body.idempotencyKey))
420
- return errorResponse(
421
- "INVALID_REQUEST",
422
- "userId, rewardId, and idempotencyKey are required.",
423
- 400
424
- );
425
- if (authenticatedUser !== null && authenticatedUser !== userId)
426
- return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
427
- const key = `claim-reward:${userId}:${body.rewardId}:${body.idempotencyKey}`;
428
- const previous = this.#idempotent.get(key);
429
- if (previous !== void 0) return previous.clone();
430
- const reward = this.#config.rewards?.find((item) => item.id === body.rewardId);
431
- if (reward === void 0) {
432
- await this.#audit(userId, "CLAIM_REWARD", body.rewardId, body.idempotencyKey, "REJECTED");
433
- return this.#remember(key, errorResponse("REWARD_NOT_FOUND", "Reward was not found.", 404));
434
- }
435
- const timestamp = now();
436
- const current = await this.#storage.getUserState(userId) ?? emptyState(this.#config, timestamp);
437
- const rewardState = current.rewards?.find((item) => item.rewardId === reward.id);
438
- if (rewardState === void 0) {
439
- await this.#audit(userId, "CLAIM_REWARD", reward.id, body.idempotencyKey, "REJECTED");
440
- return this.#remember(key, errorResponse("NOT_AVAILABLE", "Reward is not available.", 422));
441
- }
442
- const userClaims = Math.max(
443
- await this.#storage.getUserClaims(userId, reward.id),
444
- this.#claims.get(`${userId}:${reward.id}`) ?? 0
406
+ async #rememberClaim(key, result, request, timestamp) {
407
+ await this.#persistence.recordAuditLog(
408
+ audit(
409
+ request.rallyId,
410
+ request.userId,
411
+ "CLAIM_REWARD",
412
+ request.rewardId,
413
+ request.idempotencyKey,
414
+ "REJECTED",
415
+ timestamp,
416
+ result.ok ? void 0 : result.code
417
+ )
445
418
  );
446
- const stock = await this.#storage.getRewardStock(reward.id);
447
- const userLimit = reward.userClaimLimit ?? reward.limitPerUser;
448
- const canReclaimServerReward = reward.redemptionMethod === "server_claim" && (userLimit === void 0 || userClaims < userLimit) && (stock === null || stock > 0);
449
- const claimableState = canReclaimServerReward && rewardState.status === "CONSUMED" ? { ...rewardState, status: "AVAILABLE" } : rewardState;
450
- const local = core.consumeReward({
451
- reward,
452
- currentState: claimableState,
453
- now: timestamp,
454
- ...body.staffPasscode === void 0 ? {} : { inputPasscode: body.staffPasscode },
455
- ...body.staffId === void 0 ? {} : { staffId: body.staffId },
456
- userId,
457
- userRedemptionCount: userClaims
458
- });
459
- if (!local.ok)
460
- return this.#remember(
461
- key,
462
- await this.#rewardError(userId, reward.id, body.idempotencyKey, local.error)
463
- );
464
- if (stock !== null && !await this.#storage.decrementRewardStock(reward.id))
465
- return this.#remember(
466
- key,
467
- await this.#rewardError(userId, reward.id, body.idempotencyKey, {
468
- code: "OUT_OF_STOCK",
469
- rewardId: reward.id
470
- })
471
- );
472
- const nextState = {
473
- ...current,
474
- rewards: (current.rewards ?? []).map(
475
- (item) => item.rewardId === reward.id ? local.value : item
476
- ),
477
- updatedAt: timestamp
478
- };
479
- await this.#storage.saveUserState(userId, nextState);
480
- const claimKey = `${userId}:${reward.id}`;
481
- this.#claims.set(claimKey, userClaims + 1);
482
- if (this.#storage instanceof InMemoryServerStorage)
483
- this.#storage.recordClaim(userId, reward.id);
484
- await this.#audit(userId, "CLAIM_REWARD", reward.id, body.idempotencyKey, "SUCCESS", {
485
- staffId: body.staffId
486
- });
487
- return this.#remember(
419
+ await this.#persistence.saveIdempotentResult(
488
420
  key,
489
- jsonResponse({
490
- ok: true,
491
- state: nextState,
492
- claimTicketNumber: local.value.claimTicketNumber
493
- })
421
+ result,
422
+ this.#options.idempotencyTtlMs ?? 864e5
494
423
  );
495
- }
496
- async #rewardError(userId, rewardId, key, error) {
497
- await this.#audit(userId, "CLAIM_REWARD", rewardId, key, "REJECTED", void 0, error);
498
- return jsonResponse({ ok: false, error }, 422);
499
- }
500
- async #syncProgress(request) {
501
- const authenticatedUser = await this.#authenticate(request);
502
- const body = await this.#parse(request);
503
- if (body === null || body.userId === void 0)
504
- return errorResponse("INVALID_REQUEST", "userId is required.", 400);
505
- if (authenticatedUser !== null && authenticatedUser !== body.userId)
506
- return errorResponse("UNAUTHORIZED", "User identity does not match authentication.", 401);
507
- const queue = body.queue ?? body.operations ?? [];
508
- for (const operation of queue) {
509
- const userId = operation.userId ?? body.userId;
510
- const spotId = operation.spotId ?? operation.stampId;
511
- const claimMethod = operation.claimMethod ?? operation.context?.type;
512
- if (!hasText(userId) || !hasText(spotId) || !hasText(claimMethod)) continue;
513
- const synthetic = new Request(new URL("/api/check-in", request.url), {
514
- method: "POST",
515
- body: JSON.stringify({
516
- userId,
517
- spotId,
518
- claimMethod,
519
- proofData: operation.proofData ?? proofFromContext(operation.context),
520
- idempotencyKey: operation.idempotencyKey
521
- }),
522
- headers: { "content-type": "application/json" }
523
- });
524
- await this.#verifyCheckIn(synthetic);
525
- }
526
- const timestamp = now();
527
- const state = await this.#storage.getUserState(body.userId) ?? emptyState(this.#config, timestamp);
528
- await this.#storage.saveUserState(body.userId, state);
529
- return jsonResponse({ ok: true, state, accepted: queue.length });
530
- }
531
- async #audit(userId, action, resourceId, idempotencyKey, status, proofData, error) {
532
- await this.#storage.recordAuditLog({
533
- id: id("audit"),
534
- timestamp: now(),
535
- rallyId: this.#config.id,
536
- userId,
537
- action,
538
- resourceId,
539
- status,
540
- idempotencyKey,
541
- ...proofData === void 0 ? {} : { proofData },
542
- ...error === void 0 ? {} : {
543
- metadata: {
544
- errorCode: isObject(error) && typeof error.code === "string" ? error.code : "UNKNOWN"
545
- }
546
- }
547
- });
548
- }
549
- #remember(key, response) {
550
- this.#idempotent.set(key, response.clone());
551
- return response;
424
+ return result;
552
425
  }
553
426
  };
554
- function emptyState(config, timestamp) {
555
- return {
556
- rallyId: config.id,
557
- records: [],
558
- ...config.rewards === void 0 ? {} : { rewards: core.reconcileRewardStates(config.rewards, [], 0, timestamp) },
559
- updatedAt: timestamp
560
- };
561
- }
562
427
 
563
428
  exports.InMemoryServerPersistenceAdapter = InMemoryServerPersistenceAdapter;
564
- exports.InMemoryServerStorage = InMemoryServerStorage;
565
429
  exports.StampRallyServer = StampRallyServer;
566
- exports.UniversalRallyServer = UniversalRallyServer;
567
430
  //# sourceMappingURL=index.cjs.map
568
431
  //# sourceMappingURL=index.cjs.map