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