@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.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(
|
|
16
|
+
async acquireLock(key, ttlMs) {
|
|
18
17
|
const now2 = Date.now();
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
this.#locks.set(
|
|
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(
|
|
25
|
-
this.#locks.delete(
|
|
23
|
+
async releaseLock(key) {
|
|
24
|
+
this.#locks.delete(key);
|
|
26
25
|
}
|
|
27
26
|
async decrementRewardStock(rewardId) {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
43
|
+
return structuredClone(value.value);
|
|
43
44
|
}
|
|
44
|
-
async saveIdempotentResult(
|
|
45
|
-
this.#idempotent.set(
|
|
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
|
|
52
|
-
return
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
76
|
-
return
|
|
78
|
+
function isObject(value) {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
80
|
}
|
|
78
|
-
function
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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:
|
|
108
|
-
...
|
|
121
|
+
idempotencyKey: key,
|
|
122
|
+
...code === void 0 ? {} : { metadata: { errorCode: code } }
|
|
109
123
|
};
|
|
110
124
|
}
|
|
111
|
-
var
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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: "
|
|
143
|
-
request,
|
|
144
|
-
|
|
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((
|
|
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
|
-
|
|
316
|
+
timestamp
|
|
157
317
|
);
|
|
158
|
-
|
|
159
|
-
|
|
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: "
|
|
322
|
+
{ ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
|
|
164
323
|
request,
|
|
165
|
-
|
|
324
|
+
timestamp
|
|
166
325
|
);
|
|
167
|
-
|
|
326
|
+
decremented = true;
|
|
327
|
+
const next = {
|
|
168
328
|
...current,
|
|
169
|
-
|
|
170
|
-
updatedAt:
|
|
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
|
-
|
|
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 #
|
|
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 #
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
399
|
+
await this.#persistence.saveIdempotentResult(
|
|
401
400
|
key,
|
|
402
|
-
|
|
403
|
-
|
|
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 #
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|