@stamprally/server 0.6.0 → 0.8.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.d.cts CHANGED
@@ -1,6 +1,101 @@
1
- import { RallyConfig, SecureTokenSecretKey, StampRallyState, VerificationContext } from '@stamprally/core';
1
+ import { AdminRallyConfig as AdminRallyConfig$1, VerificationCondition, RallyConfig, SecureTokenSecretKey, StampRallyState, VerificationContext } from '@stamprally/core';
2
2
  export { StampError, StampRecord } from '@stamprally/core';
3
3
 
4
+ /** Persistence contract for implementations backed by Redis, SQL, or another RDB. */
5
+ interface ServerPersistenceAdapter {
6
+ acquireLock(lockKey: string, ttlMs: number): Promise<boolean>;
7
+ releaseLock(lockKey: string): Promise<void>;
8
+ decrementRewardStock(rewardId: string): Promise<{
9
+ success: boolean;
10
+ remainingStock: number;
11
+ }>;
12
+ getIdempotentResult<T>(idempotencyKey: string): Promise<T | null>;
13
+ saveIdempotentResult<T>(idempotencyKey: string, result: T, ttlMs: number): Promise<void>;
14
+ getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
15
+ saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
16
+ recordAuditLog(log: RallyAuditLog): Promise<void>;
17
+ }
18
+ interface InMemoryServerPersistenceOptions {
19
+ readonly stocks?: Readonly<Record<string, number>>;
20
+ }
21
+ /** Deterministic adapter for tests and small single-process deployments. */
22
+ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
23
+ #private;
24
+ constructor(options?: InMemoryServerPersistenceOptions);
25
+ acquireLock(lockKey: string, ttlMs: number): Promise<boolean>;
26
+ releaseLock(lockKey: string): Promise<void>;
27
+ decrementRewardStock(rewardId: string): Promise<{
28
+ success: boolean;
29
+ remainingStock: number;
30
+ }>;
31
+ getIdempotentResult<T>(idempotencyKey: string): Promise<T | null>;
32
+ saveIdempotentResult<T>(idempotencyKey: string, result: T, ttlMs: number): Promise<void>;
33
+ getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
34
+ saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
35
+ recordAuditLog(log: RallyAuditLog): Promise<void>;
36
+ getAuditLogs(): ReadonlyArray<RallyAuditLog>;
37
+ }
38
+
39
+ type UniversalVerificationContext = {
40
+ readonly type: "qr";
41
+ readonly token: string;
42
+ } | {
43
+ readonly type: "passcode";
44
+ readonly code: string;
45
+ } | {
46
+ readonly type: "gps";
47
+ readonly latitude: number;
48
+ readonly longitude: number;
49
+ } | {
50
+ readonly type: "custom";
51
+ readonly value: unknown;
52
+ };
53
+ interface UniversalCheckInRequest {
54
+ readonly rallyId: string;
55
+ readonly userId: string;
56
+ readonly spotId: string;
57
+ readonly context: UniversalVerificationContext;
58
+ readonly idempotencyKey: string;
59
+ readonly now?: string;
60
+ }
61
+ interface UniversalClaimRewardRequest {
62
+ readonly rallyId: string;
63
+ readonly userId: string;
64
+ readonly rewardId: string;
65
+ readonly idempotencyKey: string;
66
+ readonly staffPasscode?: string;
67
+ readonly staffId?: string;
68
+ readonly now?: string;
69
+ }
70
+ type UniversalCheckInResult = {
71
+ readonly ok: true;
72
+ readonly state: UserRallyState;
73
+ } | {
74
+ readonly ok: false;
75
+ readonly code: string;
76
+ readonly message: string;
77
+ };
78
+ interface UniversalRallyServerOptions {
79
+ readonly idempotencyTtlMs?: number;
80
+ readonly lockTtlMs?: number;
81
+ readonly customValidators?: Readonly<Record<string, (value: unknown, condition: VerificationCondition) => boolean>>;
82
+ readonly now?: () => string;
83
+ /** Extract the authenticated identity; request-body userId is never trusted when this is set. */
84
+ readonly authenticate?: (request: Request) => Promise<string | null> | string | null;
85
+ }
86
+ /** Atomic, adapter-backed check-in service for the universal model. */
87
+ declare class UniversalRallyServer {
88
+ #private;
89
+ constructor(config: AdminRallyConfig$1, persistence: ServerPersistenceAdapter, options?: UniversalRallyServerOptions);
90
+ /** Web Standard endpoint handler. Authentication, when configured, supplies the user identity. */
91
+ handleCheckIn(request: Request): Promise<Response>;
92
+ handleClaimReward(request: Request): Promise<Response>;
93
+ handleSync(request: Request): Promise<Response>;
94
+ handle(request: Request): Promise<Response>;
95
+ checkIn(request: UniversalCheckInRequest): Promise<UniversalCheckInResult>;
96
+ sync(rallyId: string, userId: string): Promise<UserRallyState>;
97
+ }
98
+
4
99
  interface UserRallyState extends StampRallyState {
5
100
  readonly userId?: string;
6
101
  }
@@ -88,4 +183,4 @@ declare class StampRallyServer {
88
183
  syncProgress(request: Request): Promise<Response>;
89
184
  }
90
185
 
91
- export { type AdminRallyConfig, type CheckInRequest, type ClaimRewardRequest, InMemoryServerStorage, type InMemoryServerStorageOptions, type RallyAuditLog, type ServerStorageAdapter, type StampClaimProof, StampRallyServer, type SyncCheckInOperation, type SyncRequest, type UserRallyState };
186
+ export { type AdminRallyConfig, type CheckInRequest, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, InMemoryServerStorage, type InMemoryServerStorageOptions, type RallyAuditLog, type ServerPersistenceAdapter, type ServerStorageAdapter, type StampClaimProof, StampRallyServer, type SyncCheckInOperation, type SyncRequest, type UniversalCheckInRequest, type UniversalCheckInResult, type UniversalClaimRewardRequest, UniversalRallyServer, type UniversalRallyServerOptions, type UniversalVerificationContext, type UserRallyState };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,101 @@
1
- import { RallyConfig, SecureTokenSecretKey, StampRallyState, VerificationContext } from '@stamprally/core';
1
+ import { AdminRallyConfig as AdminRallyConfig$1, VerificationCondition, RallyConfig, SecureTokenSecretKey, StampRallyState, VerificationContext } from '@stamprally/core';
2
2
  export { StampError, StampRecord } from '@stamprally/core';
3
3
 
4
+ /** Persistence contract for implementations backed by Redis, SQL, or another RDB. */
5
+ interface ServerPersistenceAdapter {
6
+ acquireLock(lockKey: string, ttlMs: number): Promise<boolean>;
7
+ releaseLock(lockKey: string): Promise<void>;
8
+ decrementRewardStock(rewardId: string): Promise<{
9
+ success: boolean;
10
+ remainingStock: number;
11
+ }>;
12
+ getIdempotentResult<T>(idempotencyKey: string): Promise<T | null>;
13
+ saveIdempotentResult<T>(idempotencyKey: string, result: T, ttlMs: number): Promise<void>;
14
+ getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
15
+ saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
16
+ recordAuditLog(log: RallyAuditLog): Promise<void>;
17
+ }
18
+ interface InMemoryServerPersistenceOptions {
19
+ readonly stocks?: Readonly<Record<string, number>>;
20
+ }
21
+ /** Deterministic adapter for tests and small single-process deployments. */
22
+ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
23
+ #private;
24
+ constructor(options?: InMemoryServerPersistenceOptions);
25
+ acquireLock(lockKey: string, ttlMs: number): Promise<boolean>;
26
+ releaseLock(lockKey: string): Promise<void>;
27
+ decrementRewardStock(rewardId: string): Promise<{
28
+ success: boolean;
29
+ remainingStock: number;
30
+ }>;
31
+ getIdempotentResult<T>(idempotencyKey: string): Promise<T | null>;
32
+ saveIdempotentResult<T>(idempotencyKey: string, result: T, ttlMs: number): Promise<void>;
33
+ getUserState(rallyId: string, userId: string): Promise<UserRallyState | null>;
34
+ saveUserState(rallyId: string, userId: string, state: UserRallyState): Promise<void>;
35
+ recordAuditLog(log: RallyAuditLog): Promise<void>;
36
+ getAuditLogs(): ReadonlyArray<RallyAuditLog>;
37
+ }
38
+
39
+ type UniversalVerificationContext = {
40
+ readonly type: "qr";
41
+ readonly token: string;
42
+ } | {
43
+ readonly type: "passcode";
44
+ readonly code: string;
45
+ } | {
46
+ readonly type: "gps";
47
+ readonly latitude: number;
48
+ readonly longitude: number;
49
+ } | {
50
+ readonly type: "custom";
51
+ readonly value: unknown;
52
+ };
53
+ interface UniversalCheckInRequest {
54
+ readonly rallyId: string;
55
+ readonly userId: string;
56
+ readonly spotId: string;
57
+ readonly context: UniversalVerificationContext;
58
+ readonly idempotencyKey: string;
59
+ readonly now?: string;
60
+ }
61
+ interface UniversalClaimRewardRequest {
62
+ readonly rallyId: string;
63
+ readonly userId: string;
64
+ readonly rewardId: string;
65
+ readonly idempotencyKey: string;
66
+ readonly staffPasscode?: string;
67
+ readonly staffId?: string;
68
+ readonly now?: string;
69
+ }
70
+ type UniversalCheckInResult = {
71
+ readonly ok: true;
72
+ readonly state: UserRallyState;
73
+ } | {
74
+ readonly ok: false;
75
+ readonly code: string;
76
+ readonly message: string;
77
+ };
78
+ interface UniversalRallyServerOptions {
79
+ readonly idempotencyTtlMs?: number;
80
+ readonly lockTtlMs?: number;
81
+ readonly customValidators?: Readonly<Record<string, (value: unknown, condition: VerificationCondition) => boolean>>;
82
+ readonly now?: () => string;
83
+ /** Extract the authenticated identity; request-body userId is never trusted when this is set. */
84
+ readonly authenticate?: (request: Request) => Promise<string | null> | string | null;
85
+ }
86
+ /** Atomic, adapter-backed check-in service for the universal model. */
87
+ declare class UniversalRallyServer {
88
+ #private;
89
+ constructor(config: AdminRallyConfig$1, persistence: ServerPersistenceAdapter, options?: UniversalRallyServerOptions);
90
+ /** Web Standard endpoint handler. Authentication, when configured, supplies the user identity. */
91
+ handleCheckIn(request: Request): Promise<Response>;
92
+ handleClaimReward(request: Request): Promise<Response>;
93
+ handleSync(request: Request): Promise<Response>;
94
+ handle(request: Request): Promise<Response>;
95
+ checkIn(request: UniversalCheckInRequest): Promise<UniversalCheckInResult>;
96
+ sync(rallyId: string, userId: string): Promise<UserRallyState>;
97
+ }
98
+
4
99
  interface UserRallyState extends StampRallyState {
5
100
  readonly userId?: string;
6
101
  }
@@ -88,4 +183,4 @@ declare class StampRallyServer {
88
183
  syncProgress(request: Request): Promise<Response>;
89
184
  }
90
185
 
91
- export { type AdminRallyConfig, type CheckInRequest, type ClaimRewardRequest, InMemoryServerStorage, type InMemoryServerStorageOptions, type RallyAuditLog, type ServerStorageAdapter, type StampClaimProof, StampRallyServer, type SyncCheckInOperation, type SyncRequest, type UserRallyState };
186
+ export { type AdminRallyConfig, type CheckInRequest, type ClaimRewardRequest, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, InMemoryServerStorage, type InMemoryServerStorageOptions, type RallyAuditLog, type ServerPersistenceAdapter, type ServerStorageAdapter, type StampClaimProof, StampRallyServer, type SyncCheckInOperation, type SyncRequest, type UniversalCheckInRequest, type UniversalCheckInResult, type UniversalClaimRewardRequest, UniversalRallyServer, type UniversalRallyServerOptions, type UniversalVerificationContext, type UserRallyState };
package/dist/index.js CHANGED
@@ -1,4 +1,395 @@
1
- import { processStamp, createSecureToken, consumeReward, reconcileRewardStates } from '@stamprally/core';
1
+ import { consumeReward, processStamp, createSecureToken, reconcileRewardStates } from '@stamprally/core';
2
+
3
+ // src/index.ts
4
+
5
+ // src/persistence.ts
6
+ var InMemoryServerPersistenceAdapter = class {
7
+ #locks = /* @__PURE__ */ new Map();
8
+ #idempotent = /* @__PURE__ */ new Map();
9
+ #states = /* @__PURE__ */ new Map();
10
+ #stocks;
11
+ #auditLogs = [];
12
+ constructor(options = {}) {
13
+ this.#stocks = new Map(Object.entries(options.stocks ?? {}));
14
+ }
15
+ async acquireLock(lockKey, ttlMs) {
16
+ const now2 = Date.now();
17
+ const expiresAt = this.#locks.get(lockKey);
18
+ if (expiresAt !== void 0 && expiresAt > now2) return false;
19
+ this.#locks.set(lockKey, now2 + Math.max(1, ttlMs));
20
+ return true;
21
+ }
22
+ async releaseLock(lockKey) {
23
+ this.#locks.delete(lockKey);
24
+ }
25
+ async decrementRewardStock(rewardId) {
26
+ const stock = this.#stocks.get(rewardId);
27
+ if (stock === void 0) return { success: true, remainingStock: Number.POSITIVE_INFINITY };
28
+ if (stock <= 0) return { success: false, remainingStock: 0 };
29
+ const remainingStock = stock - 1;
30
+ this.#stocks.set(rewardId, remainingStock);
31
+ return { success: true, remainingStock };
32
+ }
33
+ async getIdempotentResult(idempotencyKey) {
34
+ const entry = this.#idempotent.get(idempotencyKey);
35
+ if (entry === void 0) return null;
36
+ if (entry.expiresAt <= Date.now()) {
37
+ this.#idempotent.delete(idempotencyKey);
38
+ return null;
39
+ }
40
+ return structuredClone(entry.value);
41
+ }
42
+ async saveIdempotentResult(idempotencyKey, result, ttlMs) {
43
+ this.#idempotent.set(idempotencyKey, {
44
+ value: structuredClone(result),
45
+ expiresAt: Date.now() + Math.max(1, ttlMs)
46
+ });
47
+ }
48
+ async getUserState(rallyId, userId) {
49
+ const state = this.#states.get(`${rallyId}:${userId}`);
50
+ return state === void 0 ? null : structuredClone(state);
51
+ }
52
+ async saveUserState(rallyId, userId, state) {
53
+ this.#states.set(`${rallyId}:${userId}`, structuredClone(state));
54
+ }
55
+ async recordAuditLog(log) {
56
+ this.#auditLogs.push(structuredClone(log));
57
+ }
58
+ getAuditLogs() {
59
+ return structuredClone(this.#auditLogs);
60
+ }
61
+ };
62
+ function distanceMeters(aLat, aLon, bLat, bLon) {
63
+ const radians = (degrees) => degrees * Math.PI / 180;
64
+ const dLat = radians(bLat - aLat);
65
+ const dLon = radians(bLon - aLon);
66
+ const latA = radians(aLat);
67
+ const latB = radians(bLat);
68
+ const value = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLon / 2) ** 2;
69
+ return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
70
+ }
71
+ function initialState(config, now2) {
72
+ return {
73
+ rallyId: config.id,
74
+ records: [],
75
+ rewards: reconcileRewardStates(
76
+ config.rewards.map((reward) => ({ ...reward, description: reward.description ?? "" })),
77
+ [],
78
+ 0,
79
+ now2
80
+ ),
81
+ updatedAt: now2
82
+ };
83
+ }
84
+ function json(body, status = 200) {
85
+ return new Response(JSON.stringify(body), {
86
+ status,
87
+ headers: { "content-type": "application/json; charset=utf-8" }
88
+ });
89
+ }
90
+ function requestBody(request) {
91
+ return request.json().then((value) => {
92
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
93
+ return value;
94
+ }).catch(() => null);
95
+ }
96
+ function adminReward(reward) {
97
+ return { ...reward, description: reward.description ?? "" };
98
+ }
99
+ function matchesCondition(condition, context, customValidators) {
100
+ switch (condition.type) {
101
+ case "qr":
102
+ return context.type === "qr" && context.token === condition.secretToken;
103
+ case "passcode":
104
+ return context.type === "passcode" && (condition.caseSensitive === false ? context.code.toLocaleLowerCase() === condition.code.toLocaleLowerCase() : context.code === condition.code);
105
+ case "gps":
106
+ return context.type === "gps" && distanceMeters(
107
+ condition.latitude,
108
+ condition.longitude,
109
+ context.latitude,
110
+ context.longitude
111
+ ) <= condition.radiusMeters;
112
+ case "custom":
113
+ return customValidators?.[condition.validatorName]?.(
114
+ context.type === "custom" ? context.value : void 0,
115
+ condition
116
+ ) ?? false;
117
+ }
118
+ }
119
+ function audit(request, status, now2, errorCode) {
120
+ return {
121
+ id: `audit-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`,
122
+ timestamp: now2,
123
+ rallyId: request.rallyId,
124
+ userId: request.userId,
125
+ action: "CHECK_IN",
126
+ resourceId: request.spotId,
127
+ status,
128
+ idempotencyKey: request.idempotencyKey,
129
+ ...errorCode === void 0 ? {} : { metadata: { errorCode } }
130
+ };
131
+ }
132
+ var UniversalRallyServer = class {
133
+ #config;
134
+ #persistence;
135
+ #options;
136
+ constructor(config, persistence, options = {}) {
137
+ this.#config = config;
138
+ this.#persistence = persistence;
139
+ this.#options = options;
140
+ }
141
+ /** Web Standard endpoint handler. Authentication, when configured, supplies the user identity. */
142
+ async handleCheckIn(request) {
143
+ const body = await requestBody(request);
144
+ const userId = await this.#authenticatedUser(request, body?.userId);
145
+ if (this.#options.authenticate !== void 0 && userId === null)
146
+ return json(
147
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
148
+ 401
149
+ );
150
+ if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "")
151
+ return json(
152
+ {
153
+ ok: false,
154
+ error: {
155
+ code: "INVALID_REQUEST",
156
+ message: "rallyId, spotId, and idempotencyKey are required."
157
+ }
158
+ },
159
+ 400
160
+ );
161
+ if (body.context === void 0)
162
+ return json(
163
+ { ok: false, error: { code: "INVALID_REQUEST", message: "context is required." } },
164
+ 400
165
+ );
166
+ const result = await this.checkIn({ ...body, userId });
167
+ return result.ok ? json(result) : json(result, result.code === "SPOT_NOT_FOUND" ? 404 : 422);
168
+ }
169
+ async handleClaimReward(request) {
170
+ const body = await requestBody(request);
171
+ const userId = await this.#authenticatedUser(request, body?.userId);
172
+ if (this.#options.authenticate !== void 0 && userId === null)
173
+ return json(
174
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
175
+ 401
176
+ );
177
+ if (body === null || userId === null || body.rallyId !== this.#config.id || body.rewardId === "" || body.idempotencyKey === "")
178
+ return json(
179
+ {
180
+ ok: false,
181
+ error: {
182
+ code: "INVALID_REQUEST",
183
+ message: "rallyId, rewardId, and idempotencyKey are required."
184
+ }
185
+ },
186
+ 400
187
+ );
188
+ const key = `claim-reward:${body.rallyId}:${userId}:${body.rewardId}:${body.idempotencyKey}`;
189
+ const previous = await this.#persistence.getIdempotentResult(key);
190
+ if (previous !== null) return json(previous, previous.ok ? 200 : 422);
191
+ const reward = this.#config.rewards.find((item) => item.id === body.rewardId);
192
+ if (reward === void 0) {
193
+ const failure = {
194
+ ok: false,
195
+ code: "REWARD_NOT_FOUND",
196
+ message: "Reward was not found."
197
+ };
198
+ return json(await this.#rememberClaim(key, failure, body, userId), 404);
199
+ }
200
+ const timestamp = body.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
201
+ const current = await this.sync(body.rallyId, userId);
202
+ const currentReward = current.rewards?.find((item) => item.rewardId === reward.id) ?? {
203
+ rewardId: reward.id,
204
+ status: "LOCKED"
205
+ };
206
+ const userClaims = await this.#getUserClaimCount(userId, reward.id);
207
+ const local = consumeReward({
208
+ reward: adminReward(reward),
209
+ currentState: currentReward,
210
+ now: timestamp,
211
+ userId,
212
+ userRedemptionCount: userClaims,
213
+ ...body.staffPasscode === void 0 ? {} : { inputPasscode: body.staffPasscode },
214
+ ...body.staffId === void 0 ? {} : { staffId: body.staffId }
215
+ });
216
+ if (!local.ok)
217
+ return json(
218
+ await this.#rememberClaim(
219
+ key,
220
+ {
221
+ ok: false,
222
+ code: local.error.code,
223
+ message: "Reward cannot be claimed.",
224
+ error: local.error
225
+ },
226
+ body,
227
+ userId
228
+ ),
229
+ 422
230
+ );
231
+ const stock = await this.#persistence.decrementRewardStock(reward.id);
232
+ if (!stock.success)
233
+ return json(
234
+ await this.#rememberClaim(
235
+ key,
236
+ { ok: false, code: "OUT_OF_STOCK", message: "Reward is out of stock." },
237
+ body,
238
+ userId
239
+ ),
240
+ 422
241
+ );
242
+ const next = {
243
+ ...current,
244
+ rewards: (current.rewards ?? []).map(
245
+ (item) => item.rewardId === reward.id ? local.value : item
246
+ ),
247
+ updatedAt: timestamp
248
+ };
249
+ await this.#persistence.saveUserState(body.rallyId, userId, next);
250
+ const success = local.value.claimTicketNumber === void 0 ? { ok: true, state: next } : { ok: true, state: next, claimTicketNumber: local.value.claimTicketNumber };
251
+ await this.#persistence.saveIdempotentResult(
252
+ key,
253
+ success,
254
+ this.#options.idempotencyTtlMs ?? 864e5
255
+ );
256
+ await this.#persistence.recordAuditLog({
257
+ id: `audit-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
258
+ timestamp,
259
+ rallyId: body.rallyId,
260
+ userId,
261
+ action: "CLAIM_REWARD",
262
+ resourceId: reward.id,
263
+ status: "SUCCESS",
264
+ idempotencyKey: body.idempotencyKey
265
+ });
266
+ return json(success);
267
+ }
268
+ async handleSync(request) {
269
+ const body = await requestBody(request);
270
+ const userId = await this.#authenticatedUser(request, body?.userId);
271
+ if (this.#options.authenticate !== void 0 && userId === null)
272
+ return json(
273
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication is required." } },
274
+ 401
275
+ );
276
+ if (body === null || userId === null || body.rallyId !== this.#config.id)
277
+ return json(
278
+ { ok: false, error: { code: "INVALID_REQUEST", message: "rallyId is required." } },
279
+ 400
280
+ );
281
+ return json({ ok: true, state: await this.sync(body.rallyId, userId) });
282
+ }
283
+ async handle(request) {
284
+ if (request.method !== "POST")
285
+ return json({ ok: false, error: { code: "METHOD_NOT_ALLOWED" } }, 405);
286
+ const path = new URL(request.url).pathname;
287
+ if (path.endsWith("/check-in")) return this.handleCheckIn(request);
288
+ if (path.endsWith("/claim-reward")) return this.handleClaimReward(request);
289
+ if (path.endsWith("/sync")) return this.handleSync(request);
290
+ return json({ ok: false, error: { code: "NOT_FOUND" } }, 404);
291
+ }
292
+ async checkIn(request) {
293
+ const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
294
+ const previous = await this.#persistence.getIdempotentResult(key);
295
+ if (previous !== null) return previous;
296
+ const lockKey = `state:${request.rallyId}:${request.userId}`;
297
+ const locked = await this.#persistence.acquireLock(lockKey, this.#options.lockTtlMs ?? 5e3);
298
+ if (!locked)
299
+ return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
300
+ const now2 = request.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
301
+ try {
302
+ const spot = this.#config.spots.find((item) => item.id === request.spotId);
303
+ if (spot === void 0)
304
+ return this.#remember(
305
+ key,
306
+ { ok: false, code: "SPOT_NOT_FOUND", message: "Spot was not found." },
307
+ request,
308
+ now2
309
+ );
310
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, now2);
311
+ if (current.records.some((record) => record.stampId === request.spotId))
312
+ return this.#remember(
313
+ key,
314
+ { ok: false, code: "ALREADY_CLAIMED", message: "Spot was already claimed." },
315
+ request,
316
+ now2
317
+ );
318
+ const acquired = new Set(current.records.map((record) => record.stampId));
319
+ if (spot.prerequisites?.some((id2) => !acquired.has(id2)))
320
+ return this.#remember(
321
+ key,
322
+ {
323
+ ok: false,
324
+ code: "PREREQUISITES_NOT_MET",
325
+ message: "Prerequisite spots are not complete."
326
+ },
327
+ request,
328
+ now2
329
+ );
330
+ if (!spot.conditions.every(
331
+ (condition) => matchesCondition(condition, request.context, this.#options.customValidators)
332
+ ))
333
+ return this.#remember(
334
+ key,
335
+ { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
336
+ request,
337
+ now2
338
+ );
339
+ const state = {
340
+ ...current,
341
+ records: [...current.records, { stampId: request.spotId, acquiredAt: now2 }],
342
+ updatedAt: now2
343
+ };
344
+ await this.#persistence.saveUserState(request.rallyId, request.userId, state);
345
+ return this.#remember(key, { ok: true, state }, request, now2);
346
+ } finally {
347
+ await this.#persistence.releaseLock(lockKey);
348
+ }
349
+ }
350
+ async sync(rallyId, userId) {
351
+ const now2 = this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
352
+ return await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, now2);
353
+ }
354
+ async #authenticatedUser(request, requestedUserId) {
355
+ const authenticated = await this.#options.authenticate?.(request);
356
+ if (this.#options.authenticate !== void 0) return authenticated ?? null;
357
+ return requestedUserId ?? null;
358
+ }
359
+ async #getUserClaimCount(userId, rewardId) {
360
+ const state = await this.#persistence.getUserState(this.#config.id, userId);
361
+ return state?.rewards?.find((item) => item.rewardId === rewardId)?.userRedemptionCount ?? 0;
362
+ }
363
+ async #rememberClaim(key, result, request, userId) {
364
+ await this.#persistence.saveIdempotentResult(
365
+ key,
366
+ result,
367
+ this.#options.idempotencyTtlMs ?? 864e5
368
+ );
369
+ await this.#persistence.recordAuditLog({
370
+ id: `audit-${globalThis.crypto?.randomUUID?.() ?? Date.now()}`,
371
+ timestamp: request.now ?? this.#options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString(),
372
+ rallyId: request.rallyId,
373
+ userId,
374
+ action: "CLAIM_REWARD",
375
+ resourceId: request.rewardId,
376
+ status: "REJECTED",
377
+ idempotencyKey: request.idempotencyKey
378
+ });
379
+ return result;
380
+ }
381
+ async #remember(key, result, request, now2) {
382
+ await this.#persistence.recordAuditLog(
383
+ audit(request, result.ok ? "SUCCESS" : "REJECTED", now2, result.ok ? void 0 : result.code)
384
+ );
385
+ await this.#persistence.saveIdempotentResult(
386
+ key,
387
+ result,
388
+ this.#options.idempotencyTtlMs ?? 864e5
389
+ );
390
+ return result;
391
+ }
392
+ };
2
393
 
3
394
  // src/index.ts
4
395
  var InMemoryServerStorage = class {
@@ -367,6 +758,6 @@ function emptyState(config, timestamp) {
367
758
  };
368
759
  }
369
760
 
370
- export { InMemoryServerStorage, StampRallyServer };
761
+ export { InMemoryServerPersistenceAdapter, InMemoryServerStorage, StampRallyServer, UniversalRallyServer };
371
762
  //# sourceMappingURL=index.js.map
372
763
  //# sourceMappingURL=index.js.map