@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.cjs +393 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +97 -2
- package/dist/index.d.ts +97 -2
- package/dist/index.js +393 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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,
|
|
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
|