@stamprally/server 0.13.0 → 0.14.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.js CHANGED
@@ -20,8 +20,8 @@ async function executeClaimRewardTransaction(database, store, params, mutation)
20
20
  await store.writeIdempotency(transaction, params, next.result);
21
21
  return { success: true };
22
22
  });
23
- } catch (error) {
24
- return { success: false, error: error instanceof Error ? error.message : String(error) };
23
+ } catch (error2) {
24
+ return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
25
25
  }
26
26
  }
27
27
 
@@ -138,8 +138,41 @@ var InMemoryServerPersistenceAdapter = class {
138
138
  );
139
139
  return { success: true };
140
140
  });
141
- } catch (error) {
142
- return { success: false, error: error instanceof Error ? error.message : String(error) };
141
+ } catch (error2) {
142
+ return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
143
+ }
144
+ }
145
+ async executeCheckInTransaction(params, mutation) {
146
+ try {
147
+ return await this.runTransaction(params.rallyId, async () => {
148
+ const userState = await this.getUserState(params.rallyId, params.userId) ?? params.initialUserState;
149
+ if (userState === void 0)
150
+ return { success: false, error: "A user state is required for this transaction." };
151
+ const mutationResult = mutation({ userState });
152
+ if (mutationResult.error !== void 0) {
153
+ await this.recordAuditLog(mutationResult.auditLog);
154
+ if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
155
+ await this.saveIdempotentResult(
156
+ params.rallyId,
157
+ params.idempotencyKey,
158
+ mutationResult.result,
159
+ params.idempotencyTtlMs ?? 864e5
160
+ );
161
+ return { success: false, error: mutationResult.error };
162
+ }
163
+ await this.saveUserState(params.rallyId, params.userId, mutationResult.nextUserState);
164
+ await this.recordAuditLog(mutationResult.auditLog);
165
+ if (params.idempotencyKey !== void 0 && mutationResult.result !== void 0)
166
+ await this.saveIdempotentResult(
167
+ params.rallyId,
168
+ params.idempotencyKey,
169
+ mutationResult.result,
170
+ params.idempotencyTtlMs ?? 864e5
171
+ );
172
+ return { success: true };
173
+ });
174
+ } catch (error2) {
175
+ return { success: false, error: error2 instanceof Error ? error2.message : String(error2) };
143
176
  }
144
177
  }
145
178
  async rollbackUserState(rallyId, userId, previousState) {
@@ -193,7 +226,7 @@ var InMemoryServerPersistenceAdapter = class {
193
226
  if (count <= 1) this.#claims.delete(key);
194
227
  else this.#claims.set(key, count - 1);
195
228
  const index = this.#claimRecords.findIndex(
196
- (record) => record.rallyId === rallyId && record.userId === userId && record.rewardId === rewardId && record.ticketNumber === ticketNumber
229
+ (record2) => record2.rallyId === rallyId && record2.userId === userId && record2.rewardId === rewardId && record2.ticketNumber === ticketNumber
197
230
  );
198
231
  if (index >= 0) this.#claimRecords.splice(index, 1);
199
232
  }
@@ -225,7 +258,7 @@ var InMemoryServerPersistenceAdapter = class {
225
258
  };
226
259
  try {
227
260
  return await operation(this);
228
- } catch (error) {
261
+ } catch (error2) {
229
262
  this.#stocks.clear();
230
263
  for (const [key, value] of snapshot.stocks) this.#stocks.set(key, value);
231
264
  this.#idempotent.clear();
@@ -237,10 +270,63 @@ var InMemoryServerPersistenceAdapter = class {
237
270
  for (const [key, value] of snapshot.claims) this.#claims.set(key, value);
238
271
  this.#claimRecords.splice(0, this.#claimRecords.length, ...snapshot.claimRecords);
239
272
  this.#auditLogs.splice(0, this.#auditLogs.length, ...snapshot.auditLogs);
240
- throw error;
273
+ throw error2;
241
274
  }
242
275
  }
243
276
  };
277
+
278
+ // src/security.ts
279
+ function error(path, message) {
280
+ return { success: false, errors: [{ path, message, code: "invalid_request" }] };
281
+ }
282
+ function record(value) {
283
+ return typeof value === "object" && value !== null && !Array.isArray(value);
284
+ }
285
+ function nonEmpty(value) {
286
+ return typeof value === "string" && value.trim().length > 0;
287
+ }
288
+ function context(value) {
289
+ if (!record(value) || typeof value.type !== "string") return false;
290
+ if (value.type === "qr") return nonEmpty(value.token);
291
+ if (value.type === "passcode") return nonEmpty(value.code);
292
+ if (value.type === "nfc") return nonEmpty(value.tagId);
293
+ if (value.type === "custom") return "value" in value;
294
+ return value.type === "gps" && typeof value.latitude === "number" && Number.isFinite(value.latitude) && typeof value.longitude === "number" && Number.isFinite(value.longitude);
295
+ }
296
+ function common(value, fields) {
297
+ if (!record(value)) return false;
298
+ return fields.every((field) => nonEmpty(value[field]));
299
+ }
300
+ function validateCheckInRequest(value) {
301
+ if (!common(value, ["rallyId", "spotId", "idempotencyKey"]))
302
+ return error("$", "rallyId, spotId, and idempotencyKey must be non-empty strings.");
303
+ if (!context(value.context))
304
+ return error("context", "context is not a valid verification context.");
305
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
306
+ return error("userId", "userId must be non-empty.");
307
+ if (value.now !== void 0 && !nonEmpty(value.now))
308
+ return error("now", "now must be non-empty when provided.");
309
+ return { success: true, data: value };
310
+ }
311
+ function validateClaimRewardRequest(value) {
312
+ if (!common(value, ["rallyId", "rewardId", "idempotencyKey"]))
313
+ return error("$", "rallyId, rewardId, and idempotencyKey must be non-empty strings.");
314
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
315
+ return error("userId", "userId must be non-empty.");
316
+ if (value.staffPasscode !== void 0 && !nonEmpty(value.staffPasscode))
317
+ return error("staffPasscode", "staffPasscode must be non-empty.");
318
+ if (value.staffId !== void 0 && !nonEmpty(value.staffId))
319
+ return error("staffId", "staffId must be non-empty.");
320
+ if (value.now !== void 0 && !nonEmpty(value.now))
321
+ return error("now", "now must be non-empty when provided.");
322
+ return { success: true, data: value };
323
+ }
324
+ function validateSyncRequest(value) {
325
+ if (!common(value, ["rallyId"])) return error("rallyId", "rallyId must be a non-empty string.");
326
+ if (value.userId !== void 0 && !nonEmpty(value.userId))
327
+ return error("userId", "userId must be non-empty.");
328
+ return { success: true, data: value };
329
+ }
244
330
  function json(body, status = 200) {
245
331
  return new Response(JSON.stringify(body), {
246
332
  status,
@@ -253,8 +339,12 @@ function isObject(value) {
253
339
  function requestId(prefix) {
254
340
  return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
255
341
  }
256
- function now(options, requested) {
257
- return requested ?? options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
342
+ function now(options) {
343
+ return options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString();
344
+ }
345
+ function timestampMillis(timestamp) {
346
+ const value = Date.parse(timestamp);
347
+ return Number.isFinite(value) ? value : Date.now();
258
348
  }
259
349
  function initialState(config, userId, timestamp) {
260
350
  return {
@@ -265,16 +355,16 @@ function initialState(config, userId, timestamp) {
265
355
  updatedAt: timestamp
266
356
  };
267
357
  }
268
- function getProof(context) {
269
- 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;
358
+ function getProof(context2) {
359
+ return context2.type === "qr" ? context2.token : context2.type === "passcode" ? context2.code : context2.type === "gps" ? { latitude: context2.latitude, longitude: context2.longitude } : context2.type === "nfc" ? context2.tagId : context2.value;
270
360
  }
271
- async function evaluate(condition, context, validator, base) {
272
- if (condition.type !== "custom") return evaluateConditionDetailed(condition, context).ok;
361
+ async function evaluate(condition, context2, validator, base) {
362
+ if (condition.type !== "custom") return evaluateConditionDetailed(condition, context2).ok;
273
363
  if (validator === void 0) return false;
274
364
  const validationContext = {
275
365
  rallyId: base.rallyId,
276
366
  spotId: base.spotId,
277
- proofData: getProof(context),
367
+ proofData: getProof(context2),
278
368
  condition,
279
369
  userState: base.state
280
370
  };
@@ -317,47 +407,51 @@ var StampRallyServer = class {
317
407
  return json({ ok: false, code: "NOT_FOUND", message: "Route not found." }, 404);
318
408
  }
319
409
  async handleCheckIn(request) {
320
- const body = await this.#body(request);
321
- const userId = await this.#user(request, body?.userId);
322
- if (body === null || userId === null || body.rallyId !== this.#config.id || body.spotId === "" || body.idempotencyKey === "" || body.context === void 0)
410
+ const body = validateCheckInRequest(await this.#body(request));
411
+ if (!body.success || body.data.rallyId !== this.#config.id)
323
412
  return json(
324
- {
325
- ok: false,
326
- code: "INVALID_REQUEST",
327
- message: "rallyId, spotId, context, and idempotencyKey are required."
328
- },
413
+ { ok: false, code: "INVALID_REQUEST", message: "Invalid check-in request." },
329
414
  400
330
415
  );
331
- const result = await this.checkIn({ ...body, userId });
416
+ const userId = await this.#user(request);
417
+ if (userId === null)
418
+ return json(
419
+ { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
420
+ 401
421
+ );
422
+ const result = await this.checkIn({ ...body.data, userId });
332
423
  return json(
333
424
  { ...result, status: operationStatus(result) },
334
425
  result.ok ? 200 : result.code === "SPOT_NOT_FOUND" ? 404 : 422
335
426
  );
336
427
  }
337
428
  async handleClaimReward(request) {
338
- const body = await this.#body(request);
339
- const userId = await this.#user(request, body?.userId);
340
- if (body === null || userId === null || body.rallyId !== this.#config.id || body.rewardId === "" || body.idempotencyKey === "")
429
+ const body = validateClaimRewardRequest(await this.#body(request));
430
+ if (!body.success || body.data.rallyId !== this.#config.id)
431
+ return json({ ok: false, code: "INVALID_REQUEST", message: "Invalid reward request." }, 400);
432
+ const userId = await this.#user(request);
433
+ if (userId === null)
341
434
  return json(
342
- {
343
- ok: false,
344
- code: "INVALID_REQUEST",
345
- message: "rallyId, rewardId, and idempotencyKey are required."
346
- },
347
- 400
435
+ { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
436
+ 401
348
437
  );
349
- const result = await this.claimReward({ ...body, userId });
438
+ const result = await this.claimReward({ ...body.data, userId });
350
439
  return json(
351
440
  { ...result, status: operationStatus(result) },
352
441
  result.ok ? 200 : result.code === "REWARD_NOT_FOUND" ? 404 : 422
353
442
  );
354
443
  }
355
444
  async handleSync(request) {
356
- const body = await this.#body(request);
357
- const userId = await this.#user(request, body?.userId);
358
- if (body === null || userId === null || body.rallyId !== this.#config.id)
359
- return json({ ok: false, code: "INVALID_REQUEST", message: "rallyId is required." }, 400);
360
- return json({ ok: true, state: await this.sync(body.rallyId, userId) });
445
+ const body = validateSyncRequest(await this.#body(request));
446
+ if (!body.success || body.data.rallyId !== this.#config.id)
447
+ return json({ ok: false, code: "INVALID_REQUEST", message: "Invalid sync request." }, 400);
448
+ const userId = await this.#user(request);
449
+ if (userId === null)
450
+ return json(
451
+ { ok: false, code: "UNAUTHENTICATED", message: "Authentication is required." },
452
+ 401
453
+ );
454
+ return json({ ok: true, state: await this.sync(body.data.rallyId, userId) });
361
455
  }
362
456
  async checkIn(request) {
363
457
  const key = `check-in:${request.rallyId}:${request.userId}:${request.idempotencyKey}`;
@@ -373,47 +467,68 @@ var StampRallyServer = class {
373
467
  this.#options.lockTtlMs ?? 5e3
374
468
  ))
375
469
  return { ok: false, code: "CONFLICT", message: "The user state is being updated." };
376
- const timestamp = now(this.#options, request.now);
470
+ const timestamp = now(this.#options);
377
471
  try {
472
+ const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
473
+ const responseHolder = { value: null };
474
+ const makeAudit = (status, code) => audit(
475
+ request.rallyId,
476
+ request.userId,
477
+ "CHECK_IN",
478
+ request.spotId,
479
+ request.idempotencyKey,
480
+ status,
481
+ timestamp,
482
+ code
483
+ );
378
484
  const spot = this.#config.spots.find((item) => item.id === request.spotId);
379
- if (spot === void 0)
380
- return this.#remember(
485
+ if (spot === void 0) {
486
+ responseHolder.value = {
487
+ ok: false,
488
+ code: "SPOT_NOT_FOUND",
489
+ message: "Spot was not found."
490
+ };
491
+ return await this.#rememberCheckInTransaction(
492
+ request,
493
+ timestamp,
381
494
  key,
382
- { ok: false, code: "SPOT_NOT_FOUND", message: "Spot was not found." },
383
- request.rallyId,
384
- request.userId,
385
- "CHECK_IN",
386
- request.spotId,
387
- request.idempotencyKey,
388
- timestamp
495
+ current,
496
+ {
497
+ nextUserState: current,
498
+ auditLog: makeAudit("REJECTED", "SPOT_NOT_FOUND"),
499
+ result: responseHolder.value,
500
+ error: "SPOT_NOT_FOUND"
501
+ },
502
+ responseHolder
389
503
  );
390
- const current = await this.#persistence.getUserState(request.rallyId, request.userId) ?? initialState(this.#config, request.userId, timestamp);
391
- if (current.records.some((record) => record.stampId === request.spotId))
392
- return this.#remember(
504
+ }
505
+ const rejected = (code, message) => {
506
+ responseHolder.value = { ok: false, code, message };
507
+ return {
508
+ nextUserState: current,
509
+ auditLog: makeAudit("REJECTED", code),
510
+ result: responseHolder.value,
511
+ error: code
512
+ };
513
+ };
514
+ if (current.records.some((record2) => record2.stampId === request.spotId))
515
+ return await this.#rememberCheckInTransaction(
516
+ request,
517
+ timestamp,
393
518
  key,
394
- { ok: false, code: "STAMP_ALREADY_ACQUIRED", message: "Spot was already claimed." },
395
- request.rallyId,
396
- request.userId,
397
- "CHECK_IN",
398
- request.spotId,
399
- request.idempotencyKey,
400
- timestamp
519
+ current,
520
+ rejected("STAMP_ALREADY_ACQUIRED", "Spot was already claimed."),
521
+ responseHolder
401
522
  );
402
- const acquired = new Set(current.records.map((record) => record.stampId));
523
+ const acquired = new Set(current.records.map((record2) => record2.stampId));
403
524
  if (spot.prerequisites?.some((id) => !acquired.has(id)))
404
- return this.#remember(
525
+ return await this.#rememberCheckInTransaction(
526
+ request,
527
+ timestamp,
405
528
  key,
406
- {
407
- ok: false,
408
- code: "PREREQUISITES_NOT_MET",
409
- message: "Prerequisite spots are not complete."
410
- },
411
- request.rallyId,
412
- request.userId,
413
- "CHECK_IN",
414
- request.spotId,
415
- request.idempotencyKey,
416
- timestamp
529
+ current,
530
+ rejected("PREREQUISITES_NOT_MET", "Prerequisite spots are not complete."),
531
+ responseHolder
417
532
  );
418
533
  for (const condition of spot.conditions)
419
534
  if (!await evaluate(
@@ -422,15 +537,13 @@ var StampRallyServer = class {
422
537
  condition.type === "custom" ? this.#options.customValidators?.[condition.validatorName] : void 0,
423
538
  { rallyId: request.rallyId, spotId: request.spotId, state: current }
424
539
  ))
425
- return this.#remember(
540
+ return await this.#rememberCheckInTransaction(
541
+ request,
542
+ timestamp,
426
543
  key,
427
- { ok: false, code: "INVALID_PROOF", message: "Verification failed." },
428
- request.rallyId,
429
- request.userId,
430
- "CHECK_IN",
431
- request.spotId,
432
- request.idempotencyKey,
433
- timestamp
544
+ current,
545
+ rejected("INVALID_PROOF", "Verification failed."),
546
+ responseHolder
434
547
  );
435
548
  const next = {
436
549
  ...current,
@@ -443,17 +556,30 @@ var StampRallyServer = class {
443
556
  ),
444
557
  updatedAt: timestamp
445
558
  };
446
- await this.#persistence.saveUserState(request.rallyId, request.userId, next);
447
- return this.#remember(
448
- key,
449
- { ok: true, state: next },
450
- request.rallyId,
451
- request.userId,
452
- "CHECK_IN",
453
- request.spotId,
454
- request.idempotencyKey,
455
- timestamp
559
+ responseHolder.value = { ok: true, state: next };
560
+ const transaction = await this.#persistence.executeCheckInTransaction(
561
+ {
562
+ rallyId: request.rallyId,
563
+ userId: request.userId,
564
+ spotId: request.spotId,
565
+ timestamp: timestampMillis(timestamp),
566
+ idempotencyKey: key,
567
+ proofData: request.context,
568
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
569
+ initialUserState: current
570
+ },
571
+ () => ({
572
+ nextUserState: next,
573
+ auditLog: makeAudit("SUCCESS"),
574
+ result: responseHolder.value
575
+ })
456
576
  );
577
+ if (transaction.success && responseHolder.value !== null) return responseHolder.value;
578
+ return {
579
+ ok: false,
580
+ code: "PERSISTENCE_FAILED",
581
+ message: transaction.error ?? "Check-in failed."
582
+ };
457
583
  } finally {
458
584
  await this.#persistence.releaseLock(request.rallyId, lockKey);
459
585
  }
@@ -471,7 +597,7 @@ var StampRallyServer = class {
471
597
  key,
472
598
  { ok: false, code: "REWARD_NOT_FOUND", message: "Reward was not found." },
473
599
  request,
474
- now(this.#options, request.now)
600
+ now(this.#options)
475
601
  );
476
602
  const lockKey = `reward:${request.rallyId}:${reward.id}`;
477
603
  if (!await this.#persistence.acquireLock(
@@ -480,7 +606,7 @@ var StampRallyServer = class {
480
606
  this.#options.lockTtlMs ?? 5e3
481
607
  ))
482
608
  return { ok: false, code: "CONFLICT", message: "The reward is being claimed." };
483
- const timestamp = now(this.#options, request.now);
609
+ const timestamp = now(this.#options);
484
610
  try {
485
611
  const checked = await this.#persistence.getIdempotentResult(
486
612
  request.rallyId,
@@ -530,8 +656,9 @@ var StampRallyServer = class {
530
656
  error: "OUT_OF_STOCK"
531
657
  };
532
658
  }
659
+ const effectiveReward = reward.staffPasscode === void 0 && this.#config.staffPasscode !== void 0 ? { ...reward, staffPasscode: this.#config.staffPasscode } : reward;
533
660
  const local = consumeReward({
534
- reward,
661
+ reward: effectiveReward,
535
662
  currentState: currentReward,
536
663
  now: timestamp,
537
664
  userRedemptionCount: claimCount,
@@ -582,11 +709,11 @@ var StampRallyServer = class {
582
709
  code: "PERSISTENCE_FAILED",
583
710
  message: result.error ?? "Reward claim failed."
584
711
  };
585
- } catch (error) {
712
+ } catch (error2) {
586
713
  return {
587
714
  ok: false,
588
715
  code: "PERSISTENCE_FAILED",
589
- message: error instanceof Error ? error.message : "Reward claim failed."
716
+ message: error2 instanceof Error ? error2.message : "Reward claim failed."
590
717
  };
591
718
  } finally {
592
719
  await this.#persistence.releaseLock(request.rallyId, lockKey);
@@ -603,31 +730,36 @@ var StampRallyServer = class {
603
730
  return null;
604
731
  }
605
732
  }
606
- async #user(request, requested) {
607
- if (this.#options.authenticate !== void 0)
608
- return await this.#options.authenticate(request) ?? null;
609
- return requested ?? null;
610
- }
611
- async #remember(key, result, rallyId, userId, action, resourceId, idempotencyKey, timestamp) {
612
- await this.#persistence.recordAuditLog(
613
- audit(
614
- rallyId,
615
- userId,
616
- action,
617
- resourceId,
618
- idempotencyKey,
619
- result.ok ? "SUCCESS" : "REJECTED",
620
- timestamp,
621
- result.ok ? void 0 : result.code
622
- )
623
- );
624
- await this.#persistence.saveIdempotentResult(
625
- rallyId,
626
- key,
627
- result,
628
- this.#options.idempotencyTtlMs ?? 864e5
733
+ async #user(request) {
734
+ if (this.#options.authenticate !== void 0) {
735
+ const identity = await this.#options.authenticate(request);
736
+ if (typeof identity === "string") return identity.length > 0 ? identity : null;
737
+ if (identity === null) return null;
738
+ const authenticatedUserId = identity.authenticatedUserId;
739
+ return authenticatedUserId.length > 0 ? authenticatedUserId : null;
740
+ }
741
+ return "anonymous";
742
+ }
743
+ async #rememberCheckInTransaction(request, timestamp, key, current, mutation, responseHolder) {
744
+ const transaction = await this.#persistence.executeCheckInTransaction(
745
+ {
746
+ rallyId: request.rallyId,
747
+ userId: request.userId,
748
+ spotId: request.spotId,
749
+ timestamp: timestampMillis(timestamp),
750
+ idempotencyKey: key,
751
+ ...this.#options.idempotencyTtlMs === void 0 ? {} : { idempotencyTtlMs: this.#options.idempotencyTtlMs },
752
+ initialUserState: current
753
+ },
754
+ () => mutation
629
755
  );
630
- return result;
756
+ if (responseHolder.value !== null && (transaction.success || transaction.error === mutation.error))
757
+ return responseHolder.value;
758
+ return {
759
+ ok: false,
760
+ code: "PERSISTENCE_FAILED",
761
+ message: transaction.error ?? "Check-in failed."
762
+ };
631
763
  }
632
764
  async #rememberClaim(key, result, request, timestamp) {
633
765
  await this.#persistence.recordAuditLog(
@@ -652,6 +784,6 @@ var StampRallyServer = class {
652
784
  }
653
785
  };
654
786
 
655
- export { InMemoryServerPersistenceAdapter, StampRallyServer, executeClaimRewardTransaction };
787
+ export { InMemoryServerPersistenceAdapter, StampRallyServer, executeClaimRewardTransaction, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
656
788
  //# sourceMappingURL=index.js.map
657
789
  //# sourceMappingURL=index.js.map