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