@stamprally/server 0.16.0 → 0.18.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @stamprally/server v0.16.0
1
+ # @stamprally/server v0.18.0
2
2
 
3
3
  Web Standard `Request` / `Response` handlers for server-authoritative check-ins and reward claims.
4
4
 
package/dist/index.cjs CHANGED
@@ -7,7 +7,16 @@ async function executeClaimRewardTransaction(database, store, params2, mutation2
7
7
  try {
8
8
  return await database.transaction(async (transaction) => {
9
9
  const current = await store.readContext(transaction, params2);
10
- const next = mutation2(current);
10
+ const secondaryStock = current.secondaryStock ?? null;
11
+ const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : current.stock;
12
+ const next = mutation2({
13
+ ...current,
14
+ rewardStock: rewardStock2,
15
+ sharedStock: params2.stockKey === "__shared__" ? current.stock : null,
16
+ primaryStock: rewardStock2,
17
+ secondaryStock,
18
+ stock: current.stock
19
+ });
11
20
  if (next.error !== void 0) {
12
21
  await store.writeAudit(transaction, next.auditLog);
13
22
  if (params2.idempotencyKey !== void 0 && next.result !== void 0)
@@ -15,7 +24,7 @@ async function executeClaimRewardTransaction(database, store, params2, mutation2
15
24
  return { success: false, error: next.error };
16
25
  }
17
26
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock === void 0)
18
- return { success: false, error: "INVENTORY_NOT_SUPPORTED" };
27
+ return { success: false, error: "INVENTORY_STORAGE_NOT_IMPLEMENTED" };
19
28
  if (next.nextStock !== null) await store.writeStock(transaction, params2, next.nextStock);
20
29
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
21
30
  if (next.nextSecondaryStock !== null)
@@ -69,6 +78,7 @@ async function executeRedisTransaction(redis, queue) {
69
78
  // src/persistence.ts
70
79
  var InMemoryServerPersistenceAdapter = class {
71
80
  supportsRewardStock = true;
81
+ supportsSecondaryStock = true;
72
82
  #locks = /* @__PURE__ */ new Map();
73
83
  #idempotent = /* @__PURE__ */ new Map();
74
84
  #states = /* @__PURE__ */ new Map();
@@ -146,9 +156,16 @@ var InMemoryServerPersistenceAdapter = class {
146
156
  params2.stockKey ?? params2.rewardId
147
157
  );
148
158
  const storedSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
159
+ const stock = storedStock ?? initialStock;
160
+ const secondaryStock = storedSecondaryStock ?? initialSecondaryStock;
161
+ const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : stock;
162
+ const sharedStock2 = params2.stockKey === "__shared__" ? stock : null;
149
163
  const mutationResult = mutation2({
150
- stock: storedStock ?? initialStock,
151
- secondaryStock: storedSecondaryStock ?? initialSecondaryStock,
164
+ rewardStock: rewardStock2,
165
+ sharedStock: sharedStock2,
166
+ primaryStock: rewardStock2,
167
+ secondaryStock,
168
+ stock,
152
169
  claimCount: await this.getUserClaimCount(params2.rallyId, params2.userId, params2.rewardId),
153
170
  userState
154
171
  });
@@ -626,26 +643,41 @@ function initialState(config, userId, timestamp) {
626
643
  updatedAt: timestamp
627
644
  };
628
645
  }
629
- function rewardStock(config, rewardId, stockLimit) {
630
- const configured = config.inventory?.[rewardId];
646
+ function rewardStock(config, reward) {
647
+ const stockLimit = reward.stockLimit;
648
+ const key = reward.stockKey ?? reward.id;
649
+ const configured = key === "__shared__" ? config.inventory?.sharedStock : config.inventory?.[key];
631
650
  if (stockLimit === void 0) return configured ?? null;
632
651
  if (configured === void 0) return stockLimit;
633
652
  return Math.min(stockLimit, configured);
634
653
  }
635
654
  function sharedStock(config) {
636
- return config.inventory?.sharedStock ?? config.inventory?.global ?? null;
655
+ return config.inventory?.sharedStock ?? null;
637
656
  }
638
657
  function inventoryPlan(config, rewardId, stockLimit) {
639
- const individual = rewardStock(config, rewardId, stockLimit);
658
+ const reward = config.rewards.find((item) => item.id === rewardId);
659
+ const individual = reward === void 0 ? stockLimit ?? null : rewardStock(config, reward);
640
660
  const shared = sharedStock(config);
661
+ const explicitPrimaryKey = reward?.stockKey;
662
+ const explicitSecondaryKey = reward?.secondaryStockKey;
641
663
  if (config.inventoryMode === "shared" && shared !== null) {
642
664
  return {
643
- primaryKey: "__shared__",
644
- primaryInitial: shared,
645
- ...individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
665
+ primaryKey: explicitPrimaryKey ?? "__shared__",
666
+ primaryInitial: explicitPrimaryKey === void 0 ? shared : individual,
667
+ ...explicitSecondaryKey !== void 0 ? {
668
+ secondaryKey: explicitSecondaryKey,
669
+ secondaryInitial: config.inventory?.[explicitSecondaryKey] ?? individual
670
+ } : individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
646
671
  };
647
672
  }
648
- return { primaryKey: rewardId, primaryInitial: individual };
673
+ return {
674
+ primaryKey: explicitPrimaryKey ?? rewardId,
675
+ primaryInitial: individual,
676
+ ...explicitSecondaryKey === void 0 ? {} : {
677
+ secondaryKey: explicitSecondaryKey,
678
+ secondaryInitial: config.inventory?.[explicitSecondaryKey] ?? null
679
+ }
680
+ };
649
681
  }
650
682
  function getProof(context) {
651
683
  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;
@@ -680,7 +712,18 @@ function operationStatus(result) {
680
712
  if (result.ok) return "ACCEPTED";
681
713
  return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
682
714
  }
683
- function withDirectIdentity(request) {
715
+ function withDirectIdentity(request, authContext) {
716
+ if (authContext !== void 0) {
717
+ if (authContext.authenticatedUserId.trim() === "")
718
+ throw new RequestValidationException([
719
+ {
720
+ path: "authContext.authenticatedUserId",
721
+ message: "An authenticated userId is required.",
722
+ code: "REQUIRED"
723
+ }
724
+ ]);
725
+ return { ...request, userId: authContext.authenticatedUserId };
726
+ }
684
727
  if (request.userId !== void 0) return { ...request, userId: request.userId };
685
728
  if (request.anonymousSessionId !== void 0 && isUuidV4(request.anonymousSessionId))
686
729
  return { ...request, userId: request.anonymousSessionId };
@@ -796,22 +839,28 @@ var StampRallyServer = class {
796
839
  401
797
840
  );
798
841
  const sessionId = request.headers.get("x-anonymous-session-id");
842
+ const authContext = {
843
+ authenticatedUserId: userId,
844
+ ...sessionId === null ? {} : { isAnonymous: true, sessionId }
845
+ };
799
846
  try {
800
847
  return json({
801
848
  ok: true,
802
- state: await this.syncProgress({
803
- rallyId: body.data.rallyId,
804
- userId,
805
- ...sessionId === null ? {} : { anonymousSessionId: sessionId }
806
- })
849
+ state: await this.syncProgress(
850
+ {
851
+ rallyId: body.data.rallyId,
852
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
853
+ },
854
+ authContext
855
+ )
807
856
  });
808
857
  } catch (error) {
809
858
  if (error instanceof RequestValidationException) return validationResponse(error.errors);
810
859
  throw error;
811
860
  }
812
861
  }
813
- async checkIn(request) {
814
- const directRequest = withDirectIdentity(request);
862
+ async checkIn(request, authContext) {
863
+ const directRequest = withDirectIdentity(request, authContext);
815
864
  assertValidCheckInParams(directRequest, this.#config);
816
865
  const { userId } = directRequest;
817
866
  const key = `check-in:${request.rallyId}:${userId}:${request.idempotencyKey}`;
@@ -944,8 +993,8 @@ var StampRallyServer = class {
944
993
  await this.#persistence.releaseLock(request.rallyId, lockKey);
945
994
  }
946
995
  }
947
- async claimReward(request) {
948
- const directRequest = withDirectIdentity(request);
996
+ async claimReward(request, authContext) {
997
+ const directRequest = withDirectIdentity(request, authContext);
949
998
  assertValidClaimParams(directRequest, this.#config);
950
999
  const { userId } = directRequest;
951
1000
  const key = `claim:${request.rallyId}:${userId}:${request.rewardId}:${request.idempotencyKey}`;
@@ -963,13 +1012,16 @@ var StampRallyServer = class {
963
1012
  now(this.#options)
964
1013
  );
965
1014
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
966
- if (rewardStock(this.#config, reward.id, reward.stockLimit) !== null && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function"))
1015
+ if (plan.secondaryKey !== void 0 && this.#persistence.supportsSecondaryStock !== true)
1016
+ throw new Error("SECONDARY_STOCK_UNSUPPORTED");
1017
+ const inventoryEnabled = plan.primaryInitial !== null || plan.secondaryInitial !== void 0 && plan.secondaryInitial !== null;
1018
+ if (inventoryEnabled && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function" || typeof this.#persistence.executeClaimRewardTransaction !== "function"))
967
1019
  return this.#rememberClaim(
968
1020
  key,
969
1021
  {
970
1022
  ok: false,
971
- code: "INVENTORY_NOT_SUPPORTED",
972
- message: "This persistence adapter cannot store per-reward inventory."
1023
+ code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
1024
+ message: "This persistence adapter cannot atomically store inventory."
973
1025
  },
974
1026
  directRequest,
975
1027
  now(this.#options)
@@ -996,7 +1048,7 @@ var StampRallyServer = class {
996
1048
  rewardId: reward.id,
997
1049
  stockKey: plan.primaryKey,
998
1050
  ...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
999
- rewardStockLimit: rewardStock(this.#config, reward.id, reward.stockLimit),
1051
+ rewardStockLimit: rewardStock(this.#config, reward),
1000
1052
  sharedStockLimit: this.#config.inventoryMode === "shared" ? sharedStock(this.#config) : null,
1001
1053
  initialStock: plan.primaryInitial,
1002
1054
  ...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
@@ -1100,11 +1152,11 @@ var StampRallyServer = class {
1100
1152
  const response = responseHolder.value;
1101
1153
  if (!result.success) {
1102
1154
  if (response !== null && !response.ok && response.code === result.error) return response;
1103
- if (result.error === "INVENTORY_NOT_SUPPORTED")
1155
+ if (result.error === "INVENTORY_STORAGE_NOT_IMPLEMENTED")
1104
1156
  return {
1105
1157
  ok: false,
1106
- code: "INVENTORY_NOT_SUPPORTED",
1107
- message: "This persistence adapter cannot store per-reward inventory."
1158
+ code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
1159
+ message: "This persistence adapter cannot atomically store inventory."
1108
1160
  };
1109
1161
  return {
1110
1162
  ok: false,
@@ -1128,21 +1180,24 @@ var StampRallyServer = class {
1128
1180
  await this.#persistence.releaseLock(request.rallyId, lockKey);
1129
1181
  }
1130
1182
  }
1131
- async sync(rallyId, userId) {
1183
+ async sync(rallyId, identity) {
1184
+ const userId = typeof identity === "string" ? identity : identity.authenticatedUserId;
1132
1185
  assertValidSyncParams({ rallyId, userId }, this.#config);
1133
1186
  const state2 = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
1134
1187
  return this.#attachInventory(state2);
1135
1188
  }
1136
- async syncProgress(request) {
1137
- const directRequest = withDirectIdentity(request);
1189
+ async syncProgress(request, authContext) {
1190
+ const directRequest = withDirectIdentity(request, authContext);
1138
1191
  assertValidSyncParams(directRequest, this.#config);
1139
- return this.sync(directRequest.rallyId, directRequest.userId);
1192
+ return this.sync(directRequest.rallyId, authContext);
1140
1193
  }
1141
1194
  async #attachInventory(state2) {
1142
1195
  const rewardRemaining = {};
1143
1196
  for (const reward of this.#config.rewards) {
1144
1197
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
1145
1198
  if (plan.secondaryKey !== void 0) {
1199
+ if (this.#persistence.supportsSecondaryStock !== true)
1200
+ throw new Error("SECONDARY_STOCK_UNSUPPORTED");
1146
1201
  const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
1147
1202
  const remaining = stock ?? plan.secondaryInitial ?? null;
1148
1203
  if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
@@ -1312,6 +1367,15 @@ async function runPersistenceAdapterComplianceTests(createAdapter) {
1312
1367
  await adapter.getRewardStock("compliance-rally", "reward") === 0,
1313
1368
  "per-reward stock was not decremented atomically"
1314
1369
  );
1370
+ const exhausted = await adapter.executeClaimRewardTransaction(
1371
+ params("carol", "boundary"),
1372
+ mutation
1373
+ );
1374
+ assert(!exhausted.success, "a zero-stock claim was accepted");
1375
+ assert(
1376
+ await adapter.getRewardStock("compliance-rally", "__shared__") === 0 && await adapter.getRewardStock("compliance-rally", "reward") === 0,
1377
+ "zero-stock rejection did not preserve both stock boundaries"
1378
+ );
1315
1379
  const idempotentAdapter = await createAdapter();
1316
1380
  const idempotentParams = params("alice", "same-key");
1317
1381
  const firstClaim = await idempotentAdapter.executeClaimRewardTransaction(
@@ -1327,6 +1391,10 @@ async function runPersistenceAdapterComplianceTests(createAdapter) {
1327
1391
  await idempotentAdapter.getRewardStock("compliance-rally", "__shared__") === 0,
1328
1392
  "idempotent retry decremented shared stock twice"
1329
1393
  );
1394
+ assert(
1395
+ await idempotentAdapter.getRewardStock("compliance-rally", "reward") === 0,
1396
+ "idempotent retry decremented per-reward stock twice"
1397
+ );
1330
1398
  const rollbackAdapter = await createAdapter();
1331
1399
  const rollbackParams = params("alice", "rollback");
1332
1400
  const rollback = await rollbackAdapter.executeClaimRewardTransaction(rollbackParams, () => {