@stamprally/server 0.19.0 → 0.20.1

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.19.0
1
+ # @stamprally/server v0.20.1
2
2
 
3
3
  Web Standard `Request` / `Response` handlers for server-authoritative check-ins and reward claims.
4
4
 
@@ -26,6 +26,31 @@ Configure `anonymousPolicy: "session_scoped"` to use the UUID v4 from the
26
26
  HTTP 401 when no authenticated identity is present. Request validation failures use
27
27
  HTTP 400 with `{ error: "VALIDATION_FAILED", details: [...] }`.
28
28
 
29
+ ## Batch sync and trusted authentication
30
+
31
+ `syncProgress(request, authContext)` evaluates `operations` by their request
32
+ timestamp, preserving FIFO order for equal timestamps, and returns
33
+ `{ results, currentState, syncTimestamp }`. A permanently rejected
34
+ operation does not stop unrelated operations; a later operation depending on that
35
+ resource receives `REJECTED_PREREQUISITE_FAILED`. Retryable failures are returned
36
+ as `FAILED_RETRYABLE` so the client can retain them for the next request.
37
+
38
+ Direct server calls require a verified `TrustedAuthContext`:
39
+
40
+ ```ts
41
+ const progress = await server.syncProgress(
42
+ { rallyId: "city-tour", operations },
43
+ { authenticatedUserId: session.userId, claims: session.claims },
44
+ );
45
+ ```
46
+
47
+ The context identity is authoritative and overrides any caller-supplied `userId`.
48
+ For inventory, `stockKey` selects the primary bucket and `secondaryStockKey`
49
+ selects an additional bucket that must be decremented in the same transaction.
50
+ An adapter using a secondary key must expose `supportsSecondaryStock: true` and
51
+ persist both buckets atomically; otherwise the claim fails closed with
52
+ `SECONDARY_STOCK_UNSUPPORTED`.
53
+
29
54
  Hono can mount the handler directly because it accepts the same Web Standard request and response types.
30
55
 
31
56
  ## License
package/dist/index.cjs CHANGED
@@ -663,6 +663,15 @@ function timestampMillis(timestamp) {
663
663
  const value = Date.parse(timestamp);
664
664
  return Number.isFinite(value) ? value : Date.now();
665
665
  }
666
+ function operationTimestamp(operation) {
667
+ const value = operation.request.now;
668
+ if (typeof value === "number" && Number.isFinite(value)) return value;
669
+ if (typeof value === "string") {
670
+ const parsed = Date.parse(value);
671
+ if (Number.isFinite(parsed)) return parsed;
672
+ }
673
+ return Number.MAX_SAFE_INTEGER;
674
+ }
666
675
  function validationResponse(errors2) {
667
676
  return json(
668
677
  {
@@ -750,6 +759,38 @@ function operationStatus(result) {
750
759
  if (result.ok) return "ACCEPTED";
751
760
  return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
752
761
  }
762
+ function syncOperationId(operation, userId) {
763
+ return `${operation.kind}:${operation.request.rallyId}:${userId}:${operation.request.idempotencyKey}`;
764
+ }
765
+ function syncOperationResult(operation, userId, result) {
766
+ const operationId = syncOperationId(operation, userId);
767
+ const resourceId = operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
768
+ const action = operation.kind === "checkIn" ? "CHECK_IN" : "CLAIM_REWARD";
769
+ const status = operationStatus(result);
770
+ if (status === "ACCEPTED") {
771
+ return {
772
+ operationId,
773
+ status,
774
+ resourceId,
775
+ action,
776
+ appliedAt: timestampMillis(result.ok ? result.state.updatedAt : "")
777
+ };
778
+ }
779
+ if (status === "RETRYABLE_ERROR")
780
+ return {
781
+ operationId,
782
+ status: "FAILED_RETRYABLE",
783
+ resourceId,
784
+ error: result.ok ? "The operation can be retried." : result.message
785
+ };
786
+ return {
787
+ operationId,
788
+ status,
789
+ resourceId,
790
+ errorCode: result.ok ? "REJECTED_PERMANENT" : result.code,
791
+ reason: result.ok ? "The operation was rejected." : result.message
792
+ };
793
+ }
753
794
  function withDirectIdentity(request, authContext) {
754
795
  if (authContext !== void 0) {
755
796
  if (authContext.authenticatedUserId.trim() === "")
@@ -882,16 +923,17 @@ var StampRallyServer = class {
882
923
  ...sessionId === null ? {} : { isAnonymous: true, sessionId }
883
924
  };
884
925
  try {
926
+ const progress = await this.syncProgress(
927
+ {
928
+ rallyId: body.data.rallyId,
929
+ ...body.data.operations === void 0 ? {} : { operations: body.data.operations },
930
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
931
+ },
932
+ authContext
933
+ );
885
934
  return json({
886
935
  ok: true,
887
- state: await this.syncProgress(
888
- {
889
- rallyId: body.data.rallyId,
890
- ...body.data.operations === void 0 ? {} : { operations: body.data.operations },
891
- ...sessionId === null ? {} : { anonymousSessionId: sessionId }
892
- },
893
- authContext
894
- )
936
+ ...progress
895
937
  });
896
938
  } catch (error) {
897
939
  if (error instanceof RequestValidationException) return validationResponse(error.errors);
@@ -1228,11 +1270,39 @@ var StampRallyServer = class {
1228
1270
  async syncProgress(request, authContext) {
1229
1271
  const directRequest = withDirectIdentity(request, authContext);
1230
1272
  assertValidSyncParams(directRequest, this.#config);
1231
- for (const operation of request.operations ?? []) {
1232
- if (operation.kind === "checkIn") await this.checkIn(operation.request, authContext);
1233
- else await this.claimReward(operation.request, authContext);
1273
+ const results = [];
1274
+ const rejectedCheckIns = /* @__PURE__ */ new Set();
1275
+ const operations = [...request.operations ?? []].map((operation, index) => ({ operation, index })).sort(
1276
+ (left, right) => operationTimestamp(left.operation) - operationTimestamp(right.operation) || left.index - right.index
1277
+ ).map(({ operation }) => operation);
1278
+ for (const operation of operations) {
1279
+ const resourceId = operation.kind === "checkIn" ? operation.request.spotId : operation.request.rewardId;
1280
+ if (operation.kind === "checkIn") {
1281
+ const spot = this.#config.spots.find((candidate) => candidate.id === resourceId);
1282
+ if (spot?.prerequisites?.some((prerequisite) => rejectedCheckIns.has(prerequisite))) {
1283
+ results.push({
1284
+ operationId: syncOperationId(operation, directRequest.userId),
1285
+ status: "REJECTED_PERMANENT",
1286
+ resourceId,
1287
+ errorCode: "REJECTED_PREREQUISITE_FAILED",
1288
+ reason: "A prerequisite operation was rejected by the server."
1289
+ });
1290
+ rejectedCheckIns.add(resourceId);
1291
+ continue;
1292
+ }
1293
+ }
1294
+ const result = operation.kind === "checkIn" ? await this.checkIn(operation.request, authContext) : await this.claimReward(operation.request, authContext);
1295
+ const operationResult = syncOperationResult(operation, directRequest.userId, result);
1296
+ results.push(operationResult);
1297
+ if (operation.kind === "checkIn" && operationResult.status === "REJECTED_PERMANENT")
1298
+ rejectedCheckIns.add(resourceId);
1234
1299
  }
1235
- return this.sync(directRequest.rallyId, authContext);
1300
+ const currentState = await this.sync(directRequest.rallyId, authContext);
1301
+ return {
1302
+ results,
1303
+ currentState,
1304
+ syncTimestamp: timestampMillis(now(this.#options))
1305
+ };
1236
1306
  }
1237
1307
  async #attachInventory(state2) {
1238
1308
  const rewardRemaining = {};