@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/dist/index.d.cts CHANGED
@@ -2,6 +2,16 @@ import * as _stamprally_core from '@stamprally/core';
2
2
  import { UserRallyState, AdminRallyConfig } from '@stamprally/core';
3
3
  export { UserRallyState } from '@stamprally/core';
4
4
 
5
+ /** Authentication already verified by the host application's middleware. */
6
+ interface TrustedAuthContext {
7
+ readonly authenticatedUserId: string;
8
+ readonly isAnonymous?: boolean;
9
+ readonly sessionId?: string;
10
+ readonly claims?: Record<string, unknown>;
11
+ }
12
+ /** @deprecated Use TrustedAuthContext. */
13
+ type AuthenticationContext = TrustedAuthContext;
14
+
5
15
  interface UserClaimRecord {
6
16
  readonly rallyId: string;
7
17
  readonly userId: string;
@@ -39,6 +49,16 @@ interface ClaimRewardTransactionMutation {
39
49
  /** A domain rejection is committed as an audit/idempotency record without mutations. */
40
50
  readonly error?: string;
41
51
  }
52
+ interface ClaimRewardMutationContext {
53
+ readonly rewardStock: number | null;
54
+ readonly sharedStock: number | null;
55
+ readonly primaryStock: number | null;
56
+ readonly secondaryStock: number | null;
57
+ /** @deprecated Use rewardStock. */
58
+ readonly stock: number | null;
59
+ readonly claimCount: number;
60
+ readonly userState: UserRallyState;
61
+ }
42
62
  interface CheckInTransactionParams {
43
63
  readonly rallyId: string;
44
64
  readonly userId: string;
@@ -67,17 +87,14 @@ interface ServerPersistenceAdapter {
67
87
  * Atomically commits stock, user state, claim count, audit log, and idempotency data.
68
88
  * Adapters must roll back every write when the callback or any commit operation fails.
69
89
  */
70
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
71
- readonly stock: number | null;
72
- readonly secondaryStock: number | null;
73
- readonly claimCount: number;
74
- readonly userState: UserRallyState;
75
- }) => ClaimRewardTransactionMutation): Promise<{
90
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
76
91
  readonly success: boolean;
77
92
  readonly error?: string;
78
93
  }>;
79
94
  /** Set false when the adapter cannot persist per-reward inventory atomically. */
80
95
  readonly supportsRewardStock?: boolean;
96
+ /** Must be true when a claim plan uses `secondaryStockKey`. */
97
+ readonly supportsSecondaryStock?: boolean;
81
98
  acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
82
99
  releaseLock(rallyId: string, lockKey: string): Promise<void>;
83
100
  getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
@@ -101,7 +118,8 @@ interface InMemoryServerPersistenceOptions {
101
118
  }
102
119
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
103
120
  #private;
104
- readonly supportsRewardStock = true;
121
+ readonly supportsRewardStock: boolean;
122
+ readonly supportsSecondaryStock: boolean;
105
123
  constructor(options?: InMemoryServerPersistenceOptions);
106
124
  acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
107
125
  releaseLock(rallyId: string, key: string): Promise<void>;
@@ -111,12 +129,7 @@ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapt
111
129
  remainingStock: number;
112
130
  }>;
113
131
  restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
114
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
115
- readonly stock: number | null;
116
- readonly secondaryStock: number | null;
117
- readonly claimCount: number;
118
- readonly userState: UserRallyState;
119
- }) => ClaimRewardTransactionMutation): Promise<{
132
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
120
133
  readonly success: boolean;
121
134
  readonly error?: string;
122
135
  }>;
@@ -175,11 +188,7 @@ interface SqlClaimRewardStore<Tx> {
175
188
  * while unexpected write failures reject the transaction and roll everything
176
189
  * back.
177
190
  */
178
- declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: {
179
- readonly stock: number | null;
180
- readonly claimCount: number;
181
- readonly userState: UserRallyState;
182
- }) => ClaimRewardTransactionMutation): Promise<{
191
+ declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
183
192
  readonly success: boolean;
184
193
  readonly error?: string;
185
194
  }>;
@@ -263,14 +272,14 @@ declare class StampRallyServer {
263
272
  handleCheckIn(request: Request): Promise<Response>;
264
273
  handleClaimReward(request: Request): Promise<Response>;
265
274
  handleSync(request: Request): Promise<Response>;
266
- checkIn(request: CheckInRequest): Promise<CheckInResponse>;
267
- claimReward(request: ClaimRewardRequest): Promise<ClaimResponse>;
268
- sync(rallyId: string, userId: string): Promise<UserRallyState>;
275
+ checkIn(request: CheckInRequest, authContext?: TrustedAuthContext): Promise<CheckInResponse>;
276
+ claimReward(request: ClaimRewardRequest, authContext?: TrustedAuthContext): Promise<ClaimResponse>;
277
+ sync(rallyId: string, identity: string | TrustedAuthContext): Promise<UserRallyState>;
269
278
  syncProgress(request: {
270
279
  readonly rallyId: string;
271
280
  readonly userId?: string;
272
281
  readonly anonymousSessionId?: string;
273
- }): Promise<UserRallyState>;
282
+ }, authContext: TrustedAuthContext): Promise<UserRallyState>;
274
283
  }
275
284
 
276
285
  /**
@@ -323,13 +332,10 @@ type CheckInResponse = {
323
332
  interface ServerOptions {
324
333
  readonly lockTtlMs?: number;
325
334
  readonly idempotencyTtlMs?: number;
326
- readonly authenticate?: (request: Request) => Promise<string | AuthenticationContext | null> | string | AuthenticationContext | null;
335
+ readonly authenticate?: (request: Request) => Promise<string | TrustedAuthContext | null> | string | TrustedAuthContext | null;
327
336
  readonly customValidators?: Readonly<Record<string, _stamprally_core.Validator>>;
328
337
  readonly now?: () => string;
329
338
  readonly anonymousPolicy?: "session_scoped" | "reject" | "shared_global_opt_in_insecure";
330
339
  }
331
- interface AuthenticationContext {
332
- readonly authenticatedUserId: string;
333
- }
334
340
 
335
- export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
341
+ export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,16 @@ import * as _stamprally_core from '@stamprally/core';
2
2
  import { UserRallyState, AdminRallyConfig } from '@stamprally/core';
3
3
  export { UserRallyState } from '@stamprally/core';
4
4
 
5
+ /** Authentication already verified by the host application's middleware. */
6
+ interface TrustedAuthContext {
7
+ readonly authenticatedUserId: string;
8
+ readonly isAnonymous?: boolean;
9
+ readonly sessionId?: string;
10
+ readonly claims?: Record<string, unknown>;
11
+ }
12
+ /** @deprecated Use TrustedAuthContext. */
13
+ type AuthenticationContext = TrustedAuthContext;
14
+
5
15
  interface UserClaimRecord {
6
16
  readonly rallyId: string;
7
17
  readonly userId: string;
@@ -39,6 +49,16 @@ interface ClaimRewardTransactionMutation {
39
49
  /** A domain rejection is committed as an audit/idempotency record without mutations. */
40
50
  readonly error?: string;
41
51
  }
52
+ interface ClaimRewardMutationContext {
53
+ readonly rewardStock: number | null;
54
+ readonly sharedStock: number | null;
55
+ readonly primaryStock: number | null;
56
+ readonly secondaryStock: number | null;
57
+ /** @deprecated Use rewardStock. */
58
+ readonly stock: number | null;
59
+ readonly claimCount: number;
60
+ readonly userState: UserRallyState;
61
+ }
42
62
  interface CheckInTransactionParams {
43
63
  readonly rallyId: string;
44
64
  readonly userId: string;
@@ -67,17 +87,14 @@ interface ServerPersistenceAdapter {
67
87
  * Atomically commits stock, user state, claim count, audit log, and idempotency data.
68
88
  * Adapters must roll back every write when the callback or any commit operation fails.
69
89
  */
70
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
71
- readonly stock: number | null;
72
- readonly secondaryStock: number | null;
73
- readonly claimCount: number;
74
- readonly userState: UserRallyState;
75
- }) => ClaimRewardTransactionMutation): Promise<{
90
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
76
91
  readonly success: boolean;
77
92
  readonly error?: string;
78
93
  }>;
79
94
  /** Set false when the adapter cannot persist per-reward inventory atomically. */
80
95
  readonly supportsRewardStock?: boolean;
96
+ /** Must be true when a claim plan uses `secondaryStockKey`. */
97
+ readonly supportsSecondaryStock?: boolean;
81
98
  acquireLock(rallyId: string, lockKey: string, ttlMs: number): Promise<boolean>;
82
99
  releaseLock(rallyId: string, lockKey: string): Promise<void>;
83
100
  getRewardStock(rallyId: string, rewardId: string): Promise<number | null>;
@@ -101,7 +118,8 @@ interface InMemoryServerPersistenceOptions {
101
118
  }
102
119
  declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapter {
103
120
  #private;
104
- readonly supportsRewardStock = true;
121
+ readonly supportsRewardStock: boolean;
122
+ readonly supportsSecondaryStock: boolean;
105
123
  constructor(options?: InMemoryServerPersistenceOptions);
106
124
  acquireLock(rallyId: string, key: string, ttlMs: number): Promise<boolean>;
107
125
  releaseLock(rallyId: string, key: string): Promise<void>;
@@ -111,12 +129,7 @@ declare class InMemoryServerPersistenceAdapter implements ServerPersistenceAdapt
111
129
  remainingStock: number;
112
130
  }>;
113
131
  restoreRewardStock(rallyId: string, rewardId: string, count?: number): Promise<void>;
114
- executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: {
115
- readonly stock: number | null;
116
- readonly secondaryStock: number | null;
117
- readonly claimCount: number;
118
- readonly userState: UserRallyState;
119
- }) => ClaimRewardTransactionMutation): Promise<{
132
+ executeClaimRewardTransaction(params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
120
133
  readonly success: boolean;
121
134
  readonly error?: string;
122
135
  }>;
@@ -175,11 +188,7 @@ interface SqlClaimRewardStore<Tx> {
175
188
  * while unexpected write failures reject the transaction and roll everything
176
189
  * back.
177
190
  */
178
- declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: {
179
- readonly stock: number | null;
180
- readonly claimCount: number;
181
- readonly userState: UserRallyState;
182
- }) => ClaimRewardTransactionMutation): Promise<{
191
+ declare function executeClaimRewardTransaction<Tx>(database: SqlTransactionDatabase<Tx>, store: SqlClaimRewardStore<Tx>, params: ClaimRewardTransactionParams, mutation: (current: ClaimRewardMutationContext) => ClaimRewardTransactionMutation): Promise<{
183
192
  readonly success: boolean;
184
193
  readonly error?: string;
185
194
  }>;
@@ -263,14 +272,14 @@ declare class StampRallyServer {
263
272
  handleCheckIn(request: Request): Promise<Response>;
264
273
  handleClaimReward(request: Request): Promise<Response>;
265
274
  handleSync(request: Request): Promise<Response>;
266
- checkIn(request: CheckInRequest): Promise<CheckInResponse>;
267
- claimReward(request: ClaimRewardRequest): Promise<ClaimResponse>;
268
- sync(rallyId: string, userId: string): Promise<UserRallyState>;
275
+ checkIn(request: CheckInRequest, authContext?: TrustedAuthContext): Promise<CheckInResponse>;
276
+ claimReward(request: ClaimRewardRequest, authContext?: TrustedAuthContext): Promise<ClaimResponse>;
277
+ sync(rallyId: string, identity: string | TrustedAuthContext): Promise<UserRallyState>;
269
278
  syncProgress(request: {
270
279
  readonly rallyId: string;
271
280
  readonly userId?: string;
272
281
  readonly anonymousSessionId?: string;
273
- }): Promise<UserRallyState>;
282
+ }, authContext: TrustedAuthContext): Promise<UserRallyState>;
274
283
  }
275
284
 
276
285
  /**
@@ -323,13 +332,10 @@ type CheckInResponse = {
323
332
  interface ServerOptions {
324
333
  readonly lockTtlMs?: number;
325
334
  readonly idempotencyTtlMs?: number;
326
- readonly authenticate?: (request: Request) => Promise<string | AuthenticationContext | null> | string | AuthenticationContext | null;
335
+ readonly authenticate?: (request: Request) => Promise<string | TrustedAuthContext | null> | string | TrustedAuthContext | null;
327
336
  readonly customValidators?: Readonly<Record<string, _stamprally_core.Validator>>;
328
337
  readonly now?: () => string;
329
338
  readonly anonymousPolicy?: "session_scoped" | "reject" | "shared_global_opt_in_insecure";
330
339
  }
331
- interface AuthenticationContext {
332
- readonly authenticatedUserId: string;
333
- }
334
340
 
335
- export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
341
+ export { type AuditLog, type AuthenticationContext, type CheckInRequest, type CheckInResponse, type CheckInTransactionMutation, type CheckInTransactionParams, type ClaimRewardMutationContext, type ClaimRewardRequest, type ClaimRewardTransactionMutation, type ClaimRewardTransactionParams, InMemoryServerPersistenceAdapter, type InMemoryServerPersistenceOptions, type RedisMultiExecutor, type RequestValidationError, RequestValidationException, type RequestValidationResult, type ServerOptions, type ServerPersistenceAdapter, type SqlCheckInStore, type SqlClaimRewardStore, type SqlTransactionDatabase, StampRallyServer, type SyncOperationStatus, type TrustedAuthContext, type UserClaimRecord, assertValidCheckInParams, assertValidClaimParams, assertValidSyncParams, executeCheckInTransaction, executeClaimRewardTransaction, executeRedisTransaction, runPersistenceAdapterComplianceTests, validateCheckInRequest, validateClaimRewardRequest, validateSyncRequest };
package/dist/index.js CHANGED
@@ -5,7 +5,16 @@ async function executeClaimRewardTransaction(database, store, params2, mutation2
5
5
  try {
6
6
  return await database.transaction(async (transaction) => {
7
7
  const current = await store.readContext(transaction, params2);
8
- const next = mutation2(current);
8
+ const secondaryStock = current.secondaryStock ?? null;
9
+ const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : current.stock;
10
+ const next = mutation2({
11
+ ...current,
12
+ rewardStock: rewardStock2,
13
+ sharedStock: params2.stockKey === "__shared__" ? current.stock : null,
14
+ primaryStock: rewardStock2,
15
+ secondaryStock,
16
+ stock: current.stock
17
+ });
9
18
  if (next.error !== void 0) {
10
19
  await store.writeAudit(transaction, next.auditLog);
11
20
  if (params2.idempotencyKey !== void 0 && next.result !== void 0)
@@ -13,7 +22,7 @@ async function executeClaimRewardTransaction(database, store, params2, mutation2
13
22
  return { success: false, error: next.error };
14
23
  }
15
24
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock === void 0)
16
- return { success: false, error: "INVENTORY_NOT_SUPPORTED" };
25
+ return { success: false, error: "INVENTORY_STORAGE_NOT_IMPLEMENTED" };
17
26
  if (next.nextStock !== null) await store.writeStock(transaction, params2, next.nextStock);
18
27
  if (next.nextSecondaryStock !== void 0 && store.writeSecondaryStock !== void 0) {
19
28
  if (next.nextSecondaryStock !== null)
@@ -67,6 +76,7 @@ async function executeRedisTransaction(redis, queue) {
67
76
  // src/persistence.ts
68
77
  var InMemoryServerPersistenceAdapter = class {
69
78
  supportsRewardStock = true;
79
+ supportsSecondaryStock = true;
70
80
  #locks = /* @__PURE__ */ new Map();
71
81
  #idempotent = /* @__PURE__ */ new Map();
72
82
  #states = /* @__PURE__ */ new Map();
@@ -144,9 +154,16 @@ var InMemoryServerPersistenceAdapter = class {
144
154
  params2.stockKey ?? params2.rewardId
145
155
  );
146
156
  const storedSecondaryStock = params2.secondaryStockKey === void 0 ? null : await this.getRewardStock(params2.rallyId, params2.secondaryStockKey);
157
+ const stock = storedStock ?? initialStock;
158
+ const secondaryStock = storedSecondaryStock ?? initialSecondaryStock;
159
+ const rewardStock2 = params2.stockKey === "__shared__" ? secondaryStock : stock;
160
+ const sharedStock2 = params2.stockKey === "__shared__" ? stock : null;
147
161
  const mutationResult = mutation2({
148
- stock: storedStock ?? initialStock,
149
- secondaryStock: storedSecondaryStock ?? initialSecondaryStock,
162
+ rewardStock: rewardStock2,
163
+ sharedStock: sharedStock2,
164
+ primaryStock: rewardStock2,
165
+ secondaryStock,
166
+ stock,
150
167
  claimCount: await this.getUserClaimCount(params2.rallyId, params2.userId, params2.rewardId),
151
168
  userState
152
169
  });
@@ -624,26 +641,41 @@ function initialState(config, userId, timestamp) {
624
641
  updatedAt: timestamp
625
642
  };
626
643
  }
627
- function rewardStock(config, rewardId, stockLimit) {
628
- const configured = config.inventory?.[rewardId];
644
+ function rewardStock(config, reward) {
645
+ const stockLimit = reward.stockLimit;
646
+ const key = reward.stockKey ?? reward.id;
647
+ const configured = key === "__shared__" ? config.inventory?.sharedStock : config.inventory?.[key];
629
648
  if (stockLimit === void 0) return configured ?? null;
630
649
  if (configured === void 0) return stockLimit;
631
650
  return Math.min(stockLimit, configured);
632
651
  }
633
652
  function sharedStock(config) {
634
- return config.inventory?.sharedStock ?? config.inventory?.global ?? null;
653
+ return config.inventory?.sharedStock ?? null;
635
654
  }
636
655
  function inventoryPlan(config, rewardId, stockLimit) {
637
- const individual = rewardStock(config, rewardId, stockLimit);
656
+ const reward = config.rewards.find((item) => item.id === rewardId);
657
+ const individual = reward === void 0 ? stockLimit ?? null : rewardStock(config, reward);
638
658
  const shared = sharedStock(config);
659
+ const explicitPrimaryKey = reward?.stockKey;
660
+ const explicitSecondaryKey = reward?.secondaryStockKey;
639
661
  if (config.inventoryMode === "shared" && shared !== null) {
640
662
  return {
641
- primaryKey: "__shared__",
642
- primaryInitial: shared,
643
- ...individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
663
+ primaryKey: explicitPrimaryKey ?? "__shared__",
664
+ primaryInitial: explicitPrimaryKey === void 0 ? shared : individual,
665
+ ...explicitSecondaryKey !== void 0 ? {
666
+ secondaryKey: explicitSecondaryKey,
667
+ secondaryInitial: config.inventory?.[explicitSecondaryKey] ?? individual
668
+ } : individual === null ? {} : { secondaryKey: rewardId, secondaryInitial: individual }
644
669
  };
645
670
  }
646
- return { primaryKey: rewardId, primaryInitial: individual };
671
+ return {
672
+ primaryKey: explicitPrimaryKey ?? rewardId,
673
+ primaryInitial: individual,
674
+ ...explicitSecondaryKey === void 0 ? {} : {
675
+ secondaryKey: explicitSecondaryKey,
676
+ secondaryInitial: config.inventory?.[explicitSecondaryKey] ?? null
677
+ }
678
+ };
647
679
  }
648
680
  function getProof(context) {
649
681
  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;
@@ -678,7 +710,18 @@ function operationStatus(result) {
678
710
  if (result.ok) return "ACCEPTED";
679
711
  return result.code === "CONFLICT" || result.code === "PERSISTENCE_FAILED" ? "RETRYABLE_ERROR" : "REJECTED_PERMANENT";
680
712
  }
681
- function withDirectIdentity(request) {
713
+ function withDirectIdentity(request, authContext) {
714
+ if (authContext !== void 0) {
715
+ if (authContext.authenticatedUserId.trim() === "")
716
+ throw new RequestValidationException([
717
+ {
718
+ path: "authContext.authenticatedUserId",
719
+ message: "An authenticated userId is required.",
720
+ code: "REQUIRED"
721
+ }
722
+ ]);
723
+ return { ...request, userId: authContext.authenticatedUserId };
724
+ }
682
725
  if (request.userId !== void 0) return { ...request, userId: request.userId };
683
726
  if (request.anonymousSessionId !== void 0 && isUuidV4(request.anonymousSessionId))
684
727
  return { ...request, userId: request.anonymousSessionId };
@@ -794,22 +837,28 @@ var StampRallyServer = class {
794
837
  401
795
838
  );
796
839
  const sessionId = request.headers.get("x-anonymous-session-id");
840
+ const authContext = {
841
+ authenticatedUserId: userId,
842
+ ...sessionId === null ? {} : { isAnonymous: true, sessionId }
843
+ };
797
844
  try {
798
845
  return json({
799
846
  ok: true,
800
- state: await this.syncProgress({
801
- rallyId: body.data.rallyId,
802
- userId,
803
- ...sessionId === null ? {} : { anonymousSessionId: sessionId }
804
- })
847
+ state: await this.syncProgress(
848
+ {
849
+ rallyId: body.data.rallyId,
850
+ ...sessionId === null ? {} : { anonymousSessionId: sessionId }
851
+ },
852
+ authContext
853
+ )
805
854
  });
806
855
  } catch (error) {
807
856
  if (error instanceof RequestValidationException) return validationResponse(error.errors);
808
857
  throw error;
809
858
  }
810
859
  }
811
- async checkIn(request) {
812
- const directRequest = withDirectIdentity(request);
860
+ async checkIn(request, authContext) {
861
+ const directRequest = withDirectIdentity(request, authContext);
813
862
  assertValidCheckInParams(directRequest, this.#config);
814
863
  const { userId } = directRequest;
815
864
  const key = `check-in:${request.rallyId}:${userId}:${request.idempotencyKey}`;
@@ -942,8 +991,8 @@ var StampRallyServer = class {
942
991
  await this.#persistence.releaseLock(request.rallyId, lockKey);
943
992
  }
944
993
  }
945
- async claimReward(request) {
946
- const directRequest = withDirectIdentity(request);
994
+ async claimReward(request, authContext) {
995
+ const directRequest = withDirectIdentity(request, authContext);
947
996
  assertValidClaimParams(directRequest, this.#config);
948
997
  const { userId } = directRequest;
949
998
  const key = `claim:${request.rallyId}:${userId}:${request.rewardId}:${request.idempotencyKey}`;
@@ -961,13 +1010,16 @@ var StampRallyServer = class {
961
1010
  now(this.#options)
962
1011
  );
963
1012
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
964
- if (rewardStock(this.#config, reward.id, reward.stockLimit) !== null && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function"))
1013
+ if (plan.secondaryKey !== void 0 && this.#persistence.supportsSecondaryStock !== true)
1014
+ throw new Error("SECONDARY_STOCK_UNSUPPORTED");
1015
+ const inventoryEnabled = plan.primaryInitial !== null || plan.secondaryInitial !== void 0 && plan.secondaryInitial !== null;
1016
+ if (inventoryEnabled && (this.#persistence.supportsRewardStock === false || typeof this.#persistence.getRewardStock !== "function" || typeof this.#persistence.executeClaimRewardTransaction !== "function"))
965
1017
  return this.#rememberClaim(
966
1018
  key,
967
1019
  {
968
1020
  ok: false,
969
- code: "INVENTORY_NOT_SUPPORTED",
970
- message: "This persistence adapter cannot store per-reward inventory."
1021
+ code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
1022
+ message: "This persistence adapter cannot atomically store inventory."
971
1023
  },
972
1024
  directRequest,
973
1025
  now(this.#options)
@@ -994,7 +1046,7 @@ var StampRallyServer = class {
994
1046
  rewardId: reward.id,
995
1047
  stockKey: plan.primaryKey,
996
1048
  ...plan.secondaryKey === void 0 ? {} : { secondaryStockKey: plan.secondaryKey },
997
- rewardStockLimit: rewardStock(this.#config, reward.id, reward.stockLimit),
1049
+ rewardStockLimit: rewardStock(this.#config, reward),
998
1050
  sharedStockLimit: this.#config.inventoryMode === "shared" ? sharedStock(this.#config) : null,
999
1051
  initialStock: plan.primaryInitial,
1000
1052
  ...plan.secondaryInitial === void 0 ? {} : { initialSecondaryStock: plan.secondaryInitial },
@@ -1098,11 +1150,11 @@ var StampRallyServer = class {
1098
1150
  const response = responseHolder.value;
1099
1151
  if (!result.success) {
1100
1152
  if (response !== null && !response.ok && response.code === result.error) return response;
1101
- if (result.error === "INVENTORY_NOT_SUPPORTED")
1153
+ if (result.error === "INVENTORY_STORAGE_NOT_IMPLEMENTED")
1102
1154
  return {
1103
1155
  ok: false,
1104
- code: "INVENTORY_NOT_SUPPORTED",
1105
- message: "This persistence adapter cannot store per-reward inventory."
1156
+ code: "INVENTORY_STORAGE_NOT_IMPLEMENTED",
1157
+ message: "This persistence adapter cannot atomically store inventory."
1106
1158
  };
1107
1159
  return {
1108
1160
  ok: false,
@@ -1126,21 +1178,24 @@ var StampRallyServer = class {
1126
1178
  await this.#persistence.releaseLock(request.rallyId, lockKey);
1127
1179
  }
1128
1180
  }
1129
- async sync(rallyId, userId) {
1181
+ async sync(rallyId, identity) {
1182
+ const userId = typeof identity === "string" ? identity : identity.authenticatedUserId;
1130
1183
  assertValidSyncParams({ rallyId, userId }, this.#config);
1131
1184
  const state2 = await this.#persistence.getUserState(rallyId, userId) ?? initialState(this.#config, userId, now(this.#options));
1132
1185
  return this.#attachInventory(state2);
1133
1186
  }
1134
- async syncProgress(request) {
1135
- const directRequest = withDirectIdentity(request);
1187
+ async syncProgress(request, authContext) {
1188
+ const directRequest = withDirectIdentity(request, authContext);
1136
1189
  assertValidSyncParams(directRequest, this.#config);
1137
- return this.sync(directRequest.rallyId, directRequest.userId);
1190
+ return this.sync(directRequest.rallyId, authContext);
1138
1191
  }
1139
1192
  async #attachInventory(state2) {
1140
1193
  const rewardRemaining = {};
1141
1194
  for (const reward of this.#config.rewards) {
1142
1195
  const plan = inventoryPlan(this.#config, reward.id, reward.stockLimit);
1143
1196
  if (plan.secondaryKey !== void 0) {
1197
+ if (this.#persistence.supportsSecondaryStock !== true)
1198
+ throw new Error("SECONDARY_STOCK_UNSUPPORTED");
1144
1199
  const stock = await this.#persistence.getRewardStock(state2.rallyId, plan.secondaryKey);
1145
1200
  const remaining = stock ?? plan.secondaryInitial ?? null;
1146
1201
  if (remaining !== null) rewardRemaining[reward.id] = Math.max(0, remaining);
@@ -1310,6 +1365,15 @@ async function runPersistenceAdapterComplianceTests(createAdapter) {
1310
1365
  await adapter.getRewardStock("compliance-rally", "reward") === 0,
1311
1366
  "per-reward stock was not decremented atomically"
1312
1367
  );
1368
+ const exhausted = await adapter.executeClaimRewardTransaction(
1369
+ params("carol", "boundary"),
1370
+ mutation
1371
+ );
1372
+ assert(!exhausted.success, "a zero-stock claim was accepted");
1373
+ assert(
1374
+ await adapter.getRewardStock("compliance-rally", "__shared__") === 0 && await adapter.getRewardStock("compliance-rally", "reward") === 0,
1375
+ "zero-stock rejection did not preserve both stock boundaries"
1376
+ );
1313
1377
  const idempotentAdapter = await createAdapter();
1314
1378
  const idempotentParams = params("alice", "same-key");
1315
1379
  const firstClaim = await idempotentAdapter.executeClaimRewardTransaction(
@@ -1325,6 +1389,10 @@ async function runPersistenceAdapterComplianceTests(createAdapter) {
1325
1389
  await idempotentAdapter.getRewardStock("compliance-rally", "__shared__") === 0,
1326
1390
  "idempotent retry decremented shared stock twice"
1327
1391
  );
1392
+ assert(
1393
+ await idempotentAdapter.getRewardStock("compliance-rally", "reward") === 0,
1394
+ "idempotent retry decremented per-reward stock twice"
1395
+ );
1328
1396
  const rollbackAdapter = await createAdapter();
1329
1397
  const rollbackParams = params("alice", "rollback");
1330
1398
  const rollback = await rollbackAdapter.executeClaimRewardTransaction(rollbackParams, () => {