@mpgd/game-services 0.26.0 → 0.27.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/dist/client.d.ts +2 -11
- package/dist/client.js +2 -25
- package/dist/microsoft-store-purchase.d.ts +9 -5
- package/dist/microsoft-store-purchase.js +175 -43
- package/dist/notifications/ait-functional-delivery.d.ts +25 -8
- package/dist/notifications/ait-functional-delivery.js +38 -17
- package/dist/notifications/client.d.ts +168 -0
- package/dist/notifications/client.js +432 -0
- package/dist/notifications/fcm.d.ts +15 -1
- package/dist/notifications/fcm.js +107 -5
- package/dist/notifications/service.d.ts +4 -1
- package/dist/notifications/service.js +221 -37
- package/dist/notifications/store-conformance.js +19 -0
- package/dist/notifications/store.d.ts +5 -1
- package/dist/notifications/store.js +1 -1
- package/dist/request-headers.d.ts +16 -0
- package/dist/request-headers.js +30 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +9 -0
- package/package.json +9 -5
package/dist/client.d.ts
CHANGED
|
@@ -7,8 +7,10 @@ import type { MonetizationOperationStore } from './monetization-recovery.js';
|
|
|
7
7
|
import type { GameServicesOperationClient } from './operations.js';
|
|
8
8
|
export type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult, } from './operations.js';
|
|
9
9
|
import type { GameServicesContractClient } from './contract.js';
|
|
10
|
+
import { type GameServicesHeaderResolver } from './request-headers.js';
|
|
10
11
|
import type { ClaimAdRewardRequest, ClaimAdRewardResponse, GameServicesLedgerTarget, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
|
|
11
12
|
export type { GameServicesOperationKind, GameServicesOperationLocation, GameServicesOperationOptions, GameServicesOperationProgress, GameServicesPurchaseProgress, GameServicesRewardedAdProgress, } from './operation-progress.js';
|
|
13
|
+
export { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, type GameServicesHeaderResolver, } from './request-headers.js';
|
|
12
14
|
export interface PurchaseVerificationApi {
|
|
13
15
|
verifyPurchase(input: VerifyPurchaseRequest): Promise<VerifyPurchaseResponse>;
|
|
14
16
|
/**
|
|
@@ -75,7 +77,6 @@ export interface CreateGameServicesOrpcClientInput {
|
|
|
75
77
|
readonly headers?: Record<string, string>;
|
|
76
78
|
readonly getHeaders?: GameServicesHeaderResolver;
|
|
77
79
|
}
|
|
78
|
-
export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
|
|
79
80
|
export type GameServicesFetch = (url: string, init: {
|
|
80
81
|
readonly method: 'POST';
|
|
81
82
|
readonly headers: Record<string, string>;
|
|
@@ -96,16 +97,6 @@ export declare class GameServicesBackendTransportError extends Error {
|
|
|
96
97
|
readonly endpoint: GameServicesBackendEndpoint;
|
|
97
98
|
constructor(endpoint: GameServicesBackendEndpoint);
|
|
98
99
|
}
|
|
99
|
-
/** Credentials could not be resolved; no backend request was dispatched. */
|
|
100
|
-
export declare class GameServicesHeaderResolutionError extends Error {
|
|
101
|
-
constructor();
|
|
102
|
-
}
|
|
103
|
-
/** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
|
|
104
|
-
export declare function resolveGameServicesRequestHeaders(input: {
|
|
105
|
-
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
106
|
-
readonly getHeaders?: GameServicesHeaderResolver | undefined;
|
|
107
|
-
readonly requestHeaders?: Readonly<Record<string, string>> | undefined;
|
|
108
|
-
}): Promise<Readonly<Record<string, string>>>;
|
|
109
100
|
export interface GameServicesClient extends GameServicesOperationClient {
|
|
110
101
|
submitLeaderboardScore(input: GameServicesLeaderboardInput): Promise<GameServicesLeaderboardResult>;
|
|
111
102
|
/**
|
package/dist/client.js
CHANGED
|
@@ -6,6 +6,8 @@ import { createCommerceReconciler, toGameServicesPurchaseResult, } from './comme
|
|
|
6
6
|
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
7
7
|
import { createInMemoryMonetizationOperationStore } from './monetization-operation-store.js';
|
|
8
8
|
import { observeGameServicesOperation } from './operation-progress.js';
|
|
9
|
+
import { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from './request-headers.js';
|
|
10
|
+
export { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from './request-headers.js';
|
|
9
11
|
export const gameServicesBackendEndpoints = {
|
|
10
12
|
verifyPurchase: '/game-services/purchases/verify',
|
|
11
13
|
getPurchaseLedgerStatus: '/game-services/purchases/ledger-status',
|
|
@@ -33,31 +35,6 @@ export class GameServicesBackendTransportError extends Error {
|
|
|
33
35
|
this.endpoint = endpoint;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
|
-
/** Credentials could not be resolved; no backend request was dispatched. */
|
|
37
|
-
export class GameServicesHeaderResolutionError extends Error {
|
|
38
|
-
constructor() {
|
|
39
|
-
super('Game Services request headers could not be resolved before dispatch.');
|
|
40
|
-
this.name = 'GameServicesHeaderResolutionError';
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
|
|
44
|
-
export async function resolveGameServicesRequestHeaders(input) {
|
|
45
|
-
let dynamicHeaders;
|
|
46
|
-
try {
|
|
47
|
-
dynamicHeaders = await input.getHeaders?.();
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
// Resolver exceptions can contain credentials; expose a stable outcome.
|
|
51
|
-
throw new GameServicesHeaderResolutionError();
|
|
52
|
-
}
|
|
53
|
-
const merged = new Map();
|
|
54
|
-
for (const layer of [input.headers, dynamicHeaders, input.requestHeaders]) {
|
|
55
|
-
for (const [name, value] of Object.entries(layer ?? {})) {
|
|
56
|
-
merged.set(name.toLowerCase(), value);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return Object.fromEntries(merged);
|
|
60
|
-
}
|
|
61
38
|
export function createGameServicesClient(input) {
|
|
62
39
|
const now = input.now ?? (() => new Date().toISOString());
|
|
63
40
|
const requestNow = input.requestNow ?? now;
|
|
@@ -107,7 +107,12 @@ export interface MicrosoftStoreRecoveryOwnershipRecord {
|
|
|
107
107
|
readonly storeId: string;
|
|
108
108
|
readonly playerId: string;
|
|
109
109
|
readonly generation: string;
|
|
110
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Identity of one Collections purchase. `transactionId` is preferred. When it
|
|
112
|
+
* is absent, the id is the collection item id plus `modifiedDate`, so a later
|
|
113
|
+
* purchase of the same item is distinct after the previous claim is released.
|
|
114
|
+
* An unreleased claim keeps the id it was claimed with.
|
|
115
|
+
*/
|
|
111
116
|
readonly providerPurchaseId: string;
|
|
112
117
|
}
|
|
113
118
|
export type MicrosoftStoreRecoveryOwnershipLookup = Omit<MicrosoftStoreRecoveryOwnershipRecord, 'generation' | 'providerPurchaseId'> & {
|
|
@@ -125,10 +130,9 @@ export type MicrosoftStoreRecoveryOwnershipResult = {
|
|
|
125
130
|
export interface MicrosoftStoreRecoveryOwnershipStore {
|
|
126
131
|
/**
|
|
127
132
|
* Atomically claims an unconsumed Store product for one authenticated game player. An
|
|
128
|
-
* existing claim
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* the Store product binding.
|
|
133
|
+
* existing unreleased claim remains unchanged: a retry cannot replace its generation, and a
|
|
134
|
+
* different provider purchase cannot replace it until `release` runs after consume. Return
|
|
135
|
+
* the effective record, or undefined when another player owns the Store product binding.
|
|
132
136
|
*/
|
|
133
137
|
claim(input: MicrosoftStoreRecoveryOwnershipRecord): Promise<MicrosoftStoreRecoveryOwnershipRecord | undefined>;
|
|
134
138
|
get(input: MicrosoftStoreRecoveryOwnershipLookup): Promise<MicrosoftStoreRecoveryOwnershipRecord | undefined>;
|
|
@@ -11,11 +11,13 @@ export class InMemoryMicrosoftStoreRecoveryOwnershipStore {
|
|
|
11
11
|
if (owner !== undefined && owner.playerId !== input.playerId) {
|
|
12
12
|
return undefined;
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// A later Collections query can change modifiedDate or item id while quantity is still 1.
|
|
15
|
+
// Replacing that unreleased claim would mint a second ledger identity for the same unit.
|
|
16
|
+
if (owner !== undefined) {
|
|
17
|
+
return Object.freeze({ ...owner });
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
+
this.owners.set(key, Object.freeze({ ...input }));
|
|
20
|
+
return Object.freeze({ ...input });
|
|
19
21
|
}
|
|
20
22
|
async get(input) {
|
|
21
23
|
const owner = this.owners.get(createRecoveryOwnershipKey(input));
|
|
@@ -174,7 +176,10 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
174
176
|
playerId,
|
|
175
177
|
},
|
|
176
178
|
...(item.status === 'verified'
|
|
177
|
-
? {
|
|
179
|
+
? {
|
|
180
|
+
providerPurchaseId: createVerificationId(item.item),
|
|
181
|
+
item: item.item,
|
|
182
|
+
}
|
|
178
183
|
: {}),
|
|
179
184
|
...(generation === undefined ? {} : { requestedGeneration: generation }),
|
|
180
185
|
};
|
|
@@ -188,10 +193,21 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
188
193
|
if (ownership.requestedGeneration === undefined) {
|
|
189
194
|
return { status: 'denied' };
|
|
190
195
|
}
|
|
191
|
-
if (ownership.providerPurchaseId === undefined) {
|
|
196
|
+
if (ownership.providerPurchaseId === undefined || ownership.item === undefined) {
|
|
192
197
|
return { status: 'unavailable' };
|
|
193
198
|
}
|
|
194
199
|
try {
|
|
200
|
+
const existing = await input.recoveryOwnershipStore.get(ownership.record);
|
|
201
|
+
if (existing !== undefined) {
|
|
202
|
+
if (existing.playerId !== ownership.record.playerId
|
|
203
|
+
|| !sameUnconsumedPurchase(existing.providerPurchaseId, ownership.item)
|
|
204
|
+
|| existing.generation !== ownership.requestedGeneration) {
|
|
205
|
+
return { status: 'denied' };
|
|
206
|
+
}
|
|
207
|
+
// Keep the stored purchase id. Replacing it with a stable id would
|
|
208
|
+
// make a later verify miss the ledger row already keyed by it.
|
|
209
|
+
return { status: 'granted', idempotencyKey: existing.generation };
|
|
210
|
+
}
|
|
195
211
|
const effective = await input.recoveryOwnershipStore.claim({
|
|
196
212
|
...ownership.record,
|
|
197
213
|
generation: ownership.requestedGeneration,
|
|
@@ -220,7 +236,7 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
220
236
|
if (existing.playerId !== ownership.record.playerId) {
|
|
221
237
|
return { status: 'denied' };
|
|
222
238
|
}
|
|
223
|
-
if (ownership.providerPurchaseId === undefined) {
|
|
239
|
+
if (ownership.providerPurchaseId === undefined || ownership.item === undefined) {
|
|
224
240
|
// Collections stops listing an item after consume. Preserve only an exact durable
|
|
225
241
|
// generation so its already-granted finalizer can retry a failed conditional release;
|
|
226
242
|
// a generationless listing still cannot establish player ownership.
|
|
@@ -231,23 +247,17 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
231
247
|
? { status: 'granted', idempotencyKey: existing.generation }
|
|
232
248
|
: { status: 'denied' };
|
|
233
249
|
}
|
|
234
|
-
if (existing.providerPurchaseId
|
|
235
|
-
|
|
236
|
-
|
|
250
|
+
if (!sameUnconsumedPurchase(existing.providerPurchaseId, ownership.item)) {
|
|
251
|
+
// A changed item or transaction id is not a new unit while this claim is
|
|
252
|
+
// unreleased. Do not mint a generation. The exact generation may resume so
|
|
253
|
+
// its finalizer can consume and release; every other query stays denied.
|
|
254
|
+
return ownership.requestedGeneration === existing.generation
|
|
237
255
|
? { status: 'granted', idempotencyKey: existing.generation }
|
|
238
256
|
: { status: 'denied' };
|
|
239
257
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const effective = await input.recoveryOwnershipStore.claim({
|
|
244
|
-
...ownership.record,
|
|
245
|
-
generation: await createRecoveryGeneration(ownership.providerPurchaseId),
|
|
246
|
-
providerPurchaseId: ownership.providerPurchaseId,
|
|
247
|
-
});
|
|
248
|
-
return effective?.playerId === ownership.record.playerId
|
|
249
|
-
&& effective.providerPurchaseId === ownership.providerPurchaseId
|
|
250
|
-
? { status: 'granted', idempotencyKey: effective.generation }
|
|
258
|
+
return (ownership.requestedGeneration === undefined
|
|
259
|
+
|| existing.generation === ownership.requestedGeneration)
|
|
260
|
+
? { status: 'granted', idempotencyKey: existing.generation }
|
|
251
261
|
: { status: 'denied' };
|
|
252
262
|
}
|
|
253
263
|
catch {
|
|
@@ -347,27 +357,49 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
347
357
|
if (!validIsoDate(verifiedAt)) {
|
|
348
358
|
return pending('MICROSOFT_STORE_VERIFIER_CLOCK_INVALID');
|
|
349
359
|
}
|
|
350
|
-
const verificationId =
|
|
351
|
-
|
|
360
|
+
const verificationId = recoveryOwnership.providerPurchaseId;
|
|
361
|
+
const currentItemMatches = sameUnconsumedPurchase(verificationId, item.item);
|
|
362
|
+
const storedIdentity = currentItemMatches
|
|
363
|
+
? undefined
|
|
364
|
+
: readStoredCollectionIdentity(verificationId, item.item.productId);
|
|
365
|
+
if (!currentItemMatches && storedIdentity === undefined) {
|
|
352
366
|
return rejected('MICROSOFT_STORE_RECOVERY_OWNERSHIP_REQUIRED');
|
|
353
367
|
}
|
|
368
|
+
// Keep the stored purchase id when Collections rotates the item. The ledger
|
|
369
|
+
// dedupes on that id, and finalization resumes the existing grant instead of
|
|
370
|
+
// writing a second one. The resume item id is only the consume echo.
|
|
371
|
+
const collectionItemId = storedIdentity?.itemId ?? item.item.id;
|
|
372
|
+
const transactionId = storedIdentity === undefined
|
|
373
|
+
? item.item.transactionId
|
|
374
|
+
: storedIdentity.transactionId;
|
|
375
|
+
const modifiedDate = storedIdentity?.modifiedDate ?? item.item.modifiedDate;
|
|
376
|
+
const resumeCollectionItemId = storedIdentity !== undefined
|
|
377
|
+
&& item.item.id !== collectionItemId
|
|
378
|
+
? item.item.id
|
|
379
|
+
: undefined;
|
|
354
380
|
return {
|
|
355
381
|
status: 'verified',
|
|
356
382
|
verificationId,
|
|
357
383
|
verifiedAt,
|
|
358
|
-
|
|
384
|
+
// The client purchase token is the add-on product, not a transaction.
|
|
385
|
+
// Publish the stored purchase id so the ledger's platform-transaction
|
|
386
|
+
// index applies, including a re-verify that must keep the legacy key.
|
|
387
|
+
platformEvidenceId: verificationId,
|
|
359
388
|
payload: {
|
|
360
|
-
microsoftStoreCollectionItemId:
|
|
361
|
-
microsoftStoreModifiedDate:
|
|
389
|
+
microsoftStoreCollectionItemId: collectionItemId,
|
|
390
|
+
microsoftStoreModifiedDate: modifiedDate,
|
|
362
391
|
microsoftStoreProductId: item.item.productId,
|
|
363
392
|
microsoftStoreProductKind: item.item.productKind,
|
|
364
393
|
microsoftStoreQuantity: item.item.quantity,
|
|
365
394
|
microsoftStoreStatus: item.item.status,
|
|
366
395
|
microsoftStoreAccountBindingHash: accountBindingHash,
|
|
367
396
|
microsoftStoreRecoveryOwnershipGeneration: recoveryOwnership.generation,
|
|
368
|
-
...(
|
|
397
|
+
...(transactionId === undefined
|
|
398
|
+
? {}
|
|
399
|
+
: { microsoftStoreTransactionId: transactionId }),
|
|
400
|
+
...(resumeCollectionItemId === undefined
|
|
369
401
|
? {}
|
|
370
|
-
: {
|
|
402
|
+
: { microsoftStoreResumeCollectionItemId: resumeCollectionItemId }),
|
|
371
403
|
digitalGoodsItemId: clientEvidence.itemId,
|
|
372
404
|
},
|
|
373
405
|
};
|
|
@@ -432,16 +464,36 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
|
|
|
432
464
|
trackingId,
|
|
433
465
|
signal: finalizationInput.signal,
|
|
434
466
|
});
|
|
435
|
-
if (!isRecord(raw)) {
|
|
436
|
-
return finalizationPending('
|
|
467
|
+
if (!isRecord(raw) || raw.trackingId !== trackingId) {
|
|
468
|
+
return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_MISMATCH');
|
|
437
469
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
470
|
+
const orderTransactions = readConsumeOrderTransactions(raw.orderTransactions);
|
|
471
|
+
const echoedItemId = optionalIdentifier(raw.itemId);
|
|
472
|
+
const itemMatches = echoedItemId !== undefined
|
|
473
|
+
&& (echoedItemId === context.collectionItemId
|
|
474
|
+
|| echoedItemId === context.resumeCollectionItemId);
|
|
475
|
+
// A reused tracking id is a confirmation, not a second consume. Microsoft
|
|
476
|
+
// documents that retry as the current remaining balance with order ids
|
|
477
|
+
// omitted. The echoed item must be the granted item or the item Collections
|
|
478
|
+
// currently lists for this product. A missing balance, a malformed balance,
|
|
479
|
+
// or an unrelated item keeps the claim.
|
|
480
|
+
const currentBalance = readDocumentedCurrentBalance(raw.newQuantity);
|
|
481
|
+
const echoedItemIsCurrent = !itemMatches
|
|
482
|
+
&& echoedItemId !== undefined
|
|
483
|
+
&& raw.orderTransactions === undefined
|
|
484
|
+
&& currentBalance !== undefined
|
|
485
|
+
&& currentBalance > 0
|
|
486
|
+
&& await echoedItemIsCurrentCollection(input, credentials, context.storeId, echoedItemId, finalizationInput.signal);
|
|
487
|
+
const priorFulfillmentConfirmed = raw.orderTransactions === undefined
|
|
488
|
+
&& currentBalance !== undefined
|
|
489
|
+
&& currentBalance > 0
|
|
490
|
+
&& (itemMatches || echoedItemIsCurrent);
|
|
491
|
+
if (!priorFulfillmentConfirmed
|
|
492
|
+
&& (!itemMatches
|
|
493
|
+
|| currentBalance !== 0)) {
|
|
441
494
|
return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_MISMATCH');
|
|
442
495
|
}
|
|
443
496
|
if (input.persistConsumptionReceipt !== undefined) {
|
|
444
|
-
const orderTransactions = readConsumeOrderTransactions(raw.orderTransactions);
|
|
445
497
|
// Some consume responses omit the product echo. Attribution comes from the
|
|
446
498
|
// verified context and the item/tracking checks above; reject a conflicting echo.
|
|
447
499
|
if ((raw.productId !== undefined && raw.productId !== context.storeId)
|
|
@@ -493,6 +545,28 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
|
|
|
493
545
|
return finalizationPending('MICROSOFT_STORE_CONSUME_UNAVAILABLE');
|
|
494
546
|
}
|
|
495
547
|
}
|
|
548
|
+
async function echoedItemIsCurrentCollection(input, credentials, storeId, itemId, signal) {
|
|
549
|
+
let response;
|
|
550
|
+
try {
|
|
551
|
+
response = await input.client.queryProduct({ credentials, storeId, signal });
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (signal.aborted) {
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
const item = inspectPublisherQuery(response, storeId);
|
|
560
|
+
return item.status === 'verified' && item.item.id === itemId;
|
|
561
|
+
}
|
|
562
|
+
function readDocumentedCurrentBalance(value) {
|
|
563
|
+
// Consume documents newQuantity as an int: the remaining balance. A missing,
|
|
564
|
+
// fractional, or negative value is not that balance.
|
|
565
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
return value;
|
|
569
|
+
}
|
|
496
570
|
function readConsumeOrderTransactions(value) {
|
|
497
571
|
// Microsoft documents omitted order IDs for developer-managed consume retries.
|
|
498
572
|
// Never invent them from the collection item, client token, or tracking ID.
|
|
@@ -617,19 +691,23 @@ function readFinalizationContext(input) {
|
|
|
617
691
|
const modifiedDate = optionalIdentifier(input.evidencePayload?.microsoftStoreModifiedDate);
|
|
618
692
|
const accountBindingHash = optionalIdentifier(input.evidencePayload?.microsoftStoreAccountBindingHash);
|
|
619
693
|
const recoveryOwnershipGeneration = optionalIdentifier(input.evidencePayload?.microsoftStoreRecoveryOwnershipGeneration);
|
|
694
|
+
const transactionId = optionalIdentifier(input.evidencePayload?.microsoftStoreTransactionId);
|
|
695
|
+
const resumeCollectionItemId = optionalIdentifier(input.evidencePayload?.microsoftStoreResumeCollectionItemId);
|
|
620
696
|
// The binding and this verifier ship together in the first Store release. Missing bindings are
|
|
621
697
|
// therefore malformed evidence, not legacy evidence, and must fail closed to prevent an account
|
|
622
|
-
// switch between verification and consumption.
|
|
698
|
+
// switch between verification and consumption. A grant already keyed by the modifiedDate id is
|
|
699
|
+
// not malformed: finalization must still accept that stored key.
|
|
623
700
|
if (payloadStoreId === undefined
|
|
624
701
|
|| collectionItemId === undefined
|
|
625
702
|
|| modifiedDate === undefined
|
|
626
703
|
|| accountBindingHash === undefined
|
|
627
704
|
|| recoveryOwnershipGeneration === undefined
|
|
628
705
|
|| recoveryOwnershipGeneration !== input.request.idempotencyKey
|
|
629
|
-
|| input.evidenceVerificationId
|
|
706
|
+
|| !sameUnconsumedPurchase(input.evidenceVerificationId, {
|
|
630
707
|
id: collectionItemId,
|
|
631
708
|
modifiedDate,
|
|
632
709
|
productId: payloadStoreId,
|
|
710
|
+
...(transactionId === undefined ? {} : { transactionId }),
|
|
633
711
|
})) {
|
|
634
712
|
return {
|
|
635
713
|
status: 'pending',
|
|
@@ -641,22 +719,76 @@ function readFinalizationContext(input) {
|
|
|
641
719
|
return {
|
|
642
720
|
status: 'verified',
|
|
643
721
|
collectionItemId,
|
|
722
|
+
...(resumeCollectionItemId === undefined ? {} : { resumeCollectionItemId }),
|
|
644
723
|
storeId: payloadStoreId,
|
|
645
724
|
accountBindingHash,
|
|
646
725
|
recoveryOwnershipGeneration,
|
|
647
726
|
};
|
|
648
727
|
}
|
|
649
728
|
function createVerificationId(item) {
|
|
729
|
+
// transactionId names the purchase. When Collections omits it, modifiedDate
|
|
730
|
+
// is the documented per-purchase discriminator. An unreleased claim is not
|
|
731
|
+
// replaced with that new id; see sameUnconsumedPurchase.
|
|
732
|
+
return item.transactionId === undefined
|
|
733
|
+
? `microsoft-store:${item.productId}:${item.id}:${item.modifiedDate}`
|
|
734
|
+
: `microsoft-store:${item.productId}:${item.id}:${item.transactionId}`;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Parses a verification id this boundary wrote. Item ids are not expected to
|
|
738
|
+
* contain `:`; a date suffix may, so only the first separator is split.
|
|
739
|
+
*/
|
|
740
|
+
function readStoredCollectionIdentity(verificationId, storeId) {
|
|
741
|
+
const prefix = `microsoft-store:${storeId}:`;
|
|
742
|
+
if (!verificationId.startsWith(prefix) || verificationId.length <= prefix.length) {
|
|
743
|
+
return undefined;
|
|
744
|
+
}
|
|
745
|
+
const rest = verificationId.slice(prefix.length);
|
|
746
|
+
const separator = rest.indexOf(':');
|
|
747
|
+
if (separator === -1) {
|
|
748
|
+
return optionalIdentifier(rest) === undefined ? undefined : { itemId: rest };
|
|
749
|
+
}
|
|
750
|
+
const itemId = rest.slice(0, separator);
|
|
751
|
+
const suffix = rest.slice(separator + 1);
|
|
752
|
+
if (optionalIdentifier(itemId) === undefined || optionalIdentifier(suffix) === undefined) {
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
return validIsoDate(suffix)
|
|
756
|
+
? { itemId, modifiedDate: suffix }
|
|
757
|
+
: { itemId, transactionId: suffix };
|
|
758
|
+
}
|
|
759
|
+
/** Ledger key written when a purchase omits transactionId, including older rows. */
|
|
760
|
+
function createLegacyVerificationId(item) {
|
|
650
761
|
return `microsoft-store:${item.productId}:${item.id}:${item.modifiedDate}`;
|
|
651
762
|
}
|
|
652
|
-
/**
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
763
|
+
/**
|
|
764
|
+
* True when a stored purchase id still names this unconsumed Collections item.
|
|
765
|
+
* A changed `modifiedDate` still matches, so that query resumes the stored
|
|
766
|
+
* claim instead of minting another id. A stored `transactionId` still matches
|
|
767
|
+
* if the collection item id changed. After release, a new modifiedDate is a
|
|
768
|
+
* new id because no stored claim remains to match.
|
|
769
|
+
*/
|
|
770
|
+
function sameUnconsumedPurchase(storedId, item) {
|
|
771
|
+
if (storedId === createVerificationId(item)
|
|
772
|
+
|| storedId === createLegacyVerificationId(item)
|
|
773
|
+
|| storedId === `microsoft-store:${item.productId}:${item.id}`) {
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
const itemPrefix = `microsoft-store:${item.productId}:${item.id}:`;
|
|
777
|
+
if (storedId.startsWith(itemPrefix)) {
|
|
778
|
+
const suffix = storedId.slice(itemPrefix.length);
|
|
779
|
+
if (validIsoDate(suffix)) {
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
return item.transactionId === undefined || item.transactionId === suffix;
|
|
783
|
+
}
|
|
784
|
+
if (item.transactionId !== undefined && !validIsoDate(item.transactionId)) {
|
|
785
|
+
const productPrefix = `microsoft-store:${item.productId}:`;
|
|
786
|
+
const transactionSuffix = `:${item.transactionId}`;
|
|
787
|
+
return storedId.startsWith(productPrefix)
|
|
788
|
+
&& storedId.endsWith(transactionSuffix)
|
|
789
|
+
&& storedId.length > productPrefix.length + transactionSuffix.length;
|
|
656
790
|
}
|
|
657
|
-
|
|
658
|
-
const digest = new Uint8Array(await readGlobalSubtleCrypto().digest('SHA-256', encoded));
|
|
659
|
-
return `microsoft-store-recovery:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
|
|
791
|
+
return false;
|
|
660
792
|
}
|
|
661
793
|
async function deterministicTrackingId(value) {
|
|
662
794
|
const subtle = readGlobalSubtleCrypto();
|
|
@@ -31,7 +31,12 @@ import type { AppsInTossMutualTlsFetcher } from '../apps-in-toss-partner-api.js'
|
|
|
31
31
|
* recipient who has not agreed to the template. `agreementErrorCodes` and
|
|
32
32
|
* `agreementFailReasons` let a deployment name the ones it observes, so
|
|
33
33
|
* such an answer is `rejected` with reason `agreement` and records the
|
|
34
|
-
* denial; without them it is reason `recipient`.
|
|
34
|
+
* denial; without them it is reason `recipient`. A deployment list cannot
|
|
35
|
+
* override 4095, 5004, 4034, or 4010: those documented codes are classified
|
|
36
|
+
* first, so a rate limit is not stored as an agreement denial.
|
|
37
|
+
* - `baseUrl` must be the partner API host (`apps-in-toss-api.toss.im`).
|
|
38
|
+
* The mTLS client certificate and the recipient key are not sent to any
|
|
39
|
+
* other host unless the caller sets `allowUnlistedHost` (tests only).
|
|
35
40
|
* - Limits: 15,000 calls per minute per app and 10 per minute per user, the
|
|
36
41
|
* reference registry entry's `rate` and `limits`, which the scheduler and
|
|
37
42
|
* the gate apply.
|
|
@@ -39,6 +44,12 @@ import type { AppsInTossMutualTlsFetcher } from '../apps-in-toss-partner-api.js'
|
|
|
39
44
|
export declare const aitFunctionalMessageProviderId: "apps-in-toss";
|
|
40
45
|
export declare const aitFunctionalMessagePath = "api-partner/v1/apps-in-toss/messenger/send-message";
|
|
41
46
|
export declare const defaultAitFunctionalMessageBaseUrl = "https://apps-in-toss-api.toss.im";
|
|
47
|
+
/**
|
|
48
|
+
* Hosts the partner certificate and the recipient key may be sent to.
|
|
49
|
+
* `docs/APPS_IN_TOSS_PRODUCTION_EVIDENCE.md` names this production base and
|
|
50
|
+
* no sandbox API host.
|
|
51
|
+
*/
|
|
52
|
+
export declare const aitFunctionalMessageAllowedHosts: readonly string[];
|
|
42
53
|
export declare const defaultAitFunctionalMessageTimeoutMs = 10000;
|
|
43
54
|
/** The longest `Retry-After` the provider passes on; the scheduler backs off from it. */
|
|
44
55
|
export declare const maximumAitFunctionalMessageRetryAfterSeconds = 86400;
|
|
@@ -112,6 +123,12 @@ export interface CreateAitFunctionalMessageDeliveryProviderOptions {
|
|
|
112
123
|
*/
|
|
113
124
|
readonly recipientKey: 'toss-user' | 'anonymous';
|
|
114
125
|
readonly baseUrl?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Send to a `baseUrl` whose host is not
|
|
128
|
+
* {@link aitFunctionalMessageAllowedHosts}. Tests only: a deployment that
|
|
129
|
+
* sets this can leak the partner certificate and the recipient key.
|
|
130
|
+
*/
|
|
131
|
+
readonly allowUnlistedHost?: boolean;
|
|
115
132
|
/** 1 to 60,000 ms, as the partner API client accepts. */
|
|
116
133
|
readonly timeoutMs?: number;
|
|
117
134
|
/** `FAIL` error codes that mean the recipient has not agreed to the template. */
|
|
@@ -135,13 +152,13 @@ export interface AitFunctionalMessageDeliveryProvider {
|
|
|
135
152
|
* With none sent, the per-channel `fail` lists decide: `rejected` with
|
|
136
153
|
* reason `agreement` when a failure's `reachedFailReason` is one of
|
|
137
154
|
* `agreementFailReasons`, otherwise `recipient`.
|
|
138
|
-
* - `FAIL` with a code
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
155
|
+
* - `FAIL` with a documented code is classified before any deployment list:
|
|
156
|
+
* 4095 is `rate-limited` with `retryAfterSeconds` (at most a day), and
|
|
157
|
+
* 5004, 4034, and 4010 are `rejected` with reason `configuration`. A
|
|
158
|
+
* deployment cannot store those as an agreement denial. Then a code in
|
|
159
|
+
* `agreementErrorCodes` or `recipientErrorCodes` is `rejected` with that
|
|
160
|
+
* reason, on any status except HTTP 429, 5xx, 408, and 425, which the
|
|
161
|
+
* status alone decides (see below). Any other code is `rejected` with reason `recipient` on HTTP 200
|
|
145
162
|
* and `configuration` when the HTTP status refused the request (400, 401,
|
|
146
163
|
* 403, 404, 405, 413, 415, 422): a `FAIL` envelope reports a failed
|
|
147
164
|
* request, and answering `ambiguous` would replay a send that fails the
|
|
@@ -30,7 +30,12 @@ import { notificationTemplateDataIssue, referenceNotificationProviders, } from '
|
|
|
30
30
|
* recipient who has not agreed to the template. `agreementErrorCodes` and
|
|
31
31
|
* `agreementFailReasons` let a deployment name the ones it observes, so
|
|
32
32
|
* such an answer is `rejected` with reason `agreement` and records the
|
|
33
|
-
* denial; without them it is reason `recipient`.
|
|
33
|
+
* denial; without them it is reason `recipient`. A deployment list cannot
|
|
34
|
+
* override 4095, 5004, 4034, or 4010: those documented codes are classified
|
|
35
|
+
* first, so a rate limit is not stored as an agreement denial.
|
|
36
|
+
* - `baseUrl` must be the partner API host (`apps-in-toss-api.toss.im`).
|
|
37
|
+
* The mTLS client certificate and the recipient key are not sent to any
|
|
38
|
+
* other host unless the caller sets `allowUnlistedHost` (tests only).
|
|
34
39
|
* - Limits: 15,000 calls per minute per app and 10 per minute per user, the
|
|
35
40
|
* reference registry entry's `rate` and `limits`, which the scheduler and
|
|
36
41
|
* the gate apply.
|
|
@@ -41,6 +46,12 @@ export const aitFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/s
|
|
|
41
46
|
// `defaultAppsInTossPartnerApiTimeoutMs` (a test keeps them equal), kept here
|
|
42
47
|
// so this module loads none of the partner client's code.
|
|
43
48
|
export const defaultAitFunctionalMessageBaseUrl = 'https://apps-in-toss-api.toss.im';
|
|
49
|
+
/**
|
|
50
|
+
* Hosts the partner certificate and the recipient key may be sent to.
|
|
51
|
+
* `docs/APPS_IN_TOSS_PRODUCTION_EVIDENCE.md` names this production base and
|
|
52
|
+
* no sandbox API host.
|
|
53
|
+
*/
|
|
54
|
+
export const aitFunctionalMessageAllowedHosts = Object.freeze(['apps-in-toss-api.toss.im']);
|
|
44
55
|
export const defaultAitFunctionalMessageTimeoutMs = 10_000;
|
|
45
56
|
/** The longest `Retry-After` the provider passes on; the scheduler backs off from it. */
|
|
46
57
|
export const maximumAitFunctionalMessageRetryAfterSeconds = 86_400;
|
|
@@ -77,13 +88,13 @@ const maximumTimeoutMs = 60_000;
|
|
|
77
88
|
* With none sent, the per-channel `fail` lists decide: `rejected` with
|
|
78
89
|
* reason `agreement` when a failure's `reachedFailReason` is one of
|
|
79
90
|
* `agreementFailReasons`, otherwise `recipient`.
|
|
80
|
-
* - `FAIL` with a code
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
91
|
+
* - `FAIL` with a documented code is classified before any deployment list:
|
|
92
|
+
* 4095 is `rate-limited` with `retryAfterSeconds` (at most a day), and
|
|
93
|
+
* 5004, 4034, and 4010 are `rejected` with reason `configuration`. A
|
|
94
|
+
* deployment cannot store those as an agreement denial. Then a code in
|
|
95
|
+
* `agreementErrorCodes` or `recipientErrorCodes` is `rejected` with that
|
|
96
|
+
* reason, on any status except HTTP 429, 5xx, 408, and 425, which the
|
|
97
|
+
* status alone decides (see below). Any other code is `rejected` with reason `recipient` on HTTP 200
|
|
87
98
|
* and `configuration` when the HTTP status refused the request (400, 401,
|
|
88
99
|
* 403, 404, 405, 413, 415, 422): a `FAIL` envelope reports a failed
|
|
89
100
|
* request, and answering `ambiguous` would replay a send that fails the
|
|
@@ -109,24 +120,29 @@ export function createAitFunctionalMessageDeliveryProvider(options) {
|
|
|
109
120
|
throw new TypeError("recipientKey must be 'toss-user' or 'anonymous'.");
|
|
110
121
|
}
|
|
111
122
|
const recipientHeader = options.recipientKey === 'anonymous' ? 'x-anon-key' : 'x-toss-user-key';
|
|
112
|
-
const url = new URL(aitFunctionalMessagePath, normalizeBaseUrl(options.baseUrl ?? defaultAitFunctionalMessageBaseUrl
|
|
123
|
+
const url = new URL(aitFunctionalMessagePath, normalizeBaseUrl(options.baseUrl ?? defaultAitFunctionalMessageBaseUrl, {
|
|
124
|
+
allowUnlistedHost: options.allowUnlistedHost === true,
|
|
125
|
+
})).href;
|
|
113
126
|
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
114
127
|
const agreementErrorCodes = new Set(options.agreementErrorCodes ?? []);
|
|
115
128
|
const recipientErrorCodes = new Set(options.recipientErrorCodes ?? []);
|
|
116
129
|
const agreementFailReasons = new Set((options.agreementFailReasons ?? []).map((reason) => reason.trim()));
|
|
117
|
-
//
|
|
118
|
-
//
|
|
130
|
+
// Documented codes come first (RFC "Delivery providers -> Apps in Toss"),
|
|
131
|
+
// then the deployment's. A list must not turn 4095 into an agreement denial.
|
|
119
132
|
const classifyFailCode = (code, status) => {
|
|
133
|
+
if (code === rateLimitedErrorCode) {
|
|
134
|
+
return { status: 'rate-limited', providerCode: code };
|
|
135
|
+
}
|
|
136
|
+
if (configurationErrorCodes.has(code)) {
|
|
137
|
+
return { status: 'rejected', reason: 'configuration', providerCode: code };
|
|
138
|
+
}
|
|
120
139
|
if (agreementErrorCodes.has(code)) {
|
|
121
140
|
return { status: 'rejected', reason: 'agreement', providerCode: code };
|
|
122
141
|
}
|
|
123
142
|
if (recipientErrorCodes.has(code)) {
|
|
124
143
|
return { status: 'rejected', reason: 'recipient', providerCode: code };
|
|
125
144
|
}
|
|
126
|
-
if (
|
|
127
|
-
return { status: 'rate-limited', providerCode: code };
|
|
128
|
-
}
|
|
129
|
-
if (configurationErrorCodes.has(code) || refusedRequestStatuses.has(status)) {
|
|
145
|
+
if (refusedRequestStatuses.has(status)) {
|
|
130
146
|
return { status: 'rejected', reason: 'configuration', providerCode: code };
|
|
131
147
|
}
|
|
132
148
|
// An undocumented code on another status proves nothing either way.
|
|
@@ -421,8 +437,8 @@ function isCount(value) {
|
|
|
421
437
|
function isRecord(value) {
|
|
422
438
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
423
439
|
}
|
|
424
|
-
/** A credential-free HTTPS base URL ending in `/`,
|
|
425
|
-
function normalizeBaseUrl(input) {
|
|
440
|
+
/** A credential-free HTTPS base URL ending in `/`, on an allowlisted partner host. */
|
|
441
|
+
function normalizeBaseUrl(input, options) {
|
|
426
442
|
const url = new URL(input);
|
|
427
443
|
if (url.protocol !== 'https:'
|
|
428
444
|
|| url.username.length > 0
|
|
@@ -431,6 +447,11 @@ function normalizeBaseUrl(input) {
|
|
|
431
447
|
|| url.hash.length > 0) {
|
|
432
448
|
throw new TypeError('The Apps in Toss partner API baseUrl must be a credential-free HTTPS URL.');
|
|
433
449
|
}
|
|
450
|
+
const allowedHost = aitFunctionalMessageAllowedHosts.includes(url.hostname)
|
|
451
|
+
&& (url.port === '' || url.port === '443');
|
|
452
|
+
if (!allowedHost && !options.allowUnlistedHost) {
|
|
453
|
+
throw new TypeError(`The Apps in Toss partner API baseUrl host must be ${aitFunctionalMessageAllowedHosts.join(' or ')}.`);
|
|
454
|
+
}
|
|
434
455
|
url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
435
456
|
return url.href;
|
|
436
457
|
}
|