@mpgd/game-services 0.9.0 → 0.10.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/admob-ssv-conformance.d.ts +19 -0
- package/dist/admob-ssv-conformance.js +258 -0
- package/dist/admob-ssv.d.ts +54 -0
- package/dist/admob-ssv.js +566 -0
- package/dist/app-store-verifier-conformance.d.ts +25 -0
- package/dist/app-store-verifier-conformance.js +28 -0
- package/dist/app-store-verifier.d.ts +87 -0
- package/dist/app-store-verifier.js +439 -0
- package/dist/apps-in-toss-evidence-verification-conformance.d.ts +26 -0
- package/dist/apps-in-toss-evidence-verification-conformance.js +613 -0
- package/dist/apps-in-toss-evidence-verification.d.ts +162 -0
- package/dist/apps-in-toss-evidence-verification.js +458 -0
- package/dist/evidence-verification.d.ts +21 -1
- package/dist/google-play-purchase-conformance.d.ts +26 -0
- package/dist/google-play-purchase-conformance.js +39 -0
- package/dist/google-play-purchase.d.ts +40 -0
- package/dist/google-play-purchase.js +559 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +199 -14
- package/dist/types.d.ts +10 -0
- package/dist/types.js +32 -0
- package/package.json +37 -4
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { LogicalProductId, PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
2
|
+
import type { GameServicesEvidenceVerifier } from './evidence-verification';
|
|
3
|
+
import type { VerifyPurchaseRequest, VerifyPurchaseResponse } from './types';
|
|
4
|
+
export declare const appsInTossPurchaseCallbackEvidenceSchema = "apps-in-toss.iap.callback.v1";
|
|
5
|
+
export declare const appsInTossRewardCallbackEvidenceSchema = "apps-in-toss.rewarded-ad.callback.v1";
|
|
6
|
+
/** Leaves five seconds of headroom inside the SDK's documented 30-second window. */
|
|
7
|
+
export declare const appsInTossProductGrantCallbackTimeoutMs = 25000;
|
|
8
|
+
export type AppsInTossPurchaseCallbackSource = 'process-product-grant' | 'pending-order-restore';
|
|
9
|
+
export type AppsInTossPurchaseOrderStatus = 'PURCHASED' | 'PAYMENT_COMPLETED' | 'FAILED' | 'REFUNDED' | 'ORDER_IN_PROGRESS' | 'NOT_FOUND' | 'MINIAPP_MISMATCH' | 'ERROR';
|
|
10
|
+
export interface AppsInTossPurchaseCallbackEvidenceInput {
|
|
11
|
+
readonly orderId: string;
|
|
12
|
+
readonly platformSku: string;
|
|
13
|
+
readonly source: AppsInTossPurchaseCallbackSource;
|
|
14
|
+
}
|
|
15
|
+
export interface AppsInTossRewardCallbackEvidenceInput {
|
|
16
|
+
/** Game-issued correlation id created before showing the rewarded ad. */
|
|
17
|
+
readonly correlationId: string;
|
|
18
|
+
readonly platformPlacementId: string;
|
|
19
|
+
}
|
|
20
|
+
declare const appsInTossProductGrantVerificationPortBrand: unique symbol;
|
|
21
|
+
export interface AppsInTossProductGrantVerificationInput {
|
|
22
|
+
readonly request: VerifyPurchaseRequest;
|
|
23
|
+
readonly signal: AbortSignal;
|
|
24
|
+
readonly timeoutMs: number;
|
|
25
|
+
}
|
|
26
|
+
export interface AppsInTossProductGrantVerificationPort {
|
|
27
|
+
readonly [appsInTossProductGrantVerificationPortBrand]: true;
|
|
28
|
+
/**
|
|
29
|
+
* Must honor `signal` and `timeoutMs` through the transport and authoritative ledger write.
|
|
30
|
+
* Once aborted, the implementation must guarantee that the request cannot
|
|
31
|
+
* later commit a grant after the SDK callback has returned `false`.
|
|
32
|
+
*/
|
|
33
|
+
verifyPurchase(input: AppsInTossProductGrantVerificationInput): Promise<VerifyPurchaseResponse>;
|
|
34
|
+
}
|
|
35
|
+
export type AppsInTossProductGrantVerification = (input: AppsInTossProductGrantVerificationInput) => Promise<VerifyPurchaseResponse>;
|
|
36
|
+
export interface VerifyAppsInTossProductGrantInput {
|
|
37
|
+
readonly purchaseVerification: AppsInTossProductGrantVerificationPort;
|
|
38
|
+
readonly orderId: string;
|
|
39
|
+
readonly playerId: string;
|
|
40
|
+
readonly productId: LogicalProductId;
|
|
41
|
+
readonly platformSku: string;
|
|
42
|
+
readonly idempotencyKey?: string;
|
|
43
|
+
readonly source: AppsInTossPurchaseCallbackSource;
|
|
44
|
+
readonly purchasedAt: string;
|
|
45
|
+
readonly signal: AbortSignal;
|
|
46
|
+
readonly timeoutMs: number;
|
|
47
|
+
}
|
|
48
|
+
export interface CreateAppsInTossProductGrantCallbackInput {
|
|
49
|
+
readonly purchaseVerification: AppsInTossProductGrantVerificationPort;
|
|
50
|
+
readonly playerId: string;
|
|
51
|
+
readonly productId: LogicalProductId;
|
|
52
|
+
readonly platformSku: string;
|
|
53
|
+
/** Override only when the same function can be recreated for pending restore. */
|
|
54
|
+
readonly idempotencyKey?: (orderId: string) => string;
|
|
55
|
+
/** May be shortened, but never extended beyond the SDK-safe default. */
|
|
56
|
+
readonly timeoutMs?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Supplies the callback-attempt timestamp required by `VerifyPurchaseRequest`.
|
|
59
|
+
* The purchase authority's status timestamp remains the authoritative purchase time.
|
|
60
|
+
*/
|
|
61
|
+
readonly now?: () => string;
|
|
62
|
+
/** Receives backend, transport, and deadline failures before the callback fails closed. */
|
|
63
|
+
readonly onVerificationError?: (error: unknown) => void;
|
|
64
|
+
}
|
|
65
|
+
export interface AppsInTossProcessProductGrantInput {
|
|
66
|
+
readonly orderId: string;
|
|
67
|
+
}
|
|
68
|
+
export type AppsInTossProcessProductGrant = (input: AppsInTossProcessProductGrantInput) => Promise<boolean>;
|
|
69
|
+
export interface AppsInTossPurchaseAuthorityInput {
|
|
70
|
+
readonly orderId: string;
|
|
71
|
+
readonly playerId: string;
|
|
72
|
+
readonly platformSku: string;
|
|
73
|
+
readonly signal: AbortSignal;
|
|
74
|
+
}
|
|
75
|
+
export interface AppsInTossPurchaseOrderRecord {
|
|
76
|
+
readonly orderId: string;
|
|
77
|
+
/**
|
|
78
|
+
* Server-authenticated game player identity associated with the Toss login
|
|
79
|
+
* user used for the order-status lookup.
|
|
80
|
+
*/
|
|
81
|
+
readonly playerId: string;
|
|
82
|
+
readonly sku: string;
|
|
83
|
+
readonly status: AppsInTossPurchaseOrderStatus;
|
|
84
|
+
readonly statusDeterminedAt: string;
|
|
85
|
+
}
|
|
86
|
+
export type AppsInTossPurchaseAuthorityResult = {
|
|
87
|
+
readonly decision: 'resolved';
|
|
88
|
+
readonly order: AppsInTossPurchaseOrderRecord;
|
|
89
|
+
} | {
|
|
90
|
+
readonly decision: 'pending';
|
|
91
|
+
readonly reason?: string;
|
|
92
|
+
} | {
|
|
93
|
+
readonly decision: 'rejected';
|
|
94
|
+
readonly reason: string;
|
|
95
|
+
};
|
|
96
|
+
export interface AppsInTossPurchaseAuthority {
|
|
97
|
+
getOrderStatus(input: AppsInTossPurchaseAuthorityInput): Promise<AppsInTossPurchaseAuthorityResult>;
|
|
98
|
+
}
|
|
99
|
+
export interface AppsInTossRewardAuthorityInput {
|
|
100
|
+
/** Game-issued id that correlates the show request and reward callback. */
|
|
101
|
+
readonly correlationId: string;
|
|
102
|
+
readonly playerId: string;
|
|
103
|
+
readonly platformPlacementId: string;
|
|
104
|
+
readonly signal: AbortSignal;
|
|
105
|
+
}
|
|
106
|
+
export type AppsInTossRewardAuthorityResult = {
|
|
107
|
+
readonly decision: 'verified';
|
|
108
|
+
/** Stable, consume-once identity issued by the production authority. */
|
|
109
|
+
readonly authorityEventId: string;
|
|
110
|
+
readonly correlationId: string;
|
|
111
|
+
readonly playerId: string;
|
|
112
|
+
readonly platformPlacementId: string;
|
|
113
|
+
readonly verifiedAt: string;
|
|
114
|
+
} | {
|
|
115
|
+
readonly decision: 'pending';
|
|
116
|
+
readonly reason?: string;
|
|
117
|
+
} | {
|
|
118
|
+
readonly decision: 'rejected';
|
|
119
|
+
readonly reason: string;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Game-owned production verification port. Apps in Toss documents the client
|
|
123
|
+
* `userEarnedReward` event, but this contract deliberately does not prescribe
|
|
124
|
+
* an undocumented Toss server callback endpoint.
|
|
125
|
+
*/
|
|
126
|
+
export interface AppsInTossRewardAuthority {
|
|
127
|
+
verifyReward(input: AppsInTossRewardAuthorityInput): Promise<AppsInTossRewardAuthorityResult>;
|
|
128
|
+
}
|
|
129
|
+
export interface CreateAppsInTossProductionEvidenceVerifierInput {
|
|
130
|
+
/**
|
|
131
|
+
* Implement with the partner-server order-status API, authenticated Toss
|
|
132
|
+
* login identity, and runtime-injected mTLS credentials.
|
|
133
|
+
*/
|
|
134
|
+
readonly purchaseAuthority?: AppsInTossPurchaseAuthority;
|
|
135
|
+
/**
|
|
136
|
+
* Implement with a game-owned server authority that independently confirms
|
|
137
|
+
* the rewarded event. Client callbacks alone are never sufficient.
|
|
138
|
+
*/
|
|
139
|
+
readonly rewardAuthority?: AppsInTossRewardAuthority;
|
|
140
|
+
}
|
|
141
|
+
export declare function createAppsInTossPurchaseCallbackEvidence(input: AppsInTossPurchaseCallbackEvidenceInput): PlatformEvidenceEnvelope;
|
|
142
|
+
export declare function createAppsInTossRewardCallbackEvidence(input: AppsInTossRewardCallbackEvidenceInput): PlatformEvidenceEnvelope;
|
|
143
|
+
/**
|
|
144
|
+
* Creates a nominal, abort-aware port so the legacy one-argument backend API
|
|
145
|
+
* cannot be passed to the SDK callback accidentally.
|
|
146
|
+
*/
|
|
147
|
+
export declare function createAppsInTossProductGrantVerificationPort(verifyPurchase: AppsInTossProductGrantVerification): AppsInTossProductGrantVerificationPort;
|
|
148
|
+
/**
|
|
149
|
+
* Sends an Apps in Toss product-grant callback to the authoritative backend.
|
|
150
|
+
* This is intentionally callable from inside the SDK's `processProductGrant`
|
|
151
|
+
* callback, before `createOneTimePurchaseOrder` emits its success event.
|
|
152
|
+
*/
|
|
153
|
+
export declare function verifyAppsInTossProductGrant(input: VerifyAppsInTossProductGrantInput): Promise<VerifyPurchaseResponse>;
|
|
154
|
+
export declare function createAppsInTossPurchaseIdempotencyKey(orderId: string): string;
|
|
155
|
+
/**
|
|
156
|
+
* Creates the exact async boolean callback expected by Apps in Toss IAP.
|
|
157
|
+
* Backend rejection and transport failures both return `false` so the SDK can
|
|
158
|
+
* retain the order for later pending-order recovery.
|
|
159
|
+
*/
|
|
160
|
+
export declare function createAppsInTossProductGrantCallback(input: CreateAppsInTossProductGrantCallbackInput): AppsInTossProcessProductGrant;
|
|
161
|
+
export declare function createAppsInTossProductionEvidenceVerifier(input?: CreateAppsInTossProductionEvidenceVerifierInput): GameServicesEvidenceVerifier;
|
|
162
|
+
export {};
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
export const appsInTossPurchaseCallbackEvidenceSchema = 'apps-in-toss.iap.callback.v1';
|
|
2
|
+
export const appsInTossRewardCallbackEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
|
|
3
|
+
/** Leaves five seconds of headroom inside the SDK's documented 30-second window. */
|
|
4
|
+
export const appsInTossProductGrantCallbackTimeoutMs = 25000;
|
|
5
|
+
// Offset-free order-status timestamps are documented as KST (UTC+09:00).
|
|
6
|
+
const appsInTossKstOffsetMinutes = 9 * 60;
|
|
7
|
+
const appsInTossAuthorityReasonMaxLength = 4096;
|
|
8
|
+
const appsInTossTimestampPattern = new RegExp(String.raw `^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})`
|
|
9
|
+
+ String.raw `(?:\.(\d{1,3}))?(Z|([+-])(\d{2}):(\d{2}))?$`, 'u');
|
|
10
|
+
const appsInTossProductGrantVerificationPortBrand = Symbol('AppsInTossProductGrantVerificationPort');
|
|
11
|
+
export function createAppsInTossPurchaseCallbackEvidence(input) {
|
|
12
|
+
requireIdentifier(input.orderId, 'orderId');
|
|
13
|
+
requireIdentifier(input.platformSku, 'platformSku');
|
|
14
|
+
requirePurchaseCallbackSource(input.source);
|
|
15
|
+
return {
|
|
16
|
+
schema: appsInTossPurchaseCallbackEvidenceSchema,
|
|
17
|
+
payload: {
|
|
18
|
+
orderId: input.orderId,
|
|
19
|
+
sku: input.platformSku,
|
|
20
|
+
source: input.source,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function createAppsInTossRewardCallbackEvidence(input) {
|
|
25
|
+
requireIdentifier(input.correlationId, 'correlationId');
|
|
26
|
+
requireIdentifier(input.platformPlacementId, 'platformPlacementId');
|
|
27
|
+
return {
|
|
28
|
+
schema: appsInTossRewardCallbackEvidenceSchema,
|
|
29
|
+
payload: {
|
|
30
|
+
event: 'user-earned-reward',
|
|
31
|
+
correlationId: input.correlationId,
|
|
32
|
+
placementId: input.platformPlacementId,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates a nominal, abort-aware port so the legacy one-argument backend API
|
|
38
|
+
* cannot be passed to the SDK callback accidentally.
|
|
39
|
+
*/
|
|
40
|
+
export function createAppsInTossProductGrantVerificationPort(verifyPurchase) {
|
|
41
|
+
return {
|
|
42
|
+
[appsInTossProductGrantVerificationPortBrand]: true,
|
|
43
|
+
verifyPurchase,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sends an Apps in Toss product-grant callback to the authoritative backend.
|
|
48
|
+
* This is intentionally callable from inside the SDK's `processProductGrant`
|
|
49
|
+
* callback, before `createOneTimePurchaseOrder` emits its success event.
|
|
50
|
+
*/
|
|
51
|
+
export function verifyAppsInTossProductGrant(input) {
|
|
52
|
+
const evidence = createAppsInTossPurchaseCallbackEvidence({
|
|
53
|
+
orderId: input.orderId,
|
|
54
|
+
platformSku: input.platformSku,
|
|
55
|
+
source: input.source,
|
|
56
|
+
});
|
|
57
|
+
return input.purchaseVerification.verifyPurchase({
|
|
58
|
+
request: {
|
|
59
|
+
target: 'ait',
|
|
60
|
+
playerId: input.playerId,
|
|
61
|
+
productId: input.productId,
|
|
62
|
+
platformTransactionId: input.orderId,
|
|
63
|
+
idempotencyKey: input.idempotencyKey
|
|
64
|
+
?? createAppsInTossPurchaseIdempotencyKey(input.orderId),
|
|
65
|
+
purchasedAt: input.purchasedAt,
|
|
66
|
+
evidence,
|
|
67
|
+
},
|
|
68
|
+
signal: input.signal,
|
|
69
|
+
timeoutMs: input.timeoutMs,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function createAppsInTossPurchaseIdempotencyKey(orderId) {
|
|
73
|
+
requireIdentifier(orderId, 'orderId');
|
|
74
|
+
return `apps-in-toss:purchase:${encodeURIComponent(orderId)}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Creates the exact async boolean callback expected by Apps in Toss IAP.
|
|
78
|
+
* Backend rejection and transport failures both return `false` so the SDK can
|
|
79
|
+
* retain the order for later pending-order recovery.
|
|
80
|
+
*/
|
|
81
|
+
export function createAppsInTossProductGrantCallback(input) {
|
|
82
|
+
const now = input.now ?? (() => new Date().toISOString());
|
|
83
|
+
const timeoutMs = input.timeoutMs ?? appsInTossProductGrantCallbackTimeoutMs;
|
|
84
|
+
requireProductGrantCallbackTimeout(timeoutMs);
|
|
85
|
+
return async ({ orderId }) => {
|
|
86
|
+
const abortController = new AbortController();
|
|
87
|
+
let timeout;
|
|
88
|
+
try {
|
|
89
|
+
const verification = await new Promise((resolve, reject) => {
|
|
90
|
+
timeout = setTimeout(() => {
|
|
91
|
+
abortController.abort();
|
|
92
|
+
reject(new Error('Apps in Toss product-grant verification timed out.'));
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
// Attach both handlers immediately so a rejection that arrives after
|
|
95
|
+
// the deadline remains observed even though this promise is settled.
|
|
96
|
+
void verifyAppsInTossProductGrant({
|
|
97
|
+
purchaseVerification: input.purchaseVerification,
|
|
98
|
+
orderId,
|
|
99
|
+
playerId: input.playerId,
|
|
100
|
+
productId: input.productId,
|
|
101
|
+
platformSku: input.platformSku,
|
|
102
|
+
...(input.idempotencyKey === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { idempotencyKey: input.idempotencyKey(orderId) }),
|
|
105
|
+
source: 'process-product-grant',
|
|
106
|
+
purchasedAt: now(),
|
|
107
|
+
signal: abortController.signal,
|
|
108
|
+
timeoutMs,
|
|
109
|
+
}).then(resolve, reject);
|
|
110
|
+
});
|
|
111
|
+
if (!verification.verified) {
|
|
112
|
+
const reason = verification.reason ?? 'BACKEND_REJECTED';
|
|
113
|
+
const verificationError = new Error(`Apps in Toss product-grant verification failed: ${reason}.`);
|
|
114
|
+
reportProductGrantVerificationError(input.onVerificationError, verificationError);
|
|
115
|
+
}
|
|
116
|
+
return verification.verified;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
abortController.abort();
|
|
120
|
+
reportProductGrantVerificationError(input.onVerificationError, error);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (timeout !== undefined) {
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function reportProductGrantVerificationError(onVerificationError, error) {
|
|
131
|
+
try {
|
|
132
|
+
onVerificationError?.(error);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Diagnostics must never break the SDK's required boolean callback contract.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function createAppsInTossProductionEvidenceVerifier(input = {}) {
|
|
139
|
+
return {
|
|
140
|
+
async verifyPurchase(verificationInput) {
|
|
141
|
+
return verifyAppsInTossPurchase(input.purchaseAuthority, verificationInput);
|
|
142
|
+
},
|
|
143
|
+
async verifyAdReward(verificationInput) {
|
|
144
|
+
return verifyAppsInTossReward(input.rewardAuthority, verificationInput);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function verifyAppsInTossPurchase(authority, input) {
|
|
149
|
+
const { request, platformProductId } = input;
|
|
150
|
+
if (request.target !== 'ait') {
|
|
151
|
+
return rejected('AIT_PURCHASE_TARGET_REQUIRED');
|
|
152
|
+
}
|
|
153
|
+
if (request.evidence?.schema !== appsInTossPurchaseCallbackEvidenceSchema) {
|
|
154
|
+
return rejected('AIT_PURCHASE_EVIDENCE_SCHEMA_INVALID');
|
|
155
|
+
}
|
|
156
|
+
const evidenceOrderId = readEvidenceString(request.evidence.payload, 'orderId');
|
|
157
|
+
const evidenceSku = readEvidenceString(request.evidence.payload, 'sku');
|
|
158
|
+
const callbackSource = readEvidenceString(request.evidence.payload, 'source');
|
|
159
|
+
if (evidenceOrderId !== request.platformTransactionId) {
|
|
160
|
+
return rejected('AIT_PURCHASE_ORDER_ID_MISMATCH');
|
|
161
|
+
}
|
|
162
|
+
if (evidenceSku !== platformProductId) {
|
|
163
|
+
return rejected('AIT_PURCHASE_SKU_MISMATCH');
|
|
164
|
+
}
|
|
165
|
+
if (!isPurchaseCallbackSource(callbackSource)) {
|
|
166
|
+
return rejected('AIT_PURCHASE_CALLBACK_SOURCE_INVALID');
|
|
167
|
+
}
|
|
168
|
+
if (authority === undefined) {
|
|
169
|
+
return rejected('AIT_PURCHASE_AUTHORITY_UNAVAILABLE');
|
|
170
|
+
}
|
|
171
|
+
let result;
|
|
172
|
+
try {
|
|
173
|
+
result = assertPurchaseAuthorityResult(await authority.getOrderStatus({
|
|
174
|
+
orderId: request.platformTransactionId,
|
|
175
|
+
playerId: request.playerId,
|
|
176
|
+
platformSku: platformProductId,
|
|
177
|
+
signal: input.signal,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (input.signal.aborted) {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
return rejected('AIT_PURCHASE_AUTHORITY_ERROR');
|
|
185
|
+
}
|
|
186
|
+
if (result.decision === 'pending') {
|
|
187
|
+
return pending(result.reason ?? 'AIT_PURCHASE_AUTHORITY_PENDING');
|
|
188
|
+
}
|
|
189
|
+
if (result.decision === 'rejected') {
|
|
190
|
+
return rejected(result.reason);
|
|
191
|
+
}
|
|
192
|
+
const { order } = result;
|
|
193
|
+
if (order.orderId !== request.platformTransactionId) {
|
|
194
|
+
return rejected('AIT_PURCHASE_AUTHORITY_ORDER_ID_MISMATCH');
|
|
195
|
+
}
|
|
196
|
+
if (order.playerId !== request.playerId) {
|
|
197
|
+
return rejected('AIT_PURCHASE_AUTHORITY_PLAYER_MISMATCH');
|
|
198
|
+
}
|
|
199
|
+
if (order.sku !== platformProductId) {
|
|
200
|
+
return rejected('AIT_PURCHASE_AUTHORITY_SKU_MISMATCH');
|
|
201
|
+
}
|
|
202
|
+
const verifiedAt = normalizeTimestamp(order.statusDeterminedAt, appsInTossKstOffsetMinutes);
|
|
203
|
+
if (verifiedAt === undefined) {
|
|
204
|
+
return rejected('AIT_PURCHASE_AUTHORITY_TIMESTAMP_INVALID');
|
|
205
|
+
}
|
|
206
|
+
if (order.status === 'ORDER_IN_PROGRESS' || order.status === 'ERROR') {
|
|
207
|
+
return pending(`AIT_PURCHASE_ORDER_${order.status}`);
|
|
208
|
+
}
|
|
209
|
+
if (order.status !== 'PURCHASED' && order.status !== 'PAYMENT_COMPLETED') {
|
|
210
|
+
return rejected(`AIT_PURCHASE_ORDER_${order.status}`);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
status: 'verified',
|
|
214
|
+
verificationId: createVerificationId('purchase', order.orderId),
|
|
215
|
+
verifiedAt,
|
|
216
|
+
payload: {
|
|
217
|
+
appsInTossOrderId: order.orderId,
|
|
218
|
+
appsInTossSku: order.sku,
|
|
219
|
+
appsInTossOrderStatus: order.status,
|
|
220
|
+
appsInTossGrantCompletionRequired: order.status === 'PAYMENT_COMPLETED',
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
async function verifyAppsInTossReward(authority, input) {
|
|
225
|
+
const { request, platformPlacementId } = input;
|
|
226
|
+
if (request.target !== 'ait') {
|
|
227
|
+
return rejected('AIT_REWARD_TARGET_REQUIRED');
|
|
228
|
+
}
|
|
229
|
+
if (platformPlacementId === undefined || platformPlacementId.length === 0) {
|
|
230
|
+
return rejected('AIT_REWARD_PLACEMENT_UNCONFIGURED');
|
|
231
|
+
}
|
|
232
|
+
if (request.evidence?.schema !== appsInTossRewardCallbackEvidenceSchema) {
|
|
233
|
+
return rejected('AIT_REWARD_EVIDENCE_SCHEMA_INVALID');
|
|
234
|
+
}
|
|
235
|
+
const callbackEvent = readEvidenceString(request.evidence.payload, 'event');
|
|
236
|
+
const correlationId = readEvidenceString(request.evidence.payload, 'correlationId');
|
|
237
|
+
const evidencePlacementId = readEvidenceString(request.evidence.payload, 'placementId');
|
|
238
|
+
if (callbackEvent !== 'user-earned-reward') {
|
|
239
|
+
return rejected('AIT_REWARD_CALLBACK_EVENT_INVALID');
|
|
240
|
+
}
|
|
241
|
+
if (correlationId === undefined) {
|
|
242
|
+
return rejected('AIT_REWARD_CORRELATION_ID_REQUIRED');
|
|
243
|
+
}
|
|
244
|
+
if (request.platformImpressionId === undefined) {
|
|
245
|
+
return rejected('AIT_REWARD_REQUEST_CORRELATION_ID_REQUIRED');
|
|
246
|
+
}
|
|
247
|
+
if (request.platformImpressionId !== correlationId) {
|
|
248
|
+
return rejected('AIT_REWARD_CORRELATION_ID_MISMATCH');
|
|
249
|
+
}
|
|
250
|
+
if (evidencePlacementId !== platformPlacementId) {
|
|
251
|
+
return rejected('AIT_REWARD_PLACEMENT_MISMATCH');
|
|
252
|
+
}
|
|
253
|
+
if (authority === undefined) {
|
|
254
|
+
return rejected('AIT_REWARD_AUTHORITY_UNAVAILABLE');
|
|
255
|
+
}
|
|
256
|
+
let result;
|
|
257
|
+
try {
|
|
258
|
+
result = assertRewardAuthorityResult(await authority.verifyReward({
|
|
259
|
+
correlationId,
|
|
260
|
+
playerId: request.playerId,
|
|
261
|
+
platformPlacementId,
|
|
262
|
+
signal: input.signal,
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (input.signal.aborted) {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
return rejected('AIT_REWARD_AUTHORITY_ERROR');
|
|
270
|
+
}
|
|
271
|
+
if (result.decision === 'pending') {
|
|
272
|
+
return pending(result.reason ?? 'AIT_REWARD_AUTHORITY_PENDING');
|
|
273
|
+
}
|
|
274
|
+
if (result.decision === 'rejected') {
|
|
275
|
+
return rejected(result.reason);
|
|
276
|
+
}
|
|
277
|
+
if (result.correlationId !== correlationId) {
|
|
278
|
+
return rejected('AIT_REWARD_AUTHORITY_CORRELATION_ID_MISMATCH');
|
|
279
|
+
}
|
|
280
|
+
if (result.playerId !== request.playerId) {
|
|
281
|
+
return rejected('AIT_REWARD_AUTHORITY_PLAYER_MISMATCH');
|
|
282
|
+
}
|
|
283
|
+
if (result.platformPlacementId !== platformPlacementId) {
|
|
284
|
+
return rejected('AIT_REWARD_AUTHORITY_PLACEMENT_MISMATCH');
|
|
285
|
+
}
|
|
286
|
+
const verifiedAt = normalizeTimestamp(result.verifiedAt);
|
|
287
|
+
if (verifiedAt === undefined) {
|
|
288
|
+
return rejected('AIT_REWARD_AUTHORITY_TIMESTAMP_INVALID');
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
status: 'verified',
|
|
292
|
+
verificationId: createVerificationId('ad-reward', result.authorityEventId),
|
|
293
|
+
verifiedAt,
|
|
294
|
+
payload: {
|
|
295
|
+
appsInTossRewardAuthorityEventId: result.authorityEventId,
|
|
296
|
+
appsInTossRewardCorrelationId: result.correlationId,
|
|
297
|
+
appsInTossRewardPlacementId: result.platformPlacementId,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function assertPurchaseAuthorityResult(input) {
|
|
302
|
+
requireRecord(input, 'purchase authority result');
|
|
303
|
+
if (input.decision === 'pending') {
|
|
304
|
+
requireOptionalReason(input.reason);
|
|
305
|
+
return input;
|
|
306
|
+
}
|
|
307
|
+
if (input.decision === 'rejected') {
|
|
308
|
+
requireAuthorityReason(input.reason, 'purchase authority reason');
|
|
309
|
+
return input;
|
|
310
|
+
}
|
|
311
|
+
if (input.decision !== 'resolved') {
|
|
312
|
+
throw new Error('Apps in Toss purchase authority decision is invalid.');
|
|
313
|
+
}
|
|
314
|
+
requireRecord(input.order, 'purchase authority order');
|
|
315
|
+
requireIdentifier(input.order.orderId, 'purchase authority orderId');
|
|
316
|
+
requireIdentifier(input.order.playerId, 'purchase authority playerId');
|
|
317
|
+
requireIdentifier(input.order.sku, 'purchase authority sku');
|
|
318
|
+
requirePurchaseOrderStatus(input.order.status);
|
|
319
|
+
requireIdentifier(input.order.statusDeterminedAt, 'purchase authority statusDeterminedAt');
|
|
320
|
+
return input;
|
|
321
|
+
}
|
|
322
|
+
function assertRewardAuthorityResult(input) {
|
|
323
|
+
requireRecord(input, 'reward authority result');
|
|
324
|
+
if (input.decision === 'pending') {
|
|
325
|
+
requireOptionalReason(input.reason);
|
|
326
|
+
return input;
|
|
327
|
+
}
|
|
328
|
+
if (input.decision === 'rejected') {
|
|
329
|
+
requireAuthorityReason(input.reason, 'reward authority reason');
|
|
330
|
+
return input;
|
|
331
|
+
}
|
|
332
|
+
if (input.decision !== 'verified') {
|
|
333
|
+
throw new Error('Apps in Toss reward authority decision is invalid.');
|
|
334
|
+
}
|
|
335
|
+
requireIdentifier(input.authorityEventId, 'reward authority event id');
|
|
336
|
+
requireIdentifier(input.correlationId, 'reward authority correlation id');
|
|
337
|
+
requireIdentifier(input.playerId, 'reward authority player id');
|
|
338
|
+
requireIdentifier(input.platformPlacementId, 'reward authority placement id');
|
|
339
|
+
requireIdentifier(input.verifiedAt, 'reward authority verifiedAt');
|
|
340
|
+
return input;
|
|
341
|
+
}
|
|
342
|
+
function createVerificationId(kind, value) {
|
|
343
|
+
return `apps-in-toss:${kind}:${encodeURIComponent(value)}`;
|
|
344
|
+
}
|
|
345
|
+
function readEvidenceString(payload, key) {
|
|
346
|
+
const value = payload[key];
|
|
347
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
348
|
+
}
|
|
349
|
+
function normalizeTimestamp(value, offsetFreeOffsetMinutes) {
|
|
350
|
+
const match = appsInTossTimestampPattern.exec(value);
|
|
351
|
+
if (match === null) {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
const year = Number(match[1]);
|
|
355
|
+
const month = Number(match[2]);
|
|
356
|
+
const day = Number(match[3]);
|
|
357
|
+
const hour = Number(match[4]);
|
|
358
|
+
const minute = Number(match[5]);
|
|
359
|
+
const second = Number(match[6]);
|
|
360
|
+
const millisecond = Number((match[7] ?? '').padEnd(3, '0'));
|
|
361
|
+
if (year < 1000
|
|
362
|
+
|| month < 1
|
|
363
|
+
|| month > 12
|
|
364
|
+
|| day < 1
|
|
365
|
+
|| day > 31
|
|
366
|
+
|| hour > 23
|
|
367
|
+
|| minute > 59
|
|
368
|
+
|| second > 59) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const wallClock = Date.UTC(year, month - 1, day, hour, minute, second, millisecond);
|
|
372
|
+
const wallClockDate = new Date(wallClock);
|
|
373
|
+
if (wallClockDate.getUTCFullYear() !== year
|
|
374
|
+
|| wallClockDate.getUTCMonth() !== month - 1
|
|
375
|
+
|| wallClockDate.getUTCDate() !== day
|
|
376
|
+
|| wallClockDate.getUTCHours() !== hour
|
|
377
|
+
|| wallClockDate.getUTCMinutes() !== minute
|
|
378
|
+
|| wallClockDate.getUTCSeconds() !== second) {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
let offsetMinutes = offsetFreeOffsetMinutes;
|
|
382
|
+
if (match[8] === 'Z') {
|
|
383
|
+
offsetMinutes = 0;
|
|
384
|
+
}
|
|
385
|
+
else if (match[9] !== undefined) {
|
|
386
|
+
const offsetHour = Number(match[10]);
|
|
387
|
+
const offsetMinute = Number(match[11]);
|
|
388
|
+
if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
offsetMinutes = (offsetHour * 60 + offsetMinute) * (match[9] === '+' ? 1 : -1);
|
|
392
|
+
}
|
|
393
|
+
if (offsetMinutes === undefined) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
return new Date(wallClock - offsetMinutes * 60000).toISOString();
|
|
397
|
+
}
|
|
398
|
+
function pending(reason) {
|
|
399
|
+
return { status: 'pending', reason };
|
|
400
|
+
}
|
|
401
|
+
function rejected(reason) {
|
|
402
|
+
return { status: 'rejected', reason };
|
|
403
|
+
}
|
|
404
|
+
function requireRecord(input, label) {
|
|
405
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
406
|
+
throw new TypeError(`${label} must be an object.`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function requireIdentifier(input, label) {
|
|
410
|
+
if (typeof input !== 'string'
|
|
411
|
+
|| input.length === 0
|
|
412
|
+
|| input.length > 512
|
|
413
|
+
|| input.trim() !== input
|
|
414
|
+
|| /[\u0000-\u001f\u007f]/u.test(input)) {
|
|
415
|
+
throw new TypeError(`${label} must be a non-empty identifier.`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function requireOptionalReason(input) {
|
|
419
|
+
if (input !== undefined) {
|
|
420
|
+
requireAuthorityReason(input, 'authority reason');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function requireAuthorityReason(input, label) {
|
|
424
|
+
if (typeof input !== 'string'
|
|
425
|
+
|| input.trim().length === 0
|
|
426
|
+
|| input.length > appsInTossAuthorityReasonMaxLength) {
|
|
427
|
+
throw new TypeError(`${label} must be a non-empty string no longer than `
|
|
428
|
+
+ `${appsInTossAuthorityReasonMaxLength} characters.`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function requireProductGrantCallbackTimeout(input) {
|
|
432
|
+
if (!Number.isInteger(input)
|
|
433
|
+
|| input < 1
|
|
434
|
+
|| input > appsInTossProductGrantCallbackTimeoutMs) {
|
|
435
|
+
throw new RangeError(`timeoutMs must be an integer between 1 and ${appsInTossProductGrantCallbackTimeoutMs}.`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function requirePurchaseCallbackSource(input) {
|
|
439
|
+
if (!isPurchaseCallbackSource(input)) {
|
|
440
|
+
throw new TypeError('source must identify a supported Apps in Toss purchase callback.');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function isPurchaseCallbackSource(input) {
|
|
444
|
+
return input === 'process-product-grant'
|
|
445
|
+
|| input === 'pending-order-restore';
|
|
446
|
+
}
|
|
447
|
+
function requirePurchaseOrderStatus(input) {
|
|
448
|
+
if (input !== 'PURCHASED'
|
|
449
|
+
&& input !== 'PAYMENT_COMPLETED'
|
|
450
|
+
&& input !== 'FAILED'
|
|
451
|
+
&& input !== 'REFUNDED'
|
|
452
|
+
&& input !== 'ORDER_IN_PROGRESS'
|
|
453
|
+
&& input !== 'NOT_FOUND'
|
|
454
|
+
&& input !== 'MINIAPP_MISMATCH'
|
|
455
|
+
&& input !== 'ERROR') {
|
|
456
|
+
throw new TypeError('Apps in Toss purchase order status is invalid.');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { AdPlacements, ProductCatalog } from '@mpgd/catalog';
|
|
2
|
-
import type { ClaimAdRewardRequest, EntitlementLedgerPayload, VerifyPurchaseRequest } from './types';
|
|
2
|
+
import type { ClaimAdRewardRequest, EntitlementLedgerPayload, PurchaseGrantFinalization, VerifyPurchaseRequest } from './types';
|
|
3
3
|
export type EvidenceVerificationDecision = {
|
|
4
4
|
readonly status: 'verified';
|
|
5
5
|
readonly verificationId: string;
|
|
6
6
|
readonly verifiedAt: string;
|
|
7
7
|
readonly payload?: EntitlementLedgerPayload;
|
|
8
|
+
/**
|
|
9
|
+
* An authoritative provider identity for cross-idempotency replay checks.
|
|
10
|
+
* `null` explicitly suppresses the client-reported platform identity.
|
|
11
|
+
*/
|
|
12
|
+
readonly platformEvidenceId?: string | null;
|
|
8
13
|
} | {
|
|
9
14
|
readonly status: 'pending';
|
|
10
15
|
readonly reason?: string;
|
|
@@ -26,6 +31,21 @@ export interface VerifyAdRewardEvidenceInput {
|
|
|
26
31
|
readonly signal: AbortSignal;
|
|
27
32
|
readonly timeoutMs: number;
|
|
28
33
|
}
|
|
34
|
+
export interface FinalizePurchaseGrantInput {
|
|
35
|
+
readonly request: VerifyPurchaseRequest;
|
|
36
|
+
readonly product: ProductCatalog['products'][number];
|
|
37
|
+
readonly platformProductId: string;
|
|
38
|
+
readonly evidenceVerificationId: string;
|
|
39
|
+
readonly evidencePayload?: EntitlementLedgerPayload;
|
|
40
|
+
readonly ledgerEntryId: string;
|
|
41
|
+
readonly alreadyProcessed: boolean;
|
|
42
|
+
readonly signal: AbortSignal;
|
|
43
|
+
readonly timeoutMs: number;
|
|
44
|
+
}
|
|
45
|
+
export interface GameServicesPurchaseGrantFinalizer {
|
|
46
|
+
supportsPurchaseGrant?(input: Omit<FinalizePurchaseGrantInput, 'signal'>): boolean;
|
|
47
|
+
finalizePurchaseGrant(input: FinalizePurchaseGrantInput): Promise<PurchaseGrantFinalization>;
|
|
48
|
+
}
|
|
29
49
|
export interface GameServicesEvidenceVerifier {
|
|
30
50
|
verifyPurchase(input: VerifyPurchaseEvidenceInput): Promise<EvidenceVerificationDecision>;
|
|
31
51
|
verifyAdReward(input: VerifyAdRewardEvidenceInput): Promise<EvidenceVerificationDecision>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type GooglePlayAcknowledgementState, type GooglePlayConsumptionState, type GooglePlayPurchaseState } from './google-play-purchase';
|
|
2
|
+
export declare const googlePlayProductPurchaseConformanceEvidence: Readonly<{
|
|
3
|
+
schema: "google-play.product-purchase.v2";
|
|
4
|
+
payload: Readonly<{
|
|
5
|
+
purchaseToken: "google-play-conformance-token";
|
|
6
|
+
}>;
|
|
7
|
+
}>;
|
|
8
|
+
export interface CreateGooglePlayProductPurchaseConformanceFixtureInput {
|
|
9
|
+
readonly productId?: string;
|
|
10
|
+
readonly orderId?: string;
|
|
11
|
+
readonly purchaseState?: Exclude<GooglePlayPurchaseState, 'PURCHASE_STATE_UNSPECIFIED'>;
|
|
12
|
+
readonly purchaseCompletionTime?: string;
|
|
13
|
+
readonly consumptionState?: Exclude<GooglePlayConsumptionState, 'CONSUMPTION_STATE_UNSPECIFIED'>;
|
|
14
|
+
readonly acknowledgementState?: Exclude<GooglePlayAcknowledgementState, 'ACKNOWLEDGEMENT_STATE_UNSPECIFIED'>;
|
|
15
|
+
readonly quantity?: number;
|
|
16
|
+
readonly refundableQuantity?: number;
|
|
17
|
+
readonly rentOffer?: boolean;
|
|
18
|
+
readonly obfuscatedExternalAccountId?: string;
|
|
19
|
+
readonly obfuscatedExternalProfileId?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Builds a deterministic `purchases.productsv2` response for boundary and
|
|
23
|
+
* provider-client conformance tests. It intentionally contains no credentials,
|
|
24
|
+
* live purchase tokens, or environment-specific endpoints.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createGooglePlayProductPurchaseConformanceFixture(input?: CreateGooglePlayProductPurchaseConformanceFixtureInput): Readonly<Record<string, unknown>>;
|