@mpgd/game-services 0.10.0 → 0.11.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/apps-in-toss-partner-api.d.ts +48 -0
- package/dist/apps-in-toss-partner-api.js +282 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +3 -0
- package/dist/server.d.ts +5 -2
- package/dist/server.js +50 -6
- package/dist/types.d.ts +5 -0
- package/dist/types.js +30 -0
- package/package.json +9 -5
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NotificationTemplateData } from './notification-delivery';
|
|
2
|
+
export declare const appsInTossPartnerApiBaseUrl = "https://apps-in-toss-api.toss.im";
|
|
3
|
+
export declare const defaultAppsInTossPartnerApiTimeoutMs = 10000;
|
|
4
|
+
/** Compatible with a Cloudflare mTLS certificate binding (`env.BINDING.fetch`). */
|
|
5
|
+
export interface AppsInTossMutualTlsFetcher {
|
|
6
|
+
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
7
|
+
}
|
|
8
|
+
export type AppsInTossMessageRecipient = {
|
|
9
|
+
readonly type: 'anonymous';
|
|
10
|
+
readonly key: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly type: 'toss-user';
|
|
13
|
+
readonly key: string;
|
|
14
|
+
};
|
|
15
|
+
export interface SendAppsInTossFunctionalMessageInput {
|
|
16
|
+
readonly recipient: AppsInTossMessageRecipient;
|
|
17
|
+
readonly templateSetCode: string;
|
|
18
|
+
readonly context: NotificationTemplateData;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
export interface AppsInTossFunctionalMessageResult {
|
|
22
|
+
readonly messageCount: number;
|
|
23
|
+
readonly sentPushCount: number;
|
|
24
|
+
readonly sentInboxCount: number;
|
|
25
|
+
readonly sentSmsCount: number;
|
|
26
|
+
readonly sentAlimtalkCount: number;
|
|
27
|
+
readonly sentFriendtalkCount: number;
|
|
28
|
+
readonly contentIds: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export interface AppsInTossPartnerApiClient {
|
|
31
|
+
verifyAnonymousKey(input: {
|
|
32
|
+
readonly anonymousKey: string;
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
}): Promise<boolean>;
|
|
35
|
+
sendFunctionalMessage(input: SendAppsInTossFunctionalMessageInput): Promise<AppsInTossFunctionalMessageResult>;
|
|
36
|
+
}
|
|
37
|
+
export interface CreateAppsInTossPartnerApiClientInput {
|
|
38
|
+
readonly mtls: AppsInTossMutualTlsFetcher;
|
|
39
|
+
readonly baseUrl?: string;
|
|
40
|
+
readonly timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare class AppsInTossPartnerApiError extends Error {
|
|
43
|
+
readonly status: number;
|
|
44
|
+
readonly code?: string | undefined;
|
|
45
|
+
readonly name = "AppsInTossPartnerApiError";
|
|
46
|
+
constructor(message: string, status: number, code?: string | undefined, options?: ErrorOptions);
|
|
47
|
+
}
|
|
48
|
+
export declare function createAppsInTossPartnerApiClient(input: CreateAppsInTossPartnerApiClientInput): AppsInTossPartnerApiClient;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
export const appsInTossPartnerApiBaseUrl = 'https://apps-in-toss-api.toss.im';
|
|
2
|
+
export const defaultAppsInTossPartnerApiTimeoutMs = 10000;
|
|
3
|
+
const verifyAnonymousKeyPath = 'api-partner/v1/apps-in-toss/users/anon-key/verify';
|
|
4
|
+
const sendFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-message';
|
|
5
|
+
const maximumResponseBodyBytes = 256 * 1024;
|
|
6
|
+
export class AppsInTossPartnerApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
name = 'AppsInTossPartnerApiError';
|
|
10
|
+
constructor(message, status, code, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function createAppsInTossPartnerApiClient(input) {
|
|
17
|
+
const baseUrl = normalizeBaseUrl(input.baseUrl ?? appsInTossPartnerApiBaseUrl);
|
|
18
|
+
const timeoutMs = normalizeTimeout(input.timeoutMs);
|
|
19
|
+
return {
|
|
20
|
+
async verifyAnonymousKey(request) {
|
|
21
|
+
const anonymousKey = normalizeIdentifier(request.anonymousKey, 'anonymousKey');
|
|
22
|
+
const response = await postJson({
|
|
23
|
+
mtls: input.mtls,
|
|
24
|
+
url: new URL(verifyAnonymousKeyPath, baseUrl).href,
|
|
25
|
+
headers: { 'x-anon-key': anonymousKey },
|
|
26
|
+
body: undefined,
|
|
27
|
+
timeoutMs,
|
|
28
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
29
|
+
});
|
|
30
|
+
if (response.status === 401) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const envelope = requireSuccessEnvelope(response);
|
|
34
|
+
if (envelope.success !== 'true') {
|
|
35
|
+
throw new AppsInTossPartnerApiError('Apps in Toss returned an invalid anonymous-key verification response.', response.status);
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
async sendFunctionalMessage(request) {
|
|
40
|
+
const recipient = normalizeRecipient(request.recipient);
|
|
41
|
+
const templateSetCode = normalizeIdentifier(request.templateSetCode, 'templateSetCode');
|
|
42
|
+
const response = await postJson({
|
|
43
|
+
mtls: input.mtls,
|
|
44
|
+
url: new URL(sendFunctionalMessagePath, baseUrl).href,
|
|
45
|
+
headers: recipient.type === 'anonymous'
|
|
46
|
+
? { 'x-anon-key': recipient.key }
|
|
47
|
+
: { 'x-toss-user-key': recipient.key },
|
|
48
|
+
body: {
|
|
49
|
+
templateSetCode,
|
|
50
|
+
context: normalizeContext(request.context),
|
|
51
|
+
},
|
|
52
|
+
timeoutMs,
|
|
53
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
54
|
+
});
|
|
55
|
+
const envelope = requireSuccessEnvelope(response);
|
|
56
|
+
return parseFunctionalMessageResult(envelope.success, response.status);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function postJson(input) {
|
|
61
|
+
const timeout = createTimeoutSignal(input.signal, input.timeoutMs);
|
|
62
|
+
try {
|
|
63
|
+
// The documented anonymous-key verification request uses an empty body
|
|
64
|
+
// while still declaring application/json.
|
|
65
|
+
const requestBody = input.body === undefined ? '' : JSON.stringify(input.body);
|
|
66
|
+
const headers = new Headers({
|
|
67
|
+
accept: 'application/json',
|
|
68
|
+
'content-type': 'application/json',
|
|
69
|
+
...input.headers,
|
|
70
|
+
});
|
|
71
|
+
let response;
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
response = await input.mtls.fetch(input.url, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers,
|
|
77
|
+
body: requestBody,
|
|
78
|
+
signal: timeout.signal,
|
|
79
|
+
});
|
|
80
|
+
text = await readBoundedResponseText(response);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw normalizeTransportError(error, input.signal, timeout.signal);
|
|
84
|
+
}
|
|
85
|
+
let body;
|
|
86
|
+
try {
|
|
87
|
+
body = text.length === 0 ? undefined : JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new AppsInTossPartnerApiError('Apps in Toss returned a non-JSON response.', response.status);
|
|
91
|
+
}
|
|
92
|
+
return { status: response.status, ok: response.ok, body };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
timeout.cleanup();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function normalizeTransportError(error, upstream, effective) {
|
|
99
|
+
if (error instanceof AppsInTossPartnerApiError) {
|
|
100
|
+
return error;
|
|
101
|
+
}
|
|
102
|
+
if (effective.aborted && upstream?.aborted !== true) {
|
|
103
|
+
return new AppsInTossPartnerApiError('Apps in Toss partner API request timed out.', 0, 'TIMEOUT', { cause: error });
|
|
104
|
+
}
|
|
105
|
+
if (upstream?.aborted === true) {
|
|
106
|
+
return new AppsInTossPartnerApiError('Apps in Toss partner API request was aborted.', 0, 'ABORTED', { cause: error });
|
|
107
|
+
}
|
|
108
|
+
return new AppsInTossPartnerApiError('Apps in Toss partner API transport failed.', 0, 'TRANSPORT_ERROR', { cause: error });
|
|
109
|
+
}
|
|
110
|
+
async function readBoundedResponseText(response) {
|
|
111
|
+
const declaredLength = response.headers.get('content-length');
|
|
112
|
+
if (declaredLength !== null
|
|
113
|
+
&& /^\d+$/u.test(declaredLength)
|
|
114
|
+
&& Number(declaredLength) > maximumResponseBodyBytes) {
|
|
115
|
+
throw responseTooLarge(response.status);
|
|
116
|
+
}
|
|
117
|
+
if (response.body === null) {
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
const reader = response.body.getReader();
|
|
121
|
+
const chunks = [];
|
|
122
|
+
let byteLength = 0;
|
|
123
|
+
try {
|
|
124
|
+
while (true) {
|
|
125
|
+
const chunk = await reader.read();
|
|
126
|
+
if (chunk.done) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
byteLength += chunk.value.byteLength;
|
|
130
|
+
if (byteLength > maximumResponseBodyBytes) {
|
|
131
|
+
await reader.cancel().catch(() => { });
|
|
132
|
+
throw responseTooLarge(response.status);
|
|
133
|
+
}
|
|
134
|
+
chunks.push(chunk.value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
reader.releaseLock();
|
|
139
|
+
}
|
|
140
|
+
const bytes = new Uint8Array(byteLength);
|
|
141
|
+
let offset = 0;
|
|
142
|
+
for (const chunk of chunks) {
|
|
143
|
+
bytes.set(chunk, offset);
|
|
144
|
+
offset += chunk.byteLength;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new AppsInTossPartnerApiError('Apps in Toss returned a non-UTF-8 response.', response.status);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function responseTooLarge(status) {
|
|
154
|
+
return new AppsInTossPartnerApiError('Apps in Toss response exceeded the maximum accepted size.', status);
|
|
155
|
+
}
|
|
156
|
+
function requireSuccessEnvelope(response) {
|
|
157
|
+
if (!isRecord(response.body)) {
|
|
158
|
+
throw new AppsInTossPartnerApiError('Apps in Toss returned an invalid response envelope.', response.status);
|
|
159
|
+
}
|
|
160
|
+
if (response.ok && response.body.resultType === 'SUCCESS') {
|
|
161
|
+
return response.body;
|
|
162
|
+
}
|
|
163
|
+
const error = isRecord(response.body.error) ? response.body.error : undefined;
|
|
164
|
+
const code = typeof error?.errorCode === 'string' ? error.errorCode : undefined;
|
|
165
|
+
const reason = typeof error?.reason === 'string'
|
|
166
|
+
? error.reason.slice(0, 1024)
|
|
167
|
+
: 'Apps in Toss partner API request failed.';
|
|
168
|
+
throw new AppsInTossPartnerApiError(reason, response.status, code);
|
|
169
|
+
}
|
|
170
|
+
function parseFunctionalMessageResult(input, status) {
|
|
171
|
+
if (!isRecord(input)) {
|
|
172
|
+
throw new AppsInTossPartnerApiError('Apps in Toss returned an invalid message result.', status);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
messageCount: requireCount(input.msgCount, 'msgCount', status),
|
|
176
|
+
sentPushCount: requireCount(input.sentPushCount, 'sentPushCount', status),
|
|
177
|
+
sentInboxCount: requireCount(input.sentInboxCount, 'sentInboxCount', status),
|
|
178
|
+
sentSmsCount: requireCount(input.sentSmsCount, 'sentSmsCount', status),
|
|
179
|
+
sentAlimtalkCount: requireCount(input.sentAlimtalkCount, 'sentAlimtalkCount', status),
|
|
180
|
+
sentFriendtalkCount: requireCount(input.sentFriendtalkCount, 'sentFriendtalkCount', status),
|
|
181
|
+
contentIds: readContentIds(input.detail),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function readContentIds(detail) {
|
|
185
|
+
if (!isRecord(detail)) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const ids = new Set();
|
|
189
|
+
for (const channel of ['sentPush', 'sentInbox', 'sentSms', 'sentAlimtalk', 'sentFriendtalk']) {
|
|
190
|
+
const entries = detail[channel];
|
|
191
|
+
if (!Array.isArray(entries)) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (isRecord(entry) && typeof entry.contentId === 'string' && entry.contentId.length > 0) {
|
|
196
|
+
ids.add(entry.contentId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return [...ids];
|
|
201
|
+
}
|
|
202
|
+
function requireCount(value, field, status) {
|
|
203
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
204
|
+
throw new AppsInTossPartnerApiError(`Apps in Toss returned an invalid ${field}.`, status);
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
function normalizeRecipient(input) {
|
|
209
|
+
if (input.type !== 'anonymous' && input.type !== 'toss-user') {
|
|
210
|
+
throw new TypeError('AIT message recipient type is invalid.');
|
|
211
|
+
}
|
|
212
|
+
return { type: input.type, key: normalizeIdentifier(input.key, 'recipient.key') };
|
|
213
|
+
}
|
|
214
|
+
function normalizeContext(input) {
|
|
215
|
+
const entries = Object.entries(input);
|
|
216
|
+
if (entries.length > 128) {
|
|
217
|
+
throw new TypeError('AIT message context cannot contain more than 128 values.');
|
|
218
|
+
}
|
|
219
|
+
const output = Object.create(null);
|
|
220
|
+
for (const [key, value] of entries) {
|
|
221
|
+
const normalizedKey = normalizeIdentifier(key, 'context key');
|
|
222
|
+
if (typeof value !== 'string'
|
|
223
|
+
&& typeof value !== 'number'
|
|
224
|
+
&& typeof value !== 'boolean') {
|
|
225
|
+
throw new TypeError(`AIT message context value is invalid: ${normalizedKey}`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
228
|
+
throw new TypeError(`AIT message context number is invalid: ${normalizedKey}`);
|
|
229
|
+
}
|
|
230
|
+
output[normalizedKey] = value;
|
|
231
|
+
}
|
|
232
|
+
return output;
|
|
233
|
+
}
|
|
234
|
+
function normalizeIdentifier(value, field) {
|
|
235
|
+
const normalized = value.trim();
|
|
236
|
+
if (normalized.length === 0
|
|
237
|
+
|| normalized.length > 2048
|
|
238
|
+
|| /[\p{Cc}\p{Cf}]/u.test(normalized)) {
|
|
239
|
+
throw new TypeError(`${field} must contain 1 to 2048 characters without control or format characters.`);
|
|
240
|
+
}
|
|
241
|
+
return normalized;
|
|
242
|
+
}
|
|
243
|
+
function normalizeBaseUrl(input) {
|
|
244
|
+
const url = new URL(input);
|
|
245
|
+
if (url.protocol !== 'https:'
|
|
246
|
+
|| url.username.length > 0
|
|
247
|
+
|| url.password.length > 0
|
|
248
|
+
|| url.search.length > 0
|
|
249
|
+
|| url.hash.length > 0) {
|
|
250
|
+
throw new TypeError('AIT partner API baseUrl must be a credential-free HTTPS URL.');
|
|
251
|
+
}
|
|
252
|
+
url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
253
|
+
return url;
|
|
254
|
+
}
|
|
255
|
+
function normalizeTimeout(value) {
|
|
256
|
+
const timeoutMs = value ?? defaultAppsInTossPartnerApiTimeoutMs;
|
|
257
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000) {
|
|
258
|
+
throw new TypeError('AIT partner API timeoutMs must be between 1 and 60000.');
|
|
259
|
+
}
|
|
260
|
+
return timeoutMs;
|
|
261
|
+
}
|
|
262
|
+
function createTimeoutSignal(upstream, timeoutMs) {
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const abortFromUpstream = () => controller.abort(upstream?.reason);
|
|
265
|
+
if (upstream?.aborted === true) {
|
|
266
|
+
abortFromUpstream();
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
upstream?.addEventListener('abort', abortFromUpstream, { once: true });
|
|
270
|
+
}
|
|
271
|
+
const timer = globalThis.setTimeout(() => controller.abort(new Error('Apps in Toss partner API request timed out.')), timeoutMs);
|
|
272
|
+
return {
|
|
273
|
+
signal: controller.signal,
|
|
274
|
+
cleanup() {
|
|
275
|
+
globalThis.clearTimeout(timer);
|
|
276
|
+
upstream?.removeEventListener('abort', abortFromUpstream);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function isRecord(value) {
|
|
281
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
282
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface CreateGameServicesClientInput {
|
|
|
74
74
|
readonly backend: GameServicesBackendApi;
|
|
75
75
|
readonly playerId: string;
|
|
76
76
|
readonly target: GameServicesLedgerTarget;
|
|
77
|
+
readonly deploymentTarget?: string;
|
|
77
78
|
readonly analytics?: AnalyticsSink;
|
|
78
79
|
readonly analyticsSessionId?: string;
|
|
79
80
|
readonly now?: () => string;
|
package/dist/client.js
CHANGED
|
@@ -83,6 +83,9 @@ export function createGameServicesClient(input) {
|
|
|
83
83
|
}
|
|
84
84
|
const verification = await input.backend.purchases.verifyPurchase({
|
|
85
85
|
target,
|
|
86
|
+
...(input.deploymentTarget === undefined || input.deploymentTarget === target
|
|
87
|
+
? {}
|
|
88
|
+
: { deploymentTarget: input.deploymentTarget }),
|
|
86
89
|
playerId: input.playerId,
|
|
87
90
|
productId: purchaseInput.productId,
|
|
88
91
|
platformTransactionId: purchase.transactionId,
|
|
@@ -148,6 +151,9 @@ export function createGameServicesClient(input) {
|
|
|
148
151
|
}
|
|
149
152
|
const claim = await input.backend.adRewards.claimAdReward({
|
|
150
153
|
target,
|
|
154
|
+
...(input.deploymentTarget === undefined || input.deploymentTarget === target
|
|
155
|
+
? {}
|
|
156
|
+
: { deploymentTarget: input.deploymentTarget }),
|
|
151
157
|
playerId: input.playerId,
|
|
152
158
|
placementId: rewardInput.placementId,
|
|
153
159
|
...(reward.ledgerEntryId === undefined
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './admob-ssv.js';
|
|
2
2
|
export * from './app-store-verifier';
|
|
3
3
|
export * from './apps-in-toss-evidence-verification';
|
|
4
|
+
export * from './apps-in-toss-partner-api';
|
|
4
5
|
export * from './client';
|
|
5
6
|
export * from './contract';
|
|
6
7
|
export * from './evidence-verification';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './admob-ssv.js';
|
|
2
2
|
export * from './app-store-verifier';
|
|
3
3
|
export * from './apps-in-toss-evidence-verification';
|
|
4
|
+
export * from './apps-in-toss-partner-api';
|
|
4
5
|
export * from './client';
|
|
5
6
|
export * from './contract';
|
|
6
7
|
export * from './evidence-verification';
|
package/dist/runtime.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface CreateGameServicesRuntimeInput {
|
|
|
17
17
|
readonly playerId: string;
|
|
18
18
|
readonly authorityMode: GameServicesAuthorityMode;
|
|
19
19
|
readonly target?: PlatformTarget | string;
|
|
20
|
+
readonly deploymentTarget?: string;
|
|
20
21
|
readonly baseUrl?: string;
|
|
21
22
|
readonly transport?: 'http' | 'orpc';
|
|
22
23
|
readonly allowLocalBackend?: boolean;
|
package/dist/runtime.js
CHANGED
|
@@ -46,6 +46,9 @@ export function createGameServicesRuntime(input) {
|
|
|
46
46
|
backend,
|
|
47
47
|
playerId: input.playerId,
|
|
48
48
|
target,
|
|
49
|
+
...(input.deploymentTarget === undefined
|
|
50
|
+
? {}
|
|
51
|
+
: { deploymentTarget: input.deploymentTarget }),
|
|
49
52
|
...(input.analytics === undefined ? {} : { analytics: input.analytics }),
|
|
50
53
|
...(input.analyticsSessionId === undefined
|
|
51
54
|
? {}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { type AnalyticsSink } from '@mpgd/analytics';
|
|
2
|
-
import type
|
|
2
|
+
import { type AdPlacements, type ProductCatalog } from '@mpgd/catalog';
|
|
3
3
|
import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesBackendTransportRequest, type GameServicesBackendTransportResponse } from './client';
|
|
4
4
|
import { type GameServicesHealthResponse } from './contract';
|
|
5
5
|
import { type GameServicesEvidenceVerifier, type GameServicesPurchaseGrantFinalizer } from './evidence-verification';
|
|
6
|
-
import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerResult, type LeaderboardScoreTransaction, type ProductGrantTransaction, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types';
|
|
6
|
+
import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerResult, type GameServicesAdRewardTarget, type LeaderboardScoreTransaction, type ProductGrantTransaction, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types';
|
|
7
7
|
type CorsHeaders = Record<string, string>;
|
|
8
8
|
export interface CreateGameServicesBackendInput {
|
|
9
9
|
readonly catalog: ProductCatalog;
|
|
10
10
|
readonly placements: AdPlacements;
|
|
11
|
+
/** Trusted deployment-config bindings keyed by the request's platform target. */
|
|
12
|
+
readonly deploymentTargetBindings?: GameServicesDeploymentTargetBindings;
|
|
11
13
|
readonly store?: GameServicesStore;
|
|
12
14
|
readonly analytics?: AnalyticsSink;
|
|
13
15
|
readonly analyticsSessionId?: string;
|
|
@@ -18,6 +20,7 @@ export interface CreateGameServicesBackendInput {
|
|
|
18
20
|
readonly purchaseGrantFinalizationTimeoutMs?: number;
|
|
19
21
|
readonly purchaseGrantFinalizer?: GameServicesPurchaseGrantFinalizer;
|
|
20
22
|
}
|
|
23
|
+
export type GameServicesDeploymentTargetBindings = Readonly<Partial<Record<GameServicesAdRewardTarget, string>>>;
|
|
21
24
|
export interface GameServicesBackendApiHandler {
|
|
22
25
|
readonly version?: string;
|
|
23
26
|
handle(request: GameServicesBackendTransportRequest): Promise<GameServicesBackendTransportResponse>;
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { implement } from '@orpc/server';
|
|
2
2
|
import { RPCHandler } from '@orpc/server/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
|
+
import { resolveAdPlacementPlatformId, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
4
5
|
import { gameServicesBackendEndpoints, } from './client';
|
|
5
6
|
import { gameServicesContract } from './contract';
|
|
6
7
|
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification';
|
|
7
|
-
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types';
|
|
8
|
+
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types';
|
|
8
9
|
export class InMemoryGameServicesStore {
|
|
9
10
|
entitlementTransactionsByKey = new Map();
|
|
10
11
|
entitlementTransactionsByEvidence = new Map();
|
|
@@ -130,6 +131,7 @@ export function createGameServicesBackend(input) {
|
|
|
130
131
|
const evidenceVerifier = input.evidenceVerifier ?? createRejectingGameServicesEvidenceVerifier();
|
|
131
132
|
const evidenceVerificationTimeoutMs = resolveEvidenceVerificationTimeout(input.evidenceVerificationTimeoutMs);
|
|
132
133
|
const purchaseGrantFinalizationTimeoutMs = resolvePurchaseGrantFinalizationTimeout(input.purchaseGrantFinalizationTimeoutMs);
|
|
134
|
+
const deploymentTargetBindings = resolveDeploymentTargetBindings(input.deploymentTargetBindings);
|
|
133
135
|
const analytics = createAnalyticsReporter({
|
|
134
136
|
target: 'server',
|
|
135
137
|
sessionId: input.analyticsSessionId ?? 'game-services',
|
|
@@ -147,6 +149,7 @@ export function createGameServicesBackend(input) {
|
|
|
147
149
|
evidenceVerifier,
|
|
148
150
|
evidenceVerificationTimeoutMs,
|
|
149
151
|
purchaseGrantFinalizationTimeoutMs,
|
|
152
|
+
deploymentTargetBindings,
|
|
150
153
|
...(input.purchaseGrantFinalizer === undefined
|
|
151
154
|
? {}
|
|
152
155
|
: { purchaseGrantFinalizer: input.purchaseGrantFinalizer }),
|
|
@@ -177,6 +180,7 @@ export function createGameServicesBackend(input) {
|
|
|
177
180
|
now,
|
|
178
181
|
evidenceVerifier,
|
|
179
182
|
evidenceVerificationTimeoutMs,
|
|
183
|
+
deploymentTargetBindings,
|
|
180
184
|
});
|
|
181
185
|
await analytics.track({
|
|
182
186
|
name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
|
|
@@ -313,14 +317,44 @@ export function createGameServicesHttpFetchHandler(handler, options = {}) {
|
|
|
313
317
|
}
|
|
314
318
|
};
|
|
315
319
|
}
|
|
320
|
+
const gameServicesDeploymentTargetPlatforms = new Set([
|
|
321
|
+
'android',
|
|
322
|
+
'ios',
|
|
323
|
+
'ait',
|
|
324
|
+
'verse8',
|
|
325
|
+
]);
|
|
326
|
+
function resolveDeploymentTargetBindings(input) {
|
|
327
|
+
const bindings = {};
|
|
328
|
+
for (const [platformTarget, deploymentTarget] of Object.entries(input ?? {})) {
|
|
329
|
+
if (!gameServicesDeploymentTargetPlatforms.has(platformTarget)) {
|
|
330
|
+
throw new Error(`Unsupported deployment target binding platform: ${platformTarget}.`);
|
|
331
|
+
}
|
|
332
|
+
assertGameServicesDeploymentTarget(deploymentTarget);
|
|
333
|
+
bindings[platformTarget] = deploymentTarget;
|
|
334
|
+
}
|
|
335
|
+
return bindings;
|
|
336
|
+
}
|
|
337
|
+
function bindRequestDeploymentTarget(request, bindings) {
|
|
338
|
+
const deploymentTarget = bindings[request.target] ?? request.target;
|
|
339
|
+
if (request.deploymentTarget !== undefined
|
|
340
|
+
&& request.deploymentTarget !== deploymentTarget) {
|
|
341
|
+
throw new Error(`deploymentTarget must match the backend binding for ${request.target}.`);
|
|
342
|
+
}
|
|
343
|
+
return deploymentTarget === request.target || request.deploymentTarget === deploymentTarget
|
|
344
|
+
? request
|
|
345
|
+
: { ...request, deploymentTarget };
|
|
346
|
+
}
|
|
316
347
|
async function verifyPurchaseWithStore(input, context) {
|
|
317
|
-
const request = assertVerifyPurchaseRequest(input);
|
|
348
|
+
const request = bindRequestDeploymentTarget(assertVerifyPurchaseRequest(input), context.deploymentTargetBindings);
|
|
318
349
|
const retryIdentity = {
|
|
319
350
|
source: 'purchase',
|
|
320
351
|
playerId: request.playerId,
|
|
321
352
|
idempotencyKey: request.idempotencyKey,
|
|
322
353
|
grantId: request.productId,
|
|
323
354
|
target: request.target,
|
|
355
|
+
...(request.deploymentTarget === undefined
|
|
356
|
+
? {}
|
|
357
|
+
: { deploymentTarget: request.deploymentTarget }),
|
|
324
358
|
};
|
|
325
359
|
const completeExistingRetry = async (transaction) => {
|
|
326
360
|
if (!matchesEntitlementRetry(transaction, retryIdentity)) {
|
|
@@ -350,7 +384,7 @@ async function verifyPurchaseWithStore(input, context) {
|
|
|
350
384
|
reason: 'UNKNOWN_PRODUCT',
|
|
351
385
|
});
|
|
352
386
|
}
|
|
353
|
-
const platformProductId = product.
|
|
387
|
+
const platformProductId = resolveProductPlatformId(product, request.deploymentTarget ?? request.target);
|
|
354
388
|
if (platformProductId === undefined) {
|
|
355
389
|
return assertVerifyPurchaseResponse({
|
|
356
390
|
verified: false,
|
|
@@ -394,6 +428,9 @@ async function verifyPurchaseWithStore(input, context) {
|
|
|
394
428
|
payload: {
|
|
395
429
|
...verificationPayload,
|
|
396
430
|
target: request.target,
|
|
431
|
+
...(request.deploymentTarget === undefined
|
|
432
|
+
? {}
|
|
433
|
+
: { deploymentTarget: request.deploymentTarget }),
|
|
397
434
|
productId: request.productId,
|
|
398
435
|
productType: product.type,
|
|
399
436
|
platformProductId,
|
|
@@ -569,13 +606,16 @@ function createStoredPurchaseRequest(request, transaction) {
|
|
|
569
606
|
};
|
|
570
607
|
}
|
|
571
608
|
async function claimAdRewardWithStore(input, context) {
|
|
572
|
-
const request = assertClaimAdRewardRequest(input);
|
|
609
|
+
const request = bindRequestDeploymentTarget(assertClaimAdRewardRequest(input), context.deploymentTargetBindings);
|
|
573
610
|
const retryIdentity = {
|
|
574
611
|
source: 'ad_reward',
|
|
575
612
|
playerId: request.playerId,
|
|
576
613
|
idempotencyKey: request.idempotencyKey,
|
|
577
614
|
grantId: request.placementId,
|
|
578
615
|
target: request.target,
|
|
616
|
+
...(request.deploymentTarget === undefined
|
|
617
|
+
? {}
|
|
618
|
+
: { deploymentTarget: request.deploymentTarget }),
|
|
579
619
|
};
|
|
580
620
|
const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
|
|
581
621
|
if (existing !== undefined) {
|
|
@@ -600,7 +640,7 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
600
640
|
reason: placement === undefined ? 'UNKNOWN_PLACEMENT' : 'NOT_REWARDED_PLACEMENT',
|
|
601
641
|
});
|
|
602
642
|
}
|
|
603
|
-
const platformPlacementId = placement.
|
|
643
|
+
const platformPlacementId = resolveAdPlacementPlatformId(placement, request.deploymentTarget ?? request.target);
|
|
604
644
|
const verification = await verifyEvidence((signal) => {
|
|
605
645
|
return context.evidenceVerifier.verifyAdReward({
|
|
606
646
|
request,
|
|
@@ -622,6 +662,9 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
622
662
|
const payload = {
|
|
623
663
|
...verification.payload,
|
|
624
664
|
target: request.target,
|
|
665
|
+
...(request.deploymentTarget === undefined
|
|
666
|
+
? {}
|
|
667
|
+
: { deploymentTarget: request.deploymentTarget }),
|
|
625
668
|
placementId: request.placementId,
|
|
626
669
|
rewardType: placement.reward.type,
|
|
627
670
|
amount: placement.reward.amount,
|
|
@@ -908,7 +951,8 @@ function matchesEntitlementRetry(transaction, identity) {
|
|
|
908
951
|
&& transaction.playerId === identity.playerId
|
|
909
952
|
&& transaction.idempotencyKey === identity.idempotencyKey
|
|
910
953
|
&& transaction.grantId === identity.grantId
|
|
911
|
-
&& transaction.payload.target === identity.target
|
|
954
|
+
&& transaction.payload.target === identity.target
|
|
955
|
+
&& transaction.payload.deploymentTarget === identity.deploymentTarget;
|
|
912
956
|
}
|
|
913
957
|
function resolveEvidenceVerificationTimeout(timeoutMs) {
|
|
914
958
|
const resolved = timeoutMs ?? 10000;
|
package/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type EntitlementLedgerSource = 'purchase' | 'ad_reward' | 'admin';
|
|
|
12
12
|
export type EntitlementLedgerPayload = Record<string, string | number | boolean>;
|
|
13
13
|
export interface VerifyPurchaseRequest {
|
|
14
14
|
readonly target: GameServicesStoreTarget;
|
|
15
|
+
/** Deployment config key used to resolve game-owned platform product identifiers. */
|
|
16
|
+
readonly deploymentTarget?: string;
|
|
15
17
|
readonly playerId: string;
|
|
16
18
|
readonly productId: LogicalProductId;
|
|
17
19
|
readonly platformTransactionId: string;
|
|
@@ -35,6 +37,8 @@ export interface PurchaseGrantFinalization {
|
|
|
35
37
|
}
|
|
36
38
|
export interface ClaimAdRewardRequest {
|
|
37
39
|
readonly target: GameServicesAdRewardTarget;
|
|
40
|
+
/** Deployment config key used to resolve game-owned platform placement identifiers. */
|
|
41
|
+
readonly deploymentTarget?: string;
|
|
38
42
|
readonly playerId: string;
|
|
39
43
|
readonly placementId: LogicalAdPlacementId;
|
|
40
44
|
/** Platform evidence identity; AIT uses the game-issued reward correlation identifier. */
|
|
@@ -100,3 +104,4 @@ export declare function assertEntitlementLedgerGrant(input: EntitlementLedgerGra
|
|
|
100
104
|
export declare function assertEntitlementLedgerResult(input: EntitlementLedgerResult): EntitlementLedgerResult;
|
|
101
105
|
export declare function assertProductGrantTransaction(input: ProductGrantTransaction): ProductGrantTransaction;
|
|
102
106
|
export declare function assertLeaderboardScoreTransaction(input: LeaderboardScoreTransaction): LeaderboardScoreTransaction;
|
|
107
|
+
export declare function assertGameServicesDeploymentTarget(input: unknown): asserts input is string;
|
package/dist/types.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export function assertVerifyPurchaseRequest(input) {
|
|
2
2
|
assertRecord(input, 'VerifyPurchaseRequest');
|
|
3
3
|
assertStoreTarget(input.target);
|
|
4
|
+
assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
|
|
4
5
|
assertNonEmptyString(input.playerId, 'playerId');
|
|
5
6
|
assertNonEmptyString(input.productId, 'productId');
|
|
6
7
|
assertNonEmptyString(input.platformTransactionId, 'platformTransactionId');
|
|
@@ -44,6 +45,7 @@ export function assertPurchaseGrantFinalization(input) {
|
|
|
44
45
|
export function assertClaimAdRewardRequest(input) {
|
|
45
46
|
assertRecord(input, 'ClaimAdRewardRequest');
|
|
46
47
|
assertAdRewardTarget(input.target);
|
|
48
|
+
assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
|
|
47
49
|
assertNonEmptyString(input.playerId, 'playerId');
|
|
48
50
|
assertNonEmptyString(input.placementId, 'placementId');
|
|
49
51
|
assertOptionalNonEmptyString(input.platformImpressionId, 'platformImpressionId');
|
|
@@ -135,6 +137,34 @@ function assertAdRewardTarget(input) {
|
|
|
135
137
|
throw new Error('target must be android, ios, ait, or verse8.');
|
|
136
138
|
}
|
|
137
139
|
}
|
|
140
|
+
export function assertGameServicesDeploymentTarget(input) {
|
|
141
|
+
if (typeof input !== 'string'
|
|
142
|
+
|| input.length > 64
|
|
143
|
+
|| !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(input)
|
|
144
|
+
|| reservedDeploymentTargetNames.has(input)) {
|
|
145
|
+
throw new Error('deploymentTarget must use lowercase kebab-case and avoid reserved names.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function assertOptionalGameServicesDeploymentTarget(input) {
|
|
149
|
+
if (input === undefined) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
assertGameServicesDeploymentTarget(input);
|
|
153
|
+
}
|
|
154
|
+
const reservedDeploymentTargetNames = new Set([
|
|
155
|
+
'aux',
|
|
156
|
+
'browser',
|
|
157
|
+
'con',
|
|
158
|
+
'devvit',
|
|
159
|
+
'index',
|
|
160
|
+
'msstore',
|
|
161
|
+
'nul',
|
|
162
|
+
'prn',
|
|
163
|
+
'constructor',
|
|
164
|
+
'prototype',
|
|
165
|
+
...Array.from({ length: 9 }, (_, index) => `com${String(index + 1)}`),
|
|
166
|
+
...Array.from({ length: 9 }, (_, index) => `lpt${String(index + 1)}`),
|
|
167
|
+
]);
|
|
138
168
|
function assertLeaderboardTarget(input) {
|
|
139
169
|
if (input !== 'browser'
|
|
140
170
|
&& input !== 'android'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
"types": "./dist/apps-in-toss-evidence-verification-conformance.d.ts",
|
|
54
54
|
"default": "./dist/apps-in-toss-evidence-verification-conformance.js"
|
|
55
55
|
},
|
|
56
|
+
"./apps-in-toss-partner-api": {
|
|
57
|
+
"types": "./dist/apps-in-toss-partner-api.d.ts",
|
|
58
|
+
"default": "./dist/apps-in-toss-partner-api.js"
|
|
59
|
+
},
|
|
56
60
|
"./client": {
|
|
57
61
|
"types": "./dist/client.d.ts",
|
|
58
62
|
"default": "./dist/client.js"
|
|
@@ -122,9 +126,9 @@
|
|
|
122
126
|
"@orpc/client": "2.0.0-beta.14",
|
|
123
127
|
"@orpc/contract": "2.0.0-beta.14",
|
|
124
128
|
"@orpc/server": "2.0.0-beta.14",
|
|
125
|
-
"@mpgd/
|
|
126
|
-
"@mpgd/
|
|
127
|
-
"@mpgd/
|
|
129
|
+
"@mpgd/analytics": "0.3.8",
|
|
130
|
+
"@mpgd/catalog": "0.5.2",
|
|
131
|
+
"@mpgd/platform": "0.8.0"
|
|
128
132
|
},
|
|
129
133
|
"devDependencies": {
|
|
130
134
|
"ttsc": "0.18.4",
|
|
@@ -140,6 +144,6 @@
|
|
|
140
144
|
"lint": "ttsc --noEmit",
|
|
141
145
|
"format": "ttsc format",
|
|
142
146
|
"fix": "ttsc fix",
|
|
143
|
-
"test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts"
|
|
147
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts"
|
|
144
148
|
}
|
|
145
149
|
}
|