@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.
@@ -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 { AdPlacements, ProductCatalog } from '@mpgd/catalog';
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.platformProductIds[request.target];
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.platformPlacementIds[request.target];
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.10.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/catalog": "0.5.0",
126
- "@mpgd/platform": "0.7.0",
127
- "@mpgd/analytics": "0.3.7"
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
  }