@mpgd/game-services 0.10.0 → 0.11.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.
@@ -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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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/catalog": "0.5.1",
130
+ "@mpgd/analytics": "0.3.8",
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
  }