@mpgd/game-services 0.24.0 → 0.25.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,165 @@
1
+ import { type NotificationDeliveryProviderEntry, type NotificationTemplateData, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
2
+ import type { AppsInTossMutualTlsFetcher } from '../apps-in-toss-partner-api.js';
3
+ /**
4
+ * Apps in Toss functional-message delivery provider (notifications RFC
5
+ * "Delivery providers -> Apps in Toss"). It sends one `send-message` call per
6
+ * recipient over the partner API's mTLS client certificate and answers one of
7
+ * the RFC's provider answers ("Provider answers"). The backend's policy gate
8
+ * decides whether to call it and maps each answer to a delivery outcome; this
9
+ * module keeps no ledger, makes no gate decision, and uses only `fetch`
10
+ * through the injected mTLS fetcher, so it runs on Cloudflare Workers. Its
11
+ * `{ id, send }` shape is built from `@mpgd/platform/notifications` types and
12
+ * literal unions only, so the game-services delivery registry takes it
13
+ * structurally.
14
+ *
15
+ * Partner API reference, checked 2026-10-19:
16
+ * https://developers-apps-in-toss.toss.im/api/push.md ("메시지 발송하기") and
17
+ * https://developers-apps-in-toss.toss.im/documentation/common/growth/smart-message.md
18
+ * - `POST /api-partner/v1/apps-in-toss/messenger/send-message` with
19
+ * `x-toss-user-key` or `x-anon-key`, and `{ templateSetCode, context }`.
20
+ * - Business errors come as HTTP 200 with `resultType: 'FAIL'` and
21
+ * `error.errorCode`: 5004 (template not approved), 4034 (no workspace or no
22
+ * access to it), 4010 (credentials not found), 4095 (rate limit, with
23
+ * `error.data.retryAfterSeconds`). Other `resultType` values are
24
+ * `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
25
+ * `INTERNAL_ERROR`. The API may pass through undocumented codes of internal
26
+ * systems; they are failures.
27
+ * - A `SUCCESS` answer counts the messages sent per channel (push, inbox, SMS,
28
+ * Alimtalk, Friendtalk) and lists each channel's sent and failed messages,
29
+ * a failed one with a free-text `reachedFailReason`.
30
+ * - The documentation names no recipient-level code, including none for a
31
+ * recipient who has not agreed to the template. `agreementErrorCodes` and
32
+ * `agreementFailReasons` let a deployment name the ones it observes, so
33
+ * such an answer is `rejected` with reason `agreement` and records the
34
+ * denial; without them it is reason `recipient`.
35
+ * - Limits: 15,000 calls per minute per app and 10 per minute per user, the
36
+ * reference registry entry's `rate` and `limits`, which the scheduler and
37
+ * the gate apply.
38
+ */
39
+ export declare const aitFunctionalMessageProviderId: "apps-in-toss";
40
+ export declare const aitFunctionalMessagePath = "api-partner/v1/apps-in-toss/messenger/send-message";
41
+ export declare const defaultAitFunctionalMessageBaseUrl = "https://apps-in-toss-api.toss.im";
42
+ export declare const defaultAitFunctionalMessageTimeoutMs = 10000;
43
+ /** The longest `Retry-After` the provider passes on; the scheduler backs off from it. */
44
+ export declare const maximumAitFunctionalMessageRetryAfterSeconds = 86400;
45
+ /** The channels a `send-message` answer reports. */
46
+ export type AitFunctionalMessageChannel = 'push' | 'inbox' | 'sms' | 'alimtalk' | 'friendtalk';
47
+ export interface AitFunctionalMessageChannelResult {
48
+ readonly sent: readonly {
49
+ readonly contentId: string;
50
+ }[];
51
+ readonly failed: readonly {
52
+ readonly contentId: string;
53
+ readonly reachedFailReason?: string;
54
+ }[];
55
+ }
56
+ export type AitFunctionalMessageChannelResults = Readonly<Record<AitFunctionalMessageChannel, AitFunctionalMessageChannelResult>>;
57
+ /** Why a provider rejected a send (RFC "Provider answers"). */
58
+ export type AitFunctionalMessageRejection = 'configuration' | 'recipient' | 'agreement';
59
+ /**
60
+ * One of the RFC's provider answers. `unregistered` never occurs: recipients
61
+ * are platform users, not device addresses. `providerCode` keeps the partner
62
+ * API's `errorCode` or `resultType` (or `TIMEOUT`, `ABORTED`,
63
+ * `TRANSPORT_ERROR`) for logs, and `channels` the per-channel lists of a
64
+ * `SUCCESS` answer.
65
+ */
66
+ export type AitFunctionalMessageAnswer = {
67
+ readonly status: 'accepted';
68
+ /** The first sent message's `contentId`. */
69
+ readonly providerMessageId?: string;
70
+ readonly messageCount: number;
71
+ readonly channels: AitFunctionalMessageChannelResults;
72
+ } | {
73
+ readonly status: 'rate-limited';
74
+ readonly retryAfterSeconds?: number;
75
+ readonly providerCode?: string;
76
+ } | {
77
+ readonly status: 'rejected';
78
+ readonly reason: AitFunctionalMessageRejection;
79
+ readonly providerCode?: string;
80
+ readonly channels?: AitFunctionalMessageChannelResults;
81
+ } | {
82
+ readonly status: 'ambiguous';
83
+ readonly providerCode?: string;
84
+ };
85
+ /**
86
+ * Who one delivery goes to. Apps in Toss recipients are platform users whose
87
+ * id the backend resolved from the player's authenticated identity, never
88
+ * from a key the client sent; a device recipient is refused.
89
+ */
90
+ export type AitFunctionalMessageRecipient = {
91
+ readonly kind: 'platform-user';
92
+ readonly id: string;
93
+ } | {
94
+ readonly kind: 'device';
95
+ readonly installationId: string;
96
+ readonly address: unknown;
97
+ };
98
+ export interface AitFunctionalMessageDeliveryRequest {
99
+ readonly recipient: AitFunctionalMessageRecipient;
100
+ /** The topic as declared for this provider: its mapping carries `templateSetCode`. */
101
+ readonly topic: Pick<NotificationTopicDeclaration, 'id' | 'mapping'>;
102
+ /** The template's `context` values. */
103
+ readonly templateData?: NotificationTemplateData;
104
+ readonly signal?: AbortSignal;
105
+ }
106
+ export interface CreateAitFunctionalMessageDeliveryProviderOptions {
107
+ /** The partner API mTLS client, such as a Workers mTLS certificate binding (`env.BINDING`). */
108
+ readonly mtls: AppsInTossMutualTlsFetcher;
109
+ /**
110
+ * Which partner key a recipient id is: a Toss login `userKey`
111
+ * (`x-toss-user-key`) or a `User.getAnonymousKey` hash (`x-anon-key`).
112
+ */
113
+ readonly recipientKey: 'toss-user' | 'anonymous';
114
+ readonly baseUrl?: string;
115
+ /** 1 to 60,000 ms, as the partner API client accepts. */
116
+ readonly timeoutMs?: number;
117
+ /** `FAIL` error codes that mean the recipient has not agreed to the template. */
118
+ readonly agreementErrorCodes?: readonly string[];
119
+ /** `reachedFailReason` texts (trimmed) that mean the recipient has not agreed to the template. */
120
+ readonly agreementFailReasons?: readonly string[];
121
+ /** Further `FAIL` error codes that mean this recipient cannot receive the message. */
122
+ readonly recipientErrorCodes?: readonly string[];
123
+ }
124
+ export interface AitFunctionalMessageDeliveryProvider {
125
+ readonly id: typeof aitFunctionalMessageProviderId;
126
+ /** The registry entry the policy gate reads for this provider. */
127
+ readonly entry: NotificationDeliveryProviderEntry;
128
+ send(request: AitFunctionalMessageDeliveryRequest): Promise<AitFunctionalMessageAnswer>;
129
+ }
130
+ /**
131
+ * Creates the provider. `send` never throws for a partner API answer: every
132
+ * HTTP answer, transport failure, and timeout maps to a provider answer.
133
+ *
134
+ * - `SUCCESS` with at least one message sent on any channel is `accepted`.
135
+ * With none sent, the per-channel `fail` lists decide: `rejected` with
136
+ * reason `agreement` when a failure's `reachedFailReason` is one of
137
+ * `agreementFailReasons`, otherwise `recipient`.
138
+ * - `FAIL` with a code in `agreementErrorCodes` (first) or
139
+ * `recipientErrorCodes` is `rejected` with that reason, before any
140
+ * documented meaning of the code, on any status except HTTP 429, 5xx,
141
+ * 408, and 425, which the status alone decides (see below);
142
+ * otherwise 4095 is `rate-limited` with `retryAfterSeconds` (at most a
143
+ * day), and 5004, 4034, and 4010 are `rejected` with reason
144
+ * `configuration`. Any other code is `rejected` with reason `recipient` on HTTP 200
145
+ * and `configuration` when the HTTP status refused the request (400, 401,
146
+ * 403, 404, 405, 413, 415, 422): a `FAIL` envelope reports a failed
147
+ * request, and answering `ambiguous` would replay a send that fails the
148
+ * same way after every lease: the RFC sets no limit on how often an
149
+ * `ambiguous` send is replayed (only the lease paces the replays). On
150
+ * any other status it is `ambiguous`, and so is a `FAIL` without a code.
151
+ * A `rate-limited` delay comes from `error.data.retryAfterSeconds`, else
152
+ * the `Retry-After` header.
153
+ * - `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
154
+ * `INTERNAL_ERROR`, any HTTP 5xx, 408, or 425, a transport failure, a
155
+ * timeout, and an answer that cannot be read are `ambiguous`: the claim
156
+ * stays and the scheduler replays after the lease.
157
+ * - HTTP 429 is `rate-limited` whatever its body (`retryAfterSeconds`, else
158
+ * `Retry-After` in seconds, when present); HTTP 400, 401, 403, 404, 405,
159
+ * 413, 415, and 422 without a business error are `rejected` with reason
160
+ * `configuration`.
161
+ * - A request the API would refuse (a device recipient, a mapping without
162
+ * `templateSetCode`, template data outside the RFC bounds, a recipient id
163
+ * that is not a visible-ASCII header value) is `rejected` without a call.
164
+ */
165
+ export declare function createAitFunctionalMessageDeliveryProvider(options: CreateAitFunctionalMessageDeliveryProviderOptions): AitFunctionalMessageDeliveryProvider;
@@ -0,0 +1,445 @@
1
+ import { notificationTemplateDataIssue, referenceNotificationProviders, } from '@mpgd/platform/notifications';
2
+ /**
3
+ * Apps in Toss functional-message delivery provider (notifications RFC
4
+ * "Delivery providers -> Apps in Toss"). It sends one `send-message` call per
5
+ * recipient over the partner API's mTLS client certificate and answers one of
6
+ * the RFC's provider answers ("Provider answers"). The backend's policy gate
7
+ * decides whether to call it and maps each answer to a delivery outcome; this
8
+ * module keeps no ledger, makes no gate decision, and uses only `fetch`
9
+ * through the injected mTLS fetcher, so it runs on Cloudflare Workers. Its
10
+ * `{ id, send }` shape is built from `@mpgd/platform/notifications` types and
11
+ * literal unions only, so the game-services delivery registry takes it
12
+ * structurally.
13
+ *
14
+ * Partner API reference, checked 2026-10-19:
15
+ * https://developers-apps-in-toss.toss.im/api/push.md ("메시지 발송하기") and
16
+ * https://developers-apps-in-toss.toss.im/documentation/common/growth/smart-message.md
17
+ * - `POST /api-partner/v1/apps-in-toss/messenger/send-message` with
18
+ * `x-toss-user-key` or `x-anon-key`, and `{ templateSetCode, context }`.
19
+ * - Business errors come as HTTP 200 with `resultType: 'FAIL'` and
20
+ * `error.errorCode`: 5004 (template not approved), 4034 (no workspace or no
21
+ * access to it), 4010 (credentials not found), 4095 (rate limit, with
22
+ * `error.data.retryAfterSeconds`). Other `resultType` values are
23
+ * `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
24
+ * `INTERNAL_ERROR`. The API may pass through undocumented codes of internal
25
+ * systems; they are failures.
26
+ * - A `SUCCESS` answer counts the messages sent per channel (push, inbox, SMS,
27
+ * Alimtalk, Friendtalk) and lists each channel's sent and failed messages,
28
+ * a failed one with a free-text `reachedFailReason`.
29
+ * - The documentation names no recipient-level code, including none for a
30
+ * recipient who has not agreed to the template. `agreementErrorCodes` and
31
+ * `agreementFailReasons` let a deployment name the ones it observes, so
32
+ * such an answer is `rejected` with reason `agreement` and records the
33
+ * denial; without them it is reason `recipient`.
34
+ * - Limits: 15,000 calls per minute per app and 10 per minute per user, the
35
+ * reference registry entry's `rate` and `limits`, which the scheduler and
36
+ * the gate apply.
37
+ */
38
+ export const aitFunctionalMessageProviderId = 'apps-in-toss';
39
+ export const aitFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-message';
40
+ // The same values as `appsInTossPartnerApiBaseUrl` and
41
+ // `defaultAppsInTossPartnerApiTimeoutMs` (a test keeps them equal), kept here
42
+ // so this module loads none of the partner client's code.
43
+ export const defaultAitFunctionalMessageBaseUrl = 'https://apps-in-toss-api.toss.im';
44
+ export const defaultAitFunctionalMessageTimeoutMs = 10_000;
45
+ /** The longest `Retry-After` the provider passes on; the scheduler backs off from it. */
46
+ export const maximumAitFunctionalMessageRetryAfterSeconds = 86_400;
47
+ /** Codes the documentation names for the project's setup or credentials. */
48
+ const configurationErrorCodes = new Set(['5004', '4034', '4010']);
49
+ const rateLimitedErrorCode = '4095';
50
+ /** `resultType` values that cannot tell whether the message went out. */
51
+ const ambiguousResultTypes = new Set([
52
+ 'HTTP_TIMEOUT',
53
+ 'NETWORK_ERROR',
54
+ 'EXECUTION_FAIL',
55
+ 'INTERRUPTED',
56
+ 'INTERNAL_ERROR',
57
+ ]);
58
+ /** HTTP statuses that refuse the request itself, before anything is sent. */
59
+ const refusedRequestStatuses = new Set([
60
+ 400, 401, 403, 404, 405, 413, 415, 422,
61
+ ]);
62
+ const channelFields = {
63
+ push: 'sentPush',
64
+ inbox: 'sentInbox',
65
+ sms: 'sentSms',
66
+ alimtalk: 'sentAlimtalk',
67
+ friendtalk: 'sentFriendtalk',
68
+ };
69
+ const maximumResponseBodyBytes = 256 * 1_024;
70
+ const maximumIdentifierLength = 2_048;
71
+ const maximumTimeoutMs = 60_000;
72
+ /**
73
+ * Creates the provider. `send` never throws for a partner API answer: every
74
+ * HTTP answer, transport failure, and timeout maps to a provider answer.
75
+ *
76
+ * - `SUCCESS` with at least one message sent on any channel is `accepted`.
77
+ * With none sent, the per-channel `fail` lists decide: `rejected` with
78
+ * reason `agreement` when a failure's `reachedFailReason` is one of
79
+ * `agreementFailReasons`, otherwise `recipient`.
80
+ * - `FAIL` with a code in `agreementErrorCodes` (first) or
81
+ * `recipientErrorCodes` is `rejected` with that reason, before any
82
+ * documented meaning of the code, on any status except HTTP 429, 5xx,
83
+ * 408, and 425, which the status alone decides (see below);
84
+ * otherwise 4095 is `rate-limited` with `retryAfterSeconds` (at most a
85
+ * day), and 5004, 4034, and 4010 are `rejected` with reason
86
+ * `configuration`. Any other code is `rejected` with reason `recipient` on HTTP 200
87
+ * and `configuration` when the HTTP status refused the request (400, 401,
88
+ * 403, 404, 405, 413, 415, 422): a `FAIL` envelope reports a failed
89
+ * request, and answering `ambiguous` would replay a send that fails the
90
+ * same way after every lease: the RFC sets no limit on how often an
91
+ * `ambiguous` send is replayed (only the lease paces the replays). On
92
+ * any other status it is `ambiguous`, and so is a `FAIL` without a code.
93
+ * A `rate-limited` delay comes from `error.data.retryAfterSeconds`, else
94
+ * the `Retry-After` header.
95
+ * - `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
96
+ * `INTERNAL_ERROR`, any HTTP 5xx, 408, or 425, a transport failure, a
97
+ * timeout, and an answer that cannot be read are `ambiguous`: the claim
98
+ * stays and the scheduler replays after the lease.
99
+ * - HTTP 429 is `rate-limited` whatever its body (`retryAfterSeconds`, else
100
+ * `Retry-After` in seconds, when present); HTTP 400, 401, 403, 404, 405,
101
+ * 413, 415, and 422 without a business error are `rejected` with reason
102
+ * `configuration`.
103
+ * - A request the API would refuse (a device recipient, a mapping without
104
+ * `templateSetCode`, template data outside the RFC bounds, a recipient id
105
+ * that is not a visible-ASCII header value) is `rejected` without a call.
106
+ */
107
+ export function createAitFunctionalMessageDeliveryProvider(options) {
108
+ if (options.recipientKey !== 'toss-user' && options.recipientKey !== 'anonymous') {
109
+ throw new TypeError("recipientKey must be 'toss-user' or 'anonymous'.");
110
+ }
111
+ const recipientHeader = options.recipientKey === 'anonymous' ? 'x-anon-key' : 'x-toss-user-key';
112
+ const url = new URL(aitFunctionalMessagePath, normalizeBaseUrl(options.baseUrl ?? defaultAitFunctionalMessageBaseUrl)).href;
113
+ const timeoutMs = normalizeTimeout(options.timeoutMs);
114
+ const agreementErrorCodes = new Set(options.agreementErrorCodes ?? []);
115
+ const recipientErrorCodes = new Set(options.recipientErrorCodes ?? []);
116
+ const agreementFailReasons = new Set((options.agreementFailReasons ?? []).map((reason) => reason.trim()));
117
+ // The deployment's codes come first, then the documented ones (RFC
118
+ // "Delivery providers -> Apps in Toss").
119
+ const classifyFailCode = (code, status) => {
120
+ if (agreementErrorCodes.has(code)) {
121
+ return { status: 'rejected', reason: 'agreement', providerCode: code };
122
+ }
123
+ if (recipientErrorCodes.has(code)) {
124
+ return { status: 'rejected', reason: 'recipient', providerCode: code };
125
+ }
126
+ if (code === rateLimitedErrorCode) {
127
+ return { status: 'rate-limited', providerCode: code };
128
+ }
129
+ if (configurationErrorCodes.has(code) || refusedRequestStatuses.has(status)) {
130
+ return { status: 'rejected', reason: 'configuration', providerCode: code };
131
+ }
132
+ // An undocumented code on another status proves nothing either way.
133
+ return status === 200
134
+ ? { status: 'rejected', reason: 'recipient', providerCode: code }
135
+ : { status: 'ambiguous', providerCode: code };
136
+ };
137
+ return {
138
+ id: aitFunctionalMessageProviderId,
139
+ entry: referenceNotificationProviders['apps-in-toss'],
140
+ async send(request) {
141
+ const templateSetCode = request.topic.mapping?.['templateSetCode'];
142
+ if (typeof templateSetCode !== 'string' || !isIdentifier(templateSetCode)) {
143
+ return { status: 'rejected', reason: 'configuration' };
144
+ }
145
+ const context = request.templateData ?? {};
146
+ if (notificationTemplateDataIssue(context) !== undefined) {
147
+ return { status: 'rejected', reason: 'configuration' };
148
+ }
149
+ const { recipient } = request;
150
+ if (recipient.kind !== 'platform-user') {
151
+ return { status: 'rejected', reason: 'configuration' };
152
+ }
153
+ if (!isHeaderValue(recipient.id)) {
154
+ return { status: 'rejected', reason: 'recipient' };
155
+ }
156
+ const response = await post({
157
+ mtls: options.mtls,
158
+ url,
159
+ headers: { [recipientHeader]: recipient.id },
160
+ body: { templateSetCode, context },
161
+ timeoutMs,
162
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
163
+ });
164
+ if (response.kind === 'failed') {
165
+ return { status: 'ambiguous', providerCode: response.reason };
166
+ }
167
+ const { status, body } = response;
168
+ if (status >= 500 || status === 408 || status === 425) {
169
+ return { status: 'ambiguous', ...providerCodeOf(body) };
170
+ }
171
+ if (status === 429) {
172
+ // Throttled at the HTTP layer: nothing was sent, whatever the body says.
173
+ const error = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined;
174
+ const retryAfterSeconds = retryAfterOf(error, response.retryAfter);
175
+ return {
176
+ status: 'rate-limited',
177
+ ...providerCodeOf(body),
178
+ ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
179
+ };
180
+ }
181
+ const resultType = isRecord(body) ? body['resultType'] : undefined;
182
+ if (resultType === 'SUCCESS') {
183
+ return status >= 200 && status < 300
184
+ ? readSuccess(isRecord(body) ? body['success'] : undefined, agreementFailReasons)
185
+ : { status: 'ambiguous', providerCode: 'SUCCESS' };
186
+ }
187
+ if (resultType === 'FAIL' && isRecord(body)) {
188
+ const error = isRecord(body['error']) ? body['error'] : undefined;
189
+ const code = errorCodeOf(error?.['errorCode']);
190
+ if (code === undefined) {
191
+ return { status: 'ambiguous', providerCode: 'FAIL' };
192
+ }
193
+ const answer = classifyFailCode(code, status);
194
+ if (answer.status === 'rate-limited') {
195
+ const retryAfterSeconds = retryAfterOf(error, response.retryAfter);
196
+ return retryAfterSeconds === undefined ? answer : { ...answer, retryAfterSeconds };
197
+ }
198
+ return answer;
199
+ }
200
+ if (typeof resultType === 'string' && ambiguousResultTypes.has(resultType)) {
201
+ return { status: 'ambiguous', providerCode: resultType };
202
+ }
203
+ if (refusedRequestStatuses.has(status)) {
204
+ return { status: 'rejected', reason: 'configuration', ...providerCodeOf(body) };
205
+ }
206
+ // Any other answer without a readable result proves nothing either way.
207
+ return { status: 'ambiguous', ...providerCodeOf(body) };
208
+ },
209
+ };
210
+ }
211
+ function readSuccess(success, agreementFailReasons) {
212
+ if (!isRecord(success)) {
213
+ return { status: 'ambiguous', providerCode: 'SUCCESS' };
214
+ }
215
+ const messageCount = success['msgCount'];
216
+ const detail = success['detail'];
217
+ const fail = success['fail'];
218
+ if (!isCount(messageCount)) {
219
+ return { status: 'ambiguous', providerCode: 'SUCCESS' };
220
+ }
221
+ const channelOf = (field) => ({
222
+ sent: readContents(isRecord(detail) ? detail[field] : undefined),
223
+ failed: readContents(isRecord(fail) ? fail[field] : undefined),
224
+ });
225
+ const channels = {
226
+ push: channelOf(channelFields.push),
227
+ inbox: channelOf(channelFields.inbox),
228
+ sms: channelOf(channelFields.sms),
229
+ alimtalk: channelOf(channelFields.alimtalk),
230
+ friendtalk: channelOf(channelFields.friendtalk),
231
+ };
232
+ const results = Object.values(channels);
233
+ const firstSent = results.find((result) => result.sent.length > 0)?.sent[0];
234
+ if (messageCount > 0 || firstSent !== undefined) {
235
+ return {
236
+ status: 'accepted',
237
+ ...(firstSent === undefined ? {} : { providerMessageId: firstSent.contentId }),
238
+ messageCount,
239
+ channels,
240
+ };
241
+ }
242
+ // Nothing reached any channel. One recipient per call, so the per-channel
243
+ // failures are this recipient's; an agreement failure on any channel
244
+ // decides, because the template itself was not agreed to.
245
+ const agreement = results.some((result) => result.failed.some((failure) => failure.reachedFailReason !== undefined
246
+ && agreementFailReasons.has(failure.reachedFailReason.trim())));
247
+ return { status: 'rejected', reason: agreement ? 'agreement' : 'recipient', channels };
248
+ }
249
+ function readContents(input) {
250
+ if (!Array.isArray(input)) {
251
+ return [];
252
+ }
253
+ const contents = [];
254
+ for (const item of input.slice(0, 64)) {
255
+ if (!isRecord(item) || typeof item['contentId'] !== 'string') {
256
+ continue;
257
+ }
258
+ const reason = item['reachedFailReason'];
259
+ contents.push({
260
+ contentId: item['contentId'].slice(0, 256),
261
+ ...(typeof reason === 'string' ? { reachedFailReason: reason.slice(0, 1_024) } : {}),
262
+ });
263
+ }
264
+ return contents;
265
+ }
266
+ /**
267
+ * One POST over the mTLS fetcher, bounded in time (the body read included)
268
+ * and size. A body that is too large or not UTF-8 reads as no body, so the
269
+ * status decides. The request's connection is released when the call ends.
270
+ */
271
+ async function post(input) {
272
+ // A function, so the check reads the signal at each call rather than once.
273
+ const callerAborted = () => input.signal?.aborted === true;
274
+ if (callerAborted()) {
275
+ // An aborted caller sends nothing; the answer is still the ambiguous one.
276
+ return { kind: 'failed', reason: 'ABORTED' };
277
+ }
278
+ const controller = new AbortController();
279
+ let timedOut = false;
280
+ const abort = () => controller.abort();
281
+ input.signal?.addEventListener('abort', abort, { once: true });
282
+ const timer = setTimeout(() => {
283
+ timedOut = true;
284
+ controller.abort();
285
+ }, input.timeoutMs);
286
+ try {
287
+ const response = await input.mtls.fetch(input.url, {
288
+ method: 'POST',
289
+ headers: {
290
+ accept: 'application/json',
291
+ 'content-type': 'application/json',
292
+ ...input.headers,
293
+ },
294
+ body: JSON.stringify(input.body),
295
+ redirect: 'manual',
296
+ signal: controller.signal,
297
+ });
298
+ const text = await readBoundedText(response);
299
+ let body;
300
+ if (text !== undefined && text.length > 0) {
301
+ try {
302
+ body = JSON.parse(text);
303
+ }
304
+ catch {
305
+ body = undefined;
306
+ }
307
+ }
308
+ return {
309
+ kind: 'answered',
310
+ status: response.status,
311
+ body,
312
+ retryAfter: response.headers.get('retry-after') ?? undefined,
313
+ };
314
+ }
315
+ catch {
316
+ if (timedOut) {
317
+ return { kind: 'failed', reason: 'TIMEOUT' };
318
+ }
319
+ return { kind: 'failed', reason: callerAborted() ? 'ABORTED' : 'TRANSPORT_ERROR' };
320
+ }
321
+ finally {
322
+ clearTimeout(timer);
323
+ input.signal?.removeEventListener('abort', abort);
324
+ // Frees a connection whose body was not read to the end.
325
+ controller.abort();
326
+ }
327
+ }
328
+ /** The body as UTF-8 text, or `undefined` when it is larger than 256 KiB or not UTF-8. */
329
+ async function readBoundedText(response) {
330
+ const declaredLength = response.headers.get('content-length');
331
+ if (declaredLength !== null
332
+ && /^\d+$/u.test(declaredLength)
333
+ && Number(declaredLength) > maximumResponseBodyBytes) {
334
+ await response.body?.cancel().catch(() => { });
335
+ return undefined;
336
+ }
337
+ if (response.body === null) {
338
+ return '';
339
+ }
340
+ const reader = response.body.getReader();
341
+ const chunks = [];
342
+ let length = 0;
343
+ try {
344
+ while (true) {
345
+ const chunk = await reader.read();
346
+ if (chunk.done) {
347
+ break;
348
+ }
349
+ length += chunk.value.byteLength;
350
+ if (length > maximumResponseBodyBytes) {
351
+ await reader.cancel().catch(() => { });
352
+ return undefined;
353
+ }
354
+ chunks.push(chunk.value);
355
+ }
356
+ }
357
+ finally {
358
+ reader.releaseLock();
359
+ }
360
+ const bytes = new Uint8Array(length);
361
+ let offset = 0;
362
+ for (const chunk of chunks) {
363
+ bytes.set(chunk, offset);
364
+ offset += chunk.byteLength;
365
+ }
366
+ try {
367
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
368
+ }
369
+ catch {
370
+ return undefined;
371
+ }
372
+ }
373
+ /** A string code, or a finite number read as its digits; anything else is no code. */
374
+ function errorCodeOf(value) {
375
+ if (typeof value === 'string') {
376
+ return value.length > 0 && value.length <= 128 ? value : undefined;
377
+ }
378
+ return typeof value === 'number' && Number.isFinite(value) ? String(value) : undefined;
379
+ }
380
+ function providerCodeOf(body) {
381
+ if (!isRecord(body)) {
382
+ return {};
383
+ }
384
+ const error = isRecord(body['error']) ? body['error'] : undefined;
385
+ const code = errorCodeOf(error?.['errorCode']);
386
+ if (code !== undefined) {
387
+ return { providerCode: code };
388
+ }
389
+ const resultType = body['resultType'];
390
+ return typeof resultType === 'string' && resultType.length > 0
391
+ ? { providerCode: resultType.slice(0, 128) }
392
+ : {};
393
+ }
394
+ /** The body's `error.data.retryAfterSeconds` when it is a usable delay, else the `Retry-After` header's. */
395
+ function retryAfterOf(error, header) {
396
+ const data = isRecord(error?.['data']) ? error['data'] : undefined;
397
+ return positiveSeconds(data?.['retryAfterSeconds']) ?? positiveSeconds(header);
398
+ }
399
+ /** A positive number of seconds, rounded up and capped at a day, from a number or a decimal string. */
400
+ function positiveSeconds(value) {
401
+ const seconds = typeof value === 'string' && /^\d+(?:\.\d+)?$/u.test(value.trim())
402
+ ? Number(value.trim())
403
+ : value;
404
+ return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
405
+ ? Math.min(Math.ceil(seconds), maximumAitFunctionalMessageRetryAfterSeconds)
406
+ : undefined;
407
+ }
408
+ function isIdentifier(value) {
409
+ return value.length > 0
410
+ && value.length <= maximumIdentifierLength
411
+ && value.trim() === value
412
+ && !/[\p{Cc}\p{Cf}]/u.test(value);
413
+ }
414
+ /** A value a `fetch` header accepts as is: visible ASCII, at most 2,048 characters. */
415
+ function isHeaderValue(value) {
416
+ return value.length <= maximumIdentifierLength && /^[\x21-\x7e]+$/u.test(value);
417
+ }
418
+ function isCount(value) {
419
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
420
+ }
421
+ function isRecord(value) {
422
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
423
+ }
424
+ /** A credential-free HTTPS base URL ending in `/`, as the partner API client requires. */
425
+ function normalizeBaseUrl(input) {
426
+ const url = new URL(input);
427
+ if (url.protocol !== 'https:'
428
+ || url.username.length > 0
429
+ || url.password.length > 0
430
+ || url.search.length > 0
431
+ || url.hash.length > 0) {
432
+ throw new TypeError('The Apps in Toss partner API baseUrl must be a credential-free HTTPS URL.');
433
+ }
434
+ url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
435
+ return url.href;
436
+ }
437
+ function normalizeTimeout(value) {
438
+ if (value === undefined) {
439
+ return defaultAitFunctionalMessageTimeoutMs;
440
+ }
441
+ if (!Number.isFinite(value) || value < 1 || value > maximumTimeoutMs) {
442
+ throw new RangeError('The Apps in Toss functional message timeout must be between 1 and 60000 ms.');
443
+ }
444
+ return value;
445
+ }