From 7d2979ce2e1f8db02df3cc960f060dadbb2b28dc Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 17:56:43 +0900 Subject: [PATCH 01/10] feat(responses): sync query API schemas and add runtime validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getBalance 응답에 lowBalanceAlert, minimumCash, rechargeTo, deposit 등 실서버에 존재하는 필드를 반영해 스키마 확장 - getStatistics, getGroups, getGroup, getBlacks, getBlockGroups, getKakaoChannel, getKakaoAlimtalkTemplate(s) 응답의 드리프트(nullable, 누락 필드, Record→Array 등) 정합화 - MessageTypeRecord에 rcs_itpl/ltpl, fax, voice, bms_* 신규 메시지 타입 추가 - 조회 응답 전용 storedMessageSchema 신설해 발송용 messageSchema와 분리 - DefaultService.requestEffect/getWithQuery에 responseSchema 주입 경로 마련하고, decodeServerResponse 헬퍼 추가로 서버 응답을 런타임 검증해 드리프트 즉시 감지 - 15개 조회 서비스 메서드에 해당 스키마 연결 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/schemaUtils.ts | 27 ++++++- src/models/base/kakao/kakaoChannel.ts | 6 +- src/models/base/messages/storedMessage.ts | 59 ++++++++++++++++ src/models/index.ts | 4 ++ src/models/responses/iam/getBlacksResponse.ts | 4 +- .../kakao/getKakaoTemplateResponse.ts | 2 +- src/models/responses/messageResponses.ts | 70 ++++++++++++++----- src/services/cash/cashService.ts | 6 +- src/services/defaultService.ts | 35 +++++++--- src/services/iam/iamService.ts | 18 ++++- .../kakao/channels/kakaoChannelService.ts | 20 ++++-- .../kakao/templates/kakaoTemplateService.ts | 28 ++++++-- src/services/messages/groupService.ts | 6 ++ src/services/messages/messageService.ts | 4 ++ src/types/commonTypes.ts | 25 +++++-- 15 files changed, 262 insertions(+), 52 deletions(-) create mode 100644 src/models/base/messages/storedMessage.ts diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 27196cc4..7ead831c 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -1,6 +1,10 @@ import {ParseResult, Schema} from 'effect'; import * as Effect from 'effect/Effect'; -import {BadRequestError, InvalidDateError} from '../errors/defaultError'; +import { + BadRequestError, + InvalidDateError, + ServerError, +} from '../errors/defaultError'; import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer'; /** @@ -74,3 +78,24 @@ export const safeFinalize = ( message: error instanceof Error ? error.message : String(error), }), }); + +/** + * API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ServerError로 래핑. + * 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신 + * 스키마 불일치 위치를 즉시 파악할 수 있도록 한다. + */ +export const decodeServerResponse = ( + schema: Schema.Schema, + data: unknown, + context?: {url?: string; httpStatus?: number}, +): Effect.Effect => + Effect.mapError( + Schema.decodeUnknown(schema)(data), + err => + new ServerError({ + errorCode: 'ResponseSchemaMismatch', + errorMessage: ParseResult.TreeFormatter.formatErrorSync(err), + httpStatus: context?.httpStatus ?? 200, + url: context?.url, + }), + ); diff --git a/src/models/base/kakao/kakaoChannel.ts b/src/models/base/kakao/kakaoChannel.ts index 084d5372..41b03658 100644 --- a/src/models/base/kakao/kakaoChannel.ts +++ b/src/models/base/kakao/kakaoChannel.ts @@ -21,7 +21,7 @@ export const kakaoChannelSchema = Schema.Struct({ channelId: Schema.String, searchId: Schema.String, accountId: Schema.String, - phoneNumber: Schema.String, + phoneNumber: Schema.optional(Schema.String), sharedAccountIds: Schema.Array(Schema.String), dateCreated: Schema.optional( Schema.Union(Schema.String, Schema.DateFromSelf), @@ -40,7 +40,7 @@ export type KakaoChannel = { channelId: string; searchId: string; accountId: string; - phoneNumber: string; + phoneNumber?: string; sharedAccountIds: ReadonlyArray; dateCreated?: Date; dateUpdated?: Date; @@ -63,6 +63,6 @@ export function decodeKakaoChannel( sharedAccountIds: data.sharedAccountIds, dateCreated, dateUpdated, - }; + } satisfies KakaoChannel; }); } diff --git a/src/models/base/messages/storedMessage.ts b/src/models/base/messages/storedMessage.ts new file mode 100644 index 00000000..0592676d --- /dev/null +++ b/src/models/base/messages/storedMessage.ts @@ -0,0 +1,59 @@ +import {Schema} from 'effect'; +import {messageTypeSchema} from './message'; + +/** + * 조회 응답(getMessages/getGroupMessages)에 포함된 메시지 아이템 스키마. + * + * 발송용 messageSchema와 달리 서버가 저장해둔 값을 그대로 반환하므로 + * - optional 필드 상당수가 null로 내려올 수 있다. + * - kakaoOptions/rcsOptions 등 내부 구조가 발송 요청과 다르다(서버 정규화 포맷). + * + * 따라서 핵심 필드만 선언하고, 나머지는 런타임 통과를 위해 NullishOr/Unknown으로 관대하게 허용한다. + * Schema.Struct는 기본적으로 extra 필드를 무시하므로 신규 필드 추가 시에도 drift 없이 통과한다. + */ +export const storedMessageSchema = Schema.Struct({ + messageId: Schema.optional(Schema.String), + type: Schema.NullishOr(messageTypeSchema), + to: Schema.optional(Schema.Union(Schema.String, Schema.Array(Schema.String))), + from: Schema.NullishOr(Schema.String), + text: Schema.NullishOr(Schema.String), + imageId: Schema.NullishOr(Schema.String), + subject: Schema.NullishOr(Schema.String), + country: Schema.NullishOr(Schema.String), + accountId: Schema.optional(Schema.String), + groupId: Schema.optional(Schema.String), + status: Schema.NullishOr(Schema.String), + statusCode: Schema.NullishOr(Schema.String), + reason: Schema.NullishOr(Schema.String), + networkName: Schema.NullishOr(Schema.String), + networkCode: Schema.NullishOr(Schema.String), + customFields: Schema.optional( + Schema.NullishOr(Schema.Record({key: Schema.String, value: Schema.String})), + ), + autoTypeDetect: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)), + replacement: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)), + resendCount: Schema.optional(Schema.Number), + dateCreated: Schema.optional(Schema.String), + dateUpdated: Schema.optional(Schema.String), + dateProcessed: Schema.NullishOr(Schema.String), + dateReceived: Schema.NullishOr(Schema.String), + dateReported: Schema.NullishOr(Schema.String), + kakaoOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), + rcsOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), + naverOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), + faxOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), + voiceOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), + replacements: Schema.optional(Schema.NullishOr(Schema.Unknown)), + log: Schema.optional(Schema.NullishOr(Schema.Unknown)), + queues: Schema.optional(Schema.NullishOr(Schema.Unknown)), + currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)), + clusterKey: Schema.NullishOr(Schema.String), + unavailableSenderNumber: Schema.optional( + Schema.Union(Schema.Boolean, Schema.Number), + ), + faxPageCount: Schema.optional(Schema.Number), + voiceDuration: Schema.optional(Schema.Number), + voiceReplied: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)), + _id: Schema.optional(Schema.String), +}); +export type StoredMessage = Schema.Schema.Type; diff --git a/src/models/index.ts b/src/models/index.ts index a2ceee66..580bdae8 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -65,6 +65,10 @@ export { messageSchema, messageTypeSchema, } from './base/messages/message'; +export { + type StoredMessage, + storedMessageSchema, +} from './base/messages/storedMessage'; export { type NaverOptionSchema, naverOptionSchema, diff --git a/src/models/responses/iam/getBlacksResponse.ts b/src/models/responses/iam/getBlacksResponse.ts index c97dc957..f8231878 100644 --- a/src/models/responses/iam/getBlacksResponse.ts +++ b/src/models/responses/iam/getBlacksResponse.ts @@ -1,11 +1,11 @@ -import {blackSchema, handleKeySchema} from '@internal-types/commonTypes'; +import {blackSchema} from '@internal-types/commonTypes'; import {Schema} from 'effect'; export const getBlacksResponseSchema = Schema.Struct({ startKey: Schema.NullishOr(Schema.String), limit: Schema.Number, nextKey: Schema.NullishOr(Schema.String), - blackList: Schema.Record({key: handleKeySchema, value: blackSchema}), + blackList: Schema.Array(blackSchema), }); export type GetBlacksResponse = Schema.Schema.Type< typeof getBlacksResponseSchema diff --git a/src/models/responses/kakao/getKakaoTemplateResponse.ts b/src/models/responses/kakao/getKakaoTemplateResponse.ts index 9f116233..11a57fbc 100644 --- a/src/models/responses/kakao/getKakaoTemplateResponse.ts +++ b/src/models/responses/kakao/getKakaoTemplateResponse.ts @@ -9,7 +9,7 @@ export const getKakaoTemplateResponseSchema = kakaoAlimtalkTemplateSchema.pipe( Schema.extend( Schema.Struct({ assignType: kakaoAlimtalkTemplateAssignTypeSchema, - accountId: Schema.String, + accountId: Schema.NullishOr(Schema.String), commentable: Schema.Boolean, dateCreated: Schema.String, dateUpdated: Schema.String, diff --git a/src/models/responses/messageResponses.ts b/src/models/responses/messageResponses.ts index 5807b151..f09122a9 100644 --- a/src/models/responses/messageResponses.ts +++ b/src/models/responses/messageResponses.ts @@ -6,10 +6,9 @@ import { groupIdSchema, groupSchema, logSchema, - messageTypeRecordSchema, } from '@internal-types/commonTypes'; import {Schema} from 'effect'; -import {messageSchema} from '../base/messages/message'; +import {storedMessageSchema} from '../base/messages/storedMessage'; export const groupMessageResponseSchema = Schema.Struct({ count: countSchema, @@ -28,9 +27,9 @@ export const groupMessageResponseSchema = Schema.Struct({ price: Schema.Record({key: Schema.String, value: Schema.Unknown}), dateCreated: Schema.String, dateUpdated: Schema.String, - scheduledDate: Schema.optional(Schema.String), - dateSent: Schema.optional(Schema.String), - dateCompleted: Schema.optional(Schema.String), + scheduledDate: Schema.NullishOr(Schema.String), + dateSent: Schema.NullishOr(Schema.String), + dateCompleted: Schema.NullishOr(Schema.String), }); export type GroupMessageResponse = Schema.Schema.Type< typeof groupMessageResponseSchema @@ -62,10 +61,10 @@ export type AddMessageResponse = Schema.Schema.Type< >; export const getMessagesResponseSchema = Schema.Struct({ - startKey: Schema.NullOr(Schema.String), - nextKey: Schema.NullOr(Schema.String), + startKey: Schema.optional(Schema.NullOr(Schema.String)), + nextKey: Schema.optional(Schema.NullOr(Schema.String)), limit: Schema.Number, - messageList: Schema.Record({key: Schema.String, value: messageSchema}), + messageList: Schema.Record({key: Schema.String, value: storedMessageSchema}), }); export type GetMessagesResponse = Schema.Schema.Type< typeof getMessagesResponseSchema @@ -108,21 +107,37 @@ const statisticsPeriodResultSchema = Schema.Struct({ rcs_lms: Schema.Number, rcs_mms: Schema.Number, rcs_tpl: Schema.Number, + rcs_itpl: Schema.optional(Schema.Number), + rcs_ltpl: Schema.optional(Schema.Number), + fax: Schema.optional(Schema.Number), + voice: Schema.optional(Schema.Number), + bms_text: Schema.optional(Schema.Number), + bms_image: Schema.optional(Schema.Number), + bms_wide: Schema.optional(Schema.Number), + bms_wide_item_list: Schema.optional(Schema.Number), + bms_carousel_feed: Schema.optional(Schema.Number), + bms_premium_video: Schema.optional(Schema.Number), + bms_commerce: Schema.optional(Schema.Number), + bms_carousel_commerce: Schema.optional(Schema.Number), + bms_free: Schema.optional(Schema.Number), }); const refundSchema = Schema.Struct({ balance: Schema.Number, point: Schema.Number, + deposit: Schema.optional(Schema.Number), }); const dayPeriodSchema = Schema.Struct({ _id: Schema.String, month: Schema.String, + date: Schema.optional(Schema.String), balance: Schema.Number, point: Schema.Number, + deposit: Schema.optional(Schema.Number), statusCode: Schema.Record({ key: Schema.String, - value: messageTypeRecordSchema, + value: Schema.Record({key: Schema.String, value: Schema.Number}), }), refund: refundSchema, total: statisticsPeriodResultSchema, @@ -135,6 +150,8 @@ const monthPeriodRefundSchema = Schema.Struct({ balanceAvg: Schema.Number, point: Schema.Number, pointAvg: Schema.Number, + deposit: Schema.optional(Schema.Number), + depositAvg: Schema.optional(Schema.Number), }); const monthPeriodSchema = Schema.Struct({ @@ -143,8 +160,10 @@ const monthPeriodSchema = Schema.Struct({ balanceAvg: Schema.Number, point: Schema.Number, pointAvg: Schema.Number, + deposit: Schema.optional(Schema.Number), + depositAvg: Schema.optional(Schema.Number), dayPeriod: Schema.Array(dayPeriodSchema), - refund: monthPeriodRefundSchema, + refund: Schema.optional(monthPeriodRefundSchema), total: statisticsPeriodResultSchema, successed: statisticsPeriodResultSchema, failed: statisticsPeriodResultSchema, @@ -153,25 +172,44 @@ const monthPeriodSchema = Schema.Struct({ export const getStatisticsResponseSchema = Schema.Struct({ balance: Schema.Number, point: Schema.Number, + deposit: Schema.optional(Schema.Number), monthlyBalanceAvg: Schema.Number, monthlyPointAvg: Schema.Number, + monthlyDepositAvg: Schema.optional(Schema.Number), monthPeriod: Schema.Array(monthPeriodSchema), total: statisticsPeriodResultSchema, successed: statisticsPeriodResultSchema, failed: statisticsPeriodResultSchema, - dailyBalanceAvg: Schema.Number, - dailyPointAvg: Schema.Number, - dailyTotalCountAvg: Schema.Number, - dailyFailedCountAvg: Schema.Number, - dailySuccessedCountAvg: Schema.Number, + dailyBalanceAvg: Schema.optional(Schema.Number), + dailyPointAvg: Schema.optional(Schema.Number), + dailyTotalCountAvg: Schema.optional(Schema.Number), + dailyFailedCountAvg: Schema.optional(Schema.Number), + dailySuccessedCountAvg: Schema.optional(Schema.Number), }); export type GetStatisticsResponse = Schema.Schema.Type< typeof getStatisticsResponseSchema >; +const lowBalanceAlertSchema = Schema.Struct({ + notificationBalance: Schema.String, + currentBalance: Schema.String, + balances: Schema.Array(Schema.Number), + channels: Schema.Array(Schema.String), + enabled: Schema.Boolean, +}); +export type LowBalanceAlert = Schema.Schema.Type; + export const getBalanceResponseSchema = Schema.Struct({ - balance: Schema.Number, + lowBalanceAlert: Schema.optional(lowBalanceAlertSchema), point: Schema.Number, + minimumCash: Schema.optional(Schema.Number), + rechargeTo: Schema.optional(Schema.Number), + rechargeTryCount: Schema.optional(Schema.Number), + autoRecharge: Schema.optional(Schema.Number), + accountId: Schema.optional(Schema.String), + balance: Schema.Number, + deposit: Schema.optional(Schema.Number), + balanceOnly: Schema.optional(Schema.Number), }); export type GetBalanceResponse = Schema.Schema.Type< typeof getBalanceResponseSchema diff --git a/src/services/cash/cashService.ts b/src/services/cash/cashService.ts index 82d8f3e6..0348249d 100644 --- a/src/services/cash/cashService.ts +++ b/src/services/cash/cashService.ts @@ -1,5 +1,8 @@ import {runSafePromise} from '@lib/effectErrorHandler'; -import {type GetBalanceResponse} from '@models/responses/messageResponses'; +import { + type GetBalanceResponse, + getBalanceResponseSchema, +} from '@models/responses/messageResponses'; import DefaultService from '../defaultService'; export default class CashService extends DefaultService { @@ -12,6 +15,7 @@ export default class CashService extends DefaultService { this.requestEffect({ httpMethod: 'GET', url: 'cash/v1/balance', + responseSchema: getBalanceResponseSchema, }), ); } diff --git a/src/services/defaultService.ts b/src/services/defaultService.ts index 8da9c7b2..f225528c 100644 --- a/src/services/defaultService.ts +++ b/src/services/defaultService.ts @@ -1,7 +1,11 @@ import {AuthenticationParameter} from '@lib/authenticator'; import {defaultFetcherEffect} from '@lib/defaultFetcher'; import {runSafePromise} from '@lib/effectErrorHandler'; -import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils'; +import { + decodeServerResponse, + decodeWithBadRequest, + safeFinalize, +} from '@lib/schemaUtils'; import stringifyQuery from '@lib/stringifyQuery'; import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; @@ -20,10 +24,11 @@ type RequestConfig = { url: string; }; -type DefaultServiceParameter = { +type DefaultServiceParameter = { httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; url: string; body?: T; + responseSchema?: Schema.Schema; }; export default class DefaultService { @@ -37,31 +42,40 @@ export default class DefaultService { }; } - protected requestEffect( - parameter: DefaultServiceParameter, + protected requestEffect( + parameter: DefaultServiceParameter, ): Effect.Effect< R, ApiKeyError | ClientError | ServerError | NetworkError | DefaultError > { - const {httpMethod, url, body} = parameter; + const {httpMethod, url, body, responseSchema} = parameter; const requestConfig: RequestConfig = { method: httpMethod, url: `${this.baseUrl}/${url}`, }; + const fullUrl = requestConfig.url; + if (responseSchema) { + const schema = responseSchema; + return Effect.flatMap( + defaultFetcherEffect(this.authInfo, requestConfig, body), + data => decodeServerResponse(schema, data, {url: fullUrl}), + ); + } return defaultFetcherEffect(this.authInfo, requestConfig, body); } - protected async request( - parameter: DefaultServiceParameter, + protected async request( + parameter: DefaultServiceParameter, ): Promise { - return runSafePromise(this.requestEffect(parameter)); + return runSafePromise(this.requestEffect(parameter)); } - protected getWithQuery(config: { + protected getWithQuery(config: { schema: Schema.Schema; finalize: (validated?: A) => object; url: string; data?: unknown; + responseSchema?: Schema.Schema; }): Effect.Effect< R, | ApiKeyError @@ -82,9 +96,10 @@ export default class DefaultService { indices: false, addQueryPrefix: true, }); - return yield* reqEffect({ + return yield* reqEffect({ httpMethod: 'GET', url: `${config.url}${parameter}`, + responseSchema: config.responseSchema, }); }); } diff --git a/src/services/iam/iamService.ts b/src/services/iam/iamService.ts index 1bcddadb..20f6b77e 100644 --- a/src/services/iam/iamService.ts +++ b/src/services/iam/iamService.ts @@ -14,9 +14,18 @@ import { type GetBlockNumbersRequest, getBlockNumbersRequestSchema, } from '@models/requests/iam/getBlockNumbersRequest'; -import {GetBlacksResponse} from '@models/responses/iam/getBlacksResponse'; -import {GetBlockGroupsResponse} from '@models/responses/iam/getBlockGroupsResponse'; -import {GetBlockNumbersResponse} from '@models/responses/iam/getBlockNumbersResponse'; +import { + GetBlacksResponse, + getBlacksResponseSchema, +} from '@models/responses/iam/getBlacksResponse'; +import { + GetBlockGroupsResponse, + getBlockGroupsResponseSchema, +} from '@models/responses/iam/getBlockGroupsResponse'; +import { + GetBlockNumbersResponse, + getBlockNumbersResponseSchema, +} from '@models/responses/iam/getBlockNumbersResponse'; import DefaultService from '../defaultService'; export default class IamService extends DefaultService { @@ -32,6 +41,7 @@ export default class IamService extends DefaultService { finalize: finalizeGetBlacksRequest, url: 'iam/v1/black', data, + responseSchema: getBlacksResponseSchema, }), ); } @@ -50,6 +60,7 @@ export default class IamService extends DefaultService { finalize: finalizeGetBlockGroupsRequest, url: 'iam/v1/block/groups', data, + responseSchema: getBlockGroupsResponseSchema, }), ); } @@ -68,6 +79,7 @@ export default class IamService extends DefaultService { finalize: finalizeGetBlockNumbersRequest, url: 'iam/v1/block/numbers', data, + responseSchema: getBlockNumbersResponseSchema, }), ); } diff --git a/src/services/kakao/channels/kakaoChannelService.ts b/src/services/kakao/channels/kakaoChannelService.ts index b91dc754..f5c83dc9 100644 --- a/src/services/kakao/channels/kakaoChannelService.ts +++ b/src/services/kakao/channels/kakaoChannelService.ts @@ -6,6 +6,8 @@ import { type KakaoChannel, type KakaoChannelCategory, type KakaoChannelSchema, + kakaoChannelCategorySchema, + kakaoChannelSchema, } from '@models/base/kakao/kakaoChannel'; import { type CreateKakaoChannelRequest, @@ -19,21 +21,29 @@ import { import { type GetKakaoChannelsFinalizeResponse, type GetKakaoChannelsResponse, + getKakaoChannelsResponseSchema, } from '@models/responses/kakao/getKakaoChannelsResponse'; import { type CreateKakaoChannelResponse, type RequestKakaoChannelTokenResponse, } from '@models/responses/messageResponses'; +import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import DefaultService from '../../defaultService'; +const kakaoChannelCategoryListSchema = Schema.Array(kakaoChannelCategorySchema); + export default class KakaoChannelService extends DefaultService { async getKakaoChannelCategories(): Promise> { return runSafePromise( - this.requestEffect>({ - httpMethod: 'GET', - url: 'kakao/v2/channels/categories', - }), + Effect.map( + this.requestEffect>({ + httpMethod: 'GET', + url: 'kakao/v2/channels/categories', + responseSchema: kakaoChannelCategoryListSchema, + }), + list => [...list], + ), ); } @@ -56,6 +66,7 @@ export default class KakaoChannelService extends DefaultService { const response = yield* reqEffect({ httpMethod: 'GET', url: `kakao/v2/channels${parameter}`, + responseSchema: getKakaoChannelsResponseSchema, }); return { limit: response.limit, @@ -75,6 +86,7 @@ export default class KakaoChannelService extends DefaultService { this.requestEffect({ httpMethod: 'GET', url: `kakao/v2/channels/${channelId}`, + responseSchema: kakaoChannelSchema, }), decodeKakaoChannel, ), diff --git a/src/services/kakao/templates/kakaoTemplateService.ts b/src/services/kakao/templates/kakaoTemplateService.ts index 15054627..56c93ee2 100644 --- a/src/services/kakao/templates/kakaoTemplateService.ts +++ b/src/services/kakao/templates/kakaoTemplateService.ts @@ -8,6 +8,7 @@ import { type KakaoAlimtalkTemplateSchema, kakaoAlimtalkTemplateSchema, } from '@models/base/kakao/kakaoAlimtalkTemplate'; +import {kakaoChannelCategorySchema} from '@models/base/kakao/kakaoChannel'; import {type CreateKakaoAlimtalkTemplateRequest} from '@models/requests/kakao/createKakaoAlimtalkTemplateRequest'; import { finalizeGetKakaoAlimtalkTemplatesRequest, @@ -18,11 +19,20 @@ import {type UpdateKakaoAlimtalkTemplateRequest} from '@models/requests/kakao/up import { type GetKakaoAlimtalkTemplatesFinalizeResponse, type GetKakaoAlimtalkTemplatesResponseSchema, + getKakaoAlimtalkTemplatesResponseSchema, } from '@models/responses/kakao/getKakaoAlimtalkTemplatesResponse'; -import {type GetKakaoTemplateResponse} from '@models/responses/kakao/getKakaoTemplateResponse'; +import { + type GetKakaoTemplateResponse, + getKakaoTemplateResponseSchema, +} from '@models/responses/kakao/getKakaoTemplateResponse'; +import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import DefaultService from '../../defaultService'; +const kakaoAlimtalkTemplateCategoryListSchema = Schema.Array( + kakaoChannelCategorySchema, +); + export default class KakaoTemplateService extends DefaultService { /** * 카카오 템플릿 카테고리 조회 @@ -31,10 +41,16 @@ export default class KakaoTemplateService extends DefaultService { Array > { return runSafePromise( - this.requestEffect>({ - httpMethod: 'GET', - url: 'kakao/v2/templates/categories', - }), + Effect.map( + this.requestEffect>( + { + httpMethod: 'GET', + url: 'kakao/v2/templates/categories', + responseSchema: kakaoAlimtalkTemplateCategoryListSchema, + }, + ), + list => [...list], + ), ); } @@ -87,6 +103,7 @@ export default class KakaoTemplateService extends DefaultService { >({ httpMethod: 'GET', url: `kakao/v2/templates${parameter}`, + responseSchema: getKakaoAlimtalkTemplatesResponseSchema, }); const templateList = yield* Effect.all( @@ -119,6 +136,7 @@ export default class KakaoTemplateService extends DefaultService { this.requestEffect({ httpMethod: 'GET', url: `kakao/v2/templates/${templateId}`, + responseSchema: getKakaoTemplateResponseSchema, }), decodeKakaoAlimtalkTemplate, ), diff --git a/src/services/messages/groupService.ts b/src/services/messages/groupService.ts index 0cd62910..c11b3451 100644 --- a/src/services/messages/groupService.ts +++ b/src/services/messages/groupService.ts @@ -24,6 +24,9 @@ import { GetGroupsResponse, GetMessagesResponse, GroupMessageResponse, + getGroupsResponseSchema, + getMessagesResponseSchema, + groupMessageResponseSchema, RemoveGroupMessagesResponse, } from '@models/responses/messageResponses'; import * as Effect from 'effect/Effect'; @@ -151,6 +154,7 @@ export default class GroupService extends DefaultService { finalize: finalizeGetGroupsRequest, url: 'messages/v4/groups', data, + responseSchema: getGroupsResponseSchema, }), ); } @@ -164,6 +168,7 @@ export default class GroupService extends DefaultService { this.requestEffect({ httpMethod: 'GET', url: `messages/v4/groups/${groupId}`, + responseSchema: groupMessageResponseSchema, }), ); } @@ -185,6 +190,7 @@ export default class GroupService extends DefaultService { this.requestEffect({ httpMethod: 'GET', url: `messages/v4/groups/${groupId}/messages${parameter}`, + responseSchema: getMessagesResponseSchema, }), ); } diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 0a685854..a874eaec 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -23,6 +23,8 @@ import { import { GetMessagesResponse, GetStatisticsResponse, + getMessagesResponseSchema, + getStatisticsResponseSchema, } from '@models/responses/messageResponses'; import {DetailGroupMessageResponse} from '@models/responses/sendManyDetailResponse'; import * as Effect from 'effect/Effect'; @@ -121,6 +123,7 @@ export default class MessageService extends DefaultService { finalize: finalizeGetMessagesRequest, url: 'messages/v4/list', data, + responseSchema: getMessagesResponseSchema, }), ); } @@ -139,6 +142,7 @@ export default class MessageService extends DefaultService { finalize: finalizeGetStatisticsRequest, url: 'messages/v4/statistics', data, + responseSchema: getStatisticsResponseSchema, }), ); } diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index 83ca6d7a..03e3f45b 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -55,6 +55,19 @@ export const messageTypeRecordSchema = Schema.Struct({ rcs_lms: Schema.Number, rcs_mms: Schema.Number, rcs_tpl: Schema.Number, + rcs_itpl: Schema.optional(Schema.Number), + rcs_ltpl: Schema.optional(Schema.Number), + fax: Schema.optional(Schema.Number), + voice: Schema.optional(Schema.Number), + bms_text: Schema.optional(Schema.Number), + bms_image: Schema.optional(Schema.Number), + bms_wide: Schema.optional(Schema.Number), + bms_wide_item_list: Schema.optional(Schema.Number), + bms_carousel_feed: Schema.optional(Schema.Number), + bms_premium_video: Schema.optional(Schema.Number), + bms_commerce: Schema.optional(Schema.Number), + bms_carousel_commerce: Schema.optional(Schema.Number), + bms_free: Schema.optional(Schema.Number), }); export type MessageTypeRecord = Schema.Schema.Type< typeof messageTypeRecordSchema @@ -79,13 +92,13 @@ export const groupSchema = Schema.Struct({ balance: commonCashResponseSchema, point: commonCashResponseSchema, app: appSchema, - sdkVersion: Schema.String, - osPlatform: Schema.String, + sdkVersion: Schema.NullishOr(Schema.String), + osPlatform: Schema.NullishOr(Schema.String), log: logSchema, status: Schema.String, - scheduledDate: Schema.optional(Schema.String), - dateSent: Schema.optional(Schema.String), - dateCompleted: Schema.optional(Schema.String), + scheduledDate: Schema.NullishOr(Schema.String), + dateSent: Schema.NullishOr(Schema.String), + dateCompleted: Schema.NullishOr(Schema.String), isRefunded: Schema.Boolean, groupId: groupIdSchema, accountId: Schema.String, @@ -112,7 +125,7 @@ export const blockGroupSchema = Schema.Struct({ blockGroupId: Schema.String, accountId: Schema.String, status: Schema.Literal('INACTIVE', 'ACTIVE'), - name: Schema.String, + name: Schema.NullishOr(Schema.String), useAll: Schema.Boolean, senderNumbers: Schema.Array(Schema.String), dateCreated: Schema.String, From 61247e506d3c10f7259eeb1baf7561fd318951ff Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:14:49 +0900 Subject: [PATCH 02/10] fix(responses): allow nullish startKey in kakao list responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 첫 페이지 호출 시 서버가 startKey를 null 또는 미포함으로 반환하는데 getKakaoChannelsResponseSchema와 getKakaoAlimtalkTemplatesResponseSchema의 startKey가 required string으로 남아 있어 런타임 검증이 실패하는 회귀를 수정. 다른 list 응답들과 동일하게 NullishOr로 정렬. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../responses/kakao/getKakaoAlimtalkTemplatesResponse.ts | 8 ++++---- src/models/responses/kakao/getKakaoChannelsResponse.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts b/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts index 1389c33a..a3849c5e 100644 --- a/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts +++ b/src/models/responses/kakao/getKakaoAlimtalkTemplatesResponse.ts @@ -5,8 +5,8 @@ import {getKakaoTemplateResponseSchema} from './getKakaoTemplateResponse'; export const getKakaoAlimtalkTemplatesResponseSchema = Schema.Struct({ limit: Schema.Number, templateList: Schema.Array(getKakaoTemplateResponseSchema), - startKey: Schema.String, - nextKey: Schema.NullOr(Schema.String), + startKey: Schema.NullishOr(Schema.String), + nextKey: Schema.NullishOr(Schema.String), }); export type GetKakaoAlimtalkTemplatesResponseSchema = Schema.Schema.Type< typeof getKakaoAlimtalkTemplatesResponseSchema @@ -17,6 +17,6 @@ export type GetKakaoAlimtalkTemplatesResponse = export type GetKakaoAlimtalkTemplatesFinalizeResponse = { limit: number; templateList: Array; - startKey: string; - nextKey: string | null; + startKey: string | null | undefined; + nextKey: string | null | undefined; }; diff --git a/src/models/responses/kakao/getKakaoChannelsResponse.ts b/src/models/responses/kakao/getKakaoChannelsResponse.ts index 55f439d0..ce3fb75d 100644 --- a/src/models/responses/kakao/getKakaoChannelsResponse.ts +++ b/src/models/responses/kakao/getKakaoChannelsResponse.ts @@ -6,8 +6,8 @@ import { export const getKakaoChannelsResponseSchema = Schema.Struct({ limit: Schema.Number, - startKey: Schema.String, - nextKey: Schema.NullOr(Schema.String), + startKey: Schema.NullishOr(Schema.String), + nextKey: Schema.NullishOr(Schema.String), channelList: Schema.Array(kakaoChannelSchema), }); @@ -17,7 +17,7 @@ export type GetKakaoChannelsResponse = Schema.Schema.Type< export type GetKakaoChannelsFinalizeResponse = { limit: number; - startKey: string; - nextKey: string | null; + startKey: string | null | undefined; + nextKey: string | null | undefined; channelList: Array; }; From 90cd3413482ae700959eead15ac9034c36d86f54 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:35:30 +0900 Subject: [PATCH 03/10] refactor(responses): address review findings for response validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전용 ResponseSchemaMismatchError 도입 — 2xx에서 발생하는 스키마 불일치를 ServerError(200)으로 래핑하던 문제를 분리해 5xx 재시도/알림 분기 오염 방지 - validationErrors(ArrayFormatter) 및 responseBody 보존으로 운영 환경에서도 드리프트 경로 재현 가능 - decodeServerResponse에 onExcessProperty:'preserve' 적용 — 부분 스키마가 서버의 미선언 필드를 strip 해버려 소비자 응답에서 silent data loss가 발생하던 문제 수정 - storedMessageSchema의 autoTypeDetect/replacement/voiceReplied/ unavailableSenderNumber를 Schema.transform으로 boolean 정규화 - responseSchema 제네릭을 Schema.Schema로 타이트닝 — 요구사항 채널이 오염되지 않도록 강제 - decodeServerResponse 8개 유닛 테스트 추가 (fixture 기반 회귀 방어) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/errors/defaultError.ts | 30 +++++ src/lib/schemaUtils.ts | 40 ++++-- src/models/base/messages/storedMessage.ts | 28 ++-- src/services/cash/cashService.ts | 2 +- src/services/defaultService.ts | 31 +++-- .../kakao/channels/kakaoChannelService.ts | 9 +- .../kakao/templates/kakaoTemplateService.ts | 27 ++-- src/services/messages/groupService.ts | 4 +- test/lib/decodeServerResponse.test.ts | 126 ++++++++++++++++++ 9 files changed, 242 insertions(+), 55 deletions(-) create mode 100644 test/lib/decodeServerResponse.test.ts diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index d490410f..66bec5b8 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -129,6 +129,36 @@ export class UnhandledExitError extends Data.TaggedError('UnhandledExitError')<{ } } +/** + * @description 서버가 2xx로 응답했으나 body가 SDK가 기대하는 스키마를 만족하지 못할 때 발생. + * 5xx를 의미하지 않으므로 ServerError와 분리하여 소비자의 재시도/알림 분기가 오염되지 않게 한다. + */ +export class ResponseSchemaMismatchError extends Data.TaggedError( + 'ResponseSchemaMismatchError', +)<{ + readonly message: string; + readonly url?: string; + readonly validationErrors: ReadonlyArray; + readonly responseBody?: string; +}> { + toString(): string { + const isProduction = process.env.NODE_ENV === 'production'; + const header = `ResponseSchemaMismatchError: ${this.message}`; + if (isProduction) { + return header; + } + const issues = + this.validationErrors.length > 0 + ? `\nIssues:\n- ${this.validationErrors.join('\n- ')}` + : ''; + const body = this.responseBody + ? `\nResponse: ${this.responseBody.substring(0, 500)}` + : ''; + const url = this.url ? `\nURL: ${this.url}` : ''; + return `${header}${url}${issues}${body}`; + } +} + // 5xx 서버 에러용 export class ServerError extends Data.TaggedError('ServerError')<{ readonly errorCode: string; diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 7ead831c..7f62e6d9 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -3,7 +3,7 @@ import * as Effect from 'effect/Effect'; import { BadRequestError, InvalidDateError, - ServerError, + ResponseSchemaMismatchError, } from '../errors/defaultError'; import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer'; @@ -79,23 +79,41 @@ export const safeFinalize = ( }), }); +const stringifyResponseBody = (data: unknown): string | undefined => { + if (typeof data === 'string') return data; + try { + return JSON.stringify(data); + } catch { + return undefined; + } +}; + /** - * API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ServerError로 래핑. + * API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ResponseSchemaMismatchError로 래핑. * 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신 - * 스키마 불일치 위치를 즉시 파악할 수 있도록 한다. + * 스키마 불일치 위치(ArrayFormatter issue path)와 원본 responseBody를 함께 보존하여 + * 운영 환경에서도 재현 가능하게 한다. + * + * Schema는 requirement 채널을 never로 제한 — 외부 서비스를 요구하는 transform을 금지하여 + * 응답 디코딩이 항상 순수하게 끝나도록 강제한다. */ export const decodeServerResponse = ( - schema: Schema.Schema, + schema: Schema.Schema, data: unknown, - context?: {url?: string; httpStatus?: number}, -): Effect.Effect => + context?: {url?: string}, +): Effect.Effect => + // onExcessProperty: 'preserve' — 서버가 추가로 내려준 미선언 필드를 strip 하지 않는다. + // 부분 스키마로 검증하는 조회 엔드포인트에서 필드 조용히 사라지는 silent data loss를 방지. Effect.mapError( - Schema.decodeUnknown(schema)(data), + Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data), err => - new ServerError({ - errorCode: 'ResponseSchemaMismatch', - errorMessage: ParseResult.TreeFormatter.formatErrorSync(err), - httpStatus: context?.httpStatus ?? 200, + new ResponseSchemaMismatchError({ + message: ParseResult.TreeFormatter.formatErrorSync(err), + validationErrors: ParseResult.ArrayFormatter.formatErrorSync(err).map( + issue => + `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`, + ), url: context?.url, + responseBody: stringifyResponseBody(data), }), ); diff --git a/src/models/base/messages/storedMessage.ts b/src/models/base/messages/storedMessage.ts index 0592676d..eeb57e1e 100644 --- a/src/models/base/messages/storedMessage.ts +++ b/src/models/base/messages/storedMessage.ts @@ -1,6 +1,20 @@ import {Schema} from 'effect'; import {messageTypeSchema} from './message'; +/** + * 서버가 동일 필드를 boolean 또는 0/1 정수로 섞어 내려주는 경우가 있어 + * 소비자에게는 boolean으로만 노출되도록 wire 단계에서 정규화한다. + */ +const booleanOrZeroOne = Schema.transform( + Schema.Union(Schema.Boolean, Schema.Number), + Schema.Boolean, + { + decode: value => (typeof value === 'boolean' ? value : value !== 0), + encode: value => value, + strict: true, + }, +); + /** * 조회 응답(getMessages/getGroupMessages)에 포함된 메시지 아이템 스키마. * @@ -8,8 +22,8 @@ import {messageTypeSchema} from './message'; * - optional 필드 상당수가 null로 내려올 수 있다. * - kakaoOptions/rcsOptions 등 내부 구조가 발송 요청과 다르다(서버 정규화 포맷). * - * 따라서 핵심 필드만 선언하고, 나머지는 런타임 통과를 위해 NullishOr/Unknown으로 관대하게 허용한다. - * Schema.Struct는 기본적으로 extra 필드를 무시하므로 신규 필드 추가 시에도 drift 없이 통과한다. + * 핵심 필드만 선언하고 타입 수준에서 검증/정규화한다. 여기에 없는 필드는 + * decodeServerResponse의 onExcessProperty:'preserve' 옵션으로 런타임에 그대로 보존된다. */ export const storedMessageSchema = Schema.Struct({ messageId: Schema.optional(Schema.String), @@ -30,8 +44,8 @@ export const storedMessageSchema = Schema.Struct({ customFields: Schema.optional( Schema.NullishOr(Schema.Record({key: Schema.String, value: Schema.String})), ), - autoTypeDetect: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)), - replacement: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)), + autoTypeDetect: Schema.optional(booleanOrZeroOne), + replacement: Schema.optional(booleanOrZeroOne), resendCount: Schema.optional(Schema.Number), dateCreated: Schema.optional(Schema.String), dateUpdated: Schema.optional(Schema.String), @@ -48,12 +62,10 @@ export const storedMessageSchema = Schema.Struct({ queues: Schema.optional(Schema.NullishOr(Schema.Unknown)), currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)), clusterKey: Schema.NullishOr(Schema.String), - unavailableSenderNumber: Schema.optional( - Schema.Union(Schema.Boolean, Schema.Number), - ), + unavailableSenderNumber: Schema.optional(booleanOrZeroOne), faxPageCount: Schema.optional(Schema.Number), voiceDuration: Schema.optional(Schema.Number), - voiceReplied: Schema.optional(Schema.Union(Schema.Boolean, Schema.Number)), + voiceReplied: Schema.optional(booleanOrZeroOne), _id: Schema.optional(Schema.String), }); export type StoredMessage = Schema.Schema.Type; diff --git a/src/services/cash/cashService.ts b/src/services/cash/cashService.ts index 0348249d..85c7470e 100644 --- a/src/services/cash/cashService.ts +++ b/src/services/cash/cashService.ts @@ -12,7 +12,7 @@ export default class CashService extends DefaultService { */ async getBalance(): Promise { return runSafePromise( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: 'cash/v1/balance', responseSchema: getBalanceResponseSchema, diff --git a/src/services/defaultService.ts b/src/services/defaultService.ts index f225528c..94a48a7c 100644 --- a/src/services/defaultService.ts +++ b/src/services/defaultService.ts @@ -16,6 +16,7 @@ import type { DefaultError, InvalidDateError, NetworkError, + ResponseSchemaMismatchError, ServerError, } from '../errors/defaultError'; @@ -24,11 +25,16 @@ type RequestConfig = { url: string; }; -type DefaultServiceParameter = { +type DefaultServiceParameter = { httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; url: string; body?: T; - responseSchema?: Schema.Schema; + /** + * 2xx 응답 body에 대해 실행되는 런타임 검증 스키마. + * Schema.decodeUnknown은 requirement 채널을 요구하지 않도록 never로 고정 — + * 외부 의존성이 필요한 transform은 허용하지 않아 디코딩 결과가 항상 pure해진다. + */ + responseSchema?: Schema.Schema; }; export default class DefaultService { @@ -42,40 +48,44 @@ export default class DefaultService { }; } - protected requestEffect( + protected requestEffect( parameter: DefaultServiceParameter, ): Effect.Effect< R, - ApiKeyError | ClientError | ServerError | NetworkError | DefaultError + | ApiKeyError + | ClientError + | ServerError + | NetworkError + | DefaultError + | ResponseSchemaMismatchError > { const {httpMethod, url, body, responseSchema} = parameter; const requestConfig: RequestConfig = { method: httpMethod, url: `${this.baseUrl}/${url}`, }; - const fullUrl = requestConfig.url; if (responseSchema) { - const schema = responseSchema; return Effect.flatMap( defaultFetcherEffect(this.authInfo, requestConfig, body), - data => decodeServerResponse(schema, data, {url: fullUrl}), + data => + decodeServerResponse(responseSchema, data, {url: requestConfig.url}), ); } return defaultFetcherEffect(this.authInfo, requestConfig, body); } - protected async request( + protected async request( parameter: DefaultServiceParameter, ): Promise { return runSafePromise(this.requestEffect(parameter)); } - protected getWithQuery(config: { + protected getWithQuery(config: { schema: Schema.Schema; finalize: (validated?: A) => object; url: string; data?: unknown; - responseSchema?: Schema.Schema; + responseSchema?: Schema.Schema; }): Effect.Effect< R, | ApiKeyError @@ -85,6 +95,7 @@ export default class DefaultService { | DefaultError | BadRequestError | InvalidDateError + | ResponseSchemaMismatchError > { const reqEffect = this.requestEffect.bind(this); return Effect.gen(function* () { diff --git a/src/services/kakao/channels/kakaoChannelService.ts b/src/services/kakao/channels/kakaoChannelService.ts index f5c83dc9..6cb66595 100644 --- a/src/services/kakao/channels/kakaoChannelService.ts +++ b/src/services/kakao/channels/kakaoChannelService.ts @@ -20,7 +20,6 @@ import { } from '@models/requests/kakao/getKakaoChannelsRequest'; import { type GetKakaoChannelsFinalizeResponse, - type GetKakaoChannelsResponse, getKakaoChannelsResponseSchema, } from '@models/responses/kakao/getKakaoChannelsResponse'; import { @@ -37,7 +36,7 @@ export default class KakaoChannelService extends DefaultService { async getKakaoChannelCategories(): Promise> { return runSafePromise( Effect.map( - this.requestEffect>({ + this.requestEffect({ httpMethod: 'GET', url: 'kakao/v2/channels/categories', responseSchema: kakaoChannelCategoryListSchema, @@ -63,8 +62,8 @@ export default class KakaoChannelService extends DefaultService { indices: false, addQueryPrefix: true, }); - const response = yield* reqEffect({ - httpMethod: 'GET', + const response = yield* reqEffect({ + httpMethod: 'GET' as const, url: `kakao/v2/channels${parameter}`, responseSchema: getKakaoChannelsResponseSchema, }); @@ -83,7 +82,7 @@ export default class KakaoChannelService extends DefaultService { async getKakaoChannel(channelId: string): Promise { return runSafePromise( Effect.flatMap( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `kakao/v2/channels/${channelId}`, responseSchema: kakaoChannelSchema, diff --git a/src/services/kakao/templates/kakaoTemplateService.ts b/src/services/kakao/templates/kakaoTemplateService.ts index 56c93ee2..70ba95fb 100644 --- a/src/services/kakao/templates/kakaoTemplateService.ts +++ b/src/services/kakao/templates/kakaoTemplateService.ts @@ -18,13 +18,9 @@ import { import {type UpdateKakaoAlimtalkTemplateRequest} from '@models/requests/kakao/updateKakaoAlimtalkTemplateRequest'; import { type GetKakaoAlimtalkTemplatesFinalizeResponse, - type GetKakaoAlimtalkTemplatesResponseSchema, getKakaoAlimtalkTemplatesResponseSchema, } from '@models/responses/kakao/getKakaoAlimtalkTemplatesResponse'; -import { - type GetKakaoTemplateResponse, - getKakaoTemplateResponseSchema, -} from '@models/responses/kakao/getKakaoTemplateResponse'; +import {getKakaoTemplateResponseSchema} from '@models/responses/kakao/getKakaoTemplateResponse'; import {Schema} from 'effect'; import * as Effect from 'effect/Effect'; import DefaultService from '../../defaultService'; @@ -42,13 +38,11 @@ export default class KakaoTemplateService extends DefaultService { > { return runSafePromise( Effect.map( - this.requestEffect>( - { - httpMethod: 'GET', - url: 'kakao/v2/templates/categories', - responseSchema: kakaoAlimtalkTemplateCategoryListSchema, - }, - ), + this.requestEffect({ + httpMethod: 'GET', + url: 'kakao/v2/templates/categories', + responseSchema: kakaoAlimtalkTemplateCategoryListSchema, + }), list => [...list], ), ); @@ -97,11 +91,8 @@ export default class KakaoTemplateService extends DefaultService { indices: false, addQueryPrefix: true, }); - const response = yield* reqEffect< - never, - GetKakaoAlimtalkTemplatesResponseSchema - >({ - httpMethod: 'GET', + const response = yield* reqEffect({ + httpMethod: 'GET' as const, url: `kakao/v2/templates${parameter}`, responseSchema: getKakaoAlimtalkTemplatesResponseSchema, }); @@ -133,7 +124,7 @@ export default class KakaoTemplateService extends DefaultService { ): Promise { return runSafePromise( Effect.flatMap( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `kakao/v2/templates/${templateId}`, responseSchema: getKakaoTemplateResponseSchema, diff --git a/src/services/messages/groupService.ts b/src/services/messages/groupService.ts index c11b3451..455bd38f 100644 --- a/src/services/messages/groupService.ts +++ b/src/services/messages/groupService.ts @@ -165,7 +165,7 @@ export default class GroupService extends DefaultService { */ async getGroup(groupId: GroupId): Promise { return runSafePromise( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `messages/v4/groups/${groupId}`, responseSchema: groupMessageResponseSchema, @@ -187,7 +187,7 @@ export default class GroupService extends DefaultService { addQueryPrefix: true, }); return runSafePromise( - this.requestEffect({ + this.requestEffect({ httpMethod: 'GET', url: `messages/v4/groups/${groupId}/messages${parameter}`, responseSchema: getMessagesResponseSchema, diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts new file mode 100644 index 00000000..8f97b30b --- /dev/null +++ b/test/lib/decodeServerResponse.test.ts @@ -0,0 +1,126 @@ +import {Schema} from 'effect'; +import * as Effect from 'effect/Effect'; +import {describe, expect, it} from 'vitest'; +import {ResponseSchemaMismatchError} from '@/errors/defaultError'; +import {decodeServerResponse} from '@/lib/schemaUtils'; +import {storedMessageSchema} from '@/models/base/messages/storedMessage'; +import {getBalanceResponseSchema} from '@/models/responses/messageResponses'; + +const balanceFixture = { + lowBalanceAlert: { + notificationBalance: '200', + currentBalance: '196.00000000001592', + balances: [200, 1000000, 30000000], + channels: ['EMAIL', 'ATA'], + enabled: true, + }, + point: 0, + minimumCash: 3000, + rechargeTo: 50000, + rechargeTryCount: 0, + autoRecharge: 0, + accountId: '486', + balance: 0, + deposit: 0, + balanceOnly: 0, +}; + +describe('decodeServerResponse', () => { + it('실제 getBalance 응답을 성공적으로 디코딩한다', () => { + const result = Effect.runSync( + decodeServerResponse(getBalanceResponseSchema, balanceFixture), + ); + expect(result.balance).toBe(0); + expect(result.lowBalanceAlert?.enabled).toBe(true); + expect(result.lowBalanceAlert?.channels).toEqual(['EMAIL', 'ATA']); + }); + + it('타입이 맞지 않으면 ResponseSchemaMismatchError로 실패하고 path 정보를 보존한다', () => { + const invalid = {...balanceFixture, balance: 'not a number'}; + const result = Effect.runSync( + Effect.either(decodeServerResponse(getBalanceResponseSchema, invalid)), + ); + + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const err = result.left; + expect(err).toBeInstanceOf(ResponseSchemaMismatchError); + expect(err.validationErrors.length).toBeGreaterThan(0); + expect(err.validationErrors.some(m => m.includes('balance'))).toBe(true); + }); + + it('실패 시 responseBody에 원본 JSON을 보존한다', () => { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(getBalanceResponseSchema, {bogus: 'data'}), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const body = result.left.responseBody; + expect(body).toBeDefined(); + expect(body).toContain('bogus'); + }); + + it('context.url이 있으면 에러에 반영된다', () => { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(getBalanceResponseSchema, null, { + url: 'https://api.example.com/foo', + }), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.url).toBe('https://api.example.com/foo'); + }); + + it('onExcessProperty: preserve로 미선언 필드를 strip 하지 않는다', () => { + const schema = Schema.Struct({a: Schema.Number}); + const result = Effect.runSync( + decodeServerResponse(schema, {a: 1, b: 'kept', nested: {x: 'also'}}), + ); + expect(result).toEqual({a: 1, b: 'kept', nested: {x: 'also'}}); + }); +}); + +describe('storedMessageSchema boolean|number 정규화', () => { + it('autoTypeDetect/replacement/voiceReplied/unavailableSenderNumber가 0/1로 와도 boolean으로 정규화된다', () => { + const result = Effect.runSync( + decodeServerResponse(storedMessageSchema, { + autoTypeDetect: 0, + replacement: 1, + voiceReplied: 0, + unavailableSenderNumber: 1, + }), + ); + expect(result.autoTypeDetect).toBe(false); + expect(result.replacement).toBe(true); + expect(result.voiceReplied).toBe(false); + expect(result.unavailableSenderNumber).toBe(true); + }); + + it('boolean 값은 그대로 통과한다', () => { + const result = Effect.runSync( + decodeServerResponse(storedMessageSchema, { + autoTypeDetect: true, + replacement: false, + }), + ); + expect(result.autoTypeDetect).toBe(true); + expect(result.replacement).toBe(false); + }); + + it('미선언 필드(dateReceived 등)를 응답에서 strip 하지 않는다', () => { + const raw = { + messageId: 'M123', + foo: 'bar', + nested: {x: 1}, + }; + const result = Effect.runSync( + decodeServerResponse(storedMessageSchema, raw), + ); + expect((result as Record).foo).toBe('bar'); + expect((result as Record).nested).toEqual({x: 1}); + }); +}); From 5276053bac11a638929c6033ea910882f42411f7 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:40:33 +0900 Subject: [PATCH 04/10] refactor: tighten response decoding edge cases per review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - booleanOrZeroOne을 Schema.transformOrFail로 엄격화해 2/-1/NaN 같은 drift 입력을 silent true로 coerce하지 않고 ResponseSchemaMismatchError로 전파 - stringifyResponseBody가 circular/BigInt로 JSON 실패 시 undefined로 정보 소실하던 동작을 '[unserializable: reason] toStringTag' 메타데이터로 보완 - ResponseSchemaMismatchError.toString()의 production 경로에서 url과 validationErrors를 유지(responseBody만 민감 페이로드라 계속 숨김) - 엣지 케이스 유닛 테스트 추가: boolean transform 거부값, 순환참조 responseBody Co-Authored-By: Claude Opus 4.7 (1M context) --- src/errors/defaultError.ts | 12 +++++---- src/lib/schemaUtils.ts | 8 ++++-- src/models/base/messages/storedMessage.ts | 21 +++++++++++++--- test/lib/decodeServerResponse.test.ts | 30 +++++++++++++++++++++++ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 66bec5b8..62b84e95 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -142,19 +142,21 @@ export class ResponseSchemaMismatchError extends Data.TaggedError( readonly responseBody?: string; }> { toString(): string { - const isProduction = process.env.NODE_ENV === 'production'; const header = `ResponseSchemaMismatchError: ${this.message}`; - if (isProduction) { - return header; - } + const url = this.url ? `\nURL: ${this.url}` : ''; const issues = this.validationErrors.length > 0 ? `\nIssues:\n- ${this.validationErrors.join('\n- ')}` : ''; + // url과 validationErrors는 민감 정보가 아니고 운영 디버깅에 필수적이므로 production에서도 유지. + // responseBody만 민감 페이로드일 수 있어 production에서 제외. + const isProduction = process.env.NODE_ENV === 'production'; + if (isProduction) { + return `${header}${url}${issues}`; + } const body = this.responseBody ? `\nResponse: ${this.responseBody.substring(0, 500)}` : ''; - const url = this.url ? `\nURL: ${this.url}` : ''; return `${header}${url}${issues}${body}`; } } diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 7f62e6d9..868892dc 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -80,11 +80,15 @@ export const safeFinalize = ( }); const stringifyResponseBody = (data: unknown): string | undefined => { + if (data === undefined) return undefined; if (typeof data === 'string') return data; try { return JSON.stringify(data); - } catch { - return undefined; + } catch (err) { + // circular / BigInt 등 직렬화 실패를 silent 하게 버리지 않고 + // 최소한 실패 사유와 타입 태그를 운영 로그에서 확인할 수 있도록 둔다. + const reason = err instanceof Error ? err.message : String(err); + return `[unserializable: ${reason}] ${Object.prototype.toString.call(data)}`; } }; diff --git a/src/models/base/messages/storedMessage.ts b/src/models/base/messages/storedMessage.ts index eeb57e1e..dd9fc7d5 100644 --- a/src/models/base/messages/storedMessage.ts +++ b/src/models/base/messages/storedMessage.ts @@ -1,16 +1,29 @@ -import {Schema} from 'effect'; +import {ParseResult, Schema} from 'effect'; import {messageTypeSchema} from './message'; /** * 서버가 동일 필드를 boolean 또는 0/1 정수로 섞어 내려주는 경우가 있어 * 소비자에게는 boolean으로만 노출되도록 wire 단계에서 정규화한다. + * 0/1 외의 숫자(NaN, 2, -1 등)는 drift 신호이므로 silent 처리하지 않고 + * ResponseSchemaMismatchError로 전파되도록 transformOrFail을 사용한다. */ -const booleanOrZeroOne = Schema.transform( +const booleanOrZeroOne = Schema.transformOrFail( Schema.Union(Schema.Boolean, Schema.Number), Schema.Boolean, { - decode: value => (typeof value === 'boolean' ? value : value !== 0), - encode: value => value, + decode: (value, _opts, ast) => { + if (typeof value === 'boolean') return ParseResult.succeed(value); + if (value === 0) return ParseResult.succeed(false); + if (value === 1) return ParseResult.succeed(true); + return ParseResult.fail( + new ParseResult.Type( + ast, + value, + `Expected boolean, 0, or 1 but received ${String(value)}`, + ), + ); + }, + encode: value => ParseResult.succeed(value), strict: true, }, ); diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts index 8f97b30b..f14b4e7c 100644 --- a/test/lib/decodeServerResponse.test.ts +++ b/test/lib/decodeServerResponse.test.ts @@ -62,6 +62,17 @@ describe('decodeServerResponse', () => { expect(body).toContain('bogus'); }); + it('JSON.stringify 실패 시 responseBody에 사유 메타데이터를 기록한다', () => { + const circular: Record = {foo: 1}; + circular.self = circular; + const result = Effect.runSync( + Effect.either(decodeServerResponse(getBalanceResponseSchema, circular)), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.responseBody).toMatch(/unserializable/); + }); + it('context.url이 있으면 에러에 반영된다', () => { const result = Effect.runSync( Effect.either( @@ -111,6 +122,25 @@ describe('storedMessageSchema boolean|number 정규화', () => { expect(result.replacement).toBe(false); }); + it.each([ + 2, + -1, + 0.5, + Number.NaN, + ])('0/1 외의 숫자(%s)는 drift로 간주되어 ResponseSchemaMismatchError로 실패', invalid => { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(storedMessageSchema, { + autoTypeDetect: invalid, + }), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left).toBeInstanceOf(ResponseSchemaMismatchError); + expect(result.left.validationErrors.length).toBeGreaterThan(0); + }); + it('미선언 필드(dateReceived 등)를 응답에서 strip 하지 않는다', () => { const raw = { messageId: 'M123', From d268c5e813bfd89dd196ffe72e10ee128b1db6d7 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:43:21 +0900 Subject: [PATCH 05/10] fix(responses): accept new message types in countForCharge; null feature fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 2차 리뷰 발견 사항 반영: - countForChargeSchema에 rcs_itpl/ltpl, fax, voice, bms_* 13개 필드 추가 — messageTypeRecordSchema 확장과 비대칭하여 신규 메시지 타입을 가진 그룹 조회가 ResponseSchemaMismatchError로 실패하던 문제 수정 (getGroups/getGroup 회귀 방어) - storedMessageSchema의 unavailableSenderNumber/faxPageCount/voiceDuration/ voiceReplied를 NullishOr로 완화 — non-FAX/non-VOICE 메시지 행에서 null로 내려오는 경우 전체 응답 decode 실패하던 문제 수정 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/models/base/messages/storedMessage.ts | 8 ++++---- src/types/commonTypes.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/models/base/messages/storedMessage.ts b/src/models/base/messages/storedMessage.ts index dd9fc7d5..4da5dd77 100644 --- a/src/models/base/messages/storedMessage.ts +++ b/src/models/base/messages/storedMessage.ts @@ -75,10 +75,10 @@ export const storedMessageSchema = Schema.Struct({ queues: Schema.optional(Schema.NullishOr(Schema.Unknown)), currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)), clusterKey: Schema.NullishOr(Schema.String), - unavailableSenderNumber: Schema.optional(booleanOrZeroOne), - faxPageCount: Schema.optional(Schema.Number), - voiceDuration: Schema.optional(Schema.Number), - voiceReplied: Schema.optional(booleanOrZeroOne), + unavailableSenderNumber: Schema.optional(Schema.NullishOr(booleanOrZeroOne)), + faxPageCount: Schema.optional(Schema.NullishOr(Schema.Number)), + voiceDuration: Schema.optional(Schema.NullishOr(Schema.Number)), + voiceReplied: Schema.optional(Schema.NullishOr(booleanOrZeroOne)), _id: Schema.optional(Schema.String), }); export type StoredMessage = Schema.Schema.Type; diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index 03e3f45b..5c5ea46a 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -30,6 +30,19 @@ export const countForChargeSchema = Schema.Struct({ rcs_lms: countryChargeStatusSchema, rcs_mms: countryChargeStatusSchema, rcs_tpl: countryChargeStatusSchema, + rcs_itpl: Schema.optional(countryChargeStatusSchema), + rcs_ltpl: Schema.optional(countryChargeStatusSchema), + fax: Schema.optional(countryChargeStatusSchema), + voice: Schema.optional(countryChargeStatusSchema), + bms_text: Schema.optional(countryChargeStatusSchema), + bms_image: Schema.optional(countryChargeStatusSchema), + bms_wide: Schema.optional(countryChargeStatusSchema), + bms_wide_item_list: Schema.optional(countryChargeStatusSchema), + bms_carousel_feed: Schema.optional(countryChargeStatusSchema), + bms_premium_video: Schema.optional(countryChargeStatusSchema), + bms_commerce: Schema.optional(countryChargeStatusSchema), + bms_carousel_commerce: Schema.optional(countryChargeStatusSchema), + bms_free: Schema.optional(countryChargeStatusSchema), }); export type CountForCharge = Schema.Schema.Type; From ff37fe5f8f490894fb55a21d8a2b7e0e26fd0be7 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:50:34 +0900 Subject: [PATCH 06/10] fix(errors): redact responseBody in production ResponseSchemaMismatchError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 3차 리뷰 P2: production에서 ResponseSchemaMismatchError의 responseBody 필드에 원본 응답이 유지되어, toString() 가드와 무관하게 Sentry 등 에러 리포터가 enumerable 필드를 직렬화하면서 PII가 누출될 수 있었음. ServerError와 동일하게 creation 단계에서 production이면 undefined로 제외. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/schemaUtils.ts | 13 +++++++++---- test/lib/decodeServerResponse.test.ts | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 868892dc..9c320495 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -110,14 +110,19 @@ export const decodeServerResponse = ( // 부분 스키마로 검증하는 조회 엔드포인트에서 필드 조용히 사라지는 silent data loss를 방지. Effect.mapError( Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data), - err => - new ResponseSchemaMismatchError({ + err => { + // production에서는 responseBody에 PII가 실릴 수 있으므로 creation 단계에서 제외. + // 대부분의 에러 리포터(Sentry/Slack 등)는 toString() 대신 enumerable 필드를 직렬화하므로 + // toString() 가드만으로는 누출을 막지 못한다. ServerError와 동일한 정책을 따른다. + const isProduction = process.env.NODE_ENV === 'production'; + return new ResponseSchemaMismatchError({ message: ParseResult.TreeFormatter.formatErrorSync(err), validationErrors: ParseResult.ArrayFormatter.formatErrorSync(err).map( issue => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`, ), url: context?.url, - responseBody: stringifyResponseBody(data), - }), + responseBody: isProduction ? undefined : stringifyResponseBody(data), + }); + }, ); diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts index f14b4e7c..fa7074f7 100644 --- a/test/lib/decodeServerResponse.test.ts +++ b/test/lib/decodeServerResponse.test.ts @@ -73,6 +73,27 @@ describe('decodeServerResponse', () => { expect(result.left.responseBody).toMatch(/unserializable/); }); + it('production 환경에서는 responseBody를 저장하지 않는다 (PII 보호)', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const result = Effect.runSync( + Effect.either( + decodeServerResponse(getBalanceResponseSchema, { + secretPayload: 'sensitive-data-01073246890', + }), + ), + ); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.responseBody).toBeUndefined(); + // validationErrors와 url은 운영 디버깅에 필요하므로 production에서도 유지 + expect(result.left.validationErrors.length).toBeGreaterThan(0); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + it('context.url이 있으면 에러에 반영된다', () => { const result = Effect.runSync( Effect.either( From 300d9eb0206a5129f2efb24c605c92e96b30afb8 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:54:20 +0900 Subject: [PATCH 07/10] fix(errors): redact all PII channels (validationErrors/url) in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silent-failure-hunter 4차 후속: - ParseResult.ArrayFormatter/TreeFormatter는 실패 메시지에 원본 값(전화번호 등)을 그대로 문자열로 삽입하므로, production에서는 validationErrors 엔트리를 `path: [_tag]` 형태로 축약하고 message는 issue 개수 요약으로 대체 - getMessages 등 조회 API는 to/from 등을 query string에 실으므로 production에서 url의 query 부분을 '?[redacted]'로 마스킹 - 테스트 fixture를 확장해 실제 전화번호 문자열이 message/validationErrors/url 어디에도 포함되지 않음을 명시적으로 검증 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/schemaUtils.ts | 37 ++++++++++++++++++++------- test/lib/decodeServerResponse.test.ts | 30 +++++++++++++++++----- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 9c320495..1946a42b 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -92,6 +92,18 @@ const stringifyResponseBody = (data: unknown): string | undefined => { } }; +/** + * query string은 SOLAPI 조회 API에서 `to`, `from`, `startDate` 등 PII를 포함할 수 있으므로 + * production에서는 path 만 남기고 쿼리 부분을 redact 한다. + */ +const redactUrlForProduction = ( + url: string | undefined, +): string | undefined => { + if (!url) return url; + const queryIndex = url.indexOf('?'); + return queryIndex === -1 ? url : `${url.slice(0, queryIndex)}?[redacted]`; +}; + /** * API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ResponseSchemaMismatchError로 래핑. * 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신 @@ -111,17 +123,24 @@ export const decodeServerResponse = ( Effect.mapError( Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data), err => { - // production에서는 responseBody에 PII가 실릴 수 있으므로 creation 단계에서 제외. - // 대부분의 에러 리포터(Sentry/Slack 등)는 toString() 대신 enumerable 필드를 직렬화하므로 - // toString() 가드만으로는 누출을 막지 못한다. ServerError와 동일한 정책을 따른다. + // production에서는 PII 누출을 차단한다: + // - responseBody: 원본 payload에 전화번호/계정 데이터가 실릴 수 있음 + // - validationErrors 메시지: ParseResult 포맷터는 기대치와 함께 *실제 값*을 문자열로 삽입함 + // - url: getMessages 등 조회 API는 to/from 등 전화번호를 query string에 실음 + // Sentry 등은 toString() 대신 enumerable 필드를 직렬화하므로 creation 단계에서 제거해야 안전. const isProduction = process.env.NODE_ENV === 'production'; + const issues = ParseResult.ArrayFormatter.formatErrorSync(err); return new ResponseSchemaMismatchError({ - message: ParseResult.TreeFormatter.formatErrorSync(err), - validationErrors: ParseResult.ArrayFormatter.formatErrorSync(err).map( - issue => - `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`, - ), - url: context?.url, + message: isProduction + ? `Response schema mismatch on ${issues.length} field(s)` + : ParseResult.TreeFormatter.formatErrorSync(err), + validationErrors: issues.map(issue => { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return isProduction + ? `${path}: [${issue._tag}]` + : `${path}: ${issue.message}`; + }), + url: isProduction ? redactUrlForProduction(context?.url) : context?.url, responseBody: isProduction ? undefined : stringifyResponseBody(data), }); }, diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts index fa7074f7..d318bb57 100644 --- a/test/lib/decodeServerResponse.test.ts +++ b/test/lib/decodeServerResponse.test.ts @@ -73,22 +73,38 @@ describe('decodeServerResponse', () => { expect(result.left.responseBody).toMatch(/unserializable/); }); - it('production 환경에서는 responseBody를 저장하지 않는다 (PII 보호)', () => { + it('production 환경에서 PII가 실릴 수 있는 모든 경로를 redact 한다', () => { const originalEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; + const piiPhone = '01012345678'; try { const result = Effect.runSync( Effect.either( - decodeServerResponse(getBalanceResponseSchema, { - secretPayload: 'sensitive-data-01073246890', - }), + decodeServerResponse( + getBalanceResponseSchema, + {balance: piiPhone, leakingField: piiPhone}, + { + url: `https://api.example.com/messages/v4/list?to=${piiPhone}&from=02`, + }, + ), ), ); expect(result._tag).toBe('Left'); if (result._tag !== 'Left') return; - expect(result.left.responseBody).toBeUndefined(); - // validationErrors와 url은 운영 디버깅에 필요하므로 production에서도 유지 - expect(result.left.validationErrors.length).toBeGreaterThan(0); + const err = result.left; + // responseBody는 완전 제거 + expect(err.responseBody).toBeUndefined(); + // validationErrors 메시지와 message 필드에 원본 PII 값이 포함되면 안 됨 + expect(err.message).not.toContain(piiPhone); + for (const ve of err.validationErrors) { + expect(ve).not.toContain(piiPhone); + } + // url은 query string이 redact 된 형태만 유지 + expect(err.url).not.toContain(piiPhone); + expect(err.url).toContain('/messages/v4/list'); + expect(err.url).toContain('[redacted]'); + // 디버깅용 구조 정보(경로, 개수)는 유지 + expect(err.validationErrors.length).toBeGreaterThan(0); } finally { process.env.NODE_ENV = originalEnv; } From 0af8eada25f44ff390d35ee831b996c8254d6e72 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 18:59:19 +0900 Subject: [PATCH 08/10] fix(errors): safe-by-default redact gate; strip url fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silent-failure-hunter 5차: - NODE_ENV 가드를 safe-by-default로 반전 — staging/unset 등 비정형 환경에서도 redact를 기본 적용하고, development/test만 verbose 모드 - redactUrlForProduction이 URL fragment(#...)를 먼저 strip 하도록 보완해 fragment 내부 쿼리 문자열 또한 redact 경로에 포함 - ResponseSchemaMismatchError.toString()에서 이중 가드 제거 — creation 시점에 이미 필드가 redact된 상태라 단순히 보유 값만 렌더링 - 테스트 시나리오를 'production' → 'staging'으로 교체해 새로운 가드 범위 검증 Codex 4차에서 지적된 BadRequestError/ClientError/ServerError/NetworkError의 동일 PII 누출 패턴은 request/transport 계층 전반의 정책 변경을 요구하므로 이 PR 범위에서 분리 — 별도 후속 작업으로 처리 권장 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/errors/defaultError.ts | 9 +++---- src/lib/schemaUtils.ts | 34 +++++++++++++++++++-------- test/lib/decodeServerResponse.test.ts | 3 ++- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 62b84e95..2a6ca671 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -148,12 +148,9 @@ export class ResponseSchemaMismatchError extends Data.TaggedError( this.validationErrors.length > 0 ? `\nIssues:\n- ${this.validationErrors.join('\n- ')}` : ''; - // url과 validationErrors는 민감 정보가 아니고 운영 디버깅에 필수적이므로 production에서도 유지. - // responseBody만 민감 페이로드일 수 있어 production에서 제외. - const isProduction = process.env.NODE_ENV === 'production'; - if (isProduction) { - return `${header}${url}${issues}`; - } + // creation 시점에서 이미 redact 정책이 적용된 값들이 들어와 있으므로 + // toString은 추가 가드 없이 보유 중인 필드를 그대로 렌더링한다. + // responseBody는 dev/test에만 존재하므로 자동으로 그 경우에만 출력됨. const body = this.responseBody ? `\nResponse: ${this.responseBody.substring(0, 500)}` : ''; diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 1946a42b..4dd1b297 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -93,15 +93,29 @@ const stringifyResponseBody = (data: unknown): string | undefined => { }; /** - * query string은 SOLAPI 조회 API에서 `to`, `from`, `startDate` 등 PII를 포함할 수 있으므로 - * production에서는 path 만 남기고 쿼리 부분을 redact 한다. + * query string 및 fragment는 SOLAPI 조회 API에서 `to`, `from`, `startDate` 등 PII를 + * 포함할 수 있으므로 redact 경로에서는 path만 남기고 둘 다 마스킹한다. */ const redactUrlForProduction = ( url: string | undefined, ): string | undefined => { if (!url) return url; - const queryIndex = url.indexOf('?'); - return queryIndex === -1 ? url : `${url.slice(0, queryIndex)}?[redacted]`; + const hashIndex = url.indexOf('#'); + const fragmentStripped = hashIndex === -1 ? url : url.slice(0, hashIndex); + const queryIndex = fragmentStripped.indexOf('?'); + return queryIndex === -1 + ? fragmentStripped + : `${fragmentStripped.slice(0, queryIndex)}?[redacted]`; +}; + +/** + * PII 보호 gate는 safe-by-default: 명시적으로 개발자 환경(development/test)일 때만 + * 상세 정보를 노출한다. 운영/스테이징/NODE_ENV 미설정 환경은 모두 redact 경로를 탄다 — + * 원본 값이 로그/Sentry 등으로 유출되지 않도록 하기 위함. + */ +const shouldRedactSensitive = (): boolean => { + const env = process.env.NODE_ENV; + return env !== 'development' && env !== 'test'; }; /** @@ -123,25 +137,25 @@ export const decodeServerResponse = ( Effect.mapError( Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data), err => { - // production에서는 PII 누출을 차단한다: + // PII 누출을 차단한다 (safe-by-default: development/test 외에는 모두 redact): // - responseBody: 원본 payload에 전화번호/계정 데이터가 실릴 수 있음 // - validationErrors 메시지: ParseResult 포맷터는 기대치와 함께 *실제 값*을 문자열로 삽입함 // - url: getMessages 등 조회 API는 to/from 등 전화번호를 query string에 실음 // Sentry 등은 toString() 대신 enumerable 필드를 직렬화하므로 creation 단계에서 제거해야 안전. - const isProduction = process.env.NODE_ENV === 'production'; + const redact = shouldRedactSensitive(); const issues = ParseResult.ArrayFormatter.formatErrorSync(err); return new ResponseSchemaMismatchError({ - message: isProduction + message: redact ? `Response schema mismatch on ${issues.length} field(s)` : ParseResult.TreeFormatter.formatErrorSync(err), validationErrors: issues.map(issue => { const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; - return isProduction + return redact ? `${path}: [${issue._tag}]` : `${path}: ${issue.message}`; }), - url: isProduction ? redactUrlForProduction(context?.url) : context?.url, - responseBody: isProduction ? undefined : stringifyResponseBody(data), + url: redact ? redactUrlForProduction(context?.url) : context?.url, + responseBody: redact ? undefined : stringifyResponseBody(data), }); }, ); diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts index d318bb57..bffe7872 100644 --- a/test/lib/decodeServerResponse.test.ts +++ b/test/lib/decodeServerResponse.test.ts @@ -75,7 +75,8 @@ describe('decodeServerResponse', () => { it('production 환경에서 PII가 실릴 수 있는 모든 경로를 redact 한다', () => { const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; + // redact 정책은 safe-by-default: development/test 외에는 모두 redact + process.env.NODE_ENV = 'staging'; const piiPhone = '01012345678'; try { const result = Effect.runSync( From 6df299ccf037d440a86b71d0a686aba92f484355 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 19:05:41 +0900 Subject: [PATCH 09/10] refactor(stored-message): tighten option payloads to typed records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit silent-failure-hunter/Codex 5차: - NODE_ENV gate를 .trim().toLowerCase()로 정규화해 Windows PowerShell 등에서 'Development' 같은 변형도 verbose 모드로 인식 - redactUrlForProduction을 URL 객체 기반으로 전환해 query string뿐 아니라 fragment/userinfo(user:password@)까지 함께 redact - ResponseSchemaMismatchError.toString()에 defense-in-depth 가드 복원 — 클래스가 public이라 외부에서 직접 생성될 경우에도 production 경로에서 responseBody가 유출되지 않도록 한다 - storedMessageSchema의 옵션 객체(kakao/rcs/naver/fax/voice)와 배열 필드 (replacements/log/queues)를 Record/Array of Unknown으로 강화해 최소한의 구조 계약(object or array)이 타입 수준에서 유지되도록 함 - 테스트: NODE_ENV를 table-driven으로 확장해 production/staging/undefined/빈 문자열 모두 redact 되고 development/test만 verbose 되는 계약, 대소문자/공백 정규화 동작, userinfo/fragment redact 동작까지 커버 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/errors/defaultError.ts | 14 +++-- src/lib/schemaUtils.ts | 33 ++++++---- src/models/base/messages/storedMessage.ts | 39 +++++++++--- test/lib/decodeServerResponse.test.ts | 77 ++++++++++++++++++----- 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 2a6ca671..8fd919eb 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -148,12 +148,14 @@ export class ResponseSchemaMismatchError extends Data.TaggedError( this.validationErrors.length > 0 ? `\nIssues:\n- ${this.validationErrors.join('\n- ')}` : ''; - // creation 시점에서 이미 redact 정책이 적용된 값들이 들어와 있으므로 - // toString은 추가 가드 없이 보유 중인 필드를 그대로 렌더링한다. - // responseBody는 dev/test에만 존재하므로 자동으로 그 경우에만 출력됨. - const body = this.responseBody - ? `\nResponse: ${this.responseBody.substring(0, 500)}` - : ''; + // defense-in-depth: 이 클래스는 public이라 외부에서 직접 생성될 수 있으므로, + // creation 시점 정책과 무관하게 redact 환경에서는 responseBody를 출력하지 않는다. + const env = process.env.NODE_ENV?.trim().toLowerCase(); + const isVerbose = env === 'development' || env === 'test'; + const body = + isVerbose && this.responseBody + ? `\nResponse: ${this.responseBody.substring(0, 500)}` + : ''; return `${header}${url}${issues}${body}`; } } diff --git a/src/lib/schemaUtils.ts b/src/lib/schemaUtils.ts index 4dd1b297..5f366d99 100644 --- a/src/lib/schemaUtils.ts +++ b/src/lib/schemaUtils.ts @@ -93,28 +93,39 @@ const stringifyResponseBody = (data: unknown): string | undefined => { }; /** - * query string 및 fragment는 SOLAPI 조회 API에서 `to`, `from`, `startDate` 등 PII를 - * 포함할 수 있으므로 redact 경로에서는 path만 남기고 둘 다 마스킹한다. + * URL에서 PII가 실릴 수 있는 모든 부분(query, fragment, userinfo)을 redact 한다. + * SOLAPI 조회 API는 `to`, `from`, `startDate` 등을 query string에 싣고, + * 소비자가 전달한 URL에 userinfo가 포함될 여지도 있으므로 모두 제거한다. */ -const redactUrlForProduction = ( +export const redactUrlForProduction = ( url: string | undefined, ): string | undefined => { if (!url) return url; - const hashIndex = url.indexOf('#'); - const fragmentStripped = hashIndex === -1 ? url : url.slice(0, hashIndex); - const queryIndex = fragmentStripped.indexOf('?'); - return queryIndex === -1 - ? fragmentStripped - : `${fragmentStripped.slice(0, queryIndex)}?[redacted]`; + try { + const parsed = new URL(url); + const hadQuery = parsed.search.length > 0; + parsed.search = hadQuery ? '?[redacted]' : ''; + parsed.hash = ''; + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } catch { + // 파싱 불가한 상대/비정상 URL은 보수적으로 첫 구분자 이후 전부 마스킹 + const cut = url.search(/[?#;]/); + return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`; + } }; /** * PII 보호 gate는 safe-by-default: 명시적으로 개발자 환경(development/test)일 때만 * 상세 정보를 노출한다. 운영/스테이징/NODE_ENV 미설정 환경은 모두 redact 경로를 탄다 — * 원본 값이 로그/Sentry 등으로 유출되지 않도록 하기 위함. + * + * NODE_ENV는 `.trim().toLowerCase()`로 정규화해 Windows PowerShell 등에서 흔한 + * `Development` 오타를 verbose 모드로 인식하도록 한다. */ -const shouldRedactSensitive = (): boolean => { - const env = process.env.NODE_ENV; +export const shouldRedactSensitive = (): boolean => { + const env = process.env.NODE_ENV?.trim().toLowerCase(); return env !== 'development' && env !== 'test'; }; diff --git a/src/models/base/messages/storedMessage.ts b/src/models/base/messages/storedMessage.ts index 4da5dd77..ede14304 100644 --- a/src/models/base/messages/storedMessage.ts +++ b/src/models/base/messages/storedMessage.ts @@ -65,14 +65,37 @@ export const storedMessageSchema = Schema.Struct({ dateProcessed: Schema.NullishOr(Schema.String), dateReceived: Schema.NullishOr(Schema.String), dateReported: Schema.NullishOr(Schema.String), - kakaoOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), - rcsOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), - naverOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), - faxOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), - voiceOptions: Schema.optional(Schema.NullishOr(Schema.Unknown)), - replacements: Schema.optional(Schema.NullishOr(Schema.Unknown)), - log: Schema.optional(Schema.NullishOr(Schema.Unknown)), - queues: Schema.optional(Schema.NullishOr(Schema.Unknown)), + // 옵션 객체는 서버 정규화 포맷(저장 형태)으로 발송 요청용 스키마와 필드가 다르다. + // 상세 타이핑을 확정하려면 각 옵션별 별도 조회 스키마 정의가 필요하지만 본 PR 범위를 + // 벗어나므로, 최소한 "object"임을 보장해 원시 값이 섞이는 drift를 감지할 수 있게 한다. + kakaoOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + rcsOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + naverOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + faxOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + voiceOptions: Schema.optional( + Schema.NullishOr( + Schema.Record({key: Schema.String, value: Schema.Unknown}), + ), + ), + replacements: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))), + log: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))), + queues: Schema.optional(Schema.NullishOr(Schema.Array(Schema.Unknown))), currentQueue: Schema.optional(Schema.NullishOr(Schema.Unknown)), clusterKey: Schema.NullishOr(Schema.String), unavailableSenderNumber: Schema.optional(Schema.NullishOr(booleanOrZeroOne)), diff --git a/test/lib/decodeServerResponse.test.ts b/test/lib/decodeServerResponse.test.ts index bffe7872..ce73a019 100644 --- a/test/lib/decodeServerResponse.test.ts +++ b/test/lib/decodeServerResponse.test.ts @@ -73,42 +73,87 @@ describe('decodeServerResponse', () => { expect(result.left.responseBody).toMatch(/unserializable/); }); - it('production 환경에서 PII가 실릴 수 있는 모든 경로를 redact 한다', () => { - const originalEnv = process.env.NODE_ENV; - // redact 정책은 safe-by-default: development/test 외에는 모두 redact - process.env.NODE_ENV = 'staging'; + describe('safe-by-default redact gate', () => { const piiPhone = '01012345678'; - try { - const result = Effect.runSync( + const withEnv = (env: string | undefined, fn: () => T): T => { + const original = process.env.NODE_ENV; + if (env === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = env; + } + try { + return fn(); + } finally { + if (original === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = original; + } + } + }; + + const runDecode = () => + Effect.runSync( Effect.either( decodeServerResponse( getBalanceResponseSchema, - {balance: piiPhone, leakingField: piiPhone}, + // balance는 number 기대이므로 PII 전화번호를 주입하면 formatter 메시지에 값이 노출됨 + {...balanceFixture, balance: piiPhone}, { - url: `https://api.example.com/messages/v4/list?to=${piiPhone}&from=02`, + url: `https://user:secret@api.example.com/messages/v4/list?to=${piiPhone}&from=02#section-${piiPhone}`, }, ), ), ); + + it.each([ + 'production', + 'staging', + 'PRODUCTION', + '', + undefined, + ])('NODE_ENV=%s 환경은 PII를 redact 한다', envValue => { + const result = withEnv(envValue, runDecode); expect(result._tag).toBe('Left'); if (result._tag !== 'Left') return; const err = result.left; - // responseBody는 완전 제거 expect(err.responseBody).toBeUndefined(); - // validationErrors 메시지와 message 필드에 원본 PII 값이 포함되면 안 됨 expect(err.message).not.toContain(piiPhone); for (const ve of err.validationErrors) { expect(ve).not.toContain(piiPhone); } - // url은 query string이 redact 된 형태만 유지 expect(err.url).not.toContain(piiPhone); + expect(err.url).not.toContain('secret'); expect(err.url).toContain('/messages/v4/list'); - expect(err.url).toContain('[redacted]'); - // 디버깅용 구조 정보(경로, 개수)는 유지 expect(err.validationErrors.length).toBeGreaterThan(0); - } finally { - process.env.NODE_ENV = originalEnv; - } + }); + + it.each([ + 'development', + 'test', + ])('NODE_ENV=%s 환경은 상세 정보를 유지해 디버깅에 활용 가능하다', envValue => { + const result = withEnv(envValue, runDecode); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + const err = result.left; + expect(err.responseBody).toBeDefined(); + expect(err.responseBody).toContain(piiPhone); + expect(err.validationErrors.join(' ')).toContain(piiPhone); + expect(err.url).toContain(piiPhone); + }); + + it.each([ + ' DEVELOPMENT ', + 'Development', + ' test ', + 'TEST', + ])('대소문자/공백이 섞인 %s는 정규화되어 verbose로 인식된다', envValue => { + const result = withEnv(envValue, runDecode); + expect(result._tag).toBe('Left'); + if (result._tag !== 'Left') return; + expect(result.left.responseBody).toBeDefined(); + }); }); it('context.url이 있으면 에러에 반영된다', () => { From 28c912cdf1c54ed6a8fe2d43d83350991ecf48c4 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 17 Apr 2026 19:15:20 +0900 Subject: [PATCH 10/10] fix(statistics): keep dayPeriod.statusCode typed via partial MessageTypeRecord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 최종 P2: statusCode 값 타입이 generic Record로 바뀌면서 statusCode[code].sms/lms 같은 타입-narrowed 접근이 깨지던 회귀 복원. 서버가 실제 일부 필드만 내려주는 sparse 응답임을 고려해, messageTypeRecordSchema를 Schema.partial로 감싼 partialMessageTypeRecordSchema를 신설하고 dayPeriod.statusCode value에 사용. 타입 정보는 유지하면서도 한두 필드만 있는 응답도 통과한다. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/models/responses/messageResponses.ts | 3 ++- src/types/commonTypes.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/models/responses/messageResponses.ts b/src/models/responses/messageResponses.ts index f09122a9..328da7f3 100644 --- a/src/models/responses/messageResponses.ts +++ b/src/models/responses/messageResponses.ts @@ -6,6 +6,7 @@ import { groupIdSchema, groupSchema, logSchema, + partialMessageTypeRecordSchema, } from '@internal-types/commonTypes'; import {Schema} from 'effect'; import {storedMessageSchema} from '../base/messages/storedMessage'; @@ -137,7 +138,7 @@ const dayPeriodSchema = Schema.Struct({ deposit: Schema.optional(Schema.Number), statusCode: Schema.Record({ key: Schema.String, - value: Schema.Record({key: Schema.String, value: Schema.Number}), + value: partialMessageTypeRecordSchema, }), refund: refundSchema, total: statisticsPeriodResultSchema, diff --git a/src/types/commonTypes.ts b/src/types/commonTypes.ts index 5c5ea46a..c5928137 100644 --- a/src/types/commonTypes.ts +++ b/src/types/commonTypes.ts @@ -86,6 +86,18 @@ export type MessageTypeRecord = Schema.Schema.Type< typeof messageTypeRecordSchema >; +/** + * 통계 dayPeriod.statusCode 같이 status code 별로 **일부 메시지 타입만** 카운트를 내려주는 + * sparse 응답용. MessageTypeRecord의 타입 정보(`sms`/`lms` 등 개별 키)를 유지하면서도 + * 모든 키를 optional로 풀어 서버가 한두 필드만 내려줘도 decode가 통과하도록 한다. + */ +export const partialMessageTypeRecordSchema = Schema.partial( + messageTypeRecordSchema, +); +export type PartialMessageTypeRecord = Schema.Schema.Type< + typeof partialMessageTypeRecordSchema +>; + export const appSchema = Schema.Struct({ profit: messageTypeRecordSchema, appId: Schema.NullishOr(Schema.String),