Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/errors/defaultError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,37 @@ 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<string>;
readonly responseBody?: string;
}> {
toString(): string {
const header = `ResponseSchemaMismatchError: ${this.message}`;
const url = this.url ? `\nURL: ${this.url}` : '';
const issues =
this.validationErrors.length > 0
? `\nIssues:\n- ${this.validationErrors.join('\n- ')}`
: '';
// 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}`;
}
}

// 5xx 서버 에러용
export class ServerError extends Data.TaggedError('ServerError')<{
readonly errorCode: string;
Expand Down
98 changes: 97 additions & 1 deletion src/lib/schemaUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {ParseResult, Schema} from 'effect';
import * as Effect from 'effect/Effect';
import {BadRequestError, InvalidDateError} from '../errors/defaultError';
import {
BadRequestError,
InvalidDateError,
ResponseSchemaMismatchError,
} from '../errors/defaultError';
import stringDateTransfer, {formatWithTransfer} from './stringDateTransfer';

/**
Expand Down Expand Up @@ -74,3 +78,95 @@ export const safeFinalize = <T>(
message: error instanceof Error ? error.message : String(error),
}),
});

const stringifyResponseBody = (data: unknown): string | undefined => {
if (data === undefined) return undefined;
if (typeof data === 'string') return data;
try {
return JSON.stringify(data);
} catch (err) {
// circular / BigInt 등 직렬화 실패를 silent 하게 버리지 않고
// 최소한 실패 사유와 타입 태그를 운영 로그에서 확인할 수 있도록 둔다.
const reason = err instanceof Error ? err.message : String(err);
return `[unserializable: ${reason}] ${Object.prototype.toString.call(data)}`;
}
};

/**
* URL에서 PII가 실릴 수 있는 모든 부분(query, fragment, userinfo)을 redact 한다.
* SOLAPI 조회 API는 `to`, `from`, `startDate` 등을 query string에 싣고,
* 소비자가 전달한 URL에 userinfo가 포함될 여지도 있으므로 모두 제거한다.
*/
export const redactUrlForProduction = (
url: string | undefined,
): string | undefined => {
if (!url) return url;
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]`;
}
Comment on lines +113 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

URL 파싱에 실패했을 때의 fallback 로직에서 userinfo(예: user:password@)가 포함된 경우 PII가 유출될 가능성이 있습니다. 현재는 ?, #, ; 문자만 체크하여 그 이후를 마스킹하고 있는데, URL의 권한(authority) 부분에 포함될 수 있는 민감 정보도 고려하는 것이 안전합니다. 파싱이 불가능한 비정상적인 URL 문자열이더라도 보수적으로 전체를 마스킹하거나, 최소한 프로토콜 구분자(//) 이후의 첫 번째 / 이전 구간에 @가 있는지 확인하여 마스킹하는 로직을 검토해 주세요.

};

/**
* PII 보호 gate는 safe-by-default: 명시적으로 개발자 환경(development/test)일 때만
* 상세 정보를 노출한다. 운영/스테이징/NODE_ENV 미설정 환경은 모두 redact 경로를 탄다 —
* 원본 값이 로그/Sentry 등으로 유출되지 않도록 하기 위함.
*
* NODE_ENV는 `.trim().toLowerCase()`로 정규화해 Windows PowerShell 등에서 흔한
* `Development` 오타를 verbose 모드로 인식하도록 한다.
*/
export const shouldRedactSensitive = (): boolean => {
const env = process.env.NODE_ENV?.trim().toLowerCase();
return env !== 'development' && env !== 'test';
};

/**
* API 응답 body를 Effect Schema로 런타임 검증하고 실패 시 ResponseSchemaMismatchError로 래핑.
* 서버가 예고 없이 응답 구조를 바꾼 경우 소비자 측에서 조용히 undefined로 터지는 대신
* 스키마 불일치 위치(ArrayFormatter issue path)와 원본 responseBody를 함께 보존하여
* 운영 환경에서도 재현 가능하게 한다.
*
* Schema는 requirement 채널을 never로 제한 — 외부 서비스를 요구하는 transform을 금지하여
* 응답 디코딩이 항상 순수하게 끝나도록 강제한다.
*/
export const decodeServerResponse = <A, I>(
schema: Schema.Schema<A, I, never>,
data: unknown,
context?: {url?: string},
): Effect.Effect<A, ResponseSchemaMismatchError> =>
// onExcessProperty: 'preserve' — 서버가 추가로 내려준 미선언 필드를 strip 하지 않는다.
// 부분 스키마로 검증하는 조회 엔드포인트에서 필드 조용히 사라지는 silent data loss를 방지.
Effect.mapError(
Schema.decodeUnknown(schema, {onExcessProperty: 'preserve'})(data),
err => {
// PII 누출을 차단한다 (safe-by-default: development/test 외에는 모두 redact):
// - responseBody: 원본 payload에 전화번호/계정 데이터가 실릴 수 있음
// - validationErrors 메시지: ParseResult 포맷터는 기대치와 함께 *실제 값*을 문자열로 삽입함
// - url: getMessages 등 조회 API는 to/from 등 전화번호를 query string에 실음
// Sentry 등은 toString() 대신 enumerable 필드를 직렬화하므로 creation 단계에서 제거해야 안전.
const redact = shouldRedactSensitive();
const issues = ParseResult.ArrayFormatter.formatErrorSync(err);
return new ResponseSchemaMismatchError({
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 redact
? `${path}: [${issue._tag}]`
: `${path}: ${issue.message}`;
}),
url: redact ? redactUrlForProduction(context?.url) : context?.url,
responseBody: redact ? undefined : stringifyResponseBody(data),
});
},
);
6 changes: 3 additions & 3 deletions src/models/base/kakao/kakaoChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -40,7 +40,7 @@ export type KakaoChannel = {
channelId: string;
searchId: string;
accountId: string;
phoneNumber: string;
phoneNumber?: string;
sharedAccountIds: ReadonlyArray<string>;
dateCreated?: Date;
dateUpdated?: Date;
Expand All @@ -63,6 +63,6 @@ export function decodeKakaoChannel(
sharedAccountIds: data.sharedAccountIds,
dateCreated,
dateUpdated,
};
} satisfies KakaoChannel;
});
}
107 changes: 107 additions & 0 deletions src/models/base/messages/storedMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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.transformOrFail(
Schema.Union(Schema.Boolean, Schema.Number),
Schema.Boolean,
{
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,
},
);

/**
* 조회 응답(getMessages/getGroupMessages)에 포함된 메시지 아이템 스키마.
*
* 발송용 messageSchema와 달리 서버가 저장해둔 값을 그대로 반환하므로
* - optional 필드 상당수가 null로 내려올 수 있다.
* - kakaoOptions/rcsOptions 등 내부 구조가 발송 요청과 다르다(서버 정규화 포맷).
*
* 핵심 필드만 선언하고 타입 수준에서 검증/정규화한다. 여기에 없는 필드는
* decodeServerResponse의 onExcessProperty:'preserve' 옵션으로 런타임에 그대로 보존된다.
*/
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(booleanOrZeroOne),
replacement: Schema.optional(booleanOrZeroOne),
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),
// 옵션 객체는 서버 정규화 포맷(저장 형태)으로 발송 요청용 스키마와 필드가 다르다.
// 상세 타이핑을 확정하려면 각 옵션별 별도 조회 스키마 정의가 필요하지만 본 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)),
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<typeof storedMessageSchema>;
4 changes: 4 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export {
messageSchema,
messageTypeSchema,
} from './base/messages/message';
export {
type StoredMessage,
storedMessageSchema,
} from './base/messages/storedMessage';
export {
type NaverOptionSchema,
naverOptionSchema,
Expand Down
4 changes: 2 additions & 2 deletions src/models/responses/iam/getBlacksResponse.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +17,6 @@ export type GetKakaoAlimtalkTemplatesResponse =
export type GetKakaoAlimtalkTemplatesFinalizeResponse = {
limit: number;
templateList: Array<KakaoAlimtalkTemplate>;
startKey: string;
nextKey: string | null;
startKey: string | null | undefined;
nextKey: string | null | undefined;
};
8 changes: 4 additions & 4 deletions src/models/responses/kakao/getKakaoChannelsResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand All @@ -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<KakaoChannel>;
};
2 changes: 1 addition & 1 deletion src/models/responses/kakao/getKakaoTemplateResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading