@sayren/store-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/http.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { z } from "zod";
2
+
3
+ /** 표준 엔벨로프의 $.error 상세 */
4
+ export const errorDetailSchema = z.object({
5
+ code: z.string(),
6
+ message: z.string(),
7
+ traceId: z.string().optional(),
8
+ /**
9
+ * 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의
10
+ * `{ conditions: LiveGateCondition[] }`. 형태는 에러 코드마다 다르므로 소비측이 코드별 스키마로 다시 파싱한다
11
+ */
12
+ details: z.record(z.string(), z.unknown()).optional().catch(undefined),
13
+ });
14
+
15
+ /** 표준 응답 엔벨로프 — $.meta / $.data / $.error */
16
+ export const envelopeSchema = z.object({
17
+ meta: z.object({
18
+ status: z.number(),
19
+ code: z.string(),
20
+ message: z.string(),
21
+ isSuccess: z.boolean(),
22
+ }),
23
+ data: z.unknown().optional(),
24
+ /** 구형 필드 — data로 개명되기 전 응답과의 전환기 호환 */
25
+ content: z.unknown().optional(),
26
+ error: errorDetailSchema.nullable(),
27
+ });
28
+
29
+ export type ApiErrorBody = z.infer<typeof errorDetailSchema>;
30
+
31
+ /** OAuth 프로토콜 표면의 평면 에러 (RFC 6749 §5.2, RFC 7591 §3.2.2) */
32
+ const oauthErrorSchema = z.object({
33
+ error: z.string(),
34
+ error_description: z.string().optional(),
35
+ });
36
+
37
+ /** 표준 엔벨로프면 $.data(구형 $.content)를, 아니면(평면 응답) 본문 전체를 반환 */
38
+ export function unwrapData(json: unknown): unknown {
39
+ const envelope = envelopeSchema.safeParse(json);
40
+ if (!envelope.success) return json;
41
+ return envelope.data.data ?? envelope.data.content ?? null;
42
+ }
43
+
44
+ export class ApiError extends Error {
45
+ constructor(
46
+ public readonly status: number,
47
+ public readonly code: string,
48
+ message: string,
49
+ public readonly traceId?: string,
50
+ public readonly body?: unknown,
51
+ /** 엔벨로프 `$.error.details` — 에러 코드별 보조 데이터. 없으면 undefined */
52
+ public readonly details?: Record<string, unknown>,
53
+ ) {
54
+ super(message);
55
+ this.name = "ApiError";
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 에러 응답 본문 → ApiError 구성 (에러 형태의 단일 원천).
61
+ * 표준 엔벨로프($.error) 우선, 구형 평면 에러({ code, message }), OAuth 평면 에러
62
+ * ({ error, error_description } → code=error, message=error_description) 순으로 폴백하고,
63
+ * 모두 아니면 UNKNOWN. createHttp의 send와 세션(userinfo 등 raw fetch 경로)이 동일한 에러 모양을 내도록 공유한다.
64
+ */
65
+ export function toApiError(status: number, raw: unknown): ApiError {
66
+ const envelope = envelopeSchema.safeParse(raw);
67
+ const detail = envelope.success
68
+ ? envelope.data.error
69
+ : (errorDetailSchema.safeParse(raw).data ?? null);
70
+ if (detail) {
71
+ return new ApiError(status, detail.code, detail.message, detail.traceId, raw, detail.details);
72
+ }
73
+ const oauth = envelope.success ? null : oauthErrorSchema.safeParse(raw).data;
74
+ if (oauth) {
75
+ return new ApiError(
76
+ status,
77
+ oauth.error,
78
+ oauth.error_description ?? oauth.error,
79
+ undefined,
80
+ raw,
81
+ );
82
+ }
83
+ return new ApiError(status, "UNKNOWN", `HTTP ${status}`, undefined, raw);
84
+ }
85
+
86
+ /**
87
+ * 경로 템플릿 태그 — 보간 값(경로 파라미터)마다 encodeURIComponent를 적용한다.
88
+ * `/`·`?`·`#` 등이 섞인 id가 다른 경로·쿼리로 해석되지 않게 한다. 예: path`/products/${id}`
89
+ */
90
+ export function path(strings: TemplateStringsArray, ...params: Array<string | number>): string {
91
+ return strings.reduce(
92
+ (acc, part, i) => acc + part + (i < params.length ? encodeURIComponent(String(params[i])) : ""),
93
+ "",
94
+ );
95
+ }
96
+
97
+ export type TokenSource = string | null | undefined | (() => string | null | undefined);
98
+
99
+ export function resolveToken(source: TokenSource): string | null {
100
+ const value = typeof source === "function" ? source() : source;
101
+ return value ?? null;
102
+ }
103
+
104
+ export interface HttpOptions {
105
+ baseUrl: string;
106
+ fetch?: typeof globalThis.fetch;
107
+ headers?: () => Record<string, string>;
108
+ onResponse?: (response: Response) => void;
109
+ /**
110
+ * 401 시 호출 — 세션을 갱신하고 true면 요청을 **1회 재시도**한다(headers 콜백이 새 토큰을 읽는다).
111
+ * false거나 재시도 후에도 401이면 onUnauthorized로 넘어간다.
112
+ */
113
+ refresh?: () => Promise<boolean>;
114
+ /** 복구 불가능한 401(갱신 실패/재시도 후에도 401) 시 호출 — 재로그인 유도용 */
115
+ onUnauthorized?: () => void;
116
+ }
117
+
118
+ export type Query = Record<string, string | number | boolean | undefined>;
119
+
120
+ export function buildQuery(query?: Query): string {
121
+ if (!query) return "";
122
+ const params = new URLSearchParams();
123
+ for (const [key, value] of Object.entries(query)) {
124
+ if (value !== undefined) params.set(key, String(value));
125
+ }
126
+ const qs = params.toString();
127
+ return qs ? `?${qs}` : "";
128
+ }
129
+
130
+ export interface RequestOptions {
131
+ query?: Query;
132
+ body?: unknown;
133
+ headers?: Record<string, string>;
134
+ }
135
+
136
+ export interface Http {
137
+ request<T>(
138
+ method: string,
139
+ path: string,
140
+ schema: z.ZodType<T>,
141
+ options?: RequestOptions,
142
+ ): Promise<T>;
143
+ requestVoid(method: string, path: string, options?: RequestOptions): Promise<void>;
144
+ }
145
+
146
+ export function createHttp(options: HttpOptions): Http {
147
+ const fetchFn = options.fetch ?? globalThis.fetch;
148
+
149
+ async function send(
150
+ method: string,
151
+ path: string,
152
+ request?: RequestOptions,
153
+ retried = false,
154
+ ): Promise<Response> {
155
+ const url = `${options.baseUrl}${path}${buildQuery(request?.query)}`;
156
+ const headers: Record<string, string> = {
157
+ ...(request?.body !== undefined ? { "content-type": "application/json" } : {}),
158
+ ...options.headers?.(),
159
+ ...request?.headers,
160
+ };
161
+ const response = await fetchFn(url, {
162
+ method,
163
+ headers,
164
+ body: request?.body !== undefined ? JSON.stringify(request.body) : undefined,
165
+ });
166
+ options.onResponse?.(response);
167
+ // 401 → 세션 갱신 후 1회 재시도. headers 콜백이 갱신된 토큰을 읽으므로 재시도는 새 토큰으로 나간다.
168
+ if (response.status === 401 && !retried && options.refresh) {
169
+ if (await options.refresh()) return send(method, path, request, true);
170
+ }
171
+ if (!response.ok) {
172
+ if (response.status === 401) options.onUnauthorized?.();
173
+ const raw = await response.json().catch(() => null);
174
+ throw toApiError(response.status, raw);
175
+ }
176
+ return response;
177
+ }
178
+
179
+ return {
180
+ async request<T>(
181
+ method: string,
182
+ path: string,
183
+ schema: z.ZodType<T>,
184
+ request?: RequestOptions,
185
+ ): Promise<T> {
186
+ const response = await send(method, path, request);
187
+ const json: unknown = await response.json();
188
+ return schema.parse(unwrapData(json));
189
+ },
190
+ async requestVoid(method: string, path: string, request?: RequestOptions): Promise<void> {
191
+ await send(method, path, request);
192
+ },
193
+ };
194
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export type { StoreClient, StoreClientOptions } from "./client";
2
+ export { createStoreClient } from "./client";
3
+ export type { Query, TokenSource } from "./http";
4
+ export { ApiError, buildQuery } from "./http";
5
+ export {
6
+ buildAuthorizeUrl,
7
+ createPkcePair,
8
+ exchangeAuthorizationCode,
9
+ exchangeStoreTokenViaOAuth,
10
+ type OAuthTokenResponse,
11
+ oauthTokenResponseSchema,
12
+ refreshUserToken,
13
+ } from "./oauth";
14
+ export * from "./schemas/api-client";
15
+ export * from "./schemas/api-log";
16
+ export * from "./schemas/auth";
17
+ export * from "./schemas/category";
18
+ export * from "./schemas/claim";
19
+ export * from "./schemas/common";
20
+ export * from "./schemas/misc";
21
+ export * from "./schemas/order";
22
+ export * from "./schemas/payment";
23
+ export * from "./schemas/product";
24
+ export * from "./schemas/scope";
25
+ export * from "./schemas/store";
26
+ export * from "./schemas/upload";
27
+ export * from "./schemas/webhook-event";
28
+ export {
29
+ type AuthorizeOptions,
30
+ createStoreSession,
31
+ type SessionStorage,
32
+ STORE_SESSION_KEYS,
33
+ type StoreSession,
34
+ type StoreSessionOptions,
35
+ } from "./session";
package/src/oauth.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { base64UrlFromBytes } from "./base64";
3
+ import { unwrapData } from "./http";
4
+
5
+ /**
6
+ * OAuth 2.1 브라우저 플로우 헬퍼 (authorization_code + PKCE).
7
+ * Web Crypto만 사용 — 브라우저/Node 모두에서 동작하며 **시크릿을 다루지 않는다**.
8
+ */
9
+
10
+ /** RFC 6749 §5.1 토큰 응답 (+ RFC 8693 확장 멤버 허용) */
11
+ export const oauthTokenResponseSchema = z
12
+ .object({
13
+ access_token: z.string(),
14
+ token_type: z.literal("Bearer"),
15
+ expires_in: z.number().int(),
16
+ scope: z.string().optional(),
17
+ refresh_token: z.string().optional(),
18
+ })
19
+ .loose();
20
+ export type OAuthTokenResponse = z.infer<typeof oauthTokenResponseSchema>;
21
+
22
+ /** PKCE 페어 생성 — verifier(43~128자) + S256 challenge */
23
+ export async function createPkcePair(): Promise<{ verifier: string; challenge: string }> {
24
+ const random = new Uint8Array(48);
25
+ crypto.getRandomValues(random);
26
+ const verifier = base64UrlFromBytes(random); // 64자
27
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
28
+ return { verifier, challenge: base64UrlFromBytes(new Uint8Array(digest)) };
29
+ }
30
+
31
+ export interface AuthorizeUrlOptions {
32
+ /** 예: https://api.sayren.app/v1 (베이스 URL — /oauth2/authorize가 덧붙는다) */
33
+ baseUrl: string;
34
+ clientId: string;
35
+ redirectUri: string;
36
+ state: string;
37
+ codeChallenge: string;
38
+ /** 생략 시 서버 기본(stores:read). refresh가 필요하면 offline_access 포함 */
39
+ scope?: string;
40
+ }
41
+
42
+ /** authorize 엔드포인트 URL 구성 — 브라우저는 이 URL로 이동(top-level navigation) */
43
+ export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
44
+ const url = new URL(`${options.baseUrl}/oauth2/authorize`);
45
+ url.searchParams.set("response_type", "code");
46
+ url.searchParams.set("client_id", options.clientId);
47
+ url.searchParams.set("redirect_uri", options.redirectUri);
48
+ url.searchParams.set("state", options.state);
49
+ url.searchParams.set("code_challenge", options.codeChallenge);
50
+ url.searchParams.set("code_challenge_method", "S256");
51
+ if (options.scope) url.searchParams.set("scope", options.scope);
52
+ return url.toString();
53
+ }
54
+
55
+ async function tokenRequest(
56
+ baseUrl: string,
57
+ form: Record<string, string>,
58
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
59
+ ): Promise<OAuthTokenResponse> {
60
+ const response = await fetchFn(`${baseUrl}/oauth2/token`, {
61
+ method: "POST",
62
+ headers: { "content-type": "application/x-www-form-urlencoded" },
63
+ body: new URLSearchParams(form).toString(),
64
+ });
65
+ const json: unknown = await response.json().catch(() => null);
66
+ if (!response.ok) {
67
+ const body = json as { error?: string; error_description?: string } | null;
68
+ throw new Error(body?.error_description ?? body?.error ?? `HTTP ${response.status}`);
69
+ }
70
+ return oauthTokenResponseSchema.parse(unwrapData(json));
71
+ }
72
+
73
+ /** 콜백에서 받은 code를 userToken으로 교환 (PKCE verifier 동반) */
74
+ export async function exchangeAuthorizationCode(options: {
75
+ baseUrl: string;
76
+ clientId: string;
77
+ redirectUri: string;
78
+ code: string;
79
+ codeVerifier: string;
80
+ fetch?: typeof globalThis.fetch;
81
+ }): Promise<OAuthTokenResponse> {
82
+ return tokenRequest(
83
+ options.baseUrl,
84
+ {
85
+ grant_type: "authorization_code",
86
+ code: options.code,
87
+ redirect_uri: options.redirectUri,
88
+ client_id: options.clientId,
89
+ code_verifier: options.codeVerifier,
90
+ },
91
+ options.fetch,
92
+ );
93
+ }
94
+
95
+ /** refresh grant (rotation — 응답의 새 refresh_token으로 반드시 교체 저장할 것) */
96
+ export async function refreshUserToken(options: {
97
+ baseUrl: string;
98
+ clientId: string;
99
+ refreshToken: string;
100
+ fetch?: typeof globalThis.fetch;
101
+ }): Promise<OAuthTokenResponse> {
102
+ return tokenRequest(
103
+ options.baseUrl,
104
+ {
105
+ grant_type: "refresh_token",
106
+ refresh_token: options.refreshToken,
107
+ client_id: options.clientId,
108
+ },
109
+ options.fetch,
110
+ );
111
+ }
112
+
113
+ /** userToken → storeToken 교환 (RFC 8693 token-exchange grant) */
114
+ export async function exchangeStoreTokenViaOAuth(options: {
115
+ baseUrl: string;
116
+ clientId: string;
117
+ userToken: string;
118
+ storeId: string;
119
+ fetch?: typeof globalThis.fetch;
120
+ }): Promise<OAuthTokenResponse & { storeId?: string; role?: string }> {
121
+ return tokenRequest(
122
+ options.baseUrl,
123
+ {
124
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
125
+ subject_token: options.userToken,
126
+ subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
127
+ audience: options.storeId,
128
+ client_id: options.clientId,
129
+ },
130
+ options.fetch,
131
+ );
132
+ }
@@ -0,0 +1,121 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 앱(OAuth 클라이언트) 관리 — `/v1/store/api-clients` (auth-spec 부록 B-13).
5
+ * 등록은 DCR(`POST /v1/oauth2/register`, RFC 7591)이 담당하고, 이 스키마는 등록 이후의
6
+ * 목록·상세·시크릿 재발급·비활성화 응답 계약이다. 시크릿 해시는 어떤 응답에도 포함되지 않는다.
7
+ */
8
+
9
+ export const apiClientTypeSchema = z
10
+ .enum(["public", "confidential"])
11
+ .describe(
12
+ "클라이언트 유형. `public`=시크릿 없음(PKCE 로그인용), `confidential`=시크릿 보유(서버 간 연동용)",
13
+ );
14
+ export type ApiClientType = z.infer<typeof apiClientTypeSchema>;
15
+
16
+ export const apiClientStatusSchema = z
17
+ .enum(["ACTIVE", "REVOKED"])
18
+ .describe("상태. `REVOKED`는 비활성화된 클라이언트이며 다시 활성화할 수 없다");
19
+ export type ApiClientStatus = z.infer<typeof apiClientStatusSchema>;
20
+
21
+ export const apiClientSchema = z.object({
22
+ clientId: z.string().describe("클라이언트 ID (`client_id`)"),
23
+ name: z.string().describe("클라이언트 이름 (`client_name`)"),
24
+ type: apiClientTypeSchema,
25
+ status: apiClientStatusSchema,
26
+ redirectUris: z
27
+ .array(z.string())
28
+ .describe("authorization_code 리다이렉트 URI 허용 목록 (정확 일치)"),
29
+ scopes: z.array(z.string()).describe("클라이언트가 요청할 수 있는 최대 스코프"),
30
+ grantTypes: z.array(z.string()).describe("허용된 grant_type 목록"),
31
+ secretPrefix: z
32
+ .string()
33
+ .nullable()
34
+ .describe(
35
+ "시크릿 앞부분(식별용, 인증에 사용할 수 없음). public 클라이언트와 시크릿 앞부분이 기록되기 전에 등록된 클라이언트는 null",
36
+ ),
37
+ createdBy: z
38
+ .string()
39
+ .nullable()
40
+ .describe("등록자 userId. 등록자 기록이 도입되기 전에 등록된 클라이언트는 null"),
41
+ createdByName: z.string().nullable().describe("등록자 이름 (표시용). 계정이 없으면 null"),
42
+ createdByEmail: z.string().nullable().describe("등록자 이메일 (표시용). 계정이 없으면 null"),
43
+ createdAt: z.string().describe("등록 일시 (ISO 8601)"),
44
+ revokedAt: z.string().nullable().describe("비활성화 일시 (ISO 8601). 활성 상태면 null"),
45
+ });
46
+ export type ApiClient = z.infer<typeof apiClientSchema>;
47
+
48
+ /** 시크릿 재발급 응답 — 평문 시크릿은 이 응답에서 단 한 번만 노출된다 */
49
+ export const apiClientSecretRotatedSchema = apiClientSchema.extend({
50
+ clientSecret: z
51
+ .string()
52
+ .describe("새 클라이언트 시크릿 평문. 이 응답에서만 노출되며 다시 조회할 수 없다"),
53
+ });
54
+ export type ApiClientSecretRotated = z.infer<typeof apiClientSecretRotatedSchema>;
55
+
56
+ export const apiClientListQuerySchema = z.object({
57
+ status: apiClientStatusSchema.optional().describe("상태 필터. 생략하면 전체"),
58
+ });
59
+ export type ApiClientListParams = z.infer<typeof apiClientListQuerySchema>;
60
+
61
+ /**
62
+ * Dynamic Client Registration 요청 (RFC 7591 §2, auth-spec §12) — `POST /v1/oauth2/register`.
63
+ * OAuth 프로토콜 표면이라 필드명이 snake_case이고 응답·등록 오류는 평면 JSON이다.
64
+ * 생략한 필드는 서버 기본값을 따른다(SDK가 기본값을 채워 보내지 않는다).
65
+ */
66
+ export const oauthClientRegistrationRequestSchema = z.object({
67
+ client_name: z.string().min(1).max(80).describe("클라이언트 이름 (1~80자)"),
68
+ token_endpoint_auth_method: z
69
+ .enum(["none", "client_secret_basic"])
70
+ .optional()
71
+ .describe(
72
+ "토큰 엔드포인트 인증 방식. `none`=public(시크릿 없음), `client_secret_basic`=confidential. 생략하면 `client_secret_basic`",
73
+ ),
74
+ redirect_uris: z
75
+ .array(z.string())
76
+ .max(10)
77
+ .optional()
78
+ .describe(
79
+ "리다이렉트 URI 목록(최대 10개). authorization_code에는 필수이며 https(개발용 루프백 http 제외)만 허용",
80
+ ),
81
+ grant_types: z
82
+ .array(
83
+ z.enum([
84
+ "authorization_code",
85
+ "refresh_token",
86
+ "client_credentials",
87
+ "urn:ietf:params:oauth:grant-type:token-exchange",
88
+ ]),
89
+ )
90
+ .min(1)
91
+ .optional()
92
+ .describe("허용할 grant_type 목록. 생략하면 `client_credentials`"),
93
+ scope: z
94
+ .string()
95
+ .optional()
96
+ .describe(
97
+ "공백 구분 스코프. 등록자 보유 스코프 이내여야 한다. 생략하면 confidential은 등록자 스코프, public은 `stores:read`",
98
+ ),
99
+ });
100
+ export type OAuthClientRegistrationRequest = z.infer<typeof oauthClientRegistrationRequestSchema>;
101
+
102
+ /** Dynamic Client Registration 응답 (RFC 7591 §3.2.1) — client_secret은 이 응답에서만 노출된다 */
103
+ export const oauthClientRegistrationResponseSchema = z.object({
104
+ client_id: z.string().describe("발급된 클라이언트 ID"),
105
+ client_secret: z
106
+ .string()
107
+ .optional()
108
+ .describe("클라이언트 시크릿 평문. confidential만 있으며 이 응답에서만 노출된다"),
109
+ client_secret_expires_at: z
110
+ .number()
111
+ .int()
112
+ .optional()
113
+ .describe("시크릿 만료 시각(epoch 초). 0은 만료 없음. confidential만 있다"),
114
+ client_id_issued_at: z.number().int().describe("발급 시각 (epoch 초)"),
115
+ client_name: z.string(),
116
+ token_endpoint_auth_method: z.enum(["none", "client_secret_basic"]),
117
+ redirect_uris: z.array(z.string()),
118
+ grant_types: z.array(z.string()),
119
+ scope: z.string().describe("부여된 스코프 (공백 구분)"),
120
+ });
121
+ export type OAuthClientRegistrationResponse = z.infer<typeof oauthClientRegistrationResponseSchema>;
@@ -0,0 +1,104 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * API 로그 — `/v1/api-logs`. 스토어가 해석된 관리 API(`/v1/**`) 요청을 7일간 보관한다.
5
+ * 인증에 실패해 스토어를 알 수 없는 요청(401 등)은 기록되지 않는다.
6
+ * Authorization·쿠키 헤더, 요청 본문, 개인정보는 저장하지 않는다.
7
+ */
8
+
9
+ export const apiLogPrincipalTypeSchema = z
10
+ .enum(["USER", "PAT", "CLIENT"])
11
+ .describe("호출 주체 유형. `USER`=콘솔 사용자, `PAT`=API 토큰, `CLIENT`=앱(OAuth 클라이언트)");
12
+ export type ApiLogPrincipalType = z.infer<typeof apiLogPrincipalTypeSchema>;
13
+
14
+ export const apiLogPrincipalSchema = z.object({
15
+ type: apiLogPrincipalTypeSchema,
16
+ id: z.string().describe("호출 주체 ID. `USER`=userId, `PAT`=API 토큰 ID, `CLIENT`=clientId"),
17
+ name: z
18
+ .string()
19
+ .nullable()
20
+ .describe("기록 시점의 표시 이름 (사용자 이름·API 토큰 이름·앱 이름). 알 수 없으면 null"),
21
+ });
22
+ export type ApiLogPrincipal = z.infer<typeof apiLogPrincipalSchema>;
23
+
24
+ export const apiLogErrorSchema = z.object({
25
+ code: z.string().describe("에러 코드. 에러 응답 `error.code`와 같다"),
26
+ message: z.string().describe("에러 메시지. 에러 응답 `error.message`와 같다"),
27
+ });
28
+ export type ApiLogError = z.infer<typeof apiLogErrorSchema>;
29
+
30
+ export const apiLogSchema = z.object({
31
+ logId: z.string().describe("로그 ID"),
32
+ occurredAt: z.string().describe("요청 일시 (ISO 8601)"),
33
+ method: z.string().describe("HTTP 메서드"),
34
+ path: z
35
+ .string()
36
+ .describe(
37
+ "요청 경로와 쿼리. token·secret·code·password·email·phone 등 민감한 쿼리 값은 `***`로 가린다",
38
+ ),
39
+ statusCode: z.number().int().describe("응답 HTTP 상태 코드"),
40
+ latencyMs: z.number().int().min(0).describe("처리 시간(ms)"),
41
+ principal: apiLogPrincipalSchema,
42
+ traceId: z.string().nullable().describe("추적 ID. 에러 응답의 `error.traceId`와 같다"),
43
+ error: apiLogErrorSchema.nullable().describe("에러 응답 요약. 4xx·5xx일 때만 값이 있다"),
44
+ });
45
+ export type ApiLog = z.infer<typeof apiLogSchema>;
46
+
47
+ export const apiLogStatusFilterSchema = z
48
+ .enum(["2xx", "3xx", "4xx", "5xx", "error"])
49
+ .describe("응답 상태 필터. `error`는 4xx·5xx 전체");
50
+ export type ApiLogStatusFilter = z.infer<typeof apiLogStatusFilterSchema>;
51
+
52
+ /** API 로그 조회 쿼리 — `GET /api-logs` */
53
+ export const apiLogListQuerySchema = z.object({
54
+ from: z.string().optional().describe("조회 시작 일시 (ISO 8601, 포함). 보관 기간은 7일이다"),
55
+ to: z.string().optional().describe("조회 종료 일시 (ISO 8601, 미포함)"),
56
+ principalType: z
57
+ .union([z.string(), z.array(z.string())])
58
+ .transform((value) =>
59
+ (Array.isArray(value) ? value : [value])
60
+ .flatMap((part) => part.split(","))
61
+ .map((part) => part.trim())
62
+ .filter((part) => part.length > 0),
63
+ )
64
+ .pipe(z.array(apiLogPrincipalTypeSchema).min(1))
65
+ .optional()
66
+ .describe(
67
+ "호출 주체 유형 필터. 여러 개는 쉼표로 구분한다 (예: `PAT,CLIENT`). 파싱 결과는 배열로 정규화된다",
68
+ ),
69
+ principalId: z.string().optional().describe("호출 주체 ID 필터"),
70
+ status: apiLogStatusFilterSchema.optional(),
71
+ q: z.string().optional().describe("경로 부분 일치 검색어"),
72
+ cursor: z.string().optional().describe("이전 응답의 `nextCursor`. 생략하면 가장 최신부터"),
73
+ limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50)"),
74
+ });
75
+ /** 파싱된 조회 쿼리 (서버 측). `principalType`은 항상 배열이다 */
76
+ export type ApiLogListParams = z.infer<typeof apiLogListQuerySchema>;
77
+
78
+ /** 클라이언트 `apiLogs.list` 인자. `principalType`은 단일 값·배열 모두 받고 쉼표로 직렬화한다 */
79
+ export type ApiLogListOptions = Omit<ApiLogListParams, "principalType"> & {
80
+ principalType?: ApiLogPrincipalType | ApiLogPrincipalType[];
81
+ };
82
+
83
+ /** API 로그 목록 응답 (최신순, 커서 페이지네이션) */
84
+ export const apiLogPageSchema = z.object({
85
+ items: z.array(apiLogSchema),
86
+ nextCursor: z.string().nullable().describe("다음 페이지 커서. 마지막 페이지면 null"),
87
+ });
88
+ export type ApiLogPage = z.infer<typeof apiLogPageSchema>;
89
+
90
+ /**
91
+ * API 로그 요약 — `GET /api-logs/summary`. 연동 트래픽(호출 주체 `PAT`·`CLIENT`)만 집계하며
92
+ * 콘솔 사용자(`USER`) 요청은 제외한다
93
+ */
94
+ export const apiLogSummarySchema = z.object({
95
+ windowHours: z.number().int().describe("집계 구간(시간). 최근 24시간"),
96
+ total: z
97
+ .number()
98
+ .int()
99
+ .min(0)
100
+ .describe("구간 내 연동 요청 수 (호출 주체 `PAT`·`CLIENT`, 콘솔 사용자 제외)"),
101
+ errors: z.number().int().min(0).describe("구간 내 연동 요청 중 4xx·5xx 응답 수"),
102
+ serverErrors: z.number().int().min(0).describe("구간 내 연동 요청 중 5xx 응답 수"),
103
+ });
104
+ export type ApiLogSummary = z.infer<typeof apiLogSummarySchema>;
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+
3
+ export const storeRoleSchema = z.enum(["OWNER", "ADMIN", "STAFF"]);
4
+
5
+ export type StoreRole = z.infer<typeof storeRoleSchema>;
6
+
7
+ export const userTokenResponseSchema = z.object({
8
+ access_token: z.string(),
9
+ token_type: z.literal("Bearer"),
10
+ expires_in: z.number().int(),
11
+ refresh_token: z.string().optional(),
12
+ });
13
+
14
+ export type UserTokenResponse = z.infer<typeof userTokenResponseSchema>;
15
+
16
+ /** client_credentials 응답 (RFC 6749 §4.4.3) — storeToken 직발급, refresh 없음 */
17
+ export const clientCredentialsTokenResponseSchema = z.object({
18
+ access_token: z.string(),
19
+ token_type: z.literal("Bearer"),
20
+ expires_in: z.number().int(),
21
+ scope: z.string(),
22
+ });
23
+ export type ClientCredentialsTokenResponse = z.infer<typeof clientCredentialsTokenResponseSchema>;
24
+
25
+ export const storeTokenResponseSchema = z.object({
26
+ access_token: z.string(),
27
+ token_type: z.literal("Bearer"),
28
+ expires_in: z.number().int(),
29
+ storeId: z.string(),
30
+ role: storeRoleSchema,
31
+ scopes: z.array(z.string()),
32
+ });
33
+
34
+ export type StoreTokenResponse = z.infer<typeof storeTokenResponseSchema>;
35
+
36
+ export const myStoreSchema = z.object({
37
+ storeId: z.string(),
38
+ storeName: z.string(),
39
+ storeCode: z.string(),
40
+ role: storeRoleSchema,
41
+ status: z.enum(["ACTIVE", "SUSPENDED", "CLOSED"]),
42
+ });
43
+
44
+ export type MyStore = z.infer<typeof myStoreSchema>;
45
+
46
+ export const myAccountSchema = z.object({
47
+ userId: z.string(),
48
+ email: z.email(),
49
+ name: z.string(),
50
+ storeCount: z.number().int(),
51
+ createdAt: z.string(),
52
+ });
53
+
54
+ export type MyAccount = z.infer<typeof myAccountSchema>;
55
+
56
+ /** API 토큰(PAT) — 목록/조회 뷰 (평문·해시 미포함) */
57
+ export const apiTokenSchema = z.object({
58
+ tokenId: z.string(),
59
+ name: z.string(),
60
+ prefix: z.string(),
61
+ scopes: z.array(z.string()),
62
+ createdBy: z.string().describe("발급자 userId"),
63
+ createdByName: z.string().nullable().describe("발급자 이름 (표시용). 계정이 없으면 null"),
64
+ createdByEmail: z.string().nullable().describe("발급자 이메일 (표시용). 계정이 없으면 null"),
65
+ createdAt: z.string(),
66
+ expiresAt: z.string().nullable(),
67
+ lastUsedAt: z.string().nullable(),
68
+ });
69
+ export type ApiToken = z.infer<typeof apiTokenSchema>;
70
+
71
+ /** 생성 응답 — 평문 토큰은 이 응답에서 단 한 번만 노출된다 */
72
+ export const apiTokenCreatedSchema = apiTokenSchema.extend({ token: z.string() });
73
+ export type ApiTokenCreated = z.infer<typeof apiTokenCreatedSchema>;
74
+
75
+ export const apiTokenCreateRequestSchema = z.object({
76
+ name: z.string().min(1).max(50),
77
+ scopes: z.array(z.string()).min(1),
78
+ /** 일 단위 만료. null = 무기한, 생략 = 90일 */
79
+ expiresInDays: z.number().int().nullable().optional(),
80
+ });
81
+ export type ApiTokenCreateRequest = z.infer<typeof apiTokenCreateRequestSchema>;