@sayren/store-sdk 0.30.7 → 0.30.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ import { z } from "zod";
2
+ import {
3
+ type AppCapabilities,
4
+ appEventTypeSchema,
5
+ appHandleSchema,
6
+ appSettingFieldSchema,
7
+ } from "./app-manifest";
8
+
9
+ /**
10
+ * 설치 앱 관리 API 계약 (이슈 #22) — `/v1/apps/installations/**`(상점이 설치한 앱), `/v1/app/installation`(설치 토큰 자기 조회).
11
+ * 개발자 API(`/platform/v1/developer/**`)는 플랫폼 플레인이라 여기 두지 않는다.
12
+ */
13
+
14
+ /** 배포 범위 — `PRIVATE_OWN` 개발자 조직 구성원이 OWNER·ADMIN인 상점과 개발 상점(A1) · `PRIVATE_LINK` 설치 링크(A2) · `LISTED` 공개 목록(A3) */
15
+ export const appDistributionSchema = z.enum(["PRIVATE_OWN", "PRIVATE_LINK", "LISTED"]);
16
+ export type AppDistribution = z.infer<typeof appDistributionSchema>;
17
+
18
+ export const appRuntimeSchema = z.enum(["CODE", "EXTERNAL"]);
19
+ export type AppRuntime = z.infer<typeof appRuntimeSchema>;
20
+
21
+ /** 앱 상태 — `SUSPENDED`는 운영자 정지(모든 설치 토큰·실행 거부, 설치 행은 그대로) */
22
+ export const appStatusSchema = z.enum(["ACTIVE", "SUSPENDED", "DELETED"]);
23
+ export type AppStatus = z.infer<typeof appStatusSchema>;
24
+
25
+ export const appInstallationStatusSchema = z.enum(["ACTIVE", "UNINSTALLED", "SUSPENDED"]);
26
+ export type AppInstallationStatus = z.infer<typeof appInstallationStatusSchema>;
27
+
28
+ /** 설치 앱의 앱 정보 — 상점이 보는 범위만(개발자 비밀 값·소스·빌드는 없다) */
29
+ export const appInstallationAppSchema = z.object({
30
+ appId: z.string(),
31
+ handle: z.string().describe("앱 handle. 앱 화면 주소의 라벨"),
32
+ name: z.string(),
33
+ iconUrl: z.string().nullable(),
34
+ description: z.string().nullable(),
35
+ developerName: z.string().describe("개발자 조직 이름"),
36
+ supportEmail: z.string().nullable(),
37
+ distribution: appDistributionSchema,
38
+ runtime: appRuntimeSchema,
39
+ status: appStatusSchema,
40
+ });
41
+ export type AppInstallationApp = z.infer<typeof appInstallationAppSchema>;
42
+
43
+ const settingsValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
44
+
45
+ /** 설치 설정 — 비밀 필드 값은 가려서 준다(앞 4자 + `…`). 비어 있는 필드는 null */
46
+ export const appInstallationSettingsSchema = z.object({
47
+ fields: z.record(z.string(), appSettingFieldSchema).describe("실행 버전 매니페스트의 설정 필드"),
48
+ values: z.record(z.string(), settingsValueSchema),
49
+ /** 필수 설정이 모두 채워졌는가 — 아니면 이벤트 함수를 부르지 않는다 */
50
+ configured: z.boolean(),
51
+ updatedAt: z.string().nullable(),
52
+ });
53
+ export type AppInstallationSettings = z.infer<typeof appInstallationSettingsSchema>;
54
+
55
+ /** 설정 저장 — 비밀 필드를 생략하면 현재 값을 유지한다. null은 비운다 */
56
+ export const appInstallationSettingsUpdateSchema = z.object({
57
+ values: z.record(z.string(), settingsValueSchema),
58
+ });
59
+ export type AppInstallationSettingsUpdate = z.infer<typeof appInstallationSettingsUpdateSchema>;
60
+
61
+ export const appInstallationSchema = z.object({
62
+ installationId: z.string(),
63
+ storeId: z.string(),
64
+ app: appInstallationAppSchema,
65
+ status: appInstallationStatusSchema,
66
+ versionNumber: z.number().int().describe("실행 중인 앱 버전"),
67
+ pendingVersionNumber: z
68
+ .number()
69
+ .int()
70
+ .nullable()
71
+ .describe("재동의가 필요한 새 버전. 없으면 null"),
72
+ grantedScopes: z.array(z.string()).describe("동의한 스코프"),
73
+ grantedNetworkHosts: z.array(z.string()).describe("동의한 외부 호출 호스트"),
74
+ events: z.array(appEventTypeSchema).describe("이 설치가 받는 이벤트"),
75
+ adminPage: z.boolean().describe("상점 플랫폼 앱 화면이 있는가"),
76
+ /** 설치 결과의 캐시가 아니라 실행 버전 매니페스트의 요약 */
77
+ settings: appInstallationSettingsSchema,
78
+ installedBy: z.object({ userId: z.string(), name: z.string().nullable() }).nullable(),
79
+ installedAt: z.string(),
80
+ uninstalledAt: z.string().nullable(),
81
+ updatedAt: z.string(),
82
+ });
83
+ export type AppInstallation = z.infer<typeof appInstallationSchema>;
84
+
85
+ /** `POST /v1/apps/installations` — accounts 설치 동의 결과(1회용 JWT)를 낸다(상점 플랫폼 전용) */
86
+ export const appInstallRequestSchema = z.object({
87
+ result: z.string().min(1).describe("accounts가 발급한 설치 결과 JWT(`typ: sayren-install+jwt`)"),
88
+ });
89
+ export type AppInstallRequest = z.infer<typeof appInstallRequestSchema>;
90
+
91
+ /** `POST /v1/apps/installations/{id}/session` — 앱 화면 iframe이 쓸 세션 토큰(5분) */
92
+ export const appSessionSchema = z.object({
93
+ token: z.string(),
94
+ expiresAt: z.string(),
95
+ appOrigin: z.url().describe("iframe 주소의 origin(`https://{handle}.{앱 도메인}`)"),
96
+ });
97
+ export type AppSession = z.infer<typeof appSessionSchema>;
98
+
99
+ /** `GET /v1/app/installation` — 설치 토큰으로 보는 자기 설치 */
100
+ export const appInstallationSelfSchema = z.object({
101
+ installationId: z.string(),
102
+ appId: z.string(),
103
+ storeId: z.string(),
104
+ storeName: z.string(),
105
+ versionNumber: z.number().int(),
106
+ grantedScopes: z.array(z.string()),
107
+ installedAt: z.string(),
108
+ });
109
+ export type AppInstallationSelf = z.infer<typeof appInstallationSelfSchema>;
110
+
111
+ export type { AppCapabilities };
112
+
113
+ // ─── 개발자 API (플랫폼 플레인 `/platform/v1/developer/**`, 공개 문서 제외 — CLI·상점 플랫폼 개발자 화면이 쓴다) ───
114
+
115
+ export const developerOrgSchema = z.object({
116
+ orgId: z.string(),
117
+ name: z.string(),
118
+ role: z.enum(["OWNER", "MEMBER"]),
119
+ });
120
+ export type DeveloperOrg = z.infer<typeof developerOrgSchema>;
121
+
122
+ export const appVersionStatusSchema = z.enum(["BUILDING", "READY", "FAILED", "REVOKED"]);
123
+ export type AppVersionStatus = z.infer<typeof appVersionStatusSchema>;
124
+
125
+ /** 빌드 단계(매뉴얼 「앱 버전과 배포 › 빌드」). 로컬 번들(A1)은 `DEPENDENCIES`·`VERIFY`·`BUNDLE`·`UPLOAD`를 서버가 돈다 */
126
+ export const appBuildStageSchema = z.enum([
127
+ "DEPENDENCIES",
128
+ "VERIFY",
129
+ "INSTALL",
130
+ "TYPECHECK",
131
+ "BUILD",
132
+ "BUNDLE",
133
+ "UPLOAD",
134
+ ]);
135
+ export type AppBuildStage = z.infer<typeof appBuildStageSchema>;
136
+
137
+ export const appBuildSchema = z.object({
138
+ buildId: z.string(),
139
+ status: z.enum(["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "TIMED_OUT"]),
140
+ stages: z.array(z.object({ stage: z.string(), ok: z.boolean(), durationMs: z.number() })),
141
+ failureStage: z.string().nullable(),
142
+ errorCode: z.string().nullable(),
143
+ logTail: z.string().nullable(),
144
+ /** 의존성 검사 결과(거절·경고). 검사 전이면 null */
145
+ dependencies: z
146
+ .object({
147
+ rejected: z.array(z.record(z.string(), z.unknown())),
148
+ warnings: z.array(z.record(z.string(), z.unknown())),
149
+ })
150
+ .nullable(),
151
+ createdAt: z.string(),
152
+ finishedAt: z.string().nullable(),
153
+ });
154
+ export type AppBuild = z.infer<typeof appBuildSchema>;
155
+
156
+ export const appVersionSchema = z.object({
157
+ versionId: z.string(),
158
+ number: z.number().int(),
159
+ status: appVersionStatusSchema,
160
+ current: z.boolean().describe("앱의 현재 버전(release한 버전)인가"),
161
+ message: z.string().nullable(),
162
+ capabilities: z.object({
163
+ scopes: z.array(z.string()),
164
+ events: z.array(z.string()),
165
+ networkHosts: z.array(z.string()),
166
+ adminPage: z.boolean(),
167
+ requiredSettings: z.array(z.string()),
168
+ secretSettings: z.array(z.string()),
169
+ }),
170
+ build: appBuildSchema.nullable(),
171
+ releasedAt: z.string().nullable(),
172
+ revokedAt: z.string().nullable(),
173
+ createdAt: z.string(),
174
+ });
175
+ export type AppVersion = z.infer<typeof appVersionSchema>;
176
+
177
+ export const developerAppSchema = z.object({
178
+ appId: z.string(),
179
+ orgId: z.string(),
180
+ handle: z.string(),
181
+ name: z.string(),
182
+ description: z.string().nullable(),
183
+ iconUrl: z.string().nullable(),
184
+ supportEmail: z.string().nullable(),
185
+ distribution: appDistributionSchema,
186
+ runtime: appRuntimeSchema,
187
+ status: appStatusSchema,
188
+ suspendedReason: z.string().nullable(),
189
+ currentVersionNumber: z.number().int().nullable(),
190
+ installations: z.object({
191
+ active: z.number().int(),
192
+ pendingReconsent: z.number().int(),
193
+ }),
194
+ createdAt: z.string(),
195
+ updatedAt: z.string(),
196
+ });
197
+ export type DeveloperApp = z.infer<typeof developerAppSchema>;
198
+
199
+ export const developerAppCreateSchema = z.object({
200
+ handle: appHandleSchema,
201
+ name: z.string().trim().min(1).max(60),
202
+ description: z.string().trim().max(500).optional(),
203
+ });
204
+ export type DeveloperAppCreate = z.infer<typeof developerAppCreateSchema>;
205
+
206
+ /** `PUT /platform/v1/developer/apps/{appId}/versions` — CLI `app deploy`가 올리는 번들 */
207
+ export const appArtifactUploadSchema = z.object({
208
+ manifest: z.unknown().describe("sayren.app.json 원문(JSON)"),
209
+ packageJson: z.string().max(256 * 1024),
210
+ lockfile: z
211
+ .string()
212
+ .max(4 * 1024 * 1024)
213
+ .nullable(),
214
+ /** 서버 번들(ESM 한 파일) base64. gzip 3MB까지 */
215
+ server: z.string().min(1),
216
+ ui: z
217
+ .array(z.object({ path: z.string().min(1).max(512), content: z.string() }))
218
+ .max(2_000)
219
+ .default([]),
220
+ message: z.string().trim().max(200).optional(),
221
+ });
222
+ export type AppArtifactUpload = z.infer<typeof appArtifactUploadSchema>;
223
+
224
+ export const appReleaseResultSchema = z.object({
225
+ version: appVersionSchema,
226
+ /** release가 곧바로 적용된 설치 수 */
227
+ applied: z.number().int(),
228
+ /** 넓어진 범위라 재동의를 기다리는 설치 수 */
229
+ pendingReconsent: z.number().int(),
230
+ });
231
+ export type AppReleaseResult = z.infer<typeof appReleaseResultSchema>;
232
+
233
+ export const appSecretSummarySchema = z.object({
234
+ name: z.string(),
235
+ updatedAt: z.string(),
236
+ });
237
+ export type AppSecretSummary = z.infer<typeof appSecretSummarySchema>;
238
+
239
+ export const devStoreSchema = z.object({
240
+ storeId: z.string(),
241
+ name: z.string(),
242
+ storeCode: z.string(),
243
+ status: z.enum(["ACTIVE", "SUSPENDED", "CLOSED"]),
244
+ createdAt: z.string(),
245
+ });
246
+ export type DevStore = z.infer<typeof devStoreSchema>;
247
+
248
+ /** 개발자가 보는 설치 한 줄 */
249
+ export const developerInstallationSchema = z.object({
250
+ installationId: z.string(),
251
+ storeId: z.string(),
252
+ storeName: z.string(),
253
+ status: appInstallationStatusSchema,
254
+ versionNumber: z.number().int().nullable(),
255
+ pendingVersionNumber: z.number().int().nullable(),
256
+ installedAt: z.string(),
257
+ });
258
+ export type DeveloperInstallation = z.infer<typeof developerInstallationSchema>;
259
+
260
+ /** `POST /platform/v1/developer/apps/{appId}/dev-token` — `sayren app dev`가 쓰는 설치 토큰(1시간) */
261
+ export const appDevTokenSchema = z.object({
262
+ token: z.string(),
263
+ expiresAt: z.string(),
264
+ installationId: z.string(),
265
+ storeId: z.string(),
266
+ });
267
+ export type AppDevToken = z.infer<typeof appDevTokenSchema>;
268
+
269
+ /** 호출 기록 한 건(개발자 로그) */
270
+ export const appInvocationSchema = z.object({
271
+ invocationId: z.string(),
272
+ storeId: z.string(),
273
+ storeName: z.string().nullable(),
274
+ installationId: z.string(),
275
+ versionNumber: z.number().int().nullable(),
276
+ kind: z.enum(["event", "http", "schedule"]),
277
+ eventType: z.string().nullable(),
278
+ outcome: z.enum(["OK", "ERROR", "LIMIT_EXCEEDED", "REJECTED"]),
279
+ errorMessage: z.string().nullable(),
280
+ cpuMs: z.number().nullable(),
281
+ durationMs: z.number(),
282
+ /** `console.*`·`ctx.log.*` 줄(최대 100줄, 줄마다 500자) */
283
+ logs: z.array(z.object({ level: z.string(), message: z.string() })),
284
+ createdAt: z.string(),
285
+ });
286
+ export type AppInvocation = z.infer<typeof appInvocationSchema>;
287
+
288
+ /** 앱 배포 키(developer.sayren.app 「키 발행」) — 값은 만들 때 한 번만 보인다 */
289
+ export const appDeployKeySchema = z.object({
290
+ keyId: z.string(),
291
+ name: z.string(),
292
+ prefix: z.string().describe("값 앞 12자"),
293
+ createdAt: z.string(),
294
+ lastUsedAt: z.string().nullable(),
295
+ });
296
+ export type AppDeployKey = z.infer<typeof appDeployKeySchema>;
297
+
298
+ export const appDeployKeyCreatedSchema = appDeployKeySchema.extend({
299
+ /** 전체 값 — 이때만 보인다 */
300
+ value: z.string(),
301
+ });
302
+ export type AppDeployKeyCreated = z.infer<typeof appDeployKeyCreatedSchema>;
303
+
304
+ export const appDeployKeyCreateSchema = z.object({
305
+ name: z.string().trim().min(1).max(60),
306
+ });
307
+
308
+ /** 배포 키 값 형식 — `sak_{appId 뒤}_{32 hex}` */
309
+ export const APP_DEPLOY_KEY_PATTERN = /^sak_[a-z0-9]{8,40}_[0-9a-f]{32}$/;
310
+ export const APP_DEPLOY_KEY_PREFIX = "sak_";
@@ -215,9 +215,22 @@ export const webhookDeliveryStatusSchema = z
215
215
  export type WebhookDeliveryStatus = z.infer<typeof webhookDeliveryStatusSchema>;
216
216
 
217
217
  export const webhookDeliveryErrorCodeSchema = z
218
- .enum(["TIMEOUT", "CONNECTION_FAILED", "BLOCKED_ADDRESS", "NON_2XX", "WEBHOOK_SUSPENDED"])
218
+ .enum([
219
+ "TIMEOUT",
220
+ "CONNECTION_FAILED",
221
+ "BLOCKED_ADDRESS",
222
+ "NON_2XX",
223
+ "WEBHOOK_SUSPENDED",
224
+ "APP_ERROR",
225
+ "APP_LIMIT_EXCEEDED",
226
+ "APP_REJECTED",
227
+ "APP_UNCONFIGURED",
228
+ "APP_INACTIVE",
229
+ "APP_RUNNER_UNAVAILABLE",
230
+ ])
219
231
  .describe(
220
- "전송 실패 사유. `TIMEOUT`=10초 안에 응답 없음, `CONNECTION_FAILED`=연결 실패(DNS·TLS 포함), `BLOCKED_ADDRESS`=사설·루프백 등 전송이 허용되지 않는 주소, `NON_2XX`=2xx가 아닌 응답(리다이렉트 포함), `WEBHOOK_SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨",
232
+ "전송 실패 사유. `TIMEOUT`=10초 안에 응답 없음, `CONNECTION_FAILED`=연결 실패(DNS·TLS 포함), `BLOCKED_ADDRESS`=사설·루프백 등 전송이 허용되지 않는 주소, `NON_2XX`=2xx가 아닌 응답(리다이렉트 포함), `WEBHOOK_SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨. " +
233
+ "설치 앱(#22): `APP_ERROR`=앱 코드 예외, `APP_LIMIT_EXCEEDED`=CPU·요청·시간 한도 초과, `APP_REJECTED`=앱·버전 정지 등으로 거부, `APP_UNCONFIGURED`=필수 설정이 비어 보내지 않음, `APP_INACTIVE`=설치가 제거됨, `APP_RUNNER_UNAVAILABLE`=실행 환경 호출 실패",
221
234
  );
222
235
  export type WebhookDeliveryErrorCode = z.infer<typeof webhookDeliveryErrorCodeSchema>;
223
236
 
@@ -270,11 +270,14 @@ export const liveGateConditionCodeSchema = z
270
270
  "SANDBOX_REFUND",
271
271
  "LIVE_CONNECTION",
272
272
  "PLATFORM_LIVE_ENABLED",
273
+ "NOT_TEMPLATE_STORE",
274
+ "NOT_DEVELOPMENT_STORE",
273
275
  ])
274
276
  .describe(
275
277
  "라이브 개방 조건. `TEST_CONNECTION`=TEST 세트 연결 테스트 성공, `SANDBOX_PAYMENT`=현재 TEST 세트로 샌드박스 결제 1건 승인, " +
276
278
  "`SANDBOX_REFUND`=그 샌드박스 결제의 환불 1건 성공, `LIVE_CONNECTION`=LIVE 세트 연결 테스트 성공, " +
277
- "`PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠",
279
+ "`PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠, `NOT_TEMPLATE_STORE`=템플릿 상점이 아님(템플릿 상점에만 싣고 늘 미충족), " +
280
+ "`NOT_DEVELOPMENT_STORE`=앱 개발 상점이 아님(앱 개발 상점에만 싣고 늘 미충족)",
278
281
  );
279
282
  export type LiveGateConditionCode = z.infer<typeof liveGateConditionCodeSchema>;
280
283
 
@@ -0,0 +1,34 @@
1
+ import { STORE_SCOPE_RESOURCES, type StoreScopeResource } from "./scope";
2
+
3
+ /**
4
+ * 스코프를 사람이 읽는 문장 (이슈 #22) — 앱 설치 동의 화면(accounts), 상점 플랫폼 설치 앱 권한 표시, 유저 문서 스코프 레퍼런스가
5
+ * 같은 문장을 쓴다. 읽기(`r`)와 쓰기(`rw`)를 따로 둔다 — 동의 화면은 쓰기를 강조한다.
6
+ */
7
+ export const SCOPE_DESCRIPTIONS: Record<StoreScopeResource, { r: string; rw: string }> = {
8
+ product: { r: "상품 정보를 읽습니다", rw: "상품을 만들고 수정합니다" },
9
+ category: { r: "카테고리를 읽습니다", rw: "카테고리를 만들고 수정합니다" },
10
+ order: { r: "주문을 읽습니다", rw: "주문 상태를 바꿉니다(발송·배송완료·취소 등)" },
11
+ claim: { r: "취소·반품·교환 요청을 읽습니다", rw: "취소·반품·교환을 처리합니다" },
12
+ inquiry: { r: "상품 문의를 읽습니다", rw: "상품 문의에 답합니다" },
13
+ review: { r: "리뷰를 읽습니다", rw: "리뷰를 관리합니다" },
14
+ settlement: { r: "매출 리포트를 읽습니다", rw: "매출 리포트를 읽습니다" },
15
+ analytics: { r: "방문·구매 분석 데이터를 읽습니다", rw: "방문·구매 분석 데이터를 읽습니다" },
16
+ webhook: { r: "웹훅 설정을 읽습니다", rw: "웹훅을 만들고 수정합니다" },
17
+ store: { r: "상점 정보를 읽습니다", rw: "상점 설정을 수정합니다" },
18
+ member: { r: "구성원 목록을 읽습니다", rw: "구성원을 초대하고 역할을 바꿉니다" },
19
+ customer: { r: "고객 정보(이름·연락처·주소)를 읽습니다", rw: "고객 정보를 수정합니다" },
20
+ audit: { r: "감사 로그를 읽습니다", rw: "감사 로그를 읽습니다" },
21
+ promotion: { r: "쿠폰·적립금 설정을 읽습니다", rw: "쿠폰을 발급하고 적립금을 지급합니다" },
22
+ storefront: { r: "스토어프론트 설정을 읽습니다", rw: "스토어프론트를 편집하고 발행합니다" },
23
+ flow: { r: "주문 흐름 설정을 읽습니다", rw: "주문 흐름을 만들고 수정합니다" },
24
+ };
25
+
26
+ /** `{resource}:{r|rw}` → 문장. 카탈로그에 없는 스코프는 null */
27
+ export function describeScope(scope: string): string | null {
28
+ const [resource, access] = scope.split(":");
29
+ if (!resource || !(resource in STORE_SCOPE_RESOURCES)) return null;
30
+ const allowed = STORE_SCOPE_RESOURCES[resource as StoreScopeResource] as readonly string[];
31
+ if (access !== "r" && access !== "rw") return null;
32
+ if (!allowed.includes(access)) return null;
33
+ return SCOPE_DESCRIPTIONS[resource as StoreScopeResource][access];
34
+ }
@@ -0,0 +1,173 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 쇼핑몰 시작(starter, 이슈 #122) — 원격 MCP의 starter 절차가 쓰는 브랜드 브리프와 진행 상태.
5
+ *
6
+ * 공개 OpenAPI 문서에는 싣지 않는다(절차를 운영 테스트로 다듬는 동안 계약이 바뀔 수 있다). 소비자는 sayren MCP 도구다.
7
+ */
8
+
9
+ /** 판매 품목 — 빠른 시작(#70) 답과 템플릿 카탈로그 `recommendFor`가 같은 코드를 쓴다 */
10
+ export const storeIndustrySchema = z.enum(["FASHION", "FOOD", "DIGITAL", "OTHER"]);
11
+ export type StoreIndustry = z.infer<typeof storeIndustrySchema>;
12
+
13
+ /** 상품 자료 — 인터뷰의 「상품 자료」 답. 상품 등록 단계의 방법을 고른다 */
14
+ export const brandBriefProductSourceSchema = z.enum([
15
+ "SPREADSHEET",
16
+ "EXISTING_STORE",
17
+ "PHOTOS",
18
+ "NONE",
19
+ ]);
20
+ export type BrandBriefProductSource = z.infer<typeof brandBriefProductSourceSchema>;
21
+
22
+ export const BRAND_BRIEF_MAX_REFERENCES = 5;
23
+
24
+ function isHttpUrl(value: string): boolean {
25
+ try {
26
+ const { protocol } = new URL(value);
27
+ return protocol === "https:" || protocol === "http:";
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /** 참고 사이트 — 이미지는 저장하지 않고 주소와 에이전트가 쓴 레이아웃 요약만 둔다 */
34
+ export const brandBriefReferenceSchema = z.object({
35
+ url: z
36
+ .string()
37
+ .trim()
38
+ .max(2048)
39
+ .refine(isHttpUrl, {
40
+ message: "http(s) 주소여야 합니다",
41
+ })
42
+ .describe("참고 사이트 주소"),
43
+ summary: z
44
+ .string()
45
+ .trim()
46
+ .min(1)
47
+ .max(1000)
48
+ .describe("레이아웃 특징 요약 (헤더 형태·홈 섹션 순서·상세 구성 등)"),
49
+ });
50
+ export type BrandBriefReference = z.infer<typeof brandBriefReferenceSchema>;
51
+
52
+ /** 브랜드 브리프 — starter 인터뷰 결과. 이후 단계(화면 설정·상품·다듬기)의 판단 근거다 */
53
+ export const brandBriefSchema = z.object({
54
+ industry: storeIndustrySchema.nullable().describe("판매 품목"),
55
+ mood: z.string().trim().max(200).nullable().describe("쇼핑몰 분위기 (예: 깔끔한, 따뜻한)"),
56
+ pointColor: z
57
+ .string()
58
+ .regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식이어야 합니다")
59
+ .nullable()
60
+ .describe("대표 색 (`#RRGGBB`)"),
61
+ references: z.array(brandBriefReferenceSchema).max(BRAND_BRIEF_MAX_REFERENCES),
62
+ productSource: brandBriefProductSourceSchema.nullable().describe("상품 자료"),
63
+ notes: z
64
+ .string()
65
+ .trim()
66
+ .max(4000)
67
+ .nullable()
68
+ .describe("인터뷰 메모 (타깃 고객, 판매 방식, 셀러가 강조한 요구)"),
69
+ });
70
+ export type BrandBrief = z.infer<typeof brandBriefSchema>;
71
+
72
+ /** `PUT /v1/storefront/brand-brief` — 브리프 전체를 바꾼다. 생략한 항목은 비운다 */
73
+ export const putBrandBriefRequestSchema = z.object({
74
+ industry: storeIndustrySchema.nullable().default(null),
75
+ mood: z.string().trim().max(200).nullable().default(null),
76
+ pointColor: z
77
+ .string()
78
+ .regex(/^#[0-9a-fA-F]{6}$/, "#RRGGBB 형식이어야 합니다")
79
+ .nullable()
80
+ .default(null),
81
+ references: z.array(brandBriefReferenceSchema).max(BRAND_BRIEF_MAX_REFERENCES).default([]),
82
+ productSource: brandBriefProductSourceSchema.nullable().default(null),
83
+ notes: z.string().trim().max(4000).nullable().default(null),
84
+ });
85
+ export type PutBrandBriefRequest = z.input<typeof putBrandBriefRequestSchema>;
86
+
87
+ /** `GET·PUT /v1/storefront/brand-brief` 응답 — 저장한 적이 없으면 `brief`·`updatedAt`이 null이다 */
88
+ export const brandBriefViewSchema = z.object({
89
+ brief: brandBriefSchema.nullable(),
90
+ updatedAt: z.string().nullable(),
91
+ });
92
+ export type BrandBriefView = z.infer<typeof brandBriefViewSchema>;
93
+
94
+ /**
95
+ * starter 단계 — 서버 상태로 계산한다(에이전트 보고가 아니다). 앞 단계가 끝나지 않았으면 그 단계다.
96
+ * `POLISH`(다듬기·구조 감사·구매 여정 점검)부터는 로컬 검사와 셀러 확인이 판정하므로 서버는 더 나누지 않는다.
97
+ */
98
+ export const STARTER_STAGES = [
99
+ "INTERVIEW",
100
+ "SITE",
101
+ "STORE_INFO",
102
+ "PRODUCTS",
103
+ "SELLER_TASKS",
104
+ "POLISH",
105
+ ] as const;
106
+ export const starterStageSchema = z.enum(STARTER_STAGES);
107
+ export type StarterStage = z.infer<typeof starterStageSchema>;
108
+
109
+ /** 셀러가 상점 플랫폼에서 직접 하는 항목 — 결제는 연결 여부만 본다(테스트·라이브를 가리지 않는다) */
110
+ export const STARTER_SELLER_TASKS = [
111
+ "PAYMENT_CONNECTED",
112
+ "BUSINESS_INFO",
113
+ "SHIPPING_POLICY",
114
+ ] as const;
115
+ export const starterSellerTaskSchema = z.enum(STARTER_SELLER_TASKS);
116
+ export type StarterSellerTask = z.infer<typeof starterSellerTaskSchema>;
117
+
118
+ /** 상점 기본 정보 항목 — 고객센터 번호만 필수이고 로고는 권장이다(셀러가 로고가 없다고 하면 만들지 않는다) */
119
+ export const starterStoreInfoFieldSchema = z.enum(["LOGO", "CUSTOMER_CENTER_PHONE"]);
120
+
121
+ /** `GET /v1/storefront/starter` — starter 진행 상태 */
122
+ export const starterStatusSchema = z.object({
123
+ stage: starterStageSchema,
124
+ hostingEnabled: z.boolean().describe("새 호스팅 사이트를 만들 수 있는가"),
125
+ defaultSiteUrl: z
126
+ .string()
127
+ .nullable()
128
+ .describe(
129
+ "사이트를 만들면 쓰는 기본 주소(`https://{상점 코드}.{호스팅 도메인}`). 사이트가 있으면 null",
130
+ ),
131
+ brief: z.object({
132
+ saved: z.boolean(),
133
+ updatedAt: z.string().nullable(),
134
+ }),
135
+ site: z
136
+ .object({
137
+ siteId: z.string(),
138
+ url: z.string(),
139
+ templateId: z.string(),
140
+ visibility: z.enum(["COMING_SOON", "PUBLIC"]),
141
+ status: z.string(),
142
+ synced: z.boolean().describe("발행 상태가 공개 주소에 반영됐는가"),
143
+ publishedVersion: z
144
+ .object({
145
+ versionId: z.string(),
146
+ templateVersion: z.string(),
147
+ createdVia: z.string().nullable(),
148
+ publishedAt: z.string().nullable(),
149
+ })
150
+ .nullable(),
151
+ })
152
+ .nullable(),
153
+ storeInfo: z.object({
154
+ done: z.boolean().describe("필수 항목(고객센터 번호)이 모두 있는가"),
155
+ missing: z.array(starterStoreInfoFieldSchema).describe("비어 있는 필수 항목"),
156
+ suggested: z
157
+ .array(starterStoreInfoFieldSchema)
158
+ .describe("비어 있지만 완료를 막지 않는 항목(로고). 셀러가 원할 때만 채운다"),
159
+ }),
160
+ products: z.object({
161
+ realCount: z.number().int().min(0).describe("샘플이 아닌 판매 중 상품 수"),
162
+ }),
163
+ sellerTasks: z.array(z.object({ key: starterSellerTaskSchema, done: z.boolean() })),
164
+ checkout: z.object({
165
+ available: z.boolean(),
166
+ mode: z.enum(["TEST", "LIVE"]).nullable(),
167
+ reason: z.enum(["PAYMENT_NOT_CONFIGURED", "PAYMENT_NOT_LIVE"]).nullable(),
168
+ }),
169
+ orders: z.object({
170
+ count: z.number().int().min(0).describe("주문 수 (결제 모드를 가리지 않는다)"),
171
+ }),
172
+ });
173
+ export type StarterStatus = z.infer<typeof starterStatusSchema>;
@@ -260,6 +260,12 @@ export const updateStoreRequestSchema = z
260
260
  "비회원 주문 허용. false(회원만)면 로그인하지 않은 구매자의 주문서 생성·결제 요청이 403 `GUEST_CHECKOUT_DISABLED`다. " +
261
261
  "장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 막지 않는다",
262
262
  ),
263
+ testOrderReviewsAllowed: z
264
+ .boolean()
265
+ .optional()
266
+ .describe(
267
+ "테스트 결제(샌드박스) 주문의 리뷰 허용. 기본 false — 돈을 내지 않은 주문으로 구매 인증 리뷰가 쌓이지 않게 막는다",
268
+ ),
263
269
  autoDeliverDays: z
264
270
  .number()
265
271
  .int()
@@ -326,6 +332,11 @@ export const storeSchema = z.object({
326
332
  "스토어프론트 도메인 — 소셜 로그인 리디렉트와 방문 분석 수집이 함께 쓴다(`analyticsAllowedOrigins`와 같은 값)",
327
333
  ),
328
334
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
335
+ testOrderReviewsAllowed: z
336
+ .boolean()
337
+ .optional()
338
+ .default(false)
339
+ .describe("테스트 결제(샌드박스) 주문의 리뷰 허용. 서버는 항상 싣는다"),
329
340
  autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
330
341
  autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
331
342
  refundPolicy: refundPolicySchema.describe(