@sayren/store-sdk 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,841 @@
1
+ import { z } from "zod";
2
+ import { RESERVED_STORE_CODES } from "./store";
3
+
4
+ /**
5
+ * 스토어프론트 호스팅 (이슈 #53, M1: 템플릿 발행) — 관리 API `/v1/storefront/**`의 계약이다.
6
+ *
7
+ * 셀러는 템플릿을 골라 사이트를 만들고 `{slug}.{호스팅 도메인}` 주소를 받는다. 발행은 버전 단위이고,
8
+ * 되돌리기는 이전 버전을 다시 발행하는 것이다(재빌드 없음). 상태의 원천은 api DB이고 Cloudflare 쪽 반영
9
+ * (라우팅 표·사이트 Worker)은 커밋 뒤 비동기다 — 반영 상태는 `provisioning`으로 본다.
10
+ *
11
+ * 스코프는 조회 `storefront:r`, 쓰기 `storefront:rw`(역할 OWNER·ADMIN)다. 에이전트(MCP)의 발행·되돌리기·템플릿 변경·
12
+ * 공개 전환·슬러그 변경·호스팅 끄기는 승인(`APPROVAL_REQUIRED`)이다 — 구매자가 보는 화면을 바꾸는 쓰기다.
13
+ *
14
+ * 오류 코드:
15
+ * - 403 `STOREFRONT_HOSTING_DISABLED` — 플랫폼 스위치가 꺼져 새 사이트를 만들 수 없다
16
+ * - 404 `STOREFRONT_SITE_NOT_FOUND` · `STOREFRONT_TEMPLATE_NOT_FOUND` · `STOREFRONT_VERSION_NOT_FOUND`
17
+ * - 409 `STOREFRONT_SITE_EXISTS` — 이미 사이트가 있다(스토어당 1개)
18
+ * - 409 `STOREFRONT_SLUG_TAKEN` — 다른 사이트가 쓰거나 보류 중인 슬러그다
19
+ * - 409 `STOREFRONT_SLUG_CHANGE_LIMIT` — 슬러그는 스토어당 30일에 3번까지 바꾼다
20
+ * - 409 `STOREFRONT_SITE_CREATE_LIMIT` — 사이트는 스토어당 30일에 3번까지 만든다
21
+ * - 409 `STOREFRONT_SITE_CHANGED` — 읽은 상태(`expected*`)와 현재 상태가 다르다. 다시 읽고 시도한다
22
+ * - 409 `STOREFRONT_SITE_NOT_ACTIVE` — 정지(`SUSPENDED`)된 사이트는 바꾸거나 끌 수 없다
23
+ * - 409 `STOREFRONT_VERSION_NOT_READY` — 빌드가 끝나지 않았거나 실패한 버전은 발행할 수 없다
24
+ * - 409 `STOREFRONT_NO_ROLLBACK_TARGET` — 되돌릴 이전 발행 버전이 없다
25
+ * - 400 `STOREFRONT_SLUG_CONFIRM_MISMATCH` — 호스팅 끄기 확인 입력이 현재 슬러그와 다르다
26
+ * - 503 `STOREFRONT_PREVIEW_NOT_CONFIGURED` — 미리보기 서명 키가 설정되지 않았다
27
+ *
28
+ * 편집(M2b, `/v1/storefront/site/editing`·`files/**`·`checks/**`·`verify`·`builds/**`, 버전 만들기):
29
+ * - 403 `STOREFRONT_EDITING_DISABLED` — 플랫폼 편집 스위치가 꺼져 있다
30
+ * - 503 `STOREFRONT_EDITING_NOT_CONFIGURED` — 편집 Sandbox가 설정되지 않았다
31
+ * - 409 `EDIT_CAPACITY_FULL` — 플랫폼 전체 동시 편집 상한에 걸렸다. `error.details.retryAfterSeconds` 뒤에 다시 한다
32
+ * - 409 `EDIT_SESSION_REQUIRED` — 편집 미리보기 링크: 편집 환경이 없다
33
+ * - 409 `CHECK_RUNNING` — 이 사이트에서 다른 종류의 검사가 돌고 있다(`error.details.checkId`)
34
+ * - 404 `STOREFRONT_CHECK_NOT_FOUND`
35
+ * - 409 `FILE_CHANGED` — 해시 조건이 어긋났다. 아무것도 쓰지 않았다(`error.details.conflicts: [{ path, currentHash }]`)
36
+ * - 409 `FILE_EXISTS` — 옮길 자리에 파일이 있다(`overwrite: true`로 덮는다)
37
+ * - 404 `FILE_NOT_FOUND` — 파일이 없다
38
+ * - 400 `INVALID_FILE_PATH` — 경로 형식이 틀렸거나 편집할 수 없는 경로다(`node_modules`·`dist`·`.env*` 등). 쓸 수 있는 곳은
39
+ * `src/**`·`public/**`와 루트 설정 파일 몇 개이고, `package.json`·`pnpm-lock.yaml`은 읽기만 된다
40
+ * - 413 `FILE_TOO_LARGE`·`WORKSPACE_TOO_LARGE` — 파일 하나 1MiB, 작업 트리 전체 20MiB·2,000개를 넘는다
41
+ * - 409 `STOREFRONT_BUILD_IN_PROGRESS` — 이 사이트에 끝나지 않은 빌드가 있다(사이트당 하나)
42
+ * - 404 `STOREFRONT_BUILD_NOT_FOUND`
43
+ * - 502 `STOREFRONT_SANDBOX_UNAVAILABLE` — 편집 Sandbox 호출이 실패했다. 잠시 뒤 다시 한다
44
+ */
45
+
46
+ // ─── 슬러그 ───────────────────────────────────────────────
47
+
48
+ /**
49
+ * 슬러그 형식 — 스토어 코드와 같은 DNS 라벨 규칙(소문자·숫자·하이픈 3~30자, 하이픈으로 시작·끝 불가)에
50
+ * 연속 하이픈 금지를 더한다. `--`는 버전 미리보기 주소(`v3--{slug}`)와 punycode(`xn--`)가 쓰는 구분자다.
51
+ */
52
+ export const STOREFRONT_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$/;
53
+
54
+ /** 호스팅 도메인에서 플랫폼이 쓰거나 쓸 이름 — 스토어 코드 예약어에 호스팅 전용 이름을 더한다 */
55
+ export const RESERVED_STOREFRONT_SLUGS: readonly string[] = [
56
+ ...RESERVED_STORE_CODES,
57
+ "mcp",
58
+ "preview",
59
+ "previews",
60
+ "sayren",
61
+ "shop",
62
+ "shops",
63
+ "abuse",
64
+ "security",
65
+ "closed",
66
+ "coming-soon",
67
+ "suspended",
68
+ "dispatch",
69
+ "control",
70
+ "outbound",
71
+ ];
72
+
73
+ /**
74
+ * 편집 미리보기 호스트 접미사 — 편집 미리보기 주소는 `{사이트 id 정규화}-preview.{호스팅 도메인}`이다(M2b). 사이트 주소와 같은
75
+ * 1단계 이름이라 슬러그가 이 모양이면 남의 편집 미리보기 주소와 겹칠 수 있어 슬러그로 쓰지 못한다.
76
+ */
77
+ export const STOREFRONT_EDIT_PREVIEW_SUFFIX = "-preview";
78
+
79
+ export function isReservedStorefrontSlug(slug: string): boolean {
80
+ const lower = slug.toLowerCase();
81
+ return (
82
+ RESERVED_STOREFRONT_SLUGS.includes(lower) || lower.endsWith(STOREFRONT_EDIT_PREVIEW_SUFFIX)
83
+ );
84
+ }
85
+
86
+ /**
87
+ * 편집 미리보기 호스트 이름의 라벨 — 사이트 id의 `_`를 `-`로 바꾸고 `-preview`를 붙인다(`sfs_ab12cd34` → `sfs-ab12cd34-preview`).
88
+ * 편집 Sandbox Worker(`apps/hosting-sandbox/src/preview-host.ts`)가 같은 규칙의 복제본을 들고 있고 api
89
+ * `test/storefront-hosting-parity.spec.ts`가 둘을 대조한다. 규칙을 바꾸면 두 곳을 같이 고친다.
90
+ */
91
+ export function storefrontEditPreviewLabelOf(siteId: string): string {
92
+ return `${siteId.replaceAll("_", "-")}${STOREFRONT_EDIT_PREVIEW_SUFFIX}`;
93
+ }
94
+
95
+ export const storefrontSlugSchema = z
96
+ .string()
97
+ .regex(
98
+ STOREFRONT_SLUG_PATTERN,
99
+ "소문자·숫자·하이픈 3~30자여야 하고 하이픈으로 시작하거나 끝날 수 없습니다",
100
+ )
101
+ .refine((slug) => !slug.includes("--"), "하이픈을 연달아 쓸 수 없습니다")
102
+ .refine((slug) => !isReservedStorefrontSlug(slug), "플랫폼이 쓰는 이름이라 쓸 수 없습니다")
103
+ .describe("주소 슬러그. `{slug}.{호스팅 도메인}`이 사이트 주소가 된다");
104
+
105
+ /** 바꾼 슬러그를 다른 사이트가 가져가지 못하게 막는 기간(일). 보류 동안 옛 주소는 새 주소로 301한다 */
106
+ export const STOREFRONT_SLUG_HOLD_DAYS = 90;
107
+ /** 슬러그 변경 한도 — 이 기간(일) 안에 */
108
+ export const STOREFRONT_SLUG_CHANGE_WINDOW_DAYS = 30;
109
+ /** 슬러그 변경 한도 — 횟수(스토어 단위. 끄고 다시 만들어도 이어서 센다) */
110
+ export const STOREFRONT_SLUG_CHANGE_LIMIT = 3;
111
+ /** 사이트 만들기 한도 — 같은 창(30일) 안의 횟수(스토어 단위). 끄고 만들기를 되풀이해 이름을 묶어 두지 못하게 한다 */
112
+ export const STOREFRONT_SITE_CREATE_LIMIT = 3;
113
+
114
+ // ─── 템플릿 ───────────────────────────────────────────────
115
+
116
+ export const storefrontTemplateSchema = z.object({
117
+ templateId: z.string().describe("템플릿 id (예: `basic`)"),
118
+ name: z.string().describe("템플릿 이름"),
119
+ industry: z.string().describe("업종 (예: `general`·`fashion`·`food`). 목록 필터에 쓴다"),
120
+ description: z.string().describe("템플릿 설명"),
121
+ thumbnailUrl: z.string().nullable().describe("썸네일 이미지 주소. 없으면 null"),
122
+ version: z.string().describe("현재 템플릿 버전. 새 사이트·템플릿 변경은 이 버전으로 만든다"),
123
+ isDefault: z.boolean().describe("기본 템플릿인가 (스토어 생성 시 자동 생성에 쓴다)"),
124
+ });
125
+ export type StorefrontTemplate = z.infer<typeof storefrontTemplateSchema>;
126
+
127
+ // ─── 버전 ─────────────────────────────────────────────────
128
+
129
+ /**
130
+ * 버전 상태 — 템플릿 버전(`source: TEMPLATE`)은 템플릿 산출물을 그대로 쓰므로 만들 때 곧바로 `READY`다. 편집 버전
131
+ * (`source: EDIT`, M2b)은 작업 트리 스냅샷을 빌드하는 동안 `BUILDING`이고, 빌드·규칙 검증을 통과하면 `READY`, 실패하면
132
+ * `FAILED`다(실패 단계·로그 꼬리는 `buildId`의 빌드). 발행할 수 있는 것은 `READY`뿐이다. 발행 여부는 상태가 아니라 사이트의
133
+ * 발행 포인터가 원천이다(`published`).
134
+ */
135
+ export const storefrontVersionStatusSchema = z
136
+ .enum(["BUILDING", "READY", "FAILED"])
137
+ .describe("`BUILDING` 빌드 중 · `READY` 발행 가능 · `FAILED` 빌드 실패");
138
+ export type StorefrontVersionStatus = z.infer<typeof storefrontVersionStatusSchema>;
139
+
140
+ export const storefrontVersionSchema = z.object({
141
+ versionId: z.string(),
142
+ number: z.number().int().min(1).describe("사이트 안에서 1부터 늘어나는 번호 (v1, v2…)"),
143
+ templateId: z.string().describe("이 버전의 기준 템플릿"),
144
+ templateVersion: z.string().describe("이 버전의 기준 템플릿 버전"),
145
+ status: storefrontVersionStatusSchema,
146
+ published: z.boolean().describe("지금 발행된 버전인가"),
147
+ publishedAt: z
148
+ .string()
149
+ .nullable()
150
+ .describe("마지막으로 발행한 시각 (ISO 8601). 발행한 적 없으면 null"),
151
+ message: z.string().nullable().describe("버전 메모"),
152
+ source: z
153
+ .enum(["TEMPLATE", "EDIT"])
154
+ .describe("`TEMPLATE` 템플릿 산출물 그대로 · `EDIT` 편집 작업 트리를 빌드함"),
155
+ buildId: z
156
+ .string()
157
+ .nullable()
158
+ .describe("편집 버전의 빌드 (`GET /v1/storefront/site/builds/{buildId}`). 템플릿 버전은 null"),
159
+ createdByType: z
160
+ .string()
161
+ .describe("만든 행위자 유형 (`USER`·`AGENT`·`APP`·`TOKEN`·`SYSTEM`). 늘어날 수 있다"),
162
+ createdAt: z.string().describe("만든 시각 (ISO 8601)"),
163
+ });
164
+ export type StorefrontVersion = z.infer<typeof storefrontVersionSchema>;
165
+
166
+ // ─── 사이트 ───────────────────────────────────────────────
167
+
168
+ export const storefrontSiteStatusSchema = z
169
+ .enum(["ACTIVE", "SUSPENDED", "CLOSED"])
170
+ .describe("`ACTIVE` 운영 중 · `SUSPENDED` 운영자 정지 · `CLOSED` 호스팅 끔·스토어 폐쇄");
171
+ export type StorefrontSiteStatus = z.infer<typeof storefrontSiteStatusSchema>;
172
+
173
+ export const storefrontVisibilitySchema = z
174
+ .enum(["COMING_SOON", "PUBLIC"])
175
+ .describe("`COMING_SOON` 오픈 준비 중(방문자에게 준비 중 페이지) · `PUBLIC` 공개");
176
+ export type StorefrontVisibility = z.infer<typeof storefrontVisibilitySchema>;
177
+
178
+ export const storefrontSourceModeSchema = z
179
+ .enum(["MANAGED", "CUSTOM"])
180
+ .describe("`MANAGED` 템플릿 그대로 · `CUSTOM` 한 번이라도 편집함");
181
+
182
+ export const storefrontDomainSchema = z.object({
183
+ hostname: z.string().describe("호스트 이름 (예: `mystore.sayren.co`)"),
184
+ kind: z.enum(["PLATFORM", "CUSTOM"]).describe("`PLATFORM` 슬러그 주소 · `CUSTOM` 내 도메인"),
185
+ status: z
186
+ .enum(["ACTIVE", "HELD"])
187
+ .describe("`ACTIVE` 사용 중 · `HELD` 슬러그를 바꿔 보류 중(새 주소로 301)"),
188
+ isPrimary: z.boolean().describe("대표 주소인가 (`Store.storefrontUrl`)"),
189
+ holdUntil: z.string().nullable().describe("보류가 끝나는 시각 (ISO 8601). 보류가 아니면 null"),
190
+ });
191
+ export type StorefrontDomain = z.infer<typeof storefrontDomainSchema>;
192
+
193
+ /**
194
+ * 호스팅 반영 상태 — 발행·슬러그·공개 전환은 DB에 먼저 확정되고 Cloudflare 반영은 커밋 뒤에 한다.
195
+ * `PENDING`이면 반영 중이거나 재시도 대기다(잡이 다시 한다). 반영 뒤에도 라우팅 전파에 최대 1분이 걸린다.
196
+ */
197
+ export const storefrontProvisioningSchema = z.object({
198
+ status: z
199
+ .enum(["SYNCED", "PENDING", "FAILED"])
200
+ .describe(
201
+ "`SYNCED` 반영됨 · `PENDING` 반영 대기 · `FAILED` 반영이 여러 번 실패함(자동 재시도는 계속한다)",
202
+ ),
203
+ lastErrorCode: z.string().nullable().describe("마지막 반영 실패 코드. 없으면 null"),
204
+ syncedAt: z.string().nullable().describe("마지막으로 반영한 시각 (ISO 8601)"),
205
+ });
206
+ export type StorefrontProvisioning = z.infer<typeof storefrontProvisioningSchema>;
207
+
208
+ /**
209
+ * 편집 미리보기 주소(M2b) — 사이트마다 `{사이트 id 정규화}-preview.{호스팅 도메인}` 호스트 하나다. 호스팅 도메인의 와일드카드
210
+ * 인증서가 덮으므로 따로 준비할 것이 없다. 이 주소는 편집 미리보기 링크(콘솔 전용)로 받은 쿠키가 있어야 열린다.
211
+ */
212
+ export const storefrontEditPreviewSchema = z.object({
213
+ hostname: z
214
+ .string()
215
+ .nullable()
216
+ .describe(
217
+ "편집 미리보기 호스트 (예: `sfs-ab12cd34-preview.sayren.co`). 편집을 쓸 수 없으면 null",
218
+ ),
219
+ });
220
+ export type StorefrontEditPreview = z.infer<typeof storefrontEditPreviewSchema>;
221
+
222
+ export const storefrontSiteSchema = z.object({
223
+ siteId: z.string(),
224
+ slug: z.string().describe("주소 슬러그"),
225
+ hostname: z.string().describe("대표 호스트 이름"),
226
+ url: z.string().describe("대표 주소 (`https://{hostname}`)"),
227
+ domains: z.array(storefrontDomainSchema).describe("사용 중·보류 중인 호스트 이름"),
228
+ templateId: z.string().describe("지금 발행된 버전의 템플릿 (발행 전이면 만든 템플릿)"),
229
+ sourceMode: storefrontSourceModeSchema,
230
+ status: storefrontSiteStatusSchema,
231
+ visibility: storefrontVisibilitySchema,
232
+ publishedVersion: storefrontVersionSchema.nullable().describe("지금 발행된 버전"),
233
+ provisioning: storefrontProvisioningSchema,
234
+ editPreview: storefrontEditPreviewSchema,
235
+ slugChangesRemaining: z
236
+ .number()
237
+ .int()
238
+ .min(0)
239
+ .describe(`최근 ${STOREFRONT_SLUG_CHANGE_WINDOW_DAYS}일 기준 남은 슬러그 변경 횟수`),
240
+ createdAt: z.string(),
241
+ updatedAt: z.string(),
242
+ });
243
+ export type StorefrontSite = z.infer<typeof storefrontSiteSchema>;
244
+
245
+ /** `GET /v1/storefront/site` — 스위치 상태와 현재 사이트(없으면 null. 끈 사이트는 싣지 않는다) */
246
+ export const storefrontHostingSchema = z.object({
247
+ enabled: z.boolean().describe("새 사이트를 만들 수 있는가 (플랫폼 스위치)"),
248
+ domain: z.string().describe("호스팅 도메인 (예: `sayren.co`). 사이트 주소는 `{slug}.{domain}`"),
249
+ editingEnabled: z
250
+ .boolean()
251
+ .describe("파일 편집·버전 만들기를 쓸 수 있는가 (플랫폼 편집 스위치와 Sandbox 설정)"),
252
+ site: storefrontSiteSchema.nullable(),
253
+ });
254
+ export type StorefrontHosting = z.infer<typeof storefrontHostingSchema>;
255
+
256
+ export const storefrontSlugAvailabilitySchema = z.object({
257
+ slug: z.string(),
258
+ available: z.boolean(),
259
+ reason: z
260
+ .enum(["INVALID", "RESERVED", "TAKEN", "CURRENT"])
261
+ .nullable()
262
+ .describe(
263
+ "쓸 수 없는 이유. `INVALID` 형식 · `RESERVED` 예약어 · `TAKEN` 사용·보류 중 · `CURRENT` 지금 내 슬러그",
264
+ ),
265
+ });
266
+ export type StorefrontSlugAvailability = z.infer<typeof storefrontSlugAvailabilitySchema>;
267
+
268
+ // ─── 요청 ─────────────────────────────────────────────────
269
+
270
+ export const createStorefrontSiteRequestSchema = z.object({
271
+ templateId: z.string().min(1).max(64).describe("템플릿 id"),
272
+ slug: storefrontSlugSchema.optional().describe("주소 슬러그. 생략하면 스토어 코드"),
273
+ });
274
+ export type CreateStorefrontSiteRequest = z.infer<typeof createStorefrontSiteRequestSchema>;
275
+
276
+ export const changeStorefrontTemplateRequestSchema = z.object({
277
+ templateId: z.string().min(1).max(64).describe("바꿀 템플릿 id"),
278
+ publish: z
279
+ .boolean()
280
+ .optional()
281
+ .describe("새 버전을 곧바로 발행하는가 (기본 true). false면 버전만 만든다"),
282
+ message: z.string().max(200).optional().describe("버전 메모"),
283
+ });
284
+ export type ChangeStorefrontTemplateRequest = z.infer<typeof changeStorefrontTemplateRequestSchema>;
285
+
286
+ /**
287
+ * 발행·되돌리기 — 발행은 사이트의 발행 포인터를 바꾸는 조건부 쓰기다. `expectedPublishedVersionId`를 주면
288
+ * 지금 발행 버전이 그 값일 때만 바꾼다(화면을 본 뒤 다른 사람이 바꿨으면 409 `STOREFRONT_SITE_CHANGED`).
289
+ * 이미 그 버전이 발행돼 있으면 아무것도 바꾸지 않고 200이다(같은 요청의 재실행이 안전하다).
290
+ */
291
+ export const publishStorefrontVersionRequestSchema = z.object({
292
+ expectedPublishedVersionId: z
293
+ .string()
294
+ .nullable()
295
+ .optional()
296
+ .describe("화면에서 본 발행 버전 id (발행 전이었으면 null). 생략하면 확인하지 않는다"),
297
+ });
298
+ export type PublishStorefrontVersionRequest = z.infer<typeof publishStorefrontVersionRequestSchema>;
299
+
300
+ export const rollbackStorefrontRequestSchema = z.object({
301
+ versionId: z.string().optional().describe("되돌릴 버전. 생략하면 지금 버전 직전에 발행했던 버전"),
302
+ expectedPublishedVersionId: z.string().nullable().optional(),
303
+ });
304
+ export type RollbackStorefrontRequest = z.infer<typeof rollbackStorefrontRequestSchema>;
305
+
306
+ export const changeStorefrontSlugRequestSchema = z.object({
307
+ slug: storefrontSlugSchema,
308
+ });
309
+ export type ChangeStorefrontSlugRequest = z.infer<typeof changeStorefrontSlugRequestSchema>;
310
+
311
+ export const setStorefrontVisibilityRequestSchema = z.object({
312
+ visibility: storefrontVisibilitySchema,
313
+ expectedVisibility: storefrontVisibilitySchema
314
+ .optional()
315
+ .describe("화면에서 본 공개 상태. 주면 지금 값이 같을 때만 바꾼다"),
316
+ });
317
+ export type SetStorefrontVisibilityRequest = z.infer<typeof setStorefrontVisibilityRequestSchema>;
318
+
319
+ /** 호스팅 끄기 — 사이트를 닫고(`CLOSED`) 슬러그를 보류한다. 확인 입력은 현재 슬러그다 */
320
+ export const disableStorefrontHostingRequestSchema = z.object({
321
+ confirmSlug: z.string().min(1).describe("확인 입력 — 현재 슬러그"),
322
+ });
323
+ export type DisableStorefrontHostingRequest = z.infer<typeof disableStorefrontHostingRequestSchema>;
324
+
325
+ export const storefrontSlugAvailabilityQuerySchema = z.object({
326
+ slug: z.string().describe("확인할 슬러그"),
327
+ });
328
+
329
+ /**
330
+ * 오픈 준비 중 미리보기 링크 — 콘솔에서 로그인한 OWNER·ADMIN만 받는다(PAT·에이전트 불가). 링크는 5분 안에 한 번 열면
331
+ * 그 브라우저에 12시간짜리 미리보기 쿠키가 생기고 오픈 준비 중인 사이트의 실제 화면을 본다. 링크를 남에게 넘기면 그 사람도
332
+ * 볼 수 있으므로 공유하지 않는다.
333
+ */
334
+ export const createStorefrontPreviewLinkRequestSchema = z.object({
335
+ path: z.string().max(512).optional().describe("열 경로 (`/`로 시작). 생략하면 `/`"),
336
+ });
337
+ export type CreateStorefrontPreviewLinkRequest = z.infer<
338
+ typeof createStorefrontPreviewLinkRequestSchema
339
+ >;
340
+
341
+ export const storefrontPreviewLinkSchema = z.object({
342
+ url: z.string().describe("미리보기 링크 — 5분 안에 연다"),
343
+ expiresAt: z.string().describe("링크 만료 시각 (ISO 8601)"),
344
+ });
345
+ export type StorefrontPreviewLink = z.infer<typeof storefrontPreviewLinkSchema>;
346
+
347
+ // ─── 편집 (M2b) ───────────────────────────────────────────
348
+ //
349
+ // 셀러(콘솔)와 에이전트(MCP)가 사이트의 작업 트리를 고치고 버전을 만든다. 작업 트리는 사이트당 하나이고 편집 Sandbox
350
+ // (격리된 컨테이너)가 dev 서버로 띄운다. 편집 환경은 플랫폼 전체 동시 상한이 있고 15분 동안 활동이 없으면 잠든다(작업
351
+ // 트리는 보존된다). 버전 만들기는 그 시점의 작업 트리를 고정해 일회용 빌드 환경에서 규칙 검증 → 타입 검사 → 빌드를
352
+ // 하고, 통과하면 발행할 수 있는 `READY` 버전이 된다. 에이전트의 파일 쓰기·검사·버전 만들기는 승인 없이 되고, 발행·
353
+ // 되돌리기·공개 전환은 기존대로 승인이다. 편집 미리보기 링크는 콘솔 OWNER·ADMIN만 받는다(에이전트에게 주지 않는다).
354
+
355
+ /** 플랫폼 전체 동시 편집 환경 상한 */
356
+ export const STOREFRONT_EDIT_CAPACITY = 20;
357
+ /** 활동이 없으면 편집 환경이 잠드는 시간(분). 작업 트리는 보존된다 */
358
+ export const STOREFRONT_EDIT_IDLE_MINUTES = 15;
359
+ /**
360
+ * 편집 작업(파일 쓰기·검사·버전 만들기·편집 미리보기 링크 발급)이 없으면 서버가 편집 환경을 닫는 시간(분). 미리보기 탭만 열어 두면
361
+ * 유휴 잠들기(`STOREFRONT_EDIT_IDLE_MINUTES`)가 걸리지 않으므로 이 한도가 편집 환경을 끝낸다. 작업 트리는 보존된다
362
+ */
363
+ export const STOREFRONT_EDIT_ACTIVITY_TIMEOUT_MINUTES = 30;
364
+ /** 파일 하나의 최대 크기(바이트) */
365
+ export const STOREFRONT_FILE_MAX_BYTES = 1024 * 1024;
366
+ /** 작업 트리 전체 최대 크기(바이트) — 파일 수는 2,000개까지 */
367
+ export const STOREFRONT_WORKSPACE_MAX_BYTES = 20 * 1024 * 1024;
368
+ /** 일괄 쓰기 한 번의 최대 파일 수 */
369
+ export const STOREFRONT_FILE_BATCH_MAX = 50;
370
+
371
+ /** 편집할 수 없는 경로 조각 — 설치물·산출물 폴더 */
372
+ export const STOREFRONT_BLOCKED_PATH_SEGMENTS: readonly string[] = [
373
+ "node_modules",
374
+ "dist",
375
+ ".output",
376
+ ".tanstack",
377
+ ".wrangler",
378
+ ".git",
379
+ ".nitro",
380
+ ".vinxi",
381
+ ];
382
+
383
+ /** 경로 조각에 쓸 수 있는 문자 — 편집 Sandbox Worker의 경로 규칙(sandbox-api.md §2.1)과 같다. TanStack Router 파일명(`$id.tsx`·`_layout`·`(group)`·`[.]`)이 들어간다 */
384
+ export const STOREFRONT_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9$_.()[\]@+=,-]+$/;
385
+
386
+ /**
387
+ * 보이지 않거나 표시 방향을 바꾸는 문자 — 제로폭(U+200B~U+200D, U+FEFF)과 bidi 제어(U+202A~U+202E, U+2066~U+2069). 비슷해 보이는
388
+ * 경로로 규칙 검사를 피하거나 리뷰어를 속이는 데 쓰인다. 문자 규칙이 이미 막지만 이유를 따로 드러내려고 먼저 본다.
389
+ */
390
+ const INVISIBLE_OR_BIDI = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]/;
391
+
392
+ /** 경로 정규화 — NFC. 비교·저장은 이 값으로 한다(같아 보이는 두 경로가 다른 파일이 되지 않게) */
393
+ export function normalizeStorefrontPath(path: string): string {
394
+ return path.normalize("NFC");
395
+ }
396
+
397
+ /**
398
+ * 작업 트리 상대 경로인가(정규화한 값 기준) — `/`로 구분하고 `/`로 시작하지 않는다. `.`·`..`·빈 조각, 조각 문자 규칙
399
+ * (`STOREFRONT_PATH_SEGMENT_PATTERN`, 공백·역슬래시·제어·제로폭·bidi 문자 불가) 밖, 설치물·산출물 폴더
400
+ * (`STOREFRONT_BLOCKED_PATH_SEGMENTS`), `.env`로 시작하는 조각(파일·폴더)은 받지 않는다. 최대 256자·깊이 16.
401
+ * 어디에 쓸 수 있는지(`src/**`·`public/**`·루트 설정 파일)는 편집 Sandbox가 판정한다(400 `INVALID_FILE_PATH`).
402
+ * 편집 Sandbox Worker가 같은 규칙의 복제본을 들고 있다(api `test/storefront-hosting-parity.spec.ts`가 대조한다).
403
+ */
404
+ export function isEditableStorefrontPath(path: string): boolean {
405
+ if (path.length === 0 || path.length > 256) return false;
406
+ if (INVISIBLE_OR_BIDI.test(path)) return false;
407
+ if (path !== normalizeStorefrontPath(path)) return false;
408
+ if (path.startsWith("/")) return false;
409
+ const segments = path.split("/");
410
+ if (segments.length > 16) return false;
411
+ for (const segment of segments) {
412
+ if (segment === "" || segment === "." || segment === "..") return false;
413
+ if (!STOREFRONT_PATH_SEGMENT_PATTERN.test(segment)) return false;
414
+ if (STOREFRONT_BLOCKED_PATH_SEGMENTS.includes(segment)) return false;
415
+ // `.env`로 시작하는 조각은 어디에 있든 받지 않는다(폴더 이름 포함, 편집 Sandbox `paths.ts`와 같다)
416
+ if (segment.startsWith(".env")) return false;
417
+ }
418
+ return true;
419
+ }
420
+
421
+ /** 경로 입력 — NFC로 정규화한 뒤 규칙을 본다 */
422
+ export const storefrontFilePathSchema = z
423
+ .string()
424
+ .transform(normalizeStorefrontPath)
425
+ .refine(isEditableStorefrontPath, "편집할 수 없는 경로입니다")
426
+ .describe("작업 트리 상대 경로 (예: `src/routes/index.tsx`). NFC로 정규화한다");
427
+
428
+ /** 파일 해시 — 내용 바이트의 SHA-256 hex(소문자 64자) */
429
+ export const storefrontFileHashSchema = z
430
+ .string()
431
+ .regex(/^[0-9a-f]{64}$/, "SHA-256 hex여야 합니다")
432
+ .describe("내용 바이트의 SHA-256 (소문자 hex)");
433
+
434
+ export const storefrontFileEncodingSchema = z
435
+ .enum(["utf8", "base64"])
436
+ .describe("`utf8` 텍스트 · `base64` 바이너리(이미지 등)");
437
+
438
+ /**
439
+ * 편집 환경 상태 — `STARTING`은 컨테이너를 띄우고 작업 트리를 복원해 dev 서버를 올리는 중이다(보통 30초 안, 최대
440
+ * 90초). `RUNNING`이면 미리보기와 파일 쓰기가 바로 반영된다(HMR).
441
+ */
442
+ export const storefrontEditSessionStatusSchema = z
443
+ .enum(["STARTING", "RUNNING"])
444
+ .describe("`STARTING` 띄우는 중 · `RUNNING` 사용 중");
445
+
446
+ /** 편집 환경을 띄우는 단계 — 콘솔 진행 표시용이다. 값은 늘어날 수 있다 */
447
+ export const STOREFRONT_EDIT_START_STAGES = ["CONTAINER", "RESTORE", "DEV_SERVER"] as const;
448
+
449
+ export const storefrontEditSessionSchema = z.object({
450
+ sessionId: z.string(),
451
+ status: storefrontEditSessionStatusSchema,
452
+ startStage: z
453
+ .string()
454
+ .nullable()
455
+ .describe(
456
+ "`STARTING`일 때 지금 단계 (`CONTAINER` 컨테이너 기동 · `RESTORE` 작업 트리 복원 · `DEV_SERVER` 개발 서버 기동). 늘어날 수 있다. `RUNNING`이면 null",
457
+ ),
458
+ startedAt: z.string().describe("연 시각 (ISO 8601)"),
459
+ lastEditedAt: z
460
+ .string()
461
+ .nullable()
462
+ .describe("이 편집 환경에서 마지막으로 파일을 바꾼 시각. 없으면 null"),
463
+ lastActiveAt: z.string().describe("마지막 활동 시각 (파일 도구·검사·미리보기)"),
464
+ idleExpiresAt: z
465
+ .string()
466
+ .describe(
467
+ `이 시각까지 활동이 없으면 잠든다 (마지막 활동 + ${STOREFRONT_EDIT_IDLE_MINUTES}분). 잠들어도 작업 트리는 보존된다`,
468
+ ),
469
+ closesAt: z
470
+ .string()
471
+ .describe(
472
+ `이 시각까지 편집 작업(파일 쓰기·검사·버전 만들기·미리보기 링크 발급)이 없으면 서버가 편집 환경을 닫는다 (마지막 편집 작업 + ${STOREFRONT_EDIT_ACTIVITY_TIMEOUT_MINUTES}분). 미리보기만 보고 있어도 닫힌다. 작업 트리는 보존된다`,
473
+ ),
474
+ });
475
+ export type StorefrontEditSession = z.infer<typeof storefrontEditSessionSchema>;
476
+
477
+ /** `GET·POST·DELETE /v1/storefront/site/editing` — 편집 환경 상태 */
478
+ export const storefrontEditingSchema = z.object({
479
+ enabled: z.boolean().describe("편집을 쓸 수 있는가 (플랫폼 스위치와 Sandbox 설정)"),
480
+ session: storefrontEditSessionSchema
481
+ .nullable()
482
+ .describe("열린 편집 환경. 잠들었거나 닫았으면 null"),
483
+ capacity: z.object({
484
+ limit: z.number().int().describe("플랫폼 전체 동시 편집 환경 상한"),
485
+ inUse: z.number().int().describe("지금 열린 편집 환경 수"),
486
+ }),
487
+ preview: storefrontEditPreviewSchema.describe(
488
+ "편집 미리보기 주소 (사이트의 `editPreview`와 같다)",
489
+ ),
490
+ workingTree: z.object({
491
+ modified: z
492
+ .boolean()
493
+ .describe("템플릿에서 한 번이라도 고쳤는가 (사이트 `sourceMode`가 `CUSTOM`)"),
494
+ lastEditedAt: z.string().nullable().describe("마지막으로 파일을 바꾼 시각"),
495
+ }),
496
+ });
497
+ export type StorefrontEditing = z.infer<typeof storefrontEditingSchema>;
498
+
499
+ // ─── 파일 ─────────────────────────────────────────────────
500
+
501
+ export const storefrontFileEntrySchema = z.object({
502
+ path: z.string(),
503
+ size: z.number().int().min(0).describe("바이트 수"),
504
+ hash: storefrontFileHashSchema,
505
+ });
506
+ export type StorefrontFileEntry = z.infer<typeof storefrontFileEntrySchema>;
507
+
508
+ /** `GET /v1/storefront/site/files?prefix=` — 작업 트리 파일 목록(편집 환경이 없어도 된다. 편집 환경을 깨우지 않는다) */
509
+ export const storefrontFileListSchema = z.object({
510
+ files: z.array(storefrontFileEntrySchema).describe("경로 사전순"),
511
+ totalBytes: z.number().int().min(0).describe("작업 트리 전체 바이트 수"),
512
+ });
513
+ export type StorefrontFileList = z.infer<typeof storefrontFileListSchema>;
514
+
515
+ export const storefrontFileListQuerySchema = z.object({
516
+ prefix: z
517
+ .string()
518
+ .max(256)
519
+ .optional()
520
+ .describe("이 접두사로 시작하는 경로만 (예: `src/routes/`)"),
521
+ });
522
+
523
+ export const storefrontFileContentQuerySchema = z.object({
524
+ path: z.string().describe("읽을 파일 경로"),
525
+ });
526
+
527
+ /** `GET /v1/storefront/site/files/content?path=` — 편집 환경이 없어도 된다 */
528
+ export const storefrontFileContentSchema = z.object({
529
+ path: z.string(),
530
+ content: z.string().describe("내용 (`encoding`에 따라 텍스트 또는 base64)"),
531
+ encoding: storefrontFileEncodingSchema,
532
+ hash: storefrontFileHashSchema,
533
+ size: z.number().int().min(0),
534
+ });
535
+ export type StorefrontFileContent = z.infer<typeof storefrontFileContentSchema>;
536
+
537
+ /**
538
+ * `POST /v1/storefront/site/files/batch` — 여러 파일을 한 번에 쓴다. 편집 환경이 없어도 된다(잠든 작업 트리에 바로 쓰고,
539
+ * 편집 환경이 떠 있으면 미리보기에 곧바로 반영된다). `expectedHash`: 생략하면 조건 없이 쓴다 · `null`이면 파일이 없어야 한다(새 파일) · 값이면 지금
540
+ * 해시가 같아야 한다. 조건을 **모두** 확인한 뒤 쓰고, 하나라도 어긋나면 아무것도 쓰지 않는다(409 `FILE_CHANGED`,
541
+ * `error.details.conflicts`).
542
+ */
543
+ export const writeStorefrontFilesRequestSchema = z.object({
544
+ files: z
545
+ .array(
546
+ z.object({
547
+ path: storefrontFilePathSchema,
548
+ content: z.string().describe("내용 (`encoding`에 따라 텍스트 또는 base64)"),
549
+ encoding: storefrontFileEncodingSchema.optional().describe("기본 `utf8`"),
550
+ expectedHash: storefrontFileHashSchema
551
+ .nullable()
552
+ .optional()
553
+ .describe("쓰기 조건. 생략 = 조건 없음 · null = 새 파일 · 값 = 지금 해시가 같을 때만"),
554
+ }),
555
+ )
556
+ .min(1)
557
+ .max(STOREFRONT_FILE_BATCH_MAX)
558
+ .refine((files) => new Set(files.map((file) => file.path)).size === files.length, {
559
+ message: "같은 경로를 두 번 실을 수 없습니다",
560
+ }),
561
+ });
562
+ export type WriteStorefrontFilesRequest = z.infer<typeof writeStorefrontFilesRequestSchema>;
563
+
564
+ export const storefrontFileWriteResultSchema = z.object({
565
+ files: z.array(storefrontFileEntrySchema).describe("쓴 파일의 새 해시·크기 (요청 순서)"),
566
+ });
567
+ export type StorefrontFileWriteResult = z.infer<typeof storefrontFileWriteResultSchema>;
568
+
569
+ /** 409 `FILE_CHANGED`의 `error.details` */
570
+ export const storefrontFileConflictDetailsSchema = z.object({
571
+ conflicts: z.array(
572
+ z.object({
573
+ path: z.string(),
574
+ currentHash: storefrontFileHashSchema.nullable().describe("지금 해시. 파일이 없으면 null"),
575
+ }),
576
+ ),
577
+ });
578
+ export type StorefrontFileConflictDetails = z.infer<typeof storefrontFileConflictDetailsSchema>;
579
+
580
+ /** `POST /v1/storefront/site/files/delete` — 없는 파일이면 `deleted: false`(조건을 주면 409 `FILE_CHANGED`) */
581
+ export const deleteStorefrontFileRequestSchema = z.object({
582
+ path: storefrontFilePathSchema,
583
+ expectedHash: storefrontFileHashSchema.optional().describe("주면 지금 해시가 같을 때만 지운다"),
584
+ });
585
+ export type DeleteStorefrontFileRequest = z.infer<typeof deleteStorefrontFileRequestSchema>;
586
+
587
+ export const storefrontFileDeleteResultSchema = z.object({
588
+ path: z.string(),
589
+ deleted: z.boolean(),
590
+ });
591
+ export type StorefrontFileDeleteResult = z.infer<typeof storefrontFileDeleteResultSchema>;
592
+
593
+ /** `POST /v1/storefront/site/files/move` — 이름 바꾸기·옮기기 */
594
+ export const moveStorefrontFileRequestSchema = z.object({
595
+ from: storefrontFilePathSchema,
596
+ to: storefrontFilePathSchema,
597
+ expectedHash: storefrontFileHashSchema.optional().describe("주면 `from`의 지금 해시가 같을 때만"),
598
+ overwrite: z
599
+ .boolean()
600
+ .optional()
601
+ .describe("`to`에 파일이 있으면 덮는가 (기본 false → 409 `FILE_EXISTS`)"),
602
+ });
603
+ export type MoveStorefrontFileRequest = z.infer<typeof moveStorefrontFileRequestSchema>;
604
+
605
+ export const storefrontFileMoveResultSchema = z.object({
606
+ from: z.string(),
607
+ to: z.string(),
608
+ hash: storefrontFileHashSchema,
609
+ size: z.number().int().min(0),
610
+ });
611
+ export type StorefrontFileMoveResult = z.infer<typeof storefrontFileMoveResultSchema>;
612
+
613
+ /** `POST /v1/storefront/site/files/search` — 텍스트 파일 내용 검색(편집 환경이 없어도 된다) */
614
+ /** 정규식 검색의 패턴 길이 상한 — 편집 Sandbox가 실행 비용을 막으려고 더 좁게 받는다(sandbox-api §4.6) */
615
+ export const STOREFRONT_SEARCH_REGEX_MAX_LENGTH = 100;
616
+
617
+ export const searchStorefrontFilesRequestSchema = z
618
+ .object({
619
+ query: z.string().min(1).max(200),
620
+ regex: z
621
+ .boolean()
622
+ .optional()
623
+ .describe(
624
+ "`query`를 정규식으로 본다 (기본 false = 글자 그대로). 정규식은 100자까지이고 중첩 수량자(`(a+)+`)·반복되는 선택(`(a|b)+`)·역참조·뒤 보기, 가변 수량자(`*`·`+`·`?`·`{n,m}`, 넓은 범위는 2로 센다) 차수 4 초과는 400 `INVALID_REQUEST`다. 차수가 클수록 긴 줄은 건너뛰고 비용이 크면 `truncated`로 끊는다",
625
+ ),
626
+ caseSensitive: z.boolean().optional().describe("대소문자를 가리는가 (기본 false)"),
627
+ pathPrefix: z.string().max(256).optional(),
628
+ maxResults: z.number().int().min(1).max(200).optional().describe("기본 50"),
629
+ })
630
+ .refine((body) => !body.regex || body.query.length <= STOREFRONT_SEARCH_REGEX_MAX_LENGTH, {
631
+ message: `정규식은 ${STOREFRONT_SEARCH_REGEX_MAX_LENGTH}자까지입니다`,
632
+ path: ["query"],
633
+ });
634
+ export type SearchStorefrontFilesRequest = z.infer<typeof searchStorefrontFilesRequestSchema>;
635
+
636
+ export const storefrontFileSearchResultSchema = z.object({
637
+ matches: z.array(
638
+ z.object({
639
+ path: z.string(),
640
+ line: z.number().int().min(1),
641
+ column: z.number().int().min(1).optional(),
642
+ text: z.string().describe("일치한 줄 (일치 앞뒤 200자까지)"),
643
+ }),
644
+ ),
645
+ truncated: z.boolean().describe("`maxResults`에서 끊었는가"),
646
+ });
647
+ export type StorefrontFileSearchResult = z.infer<typeof storefrontFileSearchResultSchema>;
648
+
649
+ // ─── 검사 ─────────────────────────────────────────────────
650
+
651
+ /**
652
+ * `POST /v1/storefront/site/checks` (202) — 작업 트리에 명령 하나를 돌린다(`typecheck` 타입 검사 · `lint` · `test`). 편집 환경이
653
+ * 없어도 된다(일회용 검사 컨테이너에서 돈다). 검사는 30초를 넘길 수 있어 **항상 202로 검사 id만 주고** 결과는
654
+ * `GET /v1/storefront/site/checks/{checkId}`로 조회한다(권장 2~3초 간격). 사이트당 끝나지 않은 검사는 하나다 — 같은 종류가 돌고
655
+ * 있으면 그 검사를 다시 돌려주고, 다른 종류면 409 `CHECK_RUNNING`(`error.details.checkId`)이다. 출력은 꼬리만 준다.
656
+ */
657
+ export const runStorefrontCheckRequestSchema = z.object({
658
+ check: z.enum(["typecheck", "lint", "test"]),
659
+ });
660
+ export type RunStorefrontCheckRequest = z.infer<typeof runStorefrontCheckRequestSchema>;
661
+
662
+ export const storefrontCheckStatusSchema = z
663
+ .enum(["QUEUED", "RUNNING", "PASSED", "FAILED", "TIMED_OUT", "UNKNOWN"])
664
+ .describe(
665
+ "`QUEUED` 대기 · `RUNNING` 실행 중 · `PASSED` 통과 · `FAILED` 실패(명령 실패 또는 `errorCode`) · `TIMED_OUT` 마감(150초)에 걸려 끊김 · `UNKNOWN` 결과를 모른다(다시 실행하지 않는다, 새 검사를 요청한다)",
666
+ );
667
+ export type StorefrontCheckStatus = z.infer<typeof storefrontCheckStatusSchema>;
668
+
669
+ /** 끝난 검사 상태 */
670
+ export const STOREFRONT_CHECK_TERMINAL_STATUSES: readonly StorefrontCheckStatus[] = [
671
+ "PASSED",
672
+ "FAILED",
673
+ "TIMED_OUT",
674
+ "UNKNOWN",
675
+ ];
676
+
677
+ export const storefrontCheckSchema = z.object({
678
+ checkId: z.string(),
679
+ check: z.string().describe("`typecheck`·`lint`·`test`"),
680
+ status: storefrontCheckStatusSchema,
681
+ exitCode: z.number().int().nullable().describe("명령 종료 코드. 명령이 끝나지 않았으면 null"),
682
+ durationMs: z.number().int().min(0).nullable().describe("명령 시간. 끝나지 않았으면 null"),
683
+ outputTail: z.string().nullable().describe("출력 꼬리 (최대 16KiB). 끝나지 않았으면 null"),
684
+ errorCode: z
685
+ .string()
686
+ .nullable()
687
+ .describe(
688
+ "명령 밖의 실패 이유 (`CHECK_BUSY`·`SANDBOX_UNAVAILABLE`·`RESULT_UNKNOWN`, 늘어날 수 있다). 명령 결과로 끝났으면 null",
689
+ ),
690
+ message: z.string().nullable().describe("`errorCode`가 있을 때 사용자용 안내"),
691
+ createdAt: z.string(),
692
+ startedAt: z.string().nullable(),
693
+ finishedAt: z.string().nullable(),
694
+ });
695
+ export type StorefrontCheck = z.infer<typeof storefrontCheckSchema>;
696
+
697
+ /** 규칙 심각도 — `error`는 버전 발행을 막는다(빌드 `VERIFY` 실패), `warning`은 알리기만 한다. 없으면 `error`로 본다 */
698
+ export const storefrontRuleSeveritySchema = z.enum(["error", "warning"]);
699
+ export type StorefrontRuleSeverity = z.infer<typeof storefrontRuleSeveritySchema>;
700
+
701
+ /** 규칙 하나의 검사 결과 — MCP 계측(`mcpRuleResultSchema`)과 같은 모양에 심각도를 더한다 */
702
+ export const storefrontRuleResultSchema = z.object({
703
+ ruleId: z.string(),
704
+ status: z.enum(["pass", "violation"]),
705
+ count: z.number().int().min(0),
706
+ severity: storefrontRuleSeveritySchema.optional(),
707
+ });
708
+ export type StorefrontRuleResult = z.infer<typeof storefrontRuleResultSchema>;
709
+
710
+ /**
711
+ * 규칙 검증 보고서 — `@sayren/mcp`의 `verify_storefront`와 같은 모양이다(원천 `apps/mcp/src/verify.ts`). 위반마다
712
+ * 고칠 자리(`file`·`line`)와 방법(`fix`·`example`)을 싣는다. `POST /v1/storefront/site/verify`(편집 환경이 잠들어 있어도
713
+ * 된다)와 빌드의 `verify`가 이 모양이다.
714
+ */
715
+ export const storefrontVerifyReportSchema = z.object({
716
+ ok: z.boolean().describe("차단(`error`) 위반이 없는가"),
717
+ checkedFiles: z.number().int().min(0),
718
+ summary: z.object({
719
+ rules: z.number().int(),
720
+ passed: z.number().int(),
721
+ violated: z.number().int(),
722
+ findings: z.number().int(),
723
+ }),
724
+ results: z.array(storefrontRuleResultSchema),
725
+ findings: z.array(
726
+ z.object({
727
+ ruleId: z.string(),
728
+ rule: z.string(),
729
+ file: z.string(),
730
+ line: z.number().int().optional(),
731
+ detail: z.string(),
732
+ cause: z.string(),
733
+ fix: z.string(),
734
+ example: z.string(),
735
+ doc: z.string(),
736
+ docUrl: z.string(),
737
+ templateFile: z.string().optional(),
738
+ severity: storefrontRuleSeveritySchema.optional(),
739
+ }),
740
+ ),
741
+ message: z.string(),
742
+ nextSteps: z.array(z.string()),
743
+ });
744
+ export type StorefrontVerifyReport = z.infer<typeof storefrontVerifyReportSchema>;
745
+
746
+ // ─── 빌드 ─────────────────────────────────────────────────
747
+
748
+ export const storefrontBuildStatusSchema = z
749
+ .enum(["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "TIMED_OUT"])
750
+ .describe(
751
+ "`QUEUED` 대기 · `RUNNING` 빌드 중 · `SUCCEEDED` 성공(버전 `READY`) · `FAILED` 실패 · `TIMED_OUT` 시간 초과(10분)",
752
+ );
753
+ export type StorefrontBuildStatus = z.infer<typeof storefrontBuildStatusSchema>;
754
+
755
+ /** 끝난 빌드 상태 */
756
+ export const STOREFRONT_BUILD_TERMINAL_STATUSES: readonly StorefrontBuildStatus[] = [
757
+ "SUCCEEDED",
758
+ "FAILED",
759
+ "TIMED_OUT",
760
+ ];
761
+
762
+ /**
763
+ * 빌드 단계 — 이 순서로 돈다. 첫 실패에서 멈추지만 규칙 검증(`VERIFY`)은 실패해도 타입 검사까지 이어서 해 한 번에 알려 준다.
764
+ * `UPLOAD`는 사이트 버전 Worker 업로드다(성공하면 발행할 수 있다). 값은 늘어날 수 있다.
765
+ */
766
+ export const STOREFRONT_BUILD_STAGES = [
767
+ "VERIFY",
768
+ "RESTORE",
769
+ "INSTALL",
770
+ "TYPECHECK",
771
+ "BUILD",
772
+ "BUNDLE",
773
+ "PACK",
774
+ "UPLOAD",
775
+ ] as const;
776
+
777
+ export const storefrontBuildSchema = z.object({
778
+ buildId: z.string(),
779
+ versionId: z.string(),
780
+ versionNumber: z.number().int().min(1),
781
+ status: storefrontBuildStatusSchema,
782
+ failureStage: z
783
+ .string()
784
+ .nullable()
785
+ .describe(
786
+ "실패한 단계 (`VERIFY`·`RESTORE`·`INSTALL`·`TYPECHECK`·`BUILD`·`BUNDLE`·`PACK`·`UPLOAD`, 늘어날 수 있다). 실패가 아니면 null",
787
+ ),
788
+ errorCode: z
789
+ .string()
790
+ .nullable()
791
+ .describe(
792
+ "실패 코드 (`RULES_VIOLATED`·`INSTALL_FAILED`·`TYPECHECK_FAILED`·`BUILD_FAILED`·`BUNDLE_FAILED`·`ARTIFACT_INVALID`·`UPLOAD_FAILED`·`SOURCE_NOT_FOUND`·`CAPACITY_EXHAUSTED`·`INTERNAL`, 늘어날 수 있다). 실패가 아니면 null",
793
+ ),
794
+ logTail: z.string().nullable().describe("실패 단계 출력 꼬리 (최대 8KB). 없으면 null"),
795
+ stages: z
796
+ .array(
797
+ z.object({
798
+ stage: z.string(),
799
+ ok: z.boolean(),
800
+ durationMs: z.number().int().min(0),
801
+ }),
802
+ )
803
+ .describe("끝난 단계 (도는 순서)"),
804
+ verify: storefrontVerifyReportSchema
805
+ .nullable()
806
+ .describe("규칙 검증 보고서(위반 상세는 50건까지). 그 단계까지 가지 못했으면 null"),
807
+ queuedAt: z.string(),
808
+ startedAt: z.string().nullable(),
809
+ finishedAt: z.string().nullable(),
810
+ durationMs: z.number().int().nullable().describe("빌드 시간 (시작 → 끝)"),
811
+ });
812
+ export type StorefrontBuild = z.infer<typeof storefrontBuildSchema>;
813
+
814
+ export const storefrontBuildListQuerySchema = z.object({
815
+ limit: z.coerce.number().int().min(1).max(50).optional().describe("기본 20, 최신 먼저"),
816
+ });
817
+
818
+ /**
819
+ * `POST /v1/storefront/site/versions` — 지금 작업 트리를 고정해 버전을 만든다(빌드 시작). 사이트당 끝나지 않은 빌드는
820
+ * 하나다(409 `STOREFRONT_BUILD_IN_PROGRESS`). 편집 환경이 없어도 된다.
821
+ */
822
+ export const createStorefrontVersionRequestSchema = z.object({
823
+ message: z.string().max(200).optional().describe("버전 메모 (에이전트는 바꾼 내용 요약)"),
824
+ });
825
+ export type CreateStorefrontVersionRequest = z.infer<typeof createStorefrontVersionRequestSchema>;
826
+
827
+ /** 버전 만들기 응답 (201) — 버전은 `BUILDING`, 빌드는 `QUEUED`로 시작한다. 결과는 `GET builds/{buildId}`로 본다 */
828
+ export const storefrontVersionCreatedSchema = z.object({
829
+ version: storefrontVersionSchema,
830
+ build: storefrontBuildSchema,
831
+ });
832
+ export type StorefrontVersionCreated = z.infer<typeof storefrontVersionCreatedSchema>;
833
+
834
+ /**
835
+ * `POST /v1/storefront/site/editing/preview-link` — 편집 미리보기(dev 서버) 링크. 콘솔에서 로그인한 OWNER·ADMIN만
836
+ * 받는다(PAT·에이전트 불가). 5분 안에 열면 그 브라우저에 미리보기 호스트 전용 쿠키(12시간)가 생긴다. 편집 환경이
837
+ * 없으면 409 `EDIT_SESSION_REQUIRED`다(편집 환경이 `STARTING`이면 링크는 주고, 미리보기 화면이 기동 대기를 보여 준다).
838
+ */
839
+ export const createStorefrontEditPreviewLinkRequestSchema =
840
+ createStorefrontPreviewLinkRequestSchema;
841
+ export type CreateStorefrontEditPreviewLinkRequest = CreateStorefrontPreviewLinkRequest;