@sayren/store-sdk 0.20.0 → 0.22.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.
@@ -20,6 +20,7 @@ import { RESERVED_STORE_CODES } from "./store";
20
20
  * - 409 `STOREFRONT_SITE_CREATE_LIMIT` — 사이트는 스토어당 30일에 3번까지 만든다
21
21
  * - 409 `STOREFRONT_SITE_CHANGED` — 읽은 상태(`expected*`)와 현재 상태가 다르다. 다시 읽고 시도한다
22
22
  * - 409 `STOREFRONT_SITE_NOT_ACTIVE` — 정지(`SUSPENDED`)된 사이트는 바꾸거나 끌 수 없다
23
+ * - 409 `STORE_SUSPENDED` — 정지된 스토어에는 사이트를 만들 수 없다
23
24
  * - 409 `STOREFRONT_VERSION_NOT_READY` — 빌드가 끝나지 않았거나 실패한 버전은 발행할 수 없다
24
25
  * - 409 `STOREFRONT_NO_ROLLBACK_TARGET` — 되돌릴 이전 발행 버전이 없다
25
26
  * - 400 `STOREFRONT_SLUG_CONFIRM_MISMATCH` — 호스팅 끄기 확인 입력이 현재 슬러그와 다르다
@@ -41,6 +42,16 @@ import { RESERVED_STORE_CODES } from "./store";
41
42
  * - 409 `STOREFRONT_BUILD_IN_PROGRESS` — 이 사이트에 끝나지 않은 빌드가 있다(사이트당 하나)
42
43
  * - 404 `STOREFRONT_BUILD_NOT_FOUND`
43
44
  * - 502 `STOREFRONT_SANDBOX_UNAVAILABLE` — 편집 Sandbox 호출이 실패했다. 잠시 뒤 다시 한다
45
+ *
46
+ * 소스 내려받기·올리기(#60, `/v1/storefront/site/source/**`, `sayren` CLI):
47
+ * - 409 `SOURCE_CHANGED` — 작업 트리가 내려받은 뒤(`expectedBaseRef`) 바뀌었다(콘솔·AI 편집). `error.details.currentRef`
48
+ * - 409 `EDIT_SESSION_OPEN` — 편집 환경이 열려 있다. `endEditSession: true`로 끄고 진행할 수 있다
49
+ * - 409 `SOURCE_BLOBS_MISSING` — 올리지 않은 파일 내용이 있다(`error.details.hashes`). `POST source/blobs`로 올리고 다시 한다
50
+ * - 409 `DEPENDENCY_REJECTED` — 쓸 수 없는 npm 의존성이다(`error.details.rejected: [{ name, version, reason, detail }]`)
51
+ * - 413 `SOURCE_TOO_MANY_NEW_FILES` — 한 번에 새 내용이 800개를 넘는다(`error.details.max`). 나눠서 바꾼다
52
+ * - 400 `DEPENDENCY_FILES_MISMATCH` — `packageJson`·`lockfile` 내용이 목록의 해시와 다르거나 빠졌다
53
+ * - 400 `INVALID_SOURCE_REF` · `BLOB_HASH_MISMATCH` · `INVALID_FILE_PATH`(`.env*`·작업 트리 밖 경로)
54
+ * - 404 `STOREFRONT_SOURCE_NOT_FOUND` — 그 스냅샷이 없다
44
55
  */
45
56
 
46
57
  // ─── 슬러그 ───────────────────────────────────────────────
@@ -68,6 +79,8 @@ export const RESERVED_STOREFRONT_SLUGS: readonly string[] = [
68
79
  "dispatch",
69
80
  "control",
70
81
  "outbound",
82
+ // 내 도메인(M3)의 CNAME 대상·fallback origin이다(`sites.{호스팅 도메인}`)
83
+ "sites",
71
84
  ];
72
85
 
73
86
  /**
@@ -159,6 +172,13 @@ export const storefrontVersionSchema = z.object({
159
172
  createdByType: z
160
173
  .string()
161
174
  .describe("만든 행위자 유형 (`USER`·`AGENT`·`APP`·`TOKEN`·`SYSTEM`). 늘어날 수 있다"),
175
+ createdVia: z
176
+ .string()
177
+ .nullable()
178
+ .optional()
179
+ .describe(
180
+ "만든 경로 (`CONSOLE` 콘솔 · `AI` AI 에이전트(MCP) · `CLI` `sayren` CLI · `API` 관리 API · `SYSTEM` 자동). 늘어날 수 있다",
181
+ ),
162
182
  createdAt: z.string().describe("만든 시각 (ISO 8601)"),
163
183
  });
164
184
  export type StorefrontVersion = z.infer<typeof storefrontVersionSchema>;
@@ -184,7 +204,9 @@ export const storefrontDomainSchema = z.object({
184
204
  kind: z.enum(["PLATFORM", "CUSTOM"]).describe("`PLATFORM` 슬러그 주소 · `CUSTOM` 내 도메인"),
185
205
  status: z
186
206
  .enum(["ACTIVE", "HELD"])
187
- .describe("`ACTIVE` 사용 중 · `HELD` 슬러그를 바꿔 보류 중(새 주소로 301)"),
207
+ .describe(
208
+ "`ACTIVE` 사용 중 · `HELD` 슬러그를 바꿔 보류 중(새 주소로 301). 연결 중인 내 도메인은 싣지 않는다(`GET /v1/storefront/site/domains`)",
209
+ ),
188
210
  isPrimary: z.boolean().describe("대표 주소인가 (`Store.storefrontUrl`)"),
189
211
  holdUntil: z.string().nullable().describe("보류가 끝나는 시각 (ISO 8601). 보류가 아니면 null"),
190
212
  });
@@ -219,6 +241,16 @@ export const storefrontEditPreviewSchema = z.object({
219
241
  });
220
242
  export type StorefrontEditPreview = z.infer<typeof storefrontEditPreviewSchema>;
221
243
 
244
+ /**
245
+ * 사이트 정지 — 운영 정책에 따라 플랫폼 운영자가 사이트를 정지한다. 정지 중에는 방문자에게 정지 안내가 보이고 발행·되돌리기·템플릿 변경·
246
+ * 공개 전환·슬러그 변경·끄기·편집이 409 `STOREFRONT_SITE_NOT_ACTIVE`다. 셀러가 풀 수 없다(고객센터로 문의한다).
247
+ */
248
+ export const storefrontSuspensionSchema = z.object({
249
+ reason: z.string().nullable().describe("정지 사유. 운영자가 적은 안내이고 없으면 null"),
250
+ suspendedAt: z.string().nullable().describe("정지한 시각 (ISO 8601)"),
251
+ });
252
+ export type StorefrontSuspension = z.infer<typeof storefrontSuspensionSchema>;
253
+
222
254
  export const storefrontSiteSchema = z.object({
223
255
  siteId: z.string(),
224
256
  slug: z.string().describe("주소 슬러그"),
@@ -237,6 +269,10 @@ export const storefrontSiteSchema = z.object({
237
269
  .int()
238
270
  .min(0)
239
271
  .describe(`최근 ${STOREFRONT_SLUG_CHANGE_WINDOW_DAYS}일 기준 남은 슬러그 변경 횟수`),
272
+ suspension: storefrontSuspensionSchema
273
+ .nullable()
274
+ .optional()
275
+ .describe("정지 정보. `status`가 `SUSPENDED`일 때만 있고 그 밖은 null"),
240
276
  createdAt: z.string(),
241
277
  updatedAt: z.string(),
242
278
  });
@@ -344,6 +380,264 @@ export const storefrontPreviewLinkSchema = z.object({
344
380
  });
345
381
  export type StorefrontPreviewLink = z.infer<typeof storefrontPreviewLinkSchema>;
346
382
 
383
+ // ─── 내 도메인 (M3) ───────────────────────────────────────
384
+ //
385
+ // 셀러가 가진 도메인(예: `shop.brand.com`)을 사이트 주소로 연결한다. 흐름은 추가(`PENDING_DNS`) → 셀러가 DNS에 TXT(소유 확인)·
386
+ // CNAME(연결)을 넣음 → 서버가 TXT를 확인하면 도메인을 이 스토어에 잡고 인증서 발급을 시작한다(`PENDING_VALIDATION`) → 인증서가
387
+ // 발급되면 `ACTIVE`다. 확인은 서버가 주기적으로 하고 `verify`는 곧바로 한 번 더 본다. 연결된 도메인은 대표 주소로 지정할 수 있고,
388
+ // 대표가 아닌 주소(기본 주소 포함)는 대표 주소로 301한다.
389
+ //
390
+ // - TXT 소유 확인은 도메인마다 새 값이다. CNAME만으로는 연결하지 않는다 — 연결을 끊은 뒤 CNAME이 남은 도메인을 다른 스토어가
391
+ // 가져가지 못하게 한다.
392
+ // - 소유 확인 전(`PENDING_DNS`)에는 도메인을 잡지 않는다. 같은 도메인을 여러 스토어가 추가해 둘 수 있고, 먼저 TXT 확인을 통과한
393
+ // 스토어가 잡는다. 이미 다른 스토어가 잡은 도메인은 409 `STOREFRONT_DOMAIN_TAKEN`이다.
394
+ // - 해제하면 곧바로 연결을 지운다. 연결된 뒤 DNS가 7일 넘게 이 서비스를 가리키지 않으면 서버가 연결을 해제한다.
395
+ // - 결제 복귀·방문 분석·소셜 로그인 허용 목록은 연결된(`ACTIVE`) 도메인을 자동으로 더한다.
396
+ //
397
+ // 오류 코드:
398
+ // - 403 `STOREFRONT_CUSTOM_DOMAINS_DISABLED` — 플랫폼 스위치가 꺼져 도메인을 추가할 수 없다
399
+ // - 400 `STOREFRONT_DOMAIN_INVALID` — 형식이 틀렸거나 연결할 수 없는 도메인이다(`error.details.reason`)
400
+ // - 404 `STOREFRONT_DOMAIN_NOT_FOUND`
401
+ // - 409 `STOREFRONT_DOMAIN_EXISTS` — 이 스토어에 이미 추가한 도메인이다
402
+ // - 409 `STOREFRONT_DOMAIN_TAKEN` — 다른 스토어가 연결했거나 연결 중인 도메인이다
403
+ // - 409 `STOREFRONT_DOMAIN_LIMIT` — 스토어당 도메인 수 상한(`error.details.limit`)
404
+ // - 409 `STOREFRONT_DOMAIN_NOT_ACTIVE` — 연결되지 않았거나 경고(`failureCode`)가 있는 도메인은 대표 주소로 지정할 수 없다
405
+ // - 429 `TOO_MANY_REQUESTS` — 재확인은 스토어당 분당 20번까지다(`error.details.retryAfterSeconds`)
406
+ // - 400 `STOREFRONT_DOMAIN_NOT_REMOVABLE` — 기본 주소는 해제할 수 없다
407
+
408
+ /**
409
+ * 연결할 수 없는 도메인 — 플랫폼이 쓰는 도메인과 그 하위(기본 주소는 슬러그로 바꾼다). 호스팅 도메인은 서버 설정이라
410
+ * `storefrontCustomHostnameProblem`의 인자로 따로 받는다.
411
+ */
412
+ export const STOREFRONT_PLATFORM_DOMAINS: readonly string[] = [
413
+ "sayren.co",
414
+ "sayren.app",
415
+ "sayren.shop",
416
+ "avarlabs.com",
417
+ ];
418
+
419
+ /** 공개 인터넷에서 쓸 수 없는 최상위 도메인(RFC 2606·6761·6762 등) */
420
+ const NON_PUBLIC_TLDS: readonly string[] = [
421
+ "example",
422
+ "localhost",
423
+ "local",
424
+ "test",
425
+ "invalid",
426
+ "onion",
427
+ "internal",
428
+ "arpa",
429
+ "home",
430
+ "lan",
431
+ "corp",
432
+ "intranet",
433
+ "alt",
434
+ ];
435
+
436
+ /**
437
+ * 공용 접미사 — 누구나 그 아래 이름을 받는 도메인이라 접미사 자체는 연결할 수 없다. Public Suffix List 전체가 아니라 자주 틀리는
438
+ * 것만 둔다(여기 없는 접미사도 TXT 소유 확인을 통과할 수 없어 연결되지 않는다 — 이 목록은 추가 단계에서 빨리 알려 주는 안내다).
439
+ */
440
+ export const STOREFRONT_PUBLIC_SUFFIXES: readonly string[] = [
441
+ "co.kr",
442
+ "or.kr",
443
+ "ne.kr",
444
+ "go.kr",
445
+ "ac.kr",
446
+ "re.kr",
447
+ "pe.kr",
448
+ "mil.kr",
449
+ "hs.kr",
450
+ "ms.kr",
451
+ "es.kr",
452
+ "sc.kr",
453
+ "kg.kr",
454
+ "seoul.kr",
455
+ "busan.kr",
456
+ "incheon.kr",
457
+ "daegu.kr",
458
+ "gwangju.kr",
459
+ "daejeon.kr",
460
+ "ulsan.kr",
461
+ "gyeonggi.kr",
462
+ "gangwon.kr",
463
+ "chungbuk.kr",
464
+ "chungnam.kr",
465
+ "jeonbuk.kr",
466
+ "jeonnam.kr",
467
+ "gyeongbuk.kr",
468
+ "gyeongnam.kr",
469
+ "jeju.kr",
470
+ "co.jp",
471
+ "ne.jp",
472
+ "or.jp",
473
+ "com.cn",
474
+ "net.cn",
475
+ "com.tw",
476
+ "com.hk",
477
+ "co.uk",
478
+ "org.uk",
479
+ "com.au",
480
+ "net.au",
481
+ "com.sg",
482
+ "co.nz",
483
+ "com.br",
484
+ "github.io",
485
+ "gitlab.io",
486
+ "pages.dev",
487
+ "workers.dev",
488
+ "vercel.app",
489
+ "netlify.app",
490
+ "herokuapp.com",
491
+ "web.app",
492
+ "firebaseapp.com",
493
+ "blogspot.com",
494
+ "cloudfront.net",
495
+ "azurewebsites.net",
496
+ "myshopify.com",
497
+ "wixsite.com",
498
+ "cafe24.com",
499
+ "imweb.me",
500
+ ];
501
+
502
+ /** 스토어당 연결할 수 있는 도메인 수 기본값 — 요금제(#44) 전의 상한이다. 실제 값은 `GET …/domains`의 `limit` */
503
+ export const STOREFRONT_CUSTOM_DOMAIN_LIMIT_DEFAULT = 2;
504
+ /** DNS를 넣을 때까지 기다리는 기간(일) — 지나면 `FAILED`(`DNS_NOT_CONFIGURED`) */
505
+ export const STOREFRONT_DOMAIN_DNS_WAIT_DAYS = 7;
506
+ /** 연결된 도메인의 DNS가 이 서비스를 가리키지 않아도 연결을 유지하는 기간(일) — 지나면 서버가 해제한다 */
507
+ export const STOREFRONT_DOMAIN_DNS_GRACE_DAYS = 7;
508
+ /** 소유 확인 TXT 레코드의 이름 앞부분 — `_sayren-verify.{도메인}` */
509
+ export const STOREFRONT_DOMAIN_TXT_LABEL = "_sayren-verify";
510
+
511
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
512
+ const PUNYCODE_TLD = /^xn--[a-z0-9-]{1,59}$/;
513
+ const ALPHA_TLD = /^[a-z]{2,63}$/;
514
+
515
+ /**
516
+ * 도메인 정규화 — 앞뒤 공백·끝 점을 떼고 소문자로 바꾸며 국제화 도메인은 punycode(`xn--`)로 바꾼다. 스킴·경로·포트가 붙은 입력
517
+ * (`https://www.a.com/`)은 호스트만 남기지 않고 그대로 두어 형식 검사에서 떨어진다(서버는 사용자가 적은 도메인만 받는다).
518
+ * 변환할 수 없으면 입력을 소문자로만 바꿔 돌려준다(형식 검사가 거부한다).
519
+ */
520
+ export function normalizeStorefrontHostname(input: string): string {
521
+ const trimmed = input.trim().replace(/\.+$/, "");
522
+ if (!trimmed || /[\s/:?#@\\[\]%]/.test(trimmed)) return trimmed.toLowerCase();
523
+ if (/^[\x21-\x7e]+$/.test(trimmed)) return trimmed.toLowerCase();
524
+ try {
525
+ // WHATWG URL이 IDNA(UTS #46)로 punycode를 만든다. 브라우저·Node·Workers가 같은 결과다
526
+ return new URL(`http://${trimmed}`).hostname.toLowerCase();
527
+ } catch {
528
+ return trimmed.toLowerCase();
529
+ }
530
+ }
531
+
532
+ export type StorefrontHostnameProblem =
533
+ | "FORMAT"
534
+ | "IP_ADDRESS"
535
+ | "SINGLE_LABEL"
536
+ | "NON_PUBLIC_TLD"
537
+ | "PLATFORM_DOMAIN"
538
+ | "PUBLIC_SUFFIX"
539
+ | "WILDCARD";
540
+
541
+ /**
542
+ * 연결할 수 있는 도메인인가(정규화한 값 기준). 문제가 없으면 null이다. 규칙: 253자 이하, 라벨 2개 이상, 라벨은 소문자·숫자·하이픈
543
+ * 63자 이하(하이픈으로 시작·끝 불가), 최상위 도메인은 영문자이거나 punycode, IP 주소·와일드카드·공개 인터넷 밖 최상위 도메인·
544
+ * 플랫폼 도메인과 그 하위·공용 접미사 자체는 안 된다. 제어·디스패치 Worker가 형식 규칙의 복제본을 들고 있다(api
545
+ * `test/storefront-hosting-parity.spec.ts`가 대조한다).
546
+ */
547
+ export function storefrontCustomHostnameProblem(
548
+ hostname: string,
549
+ hostingDomain = "sayren.co",
550
+ ): StorefrontHostnameProblem | null {
551
+ if (hostname.startsWith("*.")) return "WILDCARD";
552
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || /^\[?[0-9a-f:.]*:[0-9a-f:.]*\]?$/.test(hostname))
553
+ return "IP_ADDRESS";
554
+ if (hostname.length === 0 || hostname.length > 253) return "FORMAT";
555
+ const labels = hostname.split(".");
556
+ if (labels.length < 2) return "SINGLE_LABEL";
557
+ if (!labels.every((label) => LABEL.test(label))) return "FORMAT";
558
+ const tld = labels[labels.length - 1] ?? "";
559
+ if (!ALPHA_TLD.test(tld) && !PUNYCODE_TLD.test(tld)) return "FORMAT";
560
+ if (NON_PUBLIC_TLDS.includes(tld)) return "NON_PUBLIC_TLD";
561
+ const platform = new Set([...STOREFRONT_PLATFORM_DOMAINS, hostingDomain.toLowerCase()]);
562
+ for (const domain of platform) {
563
+ if (hostname === domain || hostname.endsWith(`.${domain}`)) return "PLATFORM_DOMAIN";
564
+ }
565
+ if (STOREFRONT_PUBLIC_SUFFIXES.includes(hostname)) return "PUBLIC_SUFFIX";
566
+ return null;
567
+ }
568
+
569
+ /** 소유 확인 TXT 레코드의 전체 이름 */
570
+ export function storefrontDomainTxtNameOf(hostname: string): string {
571
+ return `${STOREFRONT_DOMAIN_TXT_LABEL}.${hostname}`;
572
+ }
573
+
574
+ export const storefrontCustomDomainStatusSchema = z
575
+ .enum(["PENDING_DNS", "PENDING_VALIDATION", "ACTIVE", "FAILED"])
576
+ .describe(
577
+ "`PENDING_DNS` DNS 설정 대기(소유 확인 전) · `PENDING_VALIDATION` 소유 확인됨, 인증서 발급 중 · `ACTIVE` 연결됨 · `FAILED` 연결 실패(`failureCode`)",
578
+ );
579
+ export type StorefrontCustomDomainStatus = z.infer<typeof storefrontCustomDomainStatusSchema>;
580
+
581
+ export const storefrontDnsRecordSchema = z.object({
582
+ type: z.enum(["CNAME", "TXT"]).describe("레코드 유형"),
583
+ name: z.string().describe("레코드 이름 (전체 이름, 예: `_sayren-verify.shop.brand.com`)"),
584
+ value: z.string().describe("레코드 값"),
585
+ purpose: z.enum(["ROUTING", "OWNERSHIP"]).describe("`ROUTING` 연결 · `OWNERSHIP` 소유 확인"),
586
+ });
587
+ export type StorefrontDnsRecord = z.infer<typeof storefrontDnsRecordSchema>;
588
+
589
+ export const storefrontSiteDomainSchema = z.object({
590
+ domainId: z.string(),
591
+ hostname: z.string().describe("호스트 이름 (punycode, 소문자)"),
592
+ kind: z
593
+ .enum(["PLATFORM", "CUSTOM"])
594
+ .describe("`PLATFORM` 기본 주소(슬러그) · `CUSTOM` 내 도메인"),
595
+ status: storefrontCustomDomainStatusSchema.describe(
596
+ "기본 주소는 늘 `ACTIVE`다. 내 도메인은 연결 단계",
597
+ ),
598
+ isPrimary: z
599
+ .boolean()
600
+ .describe("대표 주소인가 (`Store.storefrontUrl`). 대표가 아닌 주소는 대표로 301한다"),
601
+ dnsRecords: z.array(storefrontDnsRecordSchema).describe("DNS에 넣을 레코드. 기본 주소는 빈 배열"),
602
+ certificateStatus: z
603
+ .enum(["PENDING", "ACTIVE", "FAILED"])
604
+ .nullable()
605
+ .describe("인증서 상태. 소유 확인 전·기본 주소는 null"),
606
+ failureCode: z
607
+ .string()
608
+ .nullable()
609
+ .describe(
610
+ "실패·경고 코드. `DNS_NOT_CONFIGURED` 기한 안에 TXT를 찾지 못함 · `VALIDATION_TIMEOUT` 인증서 발급 기한 지남 · `CERTIFICATE_FAILED` 인증서 발급 실패 · `DNS_NOT_POINTING` 연결된 뒤 DNS가 이 서비스를 가리키지 않음(기한이 지나면 해제) · `DOMAIN_TAKEN` 다른 스토어가 먼저 소유를 확인함 · `HOSTNAME_BLOCKED` 연결이 막힘 · `HOSTNAME_CONFLICT` 연결 준비 중 충돌(운영 확인). 늘어날 수 있다",
611
+ ),
612
+ verifiedAt: z.string().nullable().describe("소유를 확인한 시각 (ISO 8601)"),
613
+ activatedAt: z.string().nullable().describe("연결된 시각 (ISO 8601)"),
614
+ checkedAt: z.string().nullable().describe("마지막으로 확인한 시각 (ISO 8601)"),
615
+ createdAt: z.string(),
616
+ });
617
+ export type StorefrontSiteDomain = z.infer<typeof storefrontSiteDomainSchema>;
618
+
619
+ /** `GET /v1/storefront/site/domains` — 사이트의 주소 목록(기본 주소 + 내 도메인)과 연결 설정 */
620
+ export const storefrontSiteDomainsSchema = z.object({
621
+ enabled: z.boolean().describe("내 도메인을 추가할 수 있는가 (플랫폼 스위치)"),
622
+ limit: z
623
+ .number()
624
+ .int()
625
+ .min(0)
626
+ .describe("스토어당 내 도메인 수 상한 (해제한 도메인은 세지 않는다)"),
627
+ target: z.string().describe("CNAME 대상 (예: `sites.sayren.co`)"),
628
+ domains: z.array(storefrontSiteDomainSchema).describe("대표 주소 먼저, 그다음 추가한 순서"),
629
+ });
630
+ export type StorefrontSiteDomains = z.infer<typeof storefrontSiteDomainsSchema>;
631
+
632
+ export const addStorefrontDomainRequestSchema = z.object({
633
+ hostname: z
634
+ .string()
635
+ .min(1)
636
+ .max(300)
637
+ .describe("연결할 도메인 (예: `shop.brand.com`). 서버가 소문자·punycode로 정규화한다"),
638
+ });
639
+ export type AddStorefrontDomainRequest = z.infer<typeof addStorefrontDomainRequestSchema>;
640
+
347
641
  // ─── 편집 (M2b) ───────────────────────────────────────────
348
642
  //
349
643
  // 셀러(콘솔)와 에이전트(MCP)가 사이트의 작업 트리를 고치고 버전을 만든다. 작업 트리는 사이트당 하나이고 편집 Sandbox
@@ -761,9 +1055,11 @@ export const STOREFRONT_BUILD_TERMINAL_STATUSES: readonly StorefrontBuildStatus[
761
1055
 
762
1056
  /**
763
1057
  * 빌드 단계 — 이 순서로 돈다. 첫 실패에서 멈추지만 규칙 검증(`VERIFY`)은 실패해도 타입 검사까지 이어서 해 한 번에 알려 준다.
764
- * `UPLOAD`는 사이트 버전 Worker 업로드다(성공하면 발행할 수 있다). 값은 늘어날 수 있다.
1058
+ * `UPLOAD`는 사이트 버전 Worker 업로드다(성공하면 발행할 수 있다). `DEPENDENCIES`는 빌드 시작 전 의존성 검사(거절 목록·보안 권고)다.
1059
+ * 값은 늘어날 수 있다.
765
1060
  */
766
1061
  export const STOREFRONT_BUILD_STAGES = [
1062
+ "DEPENDENCIES",
767
1063
  "VERIFY",
768
1064
  "RESTORE",
769
1065
  "INSTALL",
@@ -774,6 +1070,40 @@ export const STOREFRONT_BUILD_STAGES = [
774
1070
  "UPLOAD",
775
1071
  ] as const;
776
1072
 
1073
+ /**
1074
+ * 의존성 검사 결과 한 줄(#61) — 거절(`DEPENDENCY_REJECTED`의 `details.rejected`)·경고(빌드 `dependencies`, 소스 교체 `dependencyWarnings`)가
1075
+ * 같은 모양이다. 대상 패키지와 사유. 셀러가 고칠 수 있게 버전·권고 id·거절 사유를 싣는다.
1076
+ * `reason`: `DENYLISTED`(거절 목록) · `ADVISORY`(보안 권고) · `MALWARE`(악성 코드 권고, 패키지 전체) · `INSTALL_SCRIPT`(루트
1077
+ * package.json의 설치 훅은 거절, 의존성의 설치 스크립트는 경고) · `UNSUPPORTED_SOURCE`(레지스트리 밖 설치 경로) · `BUNDLED_DEPENDENCIES`(묶여
1078
+ * 오는 의존성, 경고) · `INVALID_MANIFEST`(package.json·잠금 파일 형식), 늘어날 수 있다.
1079
+ */
1080
+ export const storefrontDependencyFindingSchema = z.object({
1081
+ name: z.string().describe("패키지 이름. 파일 전체의 문제면 `package.json`·`pnpm-lock.yaml`"),
1082
+ version: z.string().nullable().describe("해당 버전. 버전과 무관하면 null"),
1083
+ reason: z.string().describe("사유 코드"),
1084
+ detail: z.string().describe("셀러용 설명"),
1085
+ advisory: z
1086
+ .object({
1087
+ id: z.string(),
1088
+ url: z.string().nullable(),
1089
+ severity: z.string(),
1090
+ title: z.string(),
1091
+ })
1092
+ .nullable()
1093
+ .optional()
1094
+ .describe("보안 권고(사유가 `ADVISORY`·`MALWARE`일 때)"),
1095
+ });
1096
+ export type StorefrontDependencyFinding = z.infer<typeof storefrontDependencyFindingSchema>;
1097
+
1098
+ export const storefrontBuildDependenciesSchema = z.object({
1099
+ checkedPackages: z.number().int().min(0).describe("검사한 설치 트리의 패키지(이름·버전) 수"),
1100
+ rejected: z.array(storefrontDependencyFindingSchema).describe("빌드를 막은 의존성"),
1101
+ warnings: z
1102
+ .array(storefrontDependencyFindingSchema)
1103
+ .describe("빌드는 막지 않는 경고(중간 이하 심각도 권고)"),
1104
+ });
1105
+ export type StorefrontBuildDependencies = z.infer<typeof storefrontBuildDependenciesSchema>;
1106
+
777
1107
  export const storefrontBuildSchema = z.object({
778
1108
  buildId: z.string(),
779
1109
  versionId: z.string(),
@@ -783,13 +1113,13 @@ export const storefrontBuildSchema = z.object({
783
1113
  .string()
784
1114
  .nullable()
785
1115
  .describe(
786
- "실패한 단계 (`VERIFY`·`RESTORE`·`INSTALL`·`TYPECHECK`·`BUILD`·`BUNDLE`·`PACK`·`UPLOAD`, 늘어날 수 있다). 실패가 아니면 null",
1116
+ "실패한 단계 (`DEPENDENCIES`·`VERIFY`·`RESTORE`·`INSTALL`·`TYPECHECK`·`BUILD`·`BUNDLE`·`PACK`·`UPLOAD`, 늘어날 수 있다). 실패가 아니면 null",
787
1117
  ),
788
1118
  errorCode: z
789
1119
  .string()
790
1120
  .nullable()
791
1121
  .describe(
792
- "실패 코드 (`RULES_VIOLATED`·`INSTALL_FAILED`·`TYPECHECK_FAILED`·`BUILD_FAILED`·`BUNDLE_FAILED`·`ARTIFACT_INVALID`·`UPLOAD_FAILED`·`SOURCE_NOT_FOUND`·`CAPACITY_EXHAUSTED`·`INTERNAL`, 늘어날 수 있다). 실패가 아니면 null",
1122
+ "실패 코드 (`DEPENDENCY_REJECTED`·`DEPENDENCY_CHECK_UNAVAILABLE`·`DEPENDENCIES_CHANGED`·`RULES_VIOLATED`·`INSTALL_FAILED`·`INSTALL_TOO_LARGE`·`TYPECHECK_FAILED`·`BUILD_FAILED`·`BUILD_TIMEOUT`·`BUNDLE_FAILED`·`ARTIFACT_TOO_LARGE`·`ARTIFACT_INVALID`·`UPLOAD_FAILED`·`SOURCE_NOT_FOUND`·`CAPACITY_EXHAUSTED`·`INTERNAL`, 늘어날 수 있다). 실패가 아니면 null",
793
1123
  ),
794
1124
  logTail: z.string().nullable().describe("실패 단계 출력 꼬리 (최대 8KB). 없으면 null"),
795
1125
  stages: z
@@ -804,6 +1134,13 @@ export const storefrontBuildSchema = z.object({
804
1134
  verify: storefrontVerifyReportSchema
805
1135
  .nullable()
806
1136
  .describe("규칙 검증 보고서(위반 상세는 50건까지). 그 단계까지 가지 못했으면 null"),
1137
+ // 옛 api 응답에는 없다 — 새 SDK가 배포 전 api와 함께 쓰여도 깨지지 않게 선택 필드다
1138
+ dependencies: storefrontBuildDependenciesSchema
1139
+ .nullable()
1140
+ .optional()
1141
+ .describe(
1142
+ "의존성 검사 결과(거절 목록·보안 권고). 빌드 시작 전에 돈다. 거절이 있으면 `failureStage: DEPENDENCIES`, `errorCode: DEPENDENCY_REJECTED`다. 아직 검사 전이면 null(옛 서버는 싣지 않는다)",
1143
+ ),
807
1144
  queuedAt: z.string(),
808
1145
  startedAt: z.string().nullable(),
809
1146
  finishedAt: z.string().nullable(),
@@ -821,6 +1158,13 @@ export const storefrontBuildListQuerySchema = z.object({
821
1158
  */
822
1159
  export const createStorefrontVersionRequestSchema = z.object({
823
1160
  message: z.string().max(200).optional().describe("버전 메모 (에이전트는 바꾼 내용 요약)"),
1161
+ expectedSourceRef: z
1162
+ .string()
1163
+ .max(200)
1164
+ .optional()
1165
+ .describe(
1166
+ "고정할 작업 트리 (`PUT /v1/storefront/site/source`의 `sourceRef`). 주면 지금 작업 트리가 이 값일 때만 버전을 만든다(다르면 409 `SOURCE_CHANGED`)",
1167
+ ),
824
1168
  });
825
1169
  export type CreateStorefrontVersionRequest = z.infer<typeof createStorefrontVersionRequestSchema>;
826
1170
 
@@ -839,3 +1183,142 @@ export type StorefrontVersionCreated = z.infer<typeof storefrontVersionCreatedSc
839
1183
  export const createStorefrontEditPreviewLinkRequestSchema =
840
1184
  createStorefrontPreviewLinkRequestSchema;
841
1185
  export type CreateStorefrontEditPreviewLinkRequest = CreateStorefrontPreviewLinkRequest;
1186
+
1187
+ // ─── 소스 내려받기·올리기 (#60, `sayren` CLI) ─────────────────────────────
1188
+ //
1189
+ // 셀러가 사이트 작업 트리를 로컬로 받아(`npx sayren pull`) 고치고 되돌려 놓는다(`npx sayren deploy`). 콘솔·AI 편집과 같은
1190
+ // 작업 트리다. 올리는 것은 소스뿐이고 빌드는 서버가 한다(버전 만들기). 흐름:
1191
+ // 1. `GET source` — 지금 작업 트리를 고정하고(`sourceRef`) 압축 파일 크기를 알린다
1192
+ // 2. `GET source/archive?sourceRef=&part=` — 압축 파일(tar.gz)을 4MiB씩 받는다
1193
+ // 3. `PUT source` — 바꾼 뒤 트리 전체 목록(경로·해시·크기)과 `package.json`·`pnpm-lock.yaml` 내용을 보낸다(의존성 판정). 서버에 없는 내용은 409 `SOURCE_BLOBS_MISSING`이
1194
+ // 알려 주고, `POST source/blobs`로 올린 뒤 다시 보낸다. `expectedBaseRef`(내려받은 `sourceRef`)가 지금과 다르면 409 `SOURCE_CHANGED`
1195
+
1196
+ /** 압축 파일 조각 하나의 최대 크기(바이트) */
1197
+ export const STOREFRONT_SOURCE_PART_BYTES = 4 * 1024 * 1024;
1198
+ /** 내용 올리기 한 번의 최대 파일 수 */
1199
+ export const STOREFRONT_SOURCE_BLOB_BATCH_MAX = 200;
1200
+ /** 작업 트리 파일 수 상한 */
1201
+ export const STOREFRONT_WORKSPACE_MAX_FILES = 2000;
1202
+
1203
+ /** 작업 트리 스냅샷 키 — `sources/{siteId}/{sha256}.tgz` */
1204
+ export const storefrontSourceRefSchema = z
1205
+ .string()
1206
+ .regex(/^sources\/sfs_[a-z0-9]{8,40}\/[0-9a-f]{64}\.tgz$/, "작업 트리 스냅샷 키 형식이 아닙니다")
1207
+ .describe("작업 트리 스냅샷 (`sources/{siteId}/{sha256}.tgz`, 내용 주소)");
1208
+
1209
+ /** `GET /v1/storefront/site/source` — 지금 작업 트리(편집 환경이 떠 있으면 지금 내용을 고정한다) */
1210
+ export const storefrontSourceSchema = z.object({
1211
+ siteId: z.string(),
1212
+ storeId: z.string(),
1213
+ storeCode: z.string().describe("스토어 코드 — 로컬 개발 서버의 `SAYREN_STORE_CODE`"),
1214
+ sourceRef: storefrontSourceRefSchema,
1215
+ treeSha256: z.string().describe("트리 지문 (경로 오름차순 `{path}\\t{sha256}\\n`의 SHA-256)"),
1216
+ files: z.number().int().min(0).describe("파일 수"),
1217
+ bytes: z.number().int().min(0).describe("파일 크기 합"),
1218
+ archive: z.object({
1219
+ format: z.literal("tar+gzip"),
1220
+ bytes: z.number().int().min(0).describe("압축 파일 크기"),
1221
+ sha256: z.string().describe("압축 파일의 SHA-256 (`sourceRef`의 해시와 같다)"),
1222
+ parts: z.number().int().min(0).describe("`GET source/archive`로 받을 조각 수"),
1223
+ partBytes: z.number().int().describe("조각 하나의 최대 크기"),
1224
+ }),
1225
+ editSessionOpen: z
1226
+ .boolean()
1227
+ .describe("편집 환경이 열려 있는가 (올리기 전에 끄거나 `endEditSession`)"),
1228
+ });
1229
+ export type StorefrontSource = z.infer<typeof storefrontSourceSchema>;
1230
+
1231
+ export const storefrontSourceArchiveQuerySchema = z.object({
1232
+ sourceRef: storefrontSourceRefSchema,
1233
+ part: z.coerce.number().int().min(0).max(100).optional().describe("조각 번호 (0부터, 기본 0)"),
1234
+ });
1235
+
1236
+ /** `GET /v1/storefront/site/source/archive` — 압축 파일 조각 */
1237
+ export const storefrontSourceArchivePartSchema = z.object({
1238
+ sourceRef: storefrontSourceRefSchema,
1239
+ part: z.number().int().min(0),
1240
+ parts: z.number().int().min(0),
1241
+ offset: z.number().int().min(0),
1242
+ bytes: z.number().int().min(0).describe("이 조각의 크기"),
1243
+ totalBytes: z.number().int().min(0),
1244
+ content: z.string().describe("조각 내용 (base64)"),
1245
+ });
1246
+ export type StorefrontSourceArchivePart = z.infer<typeof storefrontSourceArchivePartSchema>;
1247
+
1248
+ /** `POST /v1/storefront/site/source/blobs` — 파일 내용 올리기(해시 키, 다시 올려도 안전하다). 작업 트리는 바뀌지 않는다 */
1249
+ export const uploadStorefrontSourceBlobsRequestSchema = z.object({
1250
+ blobs: z
1251
+ .array(
1252
+ z.object({
1253
+ hash: storefrontFileHashSchema,
1254
+ content: z.string().describe("파일 내용 (base64, 1MiB까지)"),
1255
+ }),
1256
+ )
1257
+ .min(1)
1258
+ .max(STOREFRONT_SOURCE_BLOB_BATCH_MAX),
1259
+ });
1260
+ export type UploadStorefrontSourceBlobsRequest = z.infer<
1261
+ typeof uploadStorefrontSourceBlobsRequestSchema
1262
+ >;
1263
+
1264
+ export const storefrontSourceBlobsResultSchema = z.object({
1265
+ stored: z.number().int().min(0).describe("새로 올린 수"),
1266
+ existing: z.number().int().min(0).describe("이미 있던 수"),
1267
+ });
1268
+ export type StorefrontSourceBlobsResult = z.infer<typeof storefrontSourceBlobsResultSchema>;
1269
+
1270
+ /**
1271
+ * `PUT /v1/storefront/site/source` — 작업 트리 전체를 이 목록으로 바꾼다(없는 경로는 지운다). 전부 아니면 전무다.
1272
+ * `package.json`·`pnpm-lock.yaml`도 바꿀 수 있고 의존성은 허용 목록 안이어야 한다. `.env*`·작업 트리 밖 경로는 받지 않는다.
1273
+ */
1274
+ export const replaceStorefrontSourceRequestSchema = z.object({
1275
+ expectedBaseRef: storefrontSourceRefSchema
1276
+ .optional()
1277
+ .describe(
1278
+ "내려받은 작업 트리. 지금 작업 트리가 이 값일 때만 바꾼다. 생략하면 조건 없이 덮어쓴다",
1279
+ ),
1280
+ files: z
1281
+ .array(
1282
+ z.object({
1283
+ path: storefrontFilePathSchema,
1284
+ hash: storefrontFileHashSchema,
1285
+ size: z.number().int().min(0).max(STOREFRONT_FILE_MAX_BYTES),
1286
+ }),
1287
+ )
1288
+ .min(1)
1289
+ .max(STOREFRONT_WORKSPACE_MAX_FILES)
1290
+ .refine((files) => new Set(files.map((file) => file.path)).size === files.length, {
1291
+ message: "같은 경로를 두 번 실을 수 없습니다",
1292
+ }),
1293
+ endEditSession: z
1294
+ .boolean()
1295
+ .optional()
1296
+ .describe("열린 편집 환경을 끄고 진행한다 (기본 false — 열려 있으면 409 `EDIT_SESSION_OPEN`)"),
1297
+ packageJson: z
1298
+ .string()
1299
+ .max(STOREFRONT_FILE_MAX_BYTES)
1300
+ .optional()
1301
+ .describe(
1302
+ "`files`에 `package.json`이 있으면 그 내용(utf8). 서버가 의존성을 판정한다. 해시가 목록과 다르면 400 `DEPENDENCY_FILES_MISMATCH`",
1303
+ ),
1304
+ lockfile: z
1305
+ .string()
1306
+ .max(STOREFRONT_FILE_MAX_BYTES)
1307
+ .optional()
1308
+ .describe("`files`에 `pnpm-lock.yaml`이 있으면 그 내용(utf8)"),
1309
+ });
1310
+ export type ReplaceStorefrontSourceRequest = z.infer<typeof replaceStorefrontSourceRequestSchema>;
1311
+
1312
+ export const storefrontSourceReplacedSchema = z.object({
1313
+ sourceRef: storefrontSourceRefSchema.describe("새 작업 트리 — 다음 올리기의 `expectedBaseRef`"),
1314
+ previousRef: z.string().describe("바꾸기 전 작업 트리"),
1315
+ treeSha256: z.string(),
1316
+ files: z.number().int().min(0),
1317
+ bytes: z.number().int().min(0),
1318
+ changed: z.boolean().describe("내용이 바뀌었는가 (같은 트리를 다시 올리면 false)"),
1319
+ endedEditSession: z.boolean().describe("열린 편집 환경을 끄고 진행했는가"),
1320
+ dependencyWarnings: z
1321
+ .array(storefrontDependencyFindingSchema)
1322
+ .describe("배포를 막지 않는 의존성 경고 (보안 권고 등)"),
1323
+ });
1324
+ export type StorefrontSourceReplaced = z.infer<typeof storefrontSourceReplacedSchema>;
@@ -2,11 +2,16 @@ import { z } from "zod";
2
2
  import { claimStatusSchema, claimTypeSchema } from "./claim";
3
3
  import { orderItemStatusSchema } from "./common";
4
4
  import { orderItemFulfillmentSnapshotResponseSchema } from "./fulfillment";
5
+ import { customInputValueSchema, optionSelectionSchema } from "./product";
5
6
 
6
7
  /**
7
- * 웹훅 발송 형식 (공개 계약). 본문은 얇은 이벤트다. 개인정보를 싣지 않으며
8
+ * 웹훅 발송 형식 (공개 계약). 본문은 얇은 이벤트다. 구매자 개인정보(이름·연락처·배송지)를 싣지 않으며
8
9
  * 수신자는 `data`의 id로 관리 API를 조회해 최신 상태를 얻는다.
9
10
  *
11
+ * 예외 하나: `ORDER.PAID`의 `itemOptions.customInputs`는 구매자가 직접 입력 항목에 적은 글자(각인 문구·선물 메시지)를
12
+ * 그대로 싣는다(이슈 #59 결정 — 셀러가 제작에 바로 쓰는 값이다). 항목명 금지어는 개인정보를 받는 항목을 막을 뿐 입력값을
13
+ * 거르지 않으므로 수신자는 이 값을 개인정보가 섞일 수 있는 값으로 다룬다.
14
+ *
10
15
  * 서명 생성·검증은 시크릿을 다루므로 서버 엔트리포인트(`@sayren/store-sdk/server`)에 있다.
11
16
  */
12
17
 
@@ -66,6 +71,19 @@ export const orderWebhookEventDataSchema = z.object({
66
71
  "이벤트를 만든 주문상품의 주문 시점 이행 스냅샷(주문상품 응답의 `fulfillmentSnapshot`과 같다, 이슈 #57). " +
67
72
  "배송 없는 상품의 제공 완료는 `ORDER.DELIVERED`로도 오고 `type`이 `MANUAL`이다. `orderItemId`가 있는 이벤트에만 실린다",
68
73
  ),
74
+ itemOptions: z
75
+ .array(
76
+ z.object({
77
+ orderItemId: z.string(),
78
+ optionSelections: z.array(optionSelectionSchema),
79
+ customInputs: z.array(customInputValueSchema),
80
+ }),
81
+ )
82
+ .optional()
83
+ .describe(
84
+ "추가 선택·직접 입력이 있는 주문상품의 주문 시점 선택값(이슈 #59, 주문상품 응답의 `optionSelections`·`customInputs`와 같다). " +
85
+ "`ORDER.PAID`에만 실리고 그런 주문상품이 없으면 없다",
86
+ ),
69
87
  });
70
88
  export type OrderWebhookEventData = z.infer<typeof orderWebhookEventDataSchema>;
71
89