@sayren/store-sdk 0.23.0 → 0.28.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/oauth.ts CHANGED
@@ -4,10 +4,11 @@ import { unwrapData } from "./http";
4
4
 
5
5
  /**
6
6
  * OAuth 2.1 브라우저 플로우 헬퍼 (authorization_code + PKCE).
7
+ * 발급자는 Sayren 계정(accounts)이다 — 엔드포인트 주소는 호출부가 준다.
7
8
  * Web Crypto만 사용 — 브라우저/Node 모두에서 동작하며 **시크릿을 다루지 않는다**.
8
9
  */
9
10
 
10
- /** RFC 6749 §5.1 토큰 응답 (+ RFC 8693 확장 멤버 허용) */
11
+ /** RFC 6749 §5.1 토큰 응답 */
11
12
  export const oauthTokenResponseSchema = z
12
13
  .object({
13
14
  access_token: z.string(),
@@ -29,19 +30,23 @@ export async function createPkcePair(): Promise<{ verifier: string; challenge: s
29
30
  }
30
31
 
31
32
  export interface AuthorizeUrlOptions {
32
- /** 예: https://api.sayren.app/v1 (베이스 URL — /oauth2/authorize가 덧붙는다) */
33
- baseUrl: string;
33
+ /** authorize 엔드포인트 전체 주소(예: `https://accounts.sayren.app/api/auth/oauth2/authorize`) */
34
+ authorizeUrl: string;
34
35
  clientId: string;
35
36
  redirectUri: string;
36
37
  state: string;
37
38
  codeChallenge: string;
38
- /** 생략 시 서버 기본(stores:read). refresh가 필요하면 offline_access 포함 */
39
+ /** 생략 시 발급자 기본. refresh가 필요하면 offline_access 포함 */
39
40
  scope?: string;
41
+ /** RFC 8707 리소스 지시자 — 주면 JWT access token(`aud` = 리소스)을 받는다 */
42
+ resource?: string;
43
+ /** OIDC `prompt` — `login`(재인증)·`create`(가입)·`consent`(동의 다시 받기) */
44
+ prompt?: "login" | "create" | "consent";
40
45
  }
41
46
 
42
47
  /** authorize 엔드포인트 URL 구성 — 브라우저는 이 URL로 이동(top-level navigation) */
43
48
  export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
44
- const url = new URL(`${options.baseUrl}/oauth2/authorize`);
49
+ const url = new URL(options.authorizeUrl);
45
50
  url.searchParams.set("response_type", "code");
46
51
  url.searchParams.set("client_id", options.clientId);
47
52
  url.searchParams.set("redirect_uri", options.redirectUri);
@@ -49,15 +54,18 @@ export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
49
54
  url.searchParams.set("code_challenge", options.codeChallenge);
50
55
  url.searchParams.set("code_challenge_method", "S256");
51
56
  if (options.scope) url.searchParams.set("scope", options.scope);
57
+ if (options.resource) url.searchParams.set("resource", options.resource);
58
+ if (options.prompt) url.searchParams.set("prompt", options.prompt);
52
59
  return url.toString();
53
60
  }
54
61
 
55
62
  async function tokenRequest(
56
- baseUrl: string,
63
+ endpoint: string,
57
64
  form: Record<string, string>,
58
65
  fetchFn: typeof globalThis.fetch = globalThis.fetch,
59
66
  ): Promise<OAuthTokenResponse> {
60
- const response = await fetchFn(`${baseUrl}/oauth2/token`, {
67
+ // 자격 증명(쿠키)을 싣지 않는다 — public 클라이언트 + PKCE·refresh로만 인증한다(다른 origin 발급자도 같다)
68
+ const response = await fetchFn(endpoint, {
61
69
  method: "POST",
62
70
  headers: { "content-type": "application/x-www-form-urlencoded" },
63
71
  body: new URLSearchParams(form).toString(),
@@ -70,23 +78,27 @@ async function tokenRequest(
70
78
  return oauthTokenResponseSchema.parse(unwrapData(json));
71
79
  }
72
80
 
73
- /** 콜백에서 받은 code를 userToken으로 교환 (PKCE verifier 동반) */
81
+ /** 콜백에서 받은 code를 액세스 토큰으로 교환 (PKCE verifier 동반) */
74
82
  export async function exchangeAuthorizationCode(options: {
75
- baseUrl: string;
83
+ /** 토큰 엔드포인트 전체 주소 */
84
+ tokenUrl: string;
76
85
  clientId: string;
77
86
  redirectUri: string;
78
87
  code: string;
79
88
  codeVerifier: string;
89
+ /** RFC 8707 리소스 지시자 */
90
+ resource?: string;
80
91
  fetch?: typeof globalThis.fetch;
81
92
  }): Promise<OAuthTokenResponse> {
82
93
  return tokenRequest(
83
- options.baseUrl,
94
+ options.tokenUrl,
84
95
  {
85
96
  grant_type: "authorization_code",
86
97
  code: options.code,
87
98
  redirect_uri: options.redirectUri,
88
99
  client_id: options.clientId,
89
100
  code_verifier: options.codeVerifier,
101
+ ...(options.resource ? { resource: options.resource } : {}),
90
102
  },
91
103
  options.fetch,
92
104
  );
@@ -94,38 +106,21 @@ export async function exchangeAuthorizationCode(options: {
94
106
 
95
107
  /** refresh grant (rotation — 응답의 새 refresh_token으로 반드시 교체 저장할 것) */
96
108
  export async function refreshUserToken(options: {
97
- baseUrl: string;
109
+ /** 토큰 엔드포인트 전체 주소 */
110
+ tokenUrl: string;
98
111
  clientId: string;
99
112
  refreshToken: string;
113
+ /** RFC 8707 리소스 지시자 */
114
+ resource?: string;
100
115
  fetch?: typeof globalThis.fetch;
101
116
  }): Promise<OAuthTokenResponse> {
102
117
  return tokenRequest(
103
- options.baseUrl,
118
+ options.tokenUrl,
104
119
  {
105
120
  grant_type: "refresh_token",
106
121
  refresh_token: options.refreshToken,
107
122
  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,
123
+ ...(options.resource ? { resource: options.resource } : {}),
129
124
  },
130
125
  options.fetch,
131
126
  );
@@ -4,35 +4,6 @@ export const storeRoleSchema = z.enum(["OWNER", "ADMIN", "STAFF"]);
4
4
 
5
5
  export type StoreRole = z.infer<typeof storeRoleSchema>;
6
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
7
  export const myStoreSchema = z.object({
37
8
  storeId: z.string(),
38
9
  storeName: z.string(),
@@ -52,38 +23,3 @@ export const myAccountSchema = z.object({
52
23
  });
53
24
 
54
25
  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
- agent: z
69
- .boolean()
70
- .default(false)
71
- .describe(
72
- "AI 에이전트(MCP)용 토큰인가. true면 이 토큰의 쓰기는 행위자 `AGENT`로 기록되고 에이전트 쓰기 정책(승인 필요·거부)을 따른다",
73
- ),
74
- });
75
- export type ApiToken = z.infer<typeof apiTokenSchema>;
76
-
77
- /** 생성 응답 — 평문 토큰은 이 응답에서 단 한 번만 노출된다 */
78
- export const apiTokenCreatedSchema = apiTokenSchema.extend({ token: z.string() });
79
- export type ApiTokenCreated = z.infer<typeof apiTokenCreatedSchema>;
80
-
81
- export const apiTokenCreateRequestSchema = z.object({
82
- name: z.string().min(1).max(50),
83
- scopes: z.array(z.string()).min(1),
84
- /** 일 단위 만료. null = 무기한, 생략 = 90일 */
85
- expiresInDays: z.number().int().nullable().optional(),
86
- /** AI 에이전트(MCP)용 토큰. 생략 = false. 발급 뒤에는 바꿀 수 없다 */
87
- agent: z.boolean().optional(),
88
- });
89
- export type ApiTokenCreateRequest = z.infer<typeof apiTokenCreateRequestSchema>;
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 구매자 위임 REST(`POST /v1/customers/delegations`, #71) — 계정 토큰(또는 관리 API 토큰) + `X-Store-Id`로 부른다(`customer:rw`).
5
+ * 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰(30분, refresh 없음)으로 바꾼다. 옛 token-exchange
6
+ * (`member-uid`·`member-ref`)를 대신한다. 에이전트 요청은 거부된다(`x-agent-policy` DENIED).
7
+ */
8
+
9
+ const uidPattern = /^[A-Za-z0-9._:@|-]{1,128}$/;
10
+
11
+ export const customerDelegationRequestSchema = z
12
+ .object({
13
+ uid: z
14
+ .string()
15
+ .regex(uidPattern)
16
+ .optional()
17
+ .describe(
18
+ "셀러 자체 인증의 사용자 id(1~128자, 영문·숫자·`._:@|-`). 처음 보는 id면 구매자를 만든다",
19
+ ),
20
+ email: z
21
+ .email()
22
+ .max(254)
23
+ .optional()
24
+ .describe("uid가 없으면 이메일로 지목한다. uid와 함께 주면 처음 만들 때의 이메일이다"),
25
+ name: z.string().max(50).optional().describe("처음 만들 때의 이름"),
26
+ authenticatedAt: z.iso
27
+ .datetime({ offset: true })
28
+ .optional()
29
+ .describe(
30
+ "사용자가 셀러 서비스에서 실제로 로그인한 시각(uid 위임만). 주면 구매자 토큰의 인증 시각이 된다",
31
+ ),
32
+ })
33
+ .refine((value) => Boolean(value.uid || value.email), {
34
+ message: "uid 또는 email이 필요합니다",
35
+ path: ["uid"],
36
+ })
37
+ .describe("구매자 위임 요청");
38
+ export type CustomerDelegationRequest = z.infer<typeof customerDelegationRequestSchema>;
39
+
40
+ export const customerDelegationSchema = z
41
+ .object({
42
+ accessToken: z.string().describe("구매자 액세스 토큰(스토어프론트 API Bearer)"),
43
+ tokenType: z.literal("Bearer"),
44
+ expiresIn: z.number().int().describe("수명(초)"),
45
+ memberId: z.string(),
46
+ })
47
+ .describe("구매자 위임 결과");
48
+ export type CustomerDelegation = z.infer<typeof customerDelegationSchema>;
@@ -35,6 +35,13 @@ export const productSummarySchema = z.object({
35
35
  "상품 이행의 유형. 유형별 속성은 단건 조회(`GET /v1/products/{productId}`)의 `fulfillment`에 있다",
36
36
  ),
37
37
  updatedAt: z.string(),
38
+ sample: z
39
+ .boolean()
40
+ .optional()
41
+ .default(false)
42
+ .describe(
43
+ "스토어를 만들 때 넣은 샘플 상품인가(이슈 #70). 콘솔이 「샘플」 배지를 단다. 스토어프론트에는 싣지 않는다. 서버는 항상 싣는다",
44
+ ),
38
45
  });
39
46
 
40
47
  export type ProductSummary = z.infer<typeof productSummarySchema>;
@@ -49,8 +49,7 @@ export function isStoreScope(scope: string): boolean {
49
49
 
50
50
  /**
51
51
  * 역할별 자동 부여 스코프 (auth-spec §7).
52
- * storeToken(사람)의 스코프는 이 표로 결정된다. PAT·M2M 클라이언트에는 역할이 없고
53
- * 발급 시 지정한 스코프만 갖는다(발급자 보유 스코프 이내).
52
+ * 계정 토큰으로 스토어를 호출할 때의 스코프는 그 스토어에서의 역할로 이 표에서 결정된다.
54
53
  */
55
54
  export const STORE_ROLE_SCOPES = {
56
55
  OWNER: [
@@ -270,7 +270,7 @@ export type StoreMember = z.infer<typeof storeMemberSchema>;
270
270
 
271
271
  /**
272
272
  * 초대 메일 발송 결과 — `SENDING`은 발송 요청 중(결과를 아직 적지 못함), `NOT_CONFIGURED`는 메일 발송이 설정되지 않아
273
- * 보내지 않은 것이다. 발송 실패는 초대를 무효로 만들지 않는다 — 재발송으로 다시 보낸다
273
+ * 보내지 않은 것이다. 스토어의 월 메일 한도(요금제)를 다 써서 보내지 않았으면 `FAILED` + `deliveryErrorCode: MAIL_QUOTA_EXCEEDED`다. 발송 실패는 초대를 무효로 만들지 않는다 — 재발송으로 다시 보낸다
274
274
  */
275
275
  export const invitationDeliveryStatusSchema = z.enum([
276
276
  "SENDING",
@@ -24,6 +24,7 @@ import { RESERVED_STORE_CODES } from "./store";
24
24
  * - 409 `STORE_SUSPENDED` — 정지된 스토어에는 사이트를 만들 수 없다
25
25
  * - 409 `STOREFRONT_VERSION_NOT_READY` — 빌드가 끝나지 않았거나 실패한 버전은 발행할 수 없다
26
26
  * - 409 `STOREFRONT_NO_ROLLBACK_TARGET` — 되돌릴 이전 발행 버전이 없다
27
+ * - 409 `STOREFRONT_NOT_READY` — 공개 전환: 판매 중인 상품(샘플 제외)이 없다(이슈 #70). 상품을 등록한 뒤 공개한다
27
28
  * - 409 `TEMPLATE_CHANGE_NOT_SUPPORTED` — 템플릿 변경(`POST /v1/storefront/site/template`, 폐기)은 항상 이 오류다
28
29
  * - 400 `STOREFRONT_SLUG_CONFIRM_MISMATCH` — 호스팅 끄기 확인 입력이 현재 슬러그와 다르다
29
30
  * - 503 `STOREFRONT_PREVIEW_NOT_CONFIGURED` — 미리보기 서명 키가 설정되지 않았다
@@ -91,6 +92,16 @@ export const RESERVED_STOREFRONT_SLUGS: readonly string[] = [
91
92
  */
92
93
  export const STOREFRONT_EDIT_PREVIEW_SUFFIX = "-preview";
93
94
 
95
+ /**
96
+ * 공식 쇼케이스 슬러그 접두사(이슈 #72) — `demo-{템플릿 id}`는 템플릿 미리보기용 쇼케이스 사이트 주소다. 쇼케이스로 지정된
97
+ * 스토어만 쓸 수 있다(서버가 스토어 용도로 판정한다). 공식 미리보기를 흉내 내는 주소를 막는다
98
+ */
99
+ export const STOREFRONT_SHOWCASE_SLUG_PREFIX = "demo-";
100
+
101
+ export function isShowcaseStorefrontSlug(slug: string): boolean {
102
+ return slug.toLowerCase().startsWith(STOREFRONT_SHOWCASE_SLUG_PREFIX);
103
+ }
104
+
94
105
  export function isReservedStorefrontSlug(slug: string): boolean {
95
106
  const lower = slug.toLowerCase();
96
107
  return (
@@ -136,6 +147,23 @@ export const storefrontTemplateSchema = z.object({
136
147
  thumbnailUrl: z.string().nullable().describe("썸네일 이미지 주소. 없으면 null"),
137
148
  version: z.string().describe("현재 템플릿 버전. 새 사이트는 이 버전으로 만든다"),
138
149
  isDefault: z.boolean().describe("기본 템플릿인가 (스토어 생성 시 자동 생성에 쓴다)"),
150
+ tags: z
151
+ .array(z.string())
152
+ .default([])
153
+ .describe(
154
+ "템플릿 특징 태그 (예: `후기 강조`). 목록 카드에 보인다. 옛 서버 응답은 빈 배열로 읽는다",
155
+ ),
156
+ supportsCheckout: z
157
+ .boolean()
158
+ .default(true)
159
+ .describe(
160
+ "주문을 받는 템플릿인가. false는 가격·문의 중심의 카탈로그형이다. 옛 서버 응답은 true로 읽는다",
161
+ ),
162
+ previewUrl: z
163
+ .string()
164
+ .nullable()
165
+ .default(null)
166
+ .describe("공식 쇼케이스 사이트 주소. 연결된 쇼케이스가 공개 중일 때만 싣고 그 밖에는 null"),
139
167
  });
140
168
  export type StorefrontTemplate = z.infer<typeof storefrontTemplateSchema>;
141
169
 
@@ -0,0 +1,222 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 요금제·구독 — `GET /v1/store/subscription`(스코프 `store:r`). 구독 단위는 스토어다.
5
+ * 결제 수단·청구 금액은 싣지 않는다. 요금제 변경은 셀러 콘솔에서만 한다.
6
+ */
7
+
8
+ /** 요금제 코드 — 이름(`plan.name`)이 바뀌어도 코드는 바뀌지 않는다. 값은 추가만 한다 */
9
+ export const planCodeSchema = z
10
+ .enum(["BETA", "FREE", "BASIC", "PRO", "BUSINESS", "ENTERPRISE", "PREMIUM"])
11
+ .describe(
12
+ "요금제 코드. `FREE`=Free, `PRO`=Pro, `PREMIUM`=프리미엄, `ENTERPRISE`=엔터프라이즈, `BETA`=베타(운영 오픈 기간). `BASIC`·`BUSINESS`는 2026-09-30 이전 요금제(더 쓰지 않는다)",
13
+ );
14
+ export type PlanCode = z.infer<typeof planCodeSchema>;
15
+
16
+ /**
17
+ * 응답의 요금제 코드 — 아는 코드(`PlanCode`)거나 이 SDK가 모르는 새 코드(문자열)다. 요금제 코드는 추가될 수 있어 응답 파싱을 깨지 않게
18
+ * 넓게 받는다(옛 SDK는 새 코드를 모르면 파싱에 실패했다, #44 리뷰 M3). 요청에는 `planCodeSchema`를 쓴다
19
+ */
20
+ export const responsePlanCodeSchema = z
21
+ .union([planCodeSchema, z.string()])
22
+ .describe(planCodeSchema.description ?? "요금제 코드");
23
+ export type ResponsePlanCode = PlanCode | (string & {});
24
+
25
+ export const subscriptionStatusSchema = z
26
+ .enum(["ACTIVE", "PAST_DUE", "CANCELED", "INCOMPLETE"])
27
+ .describe(
28
+ "구독 상태. `ACTIVE`=이용 중, `PAST_DUE`=결제 실패 후 유예 중, `CANCELED`=해지됨, `INCOMPLETE`=첫 결제 대기",
29
+ );
30
+ export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>;
31
+
32
+ const limit = (description: string) =>
33
+ z.number().int().nullable().describe(`${description}. null이면 한도 없음`);
34
+
35
+ export const subscriptionLimitsSchema = z.object({
36
+ liveOrdersMonthly: limit("더 쓰지 않는다 — 라이브 주문 수로 요금제를 나누지 않아 항상 null"),
37
+ seatsIncluded: limit("구성원 석 — 구성원은 모든 요금제에서 무료·무제한이라 null"),
38
+ aiActionsMonthly: limit("MCP(에이전트) 쓰기(AI 작업) 월 한도"),
39
+ aiReadsMonthly: limit("MCP(에이전트) 읽기 월 한도"),
40
+ customDomains: limit("내 도메인 수"),
41
+ webhooks: limit("웹훅 구독 수(0이면 쓸 수 없다)"),
42
+ socialProviders: limit("더 쓰지 않는다 — 소셜 로그인 공급자 수로 요금제를 나누지 않아 항상 null"),
43
+ emailsMonthly: limit("더 쓰지 않는다 — 메일 한도가 없어 항상 null"),
44
+ storefrontRequestsMonthly: limit("스토어프론트 월 트래픽(요청 수, 넘어도 막지 않고 안내만)"),
45
+ apiLogRetentionDays: limit("API 로그 보관 일수(0이면 API 로그 없음)"),
46
+ auditLogRetentionDays: limit("감사 로그 보관 일수"),
47
+ storefrontPreviewHoursMonthly: limit("스토어프론트 편집 미리보기 월 시간"),
48
+ storefrontBuildMinutesMonthly: limit("스토어프론트 빌드 월 분"),
49
+ adminApiRequestsMonthly: limit("관리 API 요청 월 포함량(넘으면 속도만 낮춘다, 주문 처리 제외)"),
50
+ });
51
+ export type SubscriptionLimits = z.infer<typeof subscriptionLimitsSchema>;
52
+
53
+ export const subscriptionUsageSchema = z.object({
54
+ liveOrders: z.number().int().describe("이번 기간 라이브 주문 수(테스트 결제 주문 제외)"),
55
+ aiActions: z.number().int().describe("이번 기간 MCP(에이전트) 쓰기(AI 작업) 수"),
56
+ aiReads: z.number().int().describe("이번 기간 MCP(에이전트) 읽기 수"),
57
+ storefrontRequests: z.number().int().describe("이번 기간 스토어프론트 요청 수"),
58
+ adminApiRequests: z
59
+ .number()
60
+ .int()
61
+ .describe("이번 기간 관리 API 요청 수(API 토큰·앱·에이전트 호출, 셀러 콘솔 제외)"),
62
+ emails: z.number().int().describe("이번 기간 초대·양도 알림 메일 수(한도 없음, 표시만)"),
63
+ storefrontPreviewMinutes: z.number().int().describe("이번 기간 편집 미리보기 분(끝난 세션)"),
64
+ storefrontBuildMinutes: z.number().int().describe("이번 기간 빌드 분(끝난 빌드)"),
65
+ seats: z.number().int().describe("현재 석 수 — 구성원(OWNER 포함) + 대기 중 초대"),
66
+ customDomains: z.number().int().describe("현재 내 도메인 수(해제한 도메인 제외)"),
67
+ webhooks: z.number().int().describe("현재 웹훅 구독 수"),
68
+ });
69
+ export type SubscriptionUsage = z.infer<typeof subscriptionUsageSchema>;
70
+
71
+ export const storeSubscriptionSchema = z.object({
72
+ plan: z.object({
73
+ code: responsePlanCodeSchema,
74
+ name: z.string().describe("요금제 표시 이름(예: 프로)"),
75
+ }),
76
+ status: subscriptionStatusSchema,
77
+ interval: z
78
+ .enum(["MONTH"])
79
+ .nullable()
80
+ .describe("결제 주기. `MONTH`=월간. 요금을 내지 않는 요금제(베타·무료)는 null"),
81
+ cancelAtPeriodEnd: z.boolean().describe("기간 끝에 해지 예정인가"),
82
+ period: z
83
+ .object({
84
+ start: z.string().describe("사용량 기간 시작(ISO 8601)"),
85
+ end: z.string().describe("사용량 기간 끝(ISO 8601, 이 시각에 사용량이 초기화된다)"),
86
+ })
87
+ .describe("사용량을 세는 기간. 요금을 내지 않는 요금제는 한국 시간 달력 월이다"),
88
+ limits: subscriptionLimitsSchema,
89
+ features: z.object({
90
+ adminApi: z.boolean().describe("관리 API(개인 액세스 토큰·외부 앱 토큰 호출)를 쓸 수 있다"),
91
+ analytics: z.boolean().describe("애널리틱스 조회를 쓸 수 있다"),
92
+ storeClone: z.boolean().describe("더 쓰지 않는다 — 스토어 복제 기능을 없애 항상 false"),
93
+ removeBadge: z.boolean().describe("더 쓰지 않는다 — 배지 키를 없애 항상 false"),
94
+ }),
95
+ usage: subscriptionUsageSchema,
96
+ aiActions: z
97
+ .object({
98
+ limit: z.number().int().nullable(),
99
+ used: z.number().int(),
100
+ resetsAt: z.string().describe("한도가 초기화되는 시각(ISO 8601)"),
101
+ })
102
+ .describe("AI 작업(MCP 쓰기) 한도 요약 — 에이전트가 남은 작업 수를 판단할 때 쓴다"),
103
+ aiReads: z
104
+ .object({
105
+ limit: z.number().int().nullable(),
106
+ used: z.number().int(),
107
+ resetsAt: z.string().describe("한도가 초기화되는 시각(ISO 8601)"),
108
+ })
109
+ .describe("MCP 읽기 한도 요약"),
110
+ salesFeePercent: z.literal(0).describe("판매 수수료(%). 모든 요금제 0이다"),
111
+ });
112
+ export type StoreSubscription = z.infer<typeof storeSubscriptionSchema>;
113
+
114
+ /**
115
+ * 요금제 한도 오류의 `error.details` — 기존 오류 코드(`STORE_LIMIT_EXCEEDED`·`STOREFRONT_DOMAIN_LIMIT`·`WEBHOOK_LIMIT_EXCEEDED` 등)와
116
+ * 요금제 오류(`PLAN_LIMIT_EXCEEDED`·`PLAN_AI_ACTIONS_EXCEEDED`·`PLAN_AI_READS_EXCEEDED`·`PLAN_FEATURE_REQUIRED`·`PLAN_API_QUOTA_EXCEEDED`)가 같은 형식을 싣는다.
117
+ * 모든 필드가 선택이다(기존 코드는 요금제 판정 전 필드만 실을 수 있다).
118
+ */
119
+ export const planErrorDetailsSchema = z
120
+ .object({
121
+ feature: z.string().optional().describe("한도에 닿은 기능 키(예: `limits.ai_actions_monthly`)"),
122
+ limit: z.number().nullable().optional(),
123
+ used: z.number().optional(),
124
+ plan: responsePlanCodeSchema.optional(),
125
+ resetsAt: z.string().optional().describe("월 한도가 초기화되는 시각(ISO 8601)"),
126
+ upgradeUrl: z.string().optional().describe("요금제 화면 주소(셀러 콘솔)"),
127
+ reason: z
128
+ .string()
129
+ .optional()
130
+ .describe(
131
+ "스토어 수 한도의 이유 — `OWNED_STORES`(소유 스토어 수). `FREE_STORES`는 더 쓰지 않는다",
132
+ ),
133
+ })
134
+ .loose();
135
+ export type PlanErrorDetails = z.infer<typeof planErrorDetailsSchema>;
136
+
137
+ const limitOrContract = (description: string) =>
138
+ z.number().int().nullable().describe(`${description}. null이면 계약(엔터프라이즈)·한도 없음`);
139
+
140
+ /**
141
+ * 요금제 기능 키 값 — 요금제 비교표·콘솔 요금제 화면이 쓴다. 키는 서버 판정의 기능 키와 같다(추가만 한다). 상위 요금제는 하위 요금제
142
+ * 기능을 모두 포함한다
143
+ */
144
+ export const planEntitlementsSchema = z
145
+ .object({
146
+ "features.admin_api": z
147
+ .boolean()
148
+ .describe(
149
+ "관리 API(개인 액세스 토큰·외부 앱 토큰 호출). 셀러 콘솔·CLI·MCP는 모든 요금제에서 쓴다",
150
+ ),
151
+ "features.analytics": z.boolean().describe("애널리틱스 화면·조회 API"),
152
+ "limits.ai_reads_monthly": limitOrContract("MCP 읽기 월 한도"),
153
+ "limits.ai_actions_monthly": limitOrContract(
154
+ "MCP 쓰기(AI 작업) 월 한도 — 주문 처리는 막지 않는다",
155
+ ),
156
+ "limits.custom_domains": limitOrContract("내 도메인 수"),
157
+ "limits.webhooks": limitOrContract("웹훅 구독 수(0이면 쓸 수 없다)"),
158
+ "limits.storefront_preview_hours_monthly": limitOrContract("편집 환경 시간(월, 시간)"),
159
+ "limits.storefront_build_minutes_monthly": limitOrContract("빌드 시간(월, 분)"),
160
+ "limits.storefront_requests_monthly": limitOrContract(
161
+ "스토어프론트 월 트래픽(요청 수) — 넘어도 막지 않고 안내만 한다",
162
+ ),
163
+ "limits.admin_api_requests_monthly": limitOrContract(
164
+ "관리 API 월 요청 — 넘으면 속도만 낮춘다(주문 처리 제외)",
165
+ ),
166
+ "rate_limit.admin_api": z
167
+ .number()
168
+ .nullable()
169
+ .describe("관리 API 분당 속도 배수(기본 분당 600 × 배수)"),
170
+ "storefront.cpu_ms": z.number().int().describe("스토어프론트 사이트 요청당 CPU 상한(ms)"),
171
+ "storefront.subrequests": z
172
+ .number()
173
+ .int()
174
+ .describe("스토어프론트 사이트 요청당 하위 요청 상한"),
175
+ "storefront.rate_limit_per_minute": z
176
+ .number()
177
+ .int()
178
+ .describe("스토어프론트 남용 방지 요청 속도 상한(사이트당 분당)"),
179
+ "seats.included": limitOrContract(
180
+ "포함 구성원 석 — 구성원은 모든 요금제에서 무료·무제한이라 null",
181
+ ),
182
+ "retention.analytics_days": z.number().int().describe("애널리틱스 보관 일수"),
183
+ "retention.api_logs_days": limitOrContract("API 로그 보관 일수(0이면 API 로그 없음)"),
184
+ "retention.audit_days": limitOrContract("감사 로그 보관 일수"),
185
+ "support.tier": z
186
+ .union([z.enum(["COMMUNITY", "EMAIL", "EMAIL_PRIORITY", "DEDICATED", "SLA"]), z.string()])
187
+ .describe(
188
+ "지원 등급(`COMMUNITY`·`EMAIL`·`EMAIL_PRIORITY`·`DEDICATED`·`SLA`, 새 등급이 더해질 수 있다)",
189
+ ),
190
+ })
191
+ .describe("요금제 기능 키 값");
192
+ export type PlanEntitlementValues = z.infer<typeof planEntitlementsSchema>;
193
+
194
+ /** 공개 요금제 한 줄 — `GET /v1/public/plans` */
195
+ export const publicPlanSchema = z.object({
196
+ code: responsePlanCodeSchema,
197
+ name: z.string().describe("표시 이름(Free·Pro·프리미엄·엔터프라이즈)"),
198
+ version: z.number().int().describe("요금제 버전 — 값이 바뀌면 새 버전이 게시된다"),
199
+ sort: z.number().int(),
200
+ purchasable: z
201
+ .boolean()
202
+ .describe("셀프 결제로 고를 수 있는가(Free·엔터프라이즈는 아니다 — 엔터프라이즈는 계약)"),
203
+ prices: z.object({
204
+ currency: z.literal("KRW"),
205
+ monthly: z.number().int().nullable().describe("월 가격(공급가, 부가세 별도)"),
206
+ startingAt: z.boolean().describe("「부터」 가격(엔터프라이즈 계약)"),
207
+ vatIncluded: z
208
+ .object({
209
+ monthly: z.number().int().nullable(),
210
+ })
211
+ .describe("표시 가격(부가세 포함) — 요금제 비교표는 이 값을 보인다"),
212
+ }),
213
+ entitlements: planEntitlementsSchema,
214
+ });
215
+ export type PublicPlan = z.infer<typeof publicPlanSchema>;
216
+
217
+ /** 요금제 비교표 — `GET /v1/public/plans`(인증 없음, 캐시 가능). 값은 서버가 관리하고 언제든 바뀔 수 있다 */
218
+ export const publicPlansSchema = z.object({
219
+ vatPercent: z.number().int().describe("부가세율(%)"),
220
+ plans: z.array(publicPlanSchema).describe("공개 요금제의 지금 버전 — 정렬 순"),
221
+ });
222
+ export type PublicPlans = z.infer<typeof publicPlansSchema>;
package/src/server.ts CHANGED
@@ -5,8 +5,7 @@
5
5
 
6
6
  export {
7
7
  type DelegatedMemberToken,
8
- delegateMemberByEmail,
9
- delegateMemberByUid,
8
+ delegateMember,
10
9
  MemberDelegationError,
11
10
  } from "./member-delegation";
12
11
  export {