@sayren/store-sdk 0.22.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/dist/index.d.mts +905 -620
- package/dist/index.mjs +352 -243
- package/dist/server.d.mts +19 -28
- package/dist/server.mjs +29 -42
- package/package.json +1 -1
- package/src/client.ts +59 -141
- package/src/index.ts +3 -2
- package/src/member-delegation.ts +51 -77
- package/src/oauth.ts +28 -33
- package/src/schemas/auth.ts +0 -64
- package/src/schemas/customer-delegation.ts +48 -0
- package/src/schemas/product.ts +7 -0
- package/src/schemas/scope.ts +1 -2
- package/src/schemas/store.ts +1 -1
- package/src/schemas/storefront-hosting.ts +82 -6
- package/src/schemas/subscription.ts +222 -0
- package/src/server.ts +1 -2
- package/src/session.ts +84 -26
- package/src/schemas/api-client.ts +0 -121
|
@@ -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
package/src/session.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { type MyAccount, myAccountSchema } from "./schemas/auth";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* SDK 세션 매니저 — 콘솔(
|
|
12
|
+
* SDK 세션 매니저 — 콘솔(액세스 토큰)의 저장·무음 갱신·회전을 캡슐화한다.
|
|
13
13
|
*
|
|
14
14
|
* 저장 매체(쿠키/localStorage/…)와 그 속성(Max-Age·SameSite·Secure·Path)은 **어댑터(앱) 책임**이다.
|
|
15
15
|
* SDK는 "무엇을 어떤 키로 저장할지"만 알고, "어떻게 저장할지"는 SessionStorage 어댑터에 위임한다.
|
|
@@ -32,12 +32,28 @@ export const STORE_SESSION_KEYS = {
|
|
|
32
32
|
pkce: "sayren.store.pkce",
|
|
33
33
|
} as const;
|
|
34
34
|
|
|
35
|
+
/** 로그아웃 폐기 요청 제한 시간 */
|
|
36
|
+
const REVOKE_TIMEOUT_MS = 5_000;
|
|
37
|
+
|
|
35
38
|
/** Web Locks 이름 — 탭 간 refresh 회전 직렬화용 */
|
|
36
39
|
const REFRESH_LOCK = STORE_SESSION_KEYS.refresh;
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 인가 서버(Sayren 계정, accounts) 주소 — `https://accounts.sayren.app/api/auth/oauth2/{authorize,token,revoke}`.
|
|
43
|
+
* `resource`를 주면 authorize·code 교환·refresh에 RFC 8707 `resource`를 싣는다(JWT access token의 `aud`).
|
|
44
|
+
*/
|
|
45
|
+
export interface StoreAuthServer {
|
|
46
|
+
authorizeUrl: string;
|
|
47
|
+
tokenUrl: string;
|
|
48
|
+
revokeUrl: string;
|
|
49
|
+
resource?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
export interface StoreSessionOptions {
|
|
39
|
-
/** 예: https://api.example.com/v1 (/
|
|
53
|
+
/** api 주소(예: https://api.example.com/v1) — `userinfo()`의 `GET /me`에 쓴다 */
|
|
40
54
|
baseUrl: string;
|
|
55
|
+
/** 인가 서버(accounts) 주소들 */
|
|
56
|
+
authServer: StoreAuthServer;
|
|
41
57
|
/** OAuth public client id (예: avarlabs) */
|
|
42
58
|
clientId: string;
|
|
43
59
|
/** 저장 매체 어댑터 (앱 소유) */
|
|
@@ -48,19 +64,19 @@ export interface StoreSessionOptions {
|
|
|
48
64
|
export interface AuthorizeOptions {
|
|
49
65
|
/** OAuth 콜백 URI (예: https://store.example.com/auth/callback) */
|
|
50
66
|
redirectUri: string;
|
|
51
|
-
/** 생략 시
|
|
67
|
+
/** 생략 시 발급자 기본. refresh가 필요하면 offline_access 포함 */
|
|
52
68
|
scope?: string;
|
|
53
|
-
/**
|
|
54
|
-
|
|
69
|
+
/** OIDC `prompt` — `login`(재인증)·`create`(가입)·`consent` */
|
|
70
|
+
prompt?: "login" | "create" | "consent";
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
export interface StoreSession {
|
|
58
|
-
/** 현재
|
|
74
|
+
/** 현재 액세스 토큰 (없으면 null) */
|
|
59
75
|
getToken(): string | null;
|
|
60
76
|
/** 토큰 저장 + 구독자 통지. refresh_token이 있으면 함께 회전 저장한다(없으면 기존 유지) */
|
|
61
77
|
setTokens(tokens: { access_token: string; refresh_token?: string }): void;
|
|
62
78
|
/**
|
|
63
|
-
* 무음 세션 갱신 — refresh 토큰으로 새
|
|
79
|
+
* 무음 세션 갱신 — refresh 토큰으로 새 액세스 토큰을 받아 회전 저장한다.
|
|
64
80
|
* - 탭 내부: 동시 다발 호출을 single-flight로 합친다.
|
|
65
81
|
* - 탭 간: navigator.locks(있으면)로 회전을 직렬화하고, 락 진입 시 다른 탭이 이미
|
|
66
82
|
* 회전했으면(세션 토큰 변경) 내 refresh 토큰을 소비하지 않고 true를 반환한다.
|
|
@@ -68,11 +84,17 @@ export interface StoreSession {
|
|
|
68
84
|
* 회전이 실패(만료·재사용 폐기)하면 세션을 clear하고 false. (401 경로는 onUnauthorized→clear가 별도 처리.)
|
|
69
85
|
*/
|
|
70
86
|
refresh(): Promise<boolean>;
|
|
71
|
-
/** 세션/리프레시 키를 지우고 구독자에게 통지한다 */
|
|
87
|
+
/** 세션/리프레시 키를 지우고 구독자에게 통지한다(서버의 토큰은 그대로다 — 로그아웃은 `logout()`) */
|
|
72
88
|
clear(): void;
|
|
89
|
+
/**
|
|
90
|
+
* 로그아웃 — 인가 서버에서 **refresh 토큰(패밀리째)과 access 토큰을 폐기**(`revokeUrl`, RFC 7009)한 뒤 세션을 지운다.
|
|
91
|
+
* refresh를 폐기하지 않으면 쿠키만 지워도 서버의 refresh 패밀리가 절대 만료까지 살아 있어, 새어 나간 refresh 토큰으로
|
|
92
|
+
* 계속 새 액세스 토큰을 받을 수 있다. 폐기 요청 실패(네트워크·이미 무효)는 무시하고 세션은 항상 지운다.
|
|
93
|
+
*/
|
|
94
|
+
logout(): Promise<void>;
|
|
73
95
|
/** 변경 구독(useSyncExternalStore 소비용) — getToken을 스냅샷으로 쓴다. 해지 함수 반환 */
|
|
74
96
|
subscribe(cb: () => void): () => void;
|
|
75
|
-
/** 현재
|
|
97
|
+
/** 현재 액세스 토큰 (getToken()과 동일 — 게터 형태 소비용) */
|
|
76
98
|
readonly accessToken: string | null;
|
|
77
99
|
/** 현재 저장된 refresh 토큰 (없으면 null) */
|
|
78
100
|
readonly refreshToken: string | null;
|
|
@@ -92,7 +114,8 @@ export interface StoreSession {
|
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
95
|
-
const { baseUrl, clientId, storage } = options;
|
|
117
|
+
const { baseUrl, clientId, storage, authServer } = options;
|
|
118
|
+
const { revokeUrl, tokenUrl, resource } = authServer;
|
|
96
119
|
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
97
120
|
const listeners = new Set<() => void>();
|
|
98
121
|
|
|
@@ -116,6 +139,37 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
116
139
|
notify();
|
|
117
140
|
}
|
|
118
141
|
|
|
142
|
+
/** RFC 7009 — 무효 토큰이어도 200이다. 실패는 호출부가 무시한다. 응답이 늦어도 로그아웃이 멈추지 않게 5초로 끊는다 */
|
|
143
|
+
async function revokeToken(token: string): Promise<void> {
|
|
144
|
+
await fetchFn(revokeUrl, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
147
|
+
body: new URLSearchParams({ token, client_id: clientId }).toString(),
|
|
148
|
+
// AbortSignal.timeout이 없는 브라우저는 제한 없이 보낸다(로그아웃은 그래도 진행된다)
|
|
149
|
+
signal:
|
|
150
|
+
typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
|
|
151
|
+
? AbortSignal.timeout(REVOKE_TIMEOUT_MS)
|
|
152
|
+
: undefined,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function logout(): Promise<void> {
|
|
157
|
+
// 진행 중인 갱신이 있으면 끝나기를 기다린다 — 그 사이 회전된 새 refresh 토큰을 폐기해야 한다(옛 토큰만 폐기하면 새 패밀리 토큰이 남는다)
|
|
158
|
+
if (refreshInFlight) await refreshInFlight.catch(() => false);
|
|
159
|
+
const refreshToken = storage.get(STORE_SESSION_KEYS.refresh);
|
|
160
|
+
const accessToken = getToken();
|
|
161
|
+
// refresh 먼저 — 패밀리를 폐기해야 새 액세스 토큰 발급이 끊긴다. access는 남은 수명 동안의 사용을 끊는다
|
|
162
|
+
for (const token of [refreshToken, accessToken]) {
|
|
163
|
+
if (!token) continue;
|
|
164
|
+
try {
|
|
165
|
+
await revokeToken(token);
|
|
166
|
+
} catch {
|
|
167
|
+
// 네트워크 실패여도 로그아웃은 진행한다
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
clear();
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
let refreshInFlight: Promise<boolean> | null = null;
|
|
120
174
|
|
|
121
175
|
function refresh(): Promise<boolean> {
|
|
@@ -140,7 +194,13 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
140
194
|
const refreshToken = storage.get(STORE_SESSION_KEYS.refresh);
|
|
141
195
|
if (!refreshToken) return false;
|
|
142
196
|
try {
|
|
143
|
-
const tokens = await refreshUserToken({
|
|
197
|
+
const tokens = await refreshUserToken({
|
|
198
|
+
clientId,
|
|
199
|
+
refreshToken,
|
|
200
|
+
tokenUrl,
|
|
201
|
+
resource,
|
|
202
|
+
fetch: fetchFn,
|
|
203
|
+
});
|
|
144
204
|
setTokens({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
|
|
145
205
|
return true;
|
|
146
206
|
} catch {
|
|
@@ -178,20 +238,16 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
178
238
|
const blob: PkceBlob = { verifier, state, redirectUri: authorizeOptions.redirectUri };
|
|
179
239
|
storage.set(STORE_SESSION_KEYS.pkce, JSON.stringify(blob));
|
|
180
240
|
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
-
if (authorizeOptions.screen === "signup") {
|
|
192
|
-
url.pathname = url.pathname.replace(/\/oauth2\/authorize$/, "/oauth2/signup");
|
|
193
|
-
}
|
|
194
|
-
const finalUrl = url.toString();
|
|
241
|
+
const finalUrl = buildAuthorizeUrl({
|
|
242
|
+
authorizeUrl: authServer.authorizeUrl,
|
|
243
|
+
clientId,
|
|
244
|
+
redirectUri: authorizeOptions.redirectUri,
|
|
245
|
+
state,
|
|
246
|
+
codeChallenge: challenge,
|
|
247
|
+
scope: authorizeOptions.scope,
|
|
248
|
+
resource,
|
|
249
|
+
prompt: authorizeOptions.prompt,
|
|
250
|
+
});
|
|
195
251
|
|
|
196
252
|
if (typeof window !== "undefined") {
|
|
197
253
|
window.location.assign(finalUrl);
|
|
@@ -209,11 +265,12 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
209
265
|
throw new Error("state가 일치하지 않습니다. 다시 시도해주십시오");
|
|
210
266
|
}
|
|
211
267
|
const tokens = await exchangeAuthorizationCode({
|
|
212
|
-
baseUrl,
|
|
213
268
|
clientId,
|
|
214
269
|
redirectUri: saved.redirectUri,
|
|
215
270
|
code,
|
|
216
271
|
codeVerifier: saved.verifier,
|
|
272
|
+
tokenUrl,
|
|
273
|
+
resource,
|
|
217
274
|
fetch: fetchFn,
|
|
218
275
|
});
|
|
219
276
|
setTokens({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
|
|
@@ -235,6 +292,7 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
235
292
|
setTokens,
|
|
236
293
|
refresh,
|
|
237
294
|
clear,
|
|
295
|
+
logout,
|
|
238
296
|
subscribe,
|
|
239
297
|
authorize,
|
|
240
298
|
handleCallback,
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 앱(OAuth 클라이언트) 관리 — `/v1/store/api-clients` (auth-spec 부록 B-13).
|
|
5
|
-
* 등록은 DCR(`POST /v1/oauth2/register`, RFC 7591)이 담당하고, 이 스키마는 등록 이후의
|
|
6
|
-
* 목록·상세·시크릿 재발급·비활성화 응답 계약이다. 시크릿 해시는 어떤 응답에도 포함되지 않는다.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const apiClientTypeSchema = z
|
|
10
|
-
.enum(["public", "confidential"])
|
|
11
|
-
.describe(
|
|
12
|
-
"클라이언트 유형. `public`=시크릿 없음(PKCE 로그인용), `confidential`=시크릿 보유(서버 간 연동용)",
|
|
13
|
-
);
|
|
14
|
-
export type ApiClientType = z.infer<typeof apiClientTypeSchema>;
|
|
15
|
-
|
|
16
|
-
export const apiClientStatusSchema = z
|
|
17
|
-
.enum(["ACTIVE", "REVOKED"])
|
|
18
|
-
.describe("상태. `REVOKED`는 비활성화된 클라이언트이며 다시 활성화할 수 없다");
|
|
19
|
-
export type ApiClientStatus = z.infer<typeof apiClientStatusSchema>;
|
|
20
|
-
|
|
21
|
-
export const apiClientSchema = z.object({
|
|
22
|
-
clientId: z.string().describe("클라이언트 ID (`client_id`)"),
|
|
23
|
-
name: z.string().describe("클라이언트 이름 (`client_name`)"),
|
|
24
|
-
type: apiClientTypeSchema,
|
|
25
|
-
status: apiClientStatusSchema,
|
|
26
|
-
redirectUris: z
|
|
27
|
-
.array(z.string())
|
|
28
|
-
.describe("authorization_code 리다이렉트 URI 허용 목록 (정확 일치)"),
|
|
29
|
-
scopes: z.array(z.string()).describe("클라이언트가 요청할 수 있는 최대 스코프"),
|
|
30
|
-
grantTypes: z.array(z.string()).describe("허용된 grant_type 목록"),
|
|
31
|
-
secretPrefix: z
|
|
32
|
-
.string()
|
|
33
|
-
.nullable()
|
|
34
|
-
.describe(
|
|
35
|
-
"시크릿 앞부분(식별용, 인증에 사용할 수 없음). public 클라이언트와 시크릿 앞부분이 기록되기 전에 등록된 클라이언트는 null",
|
|
36
|
-
),
|
|
37
|
-
createdBy: z
|
|
38
|
-
.string()
|
|
39
|
-
.nullable()
|
|
40
|
-
.describe("등록자 userId. 등록자 기록이 도입되기 전에 등록된 클라이언트는 null"),
|
|
41
|
-
createdByName: z.string().nullable().describe("등록자 이름 (표시용). 계정이 없으면 null"),
|
|
42
|
-
createdByEmail: z.string().nullable().describe("등록자 이메일 (표시용). 계정이 없으면 null"),
|
|
43
|
-
createdAt: z.string().describe("등록 일시 (ISO 8601)"),
|
|
44
|
-
revokedAt: z.string().nullable().describe("비활성화 일시 (ISO 8601). 활성 상태면 null"),
|
|
45
|
-
});
|
|
46
|
-
export type ApiClient = z.infer<typeof apiClientSchema>;
|
|
47
|
-
|
|
48
|
-
/** 시크릿 재발급 응답 — 평문 시크릿은 이 응답에서 단 한 번만 노출된다 */
|
|
49
|
-
export const apiClientSecretRotatedSchema = apiClientSchema.extend({
|
|
50
|
-
clientSecret: z
|
|
51
|
-
.string()
|
|
52
|
-
.describe("새 클라이언트 시크릿 평문. 이 응답에서만 노출되며 다시 조회할 수 없다"),
|
|
53
|
-
});
|
|
54
|
-
export type ApiClientSecretRotated = z.infer<typeof apiClientSecretRotatedSchema>;
|
|
55
|
-
|
|
56
|
-
export const apiClientListQuerySchema = z.object({
|
|
57
|
-
status: apiClientStatusSchema.optional().describe("상태 필터. 생략하면 전체"),
|
|
58
|
-
});
|
|
59
|
-
export type ApiClientListParams = z.infer<typeof apiClientListQuerySchema>;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Dynamic Client Registration 요청 (RFC 7591 §2, auth-spec §12) — `POST /v1/oauth2/register`.
|
|
63
|
-
* OAuth 프로토콜 표면이라 필드명이 snake_case이고 응답·등록 오류는 평면 JSON이다.
|
|
64
|
-
* 생략한 필드는 서버 기본값을 따른다(SDK가 기본값을 채워 보내지 않는다).
|
|
65
|
-
*/
|
|
66
|
-
export const oauthClientRegistrationRequestSchema = z.object({
|
|
67
|
-
client_name: z.string().min(1).max(80).describe("클라이언트 이름 (1~80자)"),
|
|
68
|
-
token_endpoint_auth_method: z
|
|
69
|
-
.enum(["none", "client_secret_basic"])
|
|
70
|
-
.optional()
|
|
71
|
-
.describe(
|
|
72
|
-
"토큰 엔드포인트 인증 방식. `none`=public(시크릿 없음), `client_secret_basic`=confidential. 생략하면 `client_secret_basic`",
|
|
73
|
-
),
|
|
74
|
-
redirect_uris: z
|
|
75
|
-
.array(z.string())
|
|
76
|
-
.max(10)
|
|
77
|
-
.optional()
|
|
78
|
-
.describe(
|
|
79
|
-
"리다이렉트 URI 목록(최대 10개). authorization_code에는 필수이며 https(개발용 루프백 http 제외)만 허용",
|
|
80
|
-
),
|
|
81
|
-
grant_types: z
|
|
82
|
-
.array(
|
|
83
|
-
z.enum([
|
|
84
|
-
"authorization_code",
|
|
85
|
-
"refresh_token",
|
|
86
|
-
"client_credentials",
|
|
87
|
-
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
88
|
-
]),
|
|
89
|
-
)
|
|
90
|
-
.min(1)
|
|
91
|
-
.optional()
|
|
92
|
-
.describe("허용할 grant_type 목록. 생략하면 `client_credentials`"),
|
|
93
|
-
scope: z
|
|
94
|
-
.string()
|
|
95
|
-
.optional()
|
|
96
|
-
.describe(
|
|
97
|
-
"공백 구분 스코프. 등록자 보유 스코프 이내여야 한다. 생략하면 confidential은 등록자 스코프, public은 `stores:read`",
|
|
98
|
-
),
|
|
99
|
-
});
|
|
100
|
-
export type OAuthClientRegistrationRequest = z.infer<typeof oauthClientRegistrationRequestSchema>;
|
|
101
|
-
|
|
102
|
-
/** Dynamic Client Registration 응답 (RFC 7591 §3.2.1) — client_secret은 이 응답에서만 노출된다 */
|
|
103
|
-
export const oauthClientRegistrationResponseSchema = z.object({
|
|
104
|
-
client_id: z.string().describe("발급된 클라이언트 ID"),
|
|
105
|
-
client_secret: z
|
|
106
|
-
.string()
|
|
107
|
-
.optional()
|
|
108
|
-
.describe("클라이언트 시크릿 평문. confidential만 있으며 이 응답에서만 노출된다"),
|
|
109
|
-
client_secret_expires_at: z
|
|
110
|
-
.number()
|
|
111
|
-
.int()
|
|
112
|
-
.optional()
|
|
113
|
-
.describe("시크릿 만료 시각(epoch 초). 0은 만료 없음. confidential만 있다"),
|
|
114
|
-
client_id_issued_at: z.number().int().describe("발급 시각 (epoch 초)"),
|
|
115
|
-
client_name: z.string(),
|
|
116
|
-
token_endpoint_auth_method: z.enum(["none", "client_secret_basic"]),
|
|
117
|
-
redirect_uris: z.array(z.string()),
|
|
118
|
-
grant_types: z.array(z.string()),
|
|
119
|
-
scope: z.string().describe("부여된 스코프 (공백 구분)"),
|
|
120
|
-
});
|
|
121
|
-
export type OAuthClientRegistrationResponse = z.infer<typeof oauthClientRegistrationResponseSchema>;
|