@sayren/mcp 0.1.9 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sayren-mcp": "./dist/index.mjs"
@@ -12,8 +12,8 @@
12
12
  "dependencies": {
13
13
  "@modelcontextprotocol/sdk": "^1.22.0",
14
14
  "zod": "^4.6.5",
15
- "@sayren/store-sdk": "^0.5.0",
16
- "@sayren/storefront-sdk": "^0.6.0"
15
+ "@sayren/storefront-sdk": "^0.7.0",
16
+ "@sayren/store-sdk": "^0.7.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,8 +7,8 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.5.0",
11
- "@sayren/storefront-sdk": "0.6.0",
10
+ "@sayren/store-sdk": "0.7.0",
11
+ "@sayren/storefront-sdk": "0.7.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -18,6 +18,9 @@ export function SiteHeader() {
18
18
  <Link to="/orders" className="hover:text-point">
19
19
  주문 내역
20
20
  </Link>
21
+ <Link to="/account" className="hover:text-point">
22
+ 내 정보
23
+ </Link>
21
24
  </nav>
22
25
  </div>
23
26
  </header>
@@ -10,6 +10,8 @@ export interface SocialLoginState {
10
10
  state: string;
11
11
  codeVerifier: string;
12
12
  redirectTo: string;
13
+ /** 로그인한 구매자가 소셜 계정을 연결하는 흐름이면 그 공급자 — 콜백이 로그인 대신 연결로 마무리한다 */
14
+ linkProvider?: "kakao" | "naver" | "google";
13
15
  }
14
16
 
15
17
  export function writeSocialLogin(value: SocialLoginState) {
@@ -38,6 +40,12 @@ export function readSocialLogin(): SocialLoginState | null {
38
40
  state: stored.state,
39
41
  codeVerifier: stored.codeVerifier,
40
42
  redirectTo: typeof stored.redirectTo === "string" ? stored.redirectTo : "/",
43
+ linkProvider:
44
+ stored.linkProvider === "kakao" ||
45
+ stored.linkProvider === "naver" ||
46
+ stored.linkProvider === "google"
47
+ ? stored.linkProvider
48
+ : undefined,
41
49
  };
42
50
  }
43
51
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as IndexRouteImport } from './routes/index'
13
+ import { Route as AccountRouteImport } from './routes/account'
13
14
  import { Route as CartRouteImport } from './routes/cart'
14
15
  import { Route as LoginRouteImport } from './routes/login'
15
16
  import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
@@ -25,6 +26,11 @@ const IndexRoute = IndexRouteImport.update({
25
26
  path: '/',
26
27
  getParentRoute: () => rootRouteImport,
27
28
  } as any)
29
+ const AccountRoute = AccountRouteImport.update({
30
+ id: '/account',
31
+ path: '/account',
32
+ getParentRoute: () => rootRouteImport,
33
+ } as any)
28
34
  const CartRoute = CartRouteImport.update({
29
35
  id: '/cart',
30
36
  path: '/cart',
@@ -73,6 +79,7 @@ const ProductsProductIdRoute = ProductsProductIdRouteImport.update({
73
79
 
74
80
  export interface FileRoutesByFullPath {
75
81
  '/': typeof IndexRoute
82
+ '/account': typeof AccountRoute
76
83
  '/cart': typeof CartRoute
77
84
  '/login': typeof LoginRoute
78
85
  '/auth/callback': typeof AuthCallbackRoute
@@ -85,6 +92,7 @@ export interface FileRoutesByFullPath {
85
92
  }
86
93
  export interface FileRoutesByTo {
87
94
  '/': typeof IndexRoute
95
+ '/account': typeof AccountRoute
88
96
  '/cart': typeof CartRoute
89
97
  '/login': typeof LoginRoute
90
98
  '/auth/callback': typeof AuthCallbackRoute
@@ -98,6 +106,7 @@ export interface FileRoutesByTo {
98
106
  export interface FileRoutesById {
99
107
  __root__: typeof rootRouteImport
100
108
  '/': typeof IndexRoute
109
+ '/account': typeof AccountRoute
101
110
  '/cart': typeof CartRoute
102
111
  '/login': typeof LoginRoute
103
112
  '/auth/callback': typeof AuthCallbackRoute
@@ -112,6 +121,7 @@ export interface FileRouteTypes {
112
121
  fileRoutesByFullPath: FileRoutesByFullPath
113
122
  fullPaths:
114
123
  | '/'
124
+ | '/account'
115
125
  | '/cart'
116
126
  | '/login'
117
127
  | '/auth/callback'
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
124
134
  fileRoutesByTo: FileRoutesByTo
125
135
  to:
126
136
  | '/'
137
+ | '/account'
127
138
  | '/cart'
128
139
  | '/login'
129
140
  | '/auth/callback'
@@ -136,6 +147,7 @@ export interface FileRouteTypes {
136
147
  id:
137
148
  | '__root__'
138
149
  | '/'
150
+ | '/account'
139
151
  | '/cart'
140
152
  | '/login'
141
153
  | '/auth/callback'
@@ -149,6 +161,7 @@ export interface FileRouteTypes {
149
161
  }
150
162
  export interface RootRouteChildren {
151
163
  IndexRoute: typeof IndexRoute
164
+ AccountRoute: typeof AccountRoute
152
165
  CartRoute: typeof CartRoute
153
166
  LoginRoute: typeof LoginRoute
154
167
  AuthCallbackRoute: typeof AuthCallbackRoute
@@ -169,6 +182,13 @@ declare module '@tanstack/react-router' {
169
182
  preLoaderRoute: typeof IndexRouteImport
170
183
  parentRoute: typeof rootRouteImport
171
184
  }
185
+ '/account': {
186
+ id: '/account'
187
+ path: '/account'
188
+ fullPath: '/account'
189
+ preLoaderRoute: typeof AccountRouteImport
190
+ parentRoute: typeof rootRouteImport
191
+ }
172
192
  '/cart': {
173
193
  id: '/cart'
174
194
  path: '/cart'
@@ -237,6 +257,7 @@ declare module '@tanstack/react-router' {
237
257
 
238
258
  const rootRouteChildren: RootRouteChildren = {
239
259
  IndexRoute: IndexRoute,
260
+ AccountRoute: AccountRoute,
240
261
  CartRoute: CartRoute,
241
262
  LoginRoute: LoginRoute,
242
263
  AuthCallbackRoute: AuthCallbackRoute,
@@ -0,0 +1,213 @@
1
+ import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
2
+ import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
3
+ import { createServerFn } from "@tanstack/react-start";
4
+ import { getRequestUrl } from "@tanstack/react-start/server";
5
+ import { useState } from "react";
6
+ import { z } from "zod";
7
+ import { SubmitButton } from "../components/submit-button";
8
+ import { apiFor, authFor } from "../lib/api.server";
9
+ import { formatDateTime } from "../lib/format";
10
+ import { readToken } from "../lib/session.server";
11
+ import { SOCIAL_BUTTONS } from "../lib/social-buttons";
12
+ import { writeSocialLogin } from "../lib/social-login.server";
13
+
14
+ const PROVIDER_NAME: Record<SocialProvider, string> = {
15
+ kakao: "카카오",
16
+ naver: "네이버",
17
+ google: "구글",
18
+ };
19
+
20
+ const accountSearch = z.object({
21
+ linked: z.enum(["kakao", "naver", "google"]).optional().catch(undefined),
22
+ error: z.string().optional().catch(undefined),
23
+ });
24
+
25
+ /** 내 정보와 로그인 수단 — 로그인하지 않았으면 로그인 화면으로 보낸다 */
26
+ const getAccount = createServerFn({ method: "GET" }).handler(async () => {
27
+ const accessToken = readToken();
28
+ if (!accessToken) throw redirect({ to: "/login", search: { redirectTo: "/account" } });
29
+ const [me, identities, store] = await Promise.all([
30
+ apiFor({ accessToken }).member.me(),
31
+ authFor().identities(accessToken),
32
+ apiFor().store.get(),
33
+ ]);
34
+ // 셀러가 켠 소셜 로그인만 연결할 수 있다
35
+ const providers = store.loginMethods.filter(
36
+ (method): method is SocialProvider => method !== "password",
37
+ );
38
+ return { me, identities, providers };
39
+ });
40
+
41
+ const providerInput = z.object({ provider: z.enum(["kakao", "naver", "google"]) });
42
+
43
+ /**
44
+ * 소셜 계정 연결 시작 — 로그인과 같은 콜백(`/auth/callback`)으로 돌아오고, 쿠키의 `linkProvider`로 연결임을 안다.
45
+ * 연결은 흐름을 시작한 이 구매자에게만 된다(서버가 확인한다).
46
+ */
47
+ const startLink = createServerFn({ method: "POST" })
48
+ .validator(providerInput)
49
+ .handler(async ({ data }) => {
50
+ const accessToken = readToken();
51
+ if (!accessToken) return { url: null, error: "다시 로그인해 주세요" };
52
+ const origin = new URL(getRequestUrl()).origin;
53
+ try {
54
+ const started = await authFor().linkIdp(accessToken, data.provider, {
55
+ redirectUri: `${origin}/auth/callback`,
56
+ });
57
+ writeSocialLogin({
58
+ state: started.state,
59
+ codeVerifier: started.codeVerifier,
60
+ redirectTo: "/account",
61
+ linkProvider: data.provider,
62
+ });
63
+ return { url: started.url, error: null };
64
+ } catch (error) {
65
+ return { url: null, error: accountErrorMessage(error) };
66
+ }
67
+ });
68
+
69
+ const unlink = createServerFn({ method: "POST" })
70
+ .validator(providerInput)
71
+ .handler(async ({ data }) => {
72
+ const accessToken = readToken();
73
+ if (!accessToken) return { error: "다시 로그인해 주세요" };
74
+ try {
75
+ await authFor().unlinkIdp(accessToken, data.provider);
76
+ return { error: null };
77
+ } catch (error) {
78
+ return { error: accountErrorMessage(error) };
79
+ }
80
+ });
81
+
82
+ const ERROR_MESSAGE: Record<string, string> = {
83
+ IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있어요",
84
+ PROVIDER_ALREADY_LINKED:
85
+ "같은 서비스의 다른 계정이 이미 연결돼 있어요. 해제한 뒤 다시 연결해 주세요",
86
+ LAST_LOGIN_METHOD: "마지막 로그인 수단은 해제할 수 없어요. 다른 계정을 먼저 연결해 주세요",
87
+ access_denied: "연결을 취소했어요",
88
+ state_mismatch: "연결 요청이 만료됐어요. 다시 시도해 주세요",
89
+ };
90
+
91
+ function accountErrorMessage(error: unknown): string {
92
+ if (error instanceof ApiError)
93
+ return ERROR_MESSAGE[error.code] ?? "처리하지 못했어요. 잠시 후 다시 시도해 주세요";
94
+ return "처리하지 못했어요. 잠시 후 다시 시도해 주세요";
95
+ }
96
+
97
+ export const Route = createFileRoute("/account")({
98
+ validateSearch: accountSearch,
99
+ loader: () => getAccount(),
100
+ head: () => ({ meta: [{ title: "내 정보" }] }),
101
+ component: Account,
102
+ });
103
+
104
+ function Account() {
105
+ const { me, identities, providers } = Route.useLoaderData();
106
+ const { linked, error: callbackError } = Route.useSearch();
107
+ const router = useRouter();
108
+ const [pending, setPending] = useState(false);
109
+ const [actionError, setActionError] = useState<string | null>(null);
110
+ const error =
111
+ actionError ??
112
+ (callbackError
113
+ ? (ERROR_MESSAGE[callbackError] ?? "연결하지 못했어요. 다시 시도해 주세요")
114
+ : null);
115
+
116
+ const submit = (event: React.FormEvent<HTMLFormElement>) => {
117
+ event.preventDefault();
118
+ const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
119
+ const [action, provider] = (submitter?.value ?? "").split(":") as [string, SocialProvider];
120
+ if (!provider) return;
121
+ setPending(true);
122
+ setActionError(null);
123
+ if (action === "link") {
124
+ void startLink({ data: { provider } }).then((result) => {
125
+ // 공급자 화면은 다른 사이트라 문서째 이동한다
126
+ if (result.url) window.location.assign(result.url);
127
+ else {
128
+ setActionError(result.error);
129
+ setPending(false);
130
+ }
131
+ });
132
+ return;
133
+ }
134
+ void unlink({ data: { provider } }).then(async (result) => {
135
+ if (result.error) setActionError(result.error);
136
+ await router.invalidate();
137
+ setPending(false);
138
+ });
139
+ };
140
+
141
+ return (
142
+ <div className="mx-auto max-w-lg space-y-8 py-10">
143
+ <div className="space-y-1">
144
+ <h1 className="font-bold text-2xl">내 정보</h1>
145
+ <p className="text-muted text-sm">
146
+ {me.name}
147
+ {me.email ? ` · ${me.email}` : ""}
148
+ </p>
149
+ </div>
150
+
151
+ <section className="space-y-3">
152
+ <h2 className="font-bold text-lg">로그인 수단</h2>
153
+ {linked ? (
154
+ <p className="rounded-md bg-chip px-3 py-2 text-sm">
155
+ {PROVIDER_NAME[linked]} 계정을 연결했어요
156
+ </p>
157
+ ) : null}
158
+ <form onSubmit={submit}>
159
+ <ul className="divide-y divide-line rounded-lg border border-line">
160
+ <li className="flex items-center justify-between gap-3 px-4 py-3">
161
+ <span className="font-medium">이메일·비밀번호</span>
162
+ <span className="text-muted text-sm">
163
+ {identities.hasPassword ? "사용 중" : "없음"}
164
+ </span>
165
+ </li>
166
+ {identities.hasExternalAuth ? (
167
+ <li className="flex items-center justify-between gap-3 px-4 py-3">
168
+ <span className="font-medium">스토어 회원 계정</span>
169
+ <span className="text-muted text-sm">사용 중</span>
170
+ </li>
171
+ ) : null}
172
+ {providers.map((provider) => {
173
+ const identity = identities.identities.find((item) => item.provider === provider);
174
+ return (
175
+ <li key={provider} className="flex items-center justify-between gap-3 px-4 py-3">
176
+ <span className="flex flex-col gap-0.5">
177
+ <span className="font-medium">{PROVIDER_NAME[provider]}</span>
178
+ <span className="text-muted text-xs">
179
+ {identity
180
+ ? `${identity.email ?? "이메일 비공개"} · ${formatDateTime(identity.linkedAt)} 연결`
181
+ : "연결 안 됨"}
182
+ </span>
183
+ </span>
184
+ {identity ? (
185
+ <SubmitButton
186
+ value={`unlink:${provider}`}
187
+ disabled={pending}
188
+ className="h-9 rounded-md border border-line px-3 text-sm disabled:opacity-50"
189
+ >
190
+ 연결 해제
191
+ </SubmitButton>
192
+ ) : (
193
+ <SubmitButton
194
+ value={`link:${provider}`}
195
+ disabled={pending}
196
+ className={`h-9 rounded-md px-3 font-semibold text-sm disabled:opacity-50 ${SOCIAL_BUTTONS[provider].className}`}
197
+ >
198
+ 연결
199
+ </SubmitButton>
200
+ )}
201
+ </li>
202
+ );
203
+ })}
204
+ </ul>
205
+ </form>
206
+ {error ? <p className="text-point text-sm">{error}</p> : null}
207
+ <p className="text-muted text-xs">
208
+ 연결한 계정으로도 같은 회원으로 로그인해요. 마지막 로그인 수단은 해제할 수 없어요.
209
+ </p>
210
+ </section>
211
+ </div>
212
+ );
213
+ }
@@ -3,7 +3,7 @@ import { IdpCallbackError } from "@sayren/storefront-sdk/auth";
3
3
  import { createFileRoute } from "@tanstack/react-router";
4
4
  import { authFor } from "../lib/api.server";
5
5
  import { clearCartToken, readCartToken } from "../lib/cart-session.server";
6
- import { signIn } from "../lib/session.server";
6
+ import { readToken, signIn } from "../lib/session.server";
7
7
  import { clearSocialLogin, readSocialLogin } from "../lib/social-login.server";
8
8
 
9
9
  /**
@@ -24,6 +24,28 @@ export const Route = createFileRoute("/auth/callback")({
24
24
  const fail = (reason: string) => go(`/login?error=${encodeURIComponent(reason)}`);
25
25
  if (!stored) return fail("state_mismatch");
26
26
 
27
+ // 계정 연결 — 로그인한 구매자에 공급자 계정을 붙이고 계정 화면으로 돌아간다
28
+ if (stored.linkProvider) {
29
+ const accessToken = readToken();
30
+ if (!accessToken) return go("/login?redirectTo=%2Faccount");
31
+ try {
32
+ await authFor().linkIdpCallback(accessToken, stored.linkProvider, {
33
+ code: url.searchParams.get("code"),
34
+ state: url.searchParams.get("state"),
35
+ error: url.searchParams.get("error"),
36
+ expectedState: stored.state,
37
+ codeVerifier: stored.codeVerifier,
38
+ });
39
+ return go(`/account?linked=${stored.linkProvider}`);
40
+ } catch (error) {
41
+ if (error instanceof IdpCallbackError)
42
+ return go(`/account?error=${encodeURIComponent(error.reason)}`);
43
+ if (error instanceof ApiError)
44
+ return go(`/account?error=${encodeURIComponent(error.code)}`);
45
+ throw error;
46
+ }
47
+ }
48
+
27
49
  const cartToken = readCartToken();
28
50
  try {
29
51
  const tokens = await authFor().idpCallback({
@@ -25,7 +25,7 @@ const getLoginMethods = createServerFn({ method: "GET" }).handler(async () => {
25
25
 
26
26
  /**
27
27
  * 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면 주소를 돌려준다. 돌아오는 곳은
28
- * `/auth/callback`이고, 이 앱의 주소가 콘솔의 스토어프런트 도메인에 등록돼 있어야 한다.
28
+ * `/auth/callback`이고, 이 앱의 주소가 콘솔의 스토어프론트 도메인에 등록돼 있어야 한다.
29
29
  */
30
30
  const startSocial = createServerFn({ method: "POST" })
31
31
  .validator(z.object({ provider: z.enum(["kakao", "naver", "google"]), redirectTo: z.unknown() }))