@sayren/mcp 0.1.6 → 0.1.7

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.6",
3
+ "version": "0.1.7",
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.4.0",
16
- "@sayren/storefront-sdk": "^0.5.0"
15
+ "@sayren/store-sdk": "^0.5.0",
16
+ "@sayren/storefront-sdk": "^0.6.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.4.0",
11
- "@sayren/storefront-sdk": "0.5.0",
10
+ "@sayren/store-sdk": "0.5.0",
11
+ "@sayren/storefront-sdk": "0.6.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -0,0 +1,38 @@
1
+ import { createCookie } from "react-router";
2
+
3
+ /**
4
+ * 소셜 로그인 왕복 상태 — 공급자로 보내기 전에 만든 state·PKCE verifier·돌아갈 곳을 HttpOnly 쿠키에 잠깐 둔다.
5
+ * 돌아오면 state를 비교하고 verifier로 1회용 code를 토큰으로 바꾼 뒤 지운다. 10분이 지나면 사라진다.
6
+ */
7
+ export const socialLoginCookie = createCookie("sayren_social", {
8
+ httpOnly: true,
9
+ sameSite: "lax",
10
+ path: "/",
11
+ secure: process.env.NODE_ENV === "production",
12
+ maxAge: 60 * 10,
13
+ });
14
+
15
+ export interface SocialLoginState {
16
+ state: string;
17
+ codeVerifier: string;
18
+ redirectTo: string;
19
+ }
20
+
21
+ export async function readSocialLogin(request: Request): Promise<SocialLoginState | null> {
22
+ const value: unknown = await socialLoginCookie.parse(request.headers.get("cookie"));
23
+ if (!value || typeof value !== "object") return null;
24
+ const stored = value as Partial<SocialLoginState>;
25
+ if (typeof stored.state !== "string" || typeof stored.codeVerifier !== "string") return null;
26
+ return {
27
+ state: stored.state,
28
+ codeVerifier: stored.codeVerifier,
29
+ redirectTo: typeof stored.redirectTo === "string" ? stored.redirectTo : "/",
30
+ };
31
+ }
32
+
33
+ /** 공급자 표시 — 버튼 문구·색. 로고는 각 사 가이드의 공식 버튼 자산으로 바꿔 쓴다 */
34
+ export const SOCIAL_BUTTONS = {
35
+ kakao: { label: "카카오로 시작하기", className: "bg-[#FEE500] text-[#191600]" },
36
+ naver: { label: "네이버로 시작하기", className: "bg-[#03A94D] text-white" },
37
+ google: { label: "구글로 시작하기", className: "border border-line bg-white text-ink" },
38
+ } as const;
@@ -0,0 +1,44 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
2
+ import { IdpCallbackError } from "@sayren/storefront-sdk/auth";
3
+ import { redirect } from "react-router";
4
+ import { authFor } from "../lib/api.server";
5
+ import { cartCookie, readCartToken } from "../lib/cart-session.server";
6
+ import { signInCookie } from "../lib/session.server";
7
+ import { readSocialLogin, socialLoginCookie } from "../lib/social-login.server";
8
+ import type { Route } from "./+types/auth-callback";
9
+
10
+ /**
11
+ * 소셜 로그인에서 돌아오는 곳 — 쿠키에 둔 state·verifier로 1회용 code를 구매자 토큰으로 바꾸고 세션 쿠키를 심는다.
12
+ * 비회원 장바구니가 있으면 합친다. 실패하면 로그인 화면으로 이유를 들고 돌아간다.
13
+ */
14
+ export async function loader({ request }: Route.LoaderArgs) {
15
+ const url = new URL(request.url);
16
+ const stored = await readSocialLogin(request);
17
+ const clearSocial = await socialLoginCookie.serialize("", { maxAge: 0 });
18
+ const fail = (reason: string) =>
19
+ redirect(`/login?error=${encodeURIComponent(reason)}`, {
20
+ headers: { "Set-Cookie": clearSocial },
21
+ });
22
+ if (!stored) return fail("state_mismatch");
23
+
24
+ const cartToken = await readCartToken(request);
25
+ try {
26
+ const tokens = await authFor(request).idpCallback({
27
+ code: url.searchParams.get("code"),
28
+ state: url.searchParams.get("state"),
29
+ error: url.searchParams.get("error"),
30
+ expectedState: stored.state,
31
+ codeVerifier: stored.codeVerifier,
32
+ cartToken: cartToken ?? undefined,
33
+ });
34
+ const headers = new Headers();
35
+ headers.append("Set-Cookie", await signInCookie(tokens));
36
+ headers.append("Set-Cookie", clearSocial);
37
+ if (cartToken) headers.append("Set-Cookie", await cartCookie.serialize("", { maxAge: 0 }));
38
+ return redirect(stored.redirectTo, { headers });
39
+ } catch (error) {
40
+ if (error instanceof IdpCallbackError) return fail(error.reason);
41
+ if (error instanceof ApiError) return fail("provider_error");
42
+ throw error;
43
+ }
44
+ }
@@ -1,21 +1,57 @@
1
- import { ApiError } from "@sayren/storefront-sdk";
2
- import { Form, redirect } from "react-router";
3
- import { authFor } from "../lib/api.server";
1
+ import { ApiError, type SocialProvider } from "@sayren/storefront-sdk";
2
+ import { Form, redirect, useSearchParams } from "react-router";
3
+ import { apiFor, authFor } from "../lib/api.server";
4
4
  import { cartCookie, readCartToken } from "../lib/cart-session.server";
5
5
  import { safeRedirect } from "../lib/safe-redirect";
6
6
  import { signInCookie } from "../lib/session.server";
7
+ import { SOCIAL_BUTTONS, socialLoginCookie } from "../lib/social-login.server";
7
8
  import type { Route } from "./+types/login";
8
9
 
9
10
  export function meta() {
10
11
  return [{ title: "로그인" }];
11
12
  }
12
13
 
14
+ /** 켜진 로그인 방법 — 셀러가 콘솔 설정 › 로그인에서 켠 것만 온다 */
15
+ export async function loader({ request }: Route.LoaderArgs) {
16
+ const store = await apiFor(request).store.get();
17
+ return { loginMethods: store.loginMethods };
18
+ }
19
+
20
+ /**
21
+ * 소셜 로그인 시작 — state·PKCE verifier를 쿠키에 두고 공급자 로그인 화면으로 보낸다. 돌아오는 곳은 `/auth/callback`이고,
22
+ * 이 앱의 주소가 콘솔의 스토어프런트 도메인에 등록돼 있어야 한다.
23
+ */
24
+ async function startSocial(request: Request, provider: SocialProvider, redirectTo: string) {
25
+ const origin = new URL(request.url).origin;
26
+ const started = await authFor(request).idp(provider, { redirectUri: `${origin}/auth/callback` });
27
+ return redirect(started.url, {
28
+ headers: {
29
+ "Set-Cookie": await socialLoginCookie.serialize({
30
+ state: started.state,
31
+ codeVerifier: started.codeVerifier,
32
+ redirectTo,
33
+ }),
34
+ },
35
+ });
36
+ }
37
+
13
38
  /**
14
39
  * 로그인 — 화면은 이 앱이 그리고, 확인은 sayren이 한다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면
15
40
  * 회원 장바구니로 합쳐지고 그 토큰은 닫힌다. 그래서 장바구니 쿠키도 지운다.
16
41
  */
17
42
  export async function action({ request }: Route.ActionArgs) {
18
43
  const form = await request.formData();
44
+ const redirectTo = safeRedirect(
45
+ form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo"),
46
+ );
47
+ const social = form.get("social");
48
+ if (social === "kakao" || social === "naver" || social === "google") {
49
+ try {
50
+ return await startSocial(request, social, redirectTo);
51
+ } catch (error) {
52
+ return { error: loginErrorMessage(error) };
53
+ }
54
+ }
19
55
  const cartToken = await readCartToken(request);
20
56
  try {
21
57
  const tokens = await authFor(request).session({
@@ -26,9 +62,7 @@ export async function action({ request }: Route.ActionArgs) {
26
62
  const headers = new Headers();
27
63
  headers.append("Set-Cookie", await signInCookie(tokens));
28
64
  if (cartToken) headers.append("Set-Cookie", await cartCookie.serialize("", { maxAge: 0 }));
29
- const redirectTo =
30
- form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo");
31
- return redirect(safeRedirect(redirectTo), { headers });
65
+ return redirect(redirectTo, { headers });
32
66
  } catch (error) {
33
67
  return { error: loginErrorMessage(error) };
34
68
  }
@@ -37,6 +71,7 @@ export async function action({ request }: Route.ActionArgs) {
37
71
  function loginErrorMessage(error: unknown): string {
38
72
  if (error instanceof ApiError) {
39
73
  if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
74
+ if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받아요";
40
75
  if (error.code === "ACCOUNT_LOCKED")
41
76
  return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
42
77
  if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
@@ -44,30 +79,74 @@ function loginErrorMessage(error: unknown): string {
44
79
  return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
45
80
  }
46
81
 
47
- export default function Login({ actionData }: Route.ComponentProps) {
82
+ const CALLBACK_ERRORS: Record<string, string> = {
83
+ access_denied: "로그인을 취소했어요",
84
+ state_mismatch: "로그인 요청이 만료됐어요. 다시 시도해 주세요",
85
+ };
86
+
87
+ export default function Login({ loaderData, actionData }: Route.ComponentProps) {
88
+ const [params] = useSearchParams();
89
+ const callbackError = params.get("error");
90
+ const social = loaderData.loginMethods.filter(
91
+ (method): method is SocialProvider => method !== "password",
92
+ );
93
+ const password = loaderData.loginMethods.includes("password");
94
+ const error =
95
+ actionData?.error ??
96
+ (callbackError
97
+ ? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했어요. 다시 시도해 주세요")
98
+ : null);
99
+
48
100
  return (
49
101
  <div className="mx-auto max-w-sm space-y-5 py-10">
50
102
  <h1 className="font-bold text-2xl">로그인</h1>
51
- <Form method="post" className="space-y-3">
52
- <input
53
- type="email"
54
- name="email"
55
- placeholder="이메일"
56
- required
57
- className="h-11 w-full rounded-md border border-line px-3"
58
- />
59
- <input
60
- type="password"
61
- name="password"
62
- placeholder="비밀번호"
63
- required
64
- className="h-11 w-full rounded-md border border-line px-3"
65
- />
66
- {actionData?.error ? <p className="text-point text-sm">{actionData.error}</p> : null}
67
- <button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
68
- 로그인
69
- </button>
70
- </Form>
103
+ {social.length ? (
104
+ <Form method="post" className="space-y-2">
105
+ {social.map((provider) => (
106
+ <button
107
+ key={provider}
108
+ type="submit"
109
+ name="social"
110
+ value={provider}
111
+ className={`h-12 w-full rounded-md font-semibold ${SOCIAL_BUTTONS[provider].className}`}
112
+ >
113
+ {SOCIAL_BUTTONS[provider].label}
114
+ </button>
115
+ ))}
116
+ </Form>
117
+ ) : null}
118
+ {social.length && password ? (
119
+ <div className="flex items-center gap-3 text-muted text-xs">
120
+ <span className="h-px flex-1 bg-line" />
121
+ 또는 이메일로
122
+ <span className="h-px flex-1 bg-line" />
123
+ </div>
124
+ ) : null}
125
+ {password ? (
126
+ <Form method="post" className="space-y-3">
127
+ <input
128
+ type="email"
129
+ name="email"
130
+ placeholder="이메일"
131
+ required
132
+ className="h-11 w-full rounded-md border border-line px-3"
133
+ />
134
+ <input
135
+ type="password"
136
+ name="password"
137
+ placeholder="비밀번호"
138
+ required
139
+ className="h-11 w-full rounded-md border border-line px-3"
140
+ />
141
+ <button type="submit" className="h-12 w-full rounded-md bg-ink font-semibold text-white">
142
+ 로그인
143
+ </button>
144
+ </Form>
145
+ ) : null}
146
+ {error ? <p className="text-point text-sm">{error}</p> : null}
147
+ {social.length ? (
148
+ <p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 돼요</p>
149
+ ) : null}
71
150
  </div>
72
151
  );
73
152
  }
@@ -12,6 +12,7 @@ export default [
12
12
  route("checkout", "routes/checkout.tsx"),
13
13
  route("checkout/complete", "routes/checkout-complete.tsx"),
14
14
  route("login", "routes/login.tsx"),
15
+ route("auth/callback", "routes/auth-callback.tsx"),
15
16
  route("orders", "routes/orders.tsx"),
16
17
  route("orders/:orderId", "routes/order-detail.tsx"),
17
18
  ] satisfies RouteConfig;