@sayren/mcp 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
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.3.0",
16
- "@sayren/storefront-sdk": "^0.2.1"
15
+ "@sayren/store-sdk": "^0.4.0",
16
+ "@sayren/storefront-sdk": "^0.5.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.3.0",
11
- "@sayren/storefront-sdk": "0.2.1",
10
+ "@sayren/store-sdk": "0.4.0",
11
+ "@sayren/storefront-sdk": "0.5.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -1,4 +1,5 @@
1
1
  import { analyticsIdsFromCookie, createStorefrontClient } from "@sayren/storefront-sdk";
2
+ import { createStorefrontAuth } from "@sayren/storefront-sdk/auth";
2
3
  import { API_BASE_URL, resolveStoreCode } from "./config.server";
3
4
 
4
5
  /**
@@ -35,3 +36,11 @@ export function apiFor(
35
36
  },
36
37
  });
37
38
  }
39
+
40
+ /**
41
+ * 구매자 인증 — 로그인·가입·비회원 세션·토큰 갱신. 화면은 이 앱이 그리고 계정은 sayren이 보관한다.
42
+ * 결과 토큰은 `session.server.ts`가 HttpOnly 쿠키에 둔다.
43
+ */
44
+ export function authFor(request: Request) {
45
+ return createStorefrontAuth({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode(request) });
46
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { safeRedirect } from "./safe-redirect";
3
+
4
+ describe("safeRedirect", () => {
5
+ it("같은 사이트 경로는 그대로 둔다", () => {
6
+ expect(safeRedirect("/cart")).toBe("/cart");
7
+ expect(safeRedirect("/orders/ord_1?tab=items")).toBe("/orders/ord_1?tab=items");
8
+ });
9
+
10
+ it("외부로 나가는 주소와 빈 값은 기본 경로로 바꾼다", () => {
11
+ for (const value of [
12
+ null,
13
+ "",
14
+ "https://evil.com",
15
+ "//evil.com",
16
+ "/\\evil.com",
17
+ "/\t/evil.com",
18
+ "/\n/evil.com",
19
+ "cart",
20
+ ]) {
21
+ expect(safeRedirect(value)).toBe("/orders");
22
+ }
23
+ });
24
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 로그인 뒤 이동할 곳 — 같은 사이트 경로만 받는다(외부 주소로 보내는 열린 리다이렉트 방지).
3
+ * `//evil.com`과 `/\evil.com`은 브라우저가 외부 주소로 해석하므로 두 번째 글자가 `/`·`\`면 거부하고,
4
+ * 제어 문자(탭·개행은 URL 파싱에서 지워진다)도 거부한다.
5
+ */
6
+ export function safeRedirect(value: FormDataEntryValue | null): string {
7
+ const fallback = "/orders";
8
+ if (typeof value !== "string" || !value.startsWith("/")) return fallback;
9
+ if (value[1] === "/" || value[1] === "\\") return fallback;
10
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: 제어 문자를 거부하려고 찾는다
11
+ if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
12
+ return value;
13
+ }
@@ -1,24 +1,99 @@
1
- import { createCookie } from "react-router";
1
+ import { ApiError, type TokenPair } from "@sayren/storefront-sdk/auth";
2
+ import {
3
+ createContext,
4
+ createCookie,
5
+ type MiddlewareFunction,
6
+ type RouterContextProvider,
7
+ } from "react-router";
8
+ import { authFor } from "./api.server";
2
9
 
3
10
  /**
4
- * 구매자 세션 — 액세스 토큰을 HttpOnly 쿠키에 둔다. 브라우저 JS가 토큰을 읽지 못하고,
5
- * SSR loader가 그대로 쓸 수 있다.
11
+ * 구매자 세션 — 로그인 토큰 쌍을 HttpOnly 쿠키 하나에 둔다. 브라우저 JS는 토큰을 읽지 못하고,
12
+ * 서버(loader·action)만 쓴다.
6
13
  *
7
- * 확장 지점: 리프레시 토큰 회전을 쓰려면 여기에 refresh 쿠키를 더한다.
14
+ * 액세스 토큰은 30분이라 `buyerSession` 미들웨어가 요청마다 만료를 보고, 곧 끝나면 리프레시 토큰으로
15
+ * 새 쌍을 받아 쿠키를 갈아 끼운다. 리프레시 토큰이 거부되면(401: 14일 만료·폐기) 쿠키를 지워 로그아웃 상태가 된다.
16
+ * 네트워크 오류·5xx 같은 일시 장애에는 쿠키를 지우지 않는다 — 다음 요청에서 다시 갱신한다.
8
17
  */
9
18
  export const sessionCookie = createCookie("sayren_member", {
10
19
  httpOnly: true,
11
20
  sameSite: "lax",
12
21
  path: "/",
13
22
  secure: process.env.NODE_ENV === "production",
14
- maxAge: 60 * 60 * 24 * 7,
23
+ maxAge: 60 * 60 * 24 * 14,
15
24
  });
16
25
 
17
- export async function readToken(request: Request): Promise<string | null> {
18
- const value = await sessionCookie.parse(request.headers.get("cookie"));
19
- return typeof value === "string" ? value : null;
26
+ interface StoredSession {
27
+ accessToken: string;
28
+ refreshToken: string;
29
+ /** 액세스 토큰 만료 시각(epoch ms) */
30
+ expiresAt: number;
20
31
  }
21
32
 
22
- export function tokenHeaders(accessToken: string | null): Promise<string> {
23
- return sessionCookie.serialize(accessToken ?? "", accessToken ? undefined : { maxAge: 0 });
33
+ /** 만료 이만큼 전에 미리 갱신한다 — 요청 도중 만료로 401이 나지 않게 */
34
+ const REFRESH_LEEWAY_MS = 60_000;
35
+
36
+ const accessTokenContext = createContext<string | null>(null);
37
+
38
+ function toStored(tokens: TokenPair): StoredSession {
39
+ return {
40
+ accessToken: tokens.accessToken,
41
+ refreshToken: tokens.refreshToken,
42
+ expiresAt: Date.now() + tokens.expiresIn * 1000,
43
+ };
44
+ }
45
+
46
+ async function parseSession(request: Request): Promise<StoredSession | null> {
47
+ const value: unknown = await sessionCookie.parse(request.headers.get("cookie"));
48
+ if (!value || typeof value !== "object") return null;
49
+ const session = value as Partial<StoredSession>;
50
+ if (typeof session.accessToken !== "string" || typeof session.refreshToken !== "string")
51
+ return null;
52
+ return {
53
+ accessToken: session.accessToken,
54
+ refreshToken: session.refreshToken,
55
+ expiresAt: Number(session.expiresAt),
56
+ };
57
+ }
58
+
59
+ /** 루트 미들웨어 — 이 요청의 액세스 토큰을 정하고, 갱신했으면 응답에 새 쿠키를 싣는다 */
60
+ export const buyerSession: MiddlewareFunction<Response> = async ({ request, context }, next) => {
61
+ const stored = await parseSession(request);
62
+ let setCookie: string | null = null;
63
+ let accessToken = stored?.accessToken ?? null;
64
+
65
+ // 만료 시각을 모르면(형식이 다른 쿠키) 만료로 본다
66
+ if (stored && !(stored.expiresAt - REFRESH_LEEWAY_MS > Date.now())) {
67
+ try {
68
+ const refreshed = toStored(await authFor(request).refresh(stored.refreshToken));
69
+ accessToken = refreshed.accessToken;
70
+ setCookie = await sessionCookie.serialize(refreshed);
71
+ } catch (error) {
72
+ // 일시 장애면 이번 요청은 기존 토큰으로 진행한다(만료 전 여유 1분 안이면 아직 유효하다)
73
+ if (error instanceof ApiError && error.status === 401) {
74
+ accessToken = null;
75
+ setCookie = await signOutCookie();
76
+ }
77
+ }
78
+ }
79
+
80
+ context.set(accessTokenContext, accessToken);
81
+ const response = await next();
82
+ if (setCookie) response.headers.append("Set-Cookie", setCookie);
83
+ return response;
84
+ };
85
+
86
+ /** 이 요청의 구매자 액세스 토큰 — 로그인하지 않았으면 null */
87
+ export function readToken(context: Readonly<RouterContextProvider>): string | null {
88
+ return context.get(accessTokenContext);
89
+ }
90
+
91
+ /** 로그인·가입 직후 응답에 실을 쿠키 */
92
+ export function signInCookie(tokens: TokenPair): Promise<string> {
93
+ return sessionCookie.serialize(toStored(tokens));
94
+ }
95
+
96
+ /** 로그아웃 — 쿠키를 지운다 */
97
+ export function signOutCookie(): Promise<string> {
98
+ return sessionCookie.serialize("", { maxAge: 0 });
24
99
  }
@@ -11,6 +11,7 @@ import type { Route } from "./+types/root";
11
11
  import { SiteHeader } from "./components/site-header";
12
12
  import { type AnalyticsConfig, startAnalytics } from "./lib/analytics";
13
13
  import { ANALYTICS_DEBUG, API_BASE_URL, resolveStoreCode } from "./lib/config.server";
14
+ import { buyerSession } from "./lib/session.server";
14
15
  import "./app.css";
15
16
 
16
17
  export function Layout({ children }: { children: React.ReactNode }) {
@@ -32,6 +33,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
32
33
  );
33
34
  }
34
35
 
36
+ /** 구매자 세션 — 모든 요청에서 액세스 토큰을 정하고 만료가 가까우면 갱신한다 */
37
+ export const middleware: Route.MiddlewareFunction[] = [buyerSession];
38
+
35
39
  export function loader({ request }: Route.LoaderArgs) {
36
40
  const analytics: AnalyticsConfig = {
37
41
  apiBaseUrl: API_BASE_URL,
@@ -9,21 +9,26 @@ export function meta() {
9
9
  return [{ title: "장바구니" }];
10
10
  }
11
11
 
12
- export async function loader({ request }: Route.LoaderArgs) {
13
- const accessToken = await readToken(request);
12
+ export async function loader({ request, context }: Route.LoaderArgs) {
13
+ const accessToken = readToken(context);
14
14
  const cartToken = await readCartToken(request);
15
15
  // 비회원이 아직 장바구니를 만든 적이 없으면 서버에 물어볼 것이 없다
16
16
  if (!accessToken && !cartToken) {
17
- return { cart: { items: [], summary: { productAmount: 0, deliveryFee: 0, totalAmount: 0 } } };
17
+ return {
18
+ cart: { items: [], summary: { productAmount: 0, deliveryFee: 0, totalAmount: 0 } },
19
+ loginRequired: false,
20
+ };
18
21
  }
19
22
  const api = apiFor(request, { accessToken, cartToken });
20
- return { cart: await api.cart.get() };
23
+ // 회원만 주문받는 스토어면 비회원에게 주문 버튼 대신 로그인 안내를 보인다(담기는 그대로 된다)
24
+ const [cart, store] = await Promise.all([api.cart.get(), accessToken ? null : api.store.get()]);
25
+ return { cart, loginRequired: store ? !store.guestCheckout : false };
21
26
  }
22
27
 
23
- export async function action({ request }: Route.ActionArgs) {
28
+ export async function action({ request, context }: Route.ActionArgs) {
24
29
  const form = await request.formData();
25
30
  const intent = String(form.get("intent") ?? "");
26
- const accessToken = await readToken(request);
31
+ const accessToken = readToken(context);
27
32
  const cartToken = await readCartToken(request);
28
33
  let issued: string | null = null;
29
34
  const api = apiFor(request, {
@@ -49,7 +54,7 @@ export async function action({ request }: Route.ActionArgs) {
49
54
  }
50
55
 
51
56
  export default function CartPage({ loaderData }: Route.ComponentProps) {
52
- const { cart } = loaderData;
57
+ const { cart, loginRequired } = loaderData;
53
58
 
54
59
  if (!cart.items.length) {
55
60
  return (
@@ -142,19 +147,38 @@ export default function CartPage({ loaderData }: Route.ComponentProps) {
142
147
  </div>
143
148
  </dl>
144
149
 
145
- {/* 주문서는 GET으로 연다 — 진입 조건이 URL에 남아 새로고침·뒤로가기가 그대로 동작한다 */}
146
- <Form method="get" action="/checkout" className="w-full md:w-64">
147
- {purchasable.map((item) => (
148
- <input key={item.cartItemId} type="hidden" name="cartItemId" value={item.cartItemId} />
149
- ))}
150
- <button
151
- type="submit"
152
- disabled={!purchasable.length}
153
- className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
154
- >
155
- 주문하기
156
- </button>
157
- </Form>
150
+ {loginRequired ? (
151
+ <div className="flex w-full flex-col gap-2 md:w-64">
152
+ <p className="text-center text-muted text-xs">
153
+ 이 스토어는 회원만 주문할 수 있어요. 담은 상품은 로그인하면 그대로 이어져요
154
+ </p>
155
+ <Link
156
+ to="/login?redirectTo=/cart"
157
+ className="flex h-12 items-center justify-center rounded-md bg-ink font-semibold text-white"
158
+ >
159
+ 로그인하고 주문하기
160
+ </Link>
161
+ </div>
162
+ ) : (
163
+ /* 주문서는 GET으로 연다 — 진입 조건이 URL에 남아 새로고침·뒤로가기가 그대로 동작한다 */
164
+ <Form method="get" action="/checkout" className="w-full md:w-64">
165
+ {purchasable.map((item) => (
166
+ <input
167
+ key={item.cartItemId}
168
+ type="hidden"
169
+ name="cartItemId"
170
+ value={item.cartItemId}
171
+ />
172
+ ))}
173
+ <button
174
+ type="submit"
175
+ disabled={!purchasable.length}
176
+ className="h-12 w-full rounded-md bg-ink font-semibold text-white disabled:opacity-50"
177
+ >
178
+ 주문하기
179
+ </button>
180
+ </Form>
181
+ )}
158
182
  </div>
159
183
  </div>
160
184
  );
@@ -8,12 +8,12 @@ export function meta() {
8
8
  return [{ title: "주문 완료" }];
9
9
  }
10
10
 
11
- export async function loader({ request }: Route.LoaderArgs) {
11
+ export async function loader({ request, context }: Route.LoaderArgs) {
12
12
  const orderId = new URL(request.url).searchParams.get("orderId");
13
13
  if (!orderId) return { orderId: null, order: null };
14
14
 
15
15
  // 비회원은 주문 조회에 비밀번호가 필요하다 — 완료 화면에서는 주문번호만 보여 준다
16
- const accessToken = await readToken(request);
16
+ const accessToken = readToken(context);
17
17
  if (!accessToken) return { orderId, order: null };
18
18
 
19
19
  const api = apiFor(request, { accessToken });
@@ -1,3 +1,4 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
1
2
  import { useEffect, useRef, useState } from "react";
2
3
  import { Form, redirect, useNavigate } from "react-router";
3
4
  import { browserApi } from "../lib/api";
@@ -14,16 +15,25 @@ export function meta() {
14
15
  return [{ title: "주문서" }];
15
16
  }
16
17
 
17
- export async function loader({ request }: Route.LoaderArgs) {
18
- const intent = checkoutIntentFrom(new URL(request.url).searchParams);
18
+ export async function loader({ request, context }: Route.LoaderArgs) {
19
+ const url = new URL(request.url);
20
+ const intent = checkoutIntentFrom(url.searchParams);
19
21
  if (!intent) throw redirect("/cart");
20
22
 
21
- const accessToken = await readToken(request);
23
+ const accessToken = readToken(context);
22
24
  const cartToken = await readCartToken(request);
23
25
  const api = apiFor(request, { accessToken, cartToken });
24
26
  // 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
25
27
  const [checkout, member] = await Promise.all([
26
- api.checkout.create(intent),
28
+ api.checkout.create(intent).catch((error: unknown) => {
29
+ // 회원만 주문받는 스토어 — 로그인 뒤 돌아온다. 장바구니 주문은 로그인하며 장바구니가 합쳐져 항목 id가
30
+ // 바뀔 수 있으니 장바구니로, 바로구매는 같은 주문서로 돌아온다
31
+ if (error instanceof ApiError && error.code === "GUEST_CHECKOUT_DISABLED") {
32
+ const back = intent.cartItemIds ? "/cart" : `${url.pathname}${url.search}`;
33
+ throw redirect(`/login?redirectTo=${encodeURIComponent(back)}`);
34
+ }
35
+ throw error;
36
+ }),
27
37
  accessToken ? api.member.me().catch(() => null) : null,
28
38
  ]);
29
39
  return {
@@ -34,10 +44,10 @@ export async function loader({ request }: Route.LoaderArgs) {
34
44
  }
35
45
 
36
46
  /** 결제 요청은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다 */
37
- export async function action({ request }: Route.ActionArgs) {
47
+ export async function action({ request, context }: Route.ActionArgs) {
38
48
  const form = await request.formData();
39
49
  const checkoutId = String(form.get("checkoutId") ?? "");
40
- const accessToken = await readToken(request);
50
+ const accessToken = readToken(context);
41
51
  const cartToken = await readCartToken(request);
42
52
  const api = apiFor(request, { accessToken, cartToken });
43
53
 
@@ -1,26 +1,47 @@
1
+ import { ApiError } from "@sayren/storefront-sdk";
1
2
  import { Form, redirect } from "react-router";
2
- import { apiFor } from "../lib/api.server";
3
- import { sessionCookie } from "../lib/session.server";
3
+ import { authFor } from "../lib/api.server";
4
+ import { cartCookie, readCartToken } from "../lib/cart-session.server";
5
+ import { safeRedirect } from "../lib/safe-redirect";
6
+ import { signInCookie } from "../lib/session.server";
4
7
  import type { Route } from "./+types/login";
5
8
 
6
9
  export function meta() {
7
10
  return [{ title: "로그인" }];
8
11
  }
9
12
 
13
+ /**
14
+ * 로그인 — 화면은 이 앱이 그리고, 확인은 sayren이 한다. 비회원으로 담아 둔 장바구니 토큰을 함께 보내면
15
+ * 회원 장바구니로 합쳐지고 그 토큰은 닫힌다. 그래서 장바구니 쿠키도 지운다.
16
+ */
10
17
  export async function action({ request }: Route.ActionArgs) {
11
18
  const form = await request.formData();
12
- const api = apiFor(request);
19
+ const cartToken = await readCartToken(request);
13
20
  try {
14
- const session = await api.auth.login({
21
+ const tokens = await authFor(request).session({
15
22
  email: String(form.get("email") ?? ""),
16
23
  password: String(form.get("password") ?? ""),
24
+ cartToken: cartToken ?? undefined,
17
25
  });
18
- return redirect(String(form.get("redirectTo") ?? "/orders"), {
19
- headers: { "Set-Cookie": await sessionCookie.serialize(session.accessToken) },
20
- });
21
- } catch {
22
- return { error: "이메일이나 비밀번호가 맞지 않아요" };
26
+ const headers = new Headers();
27
+ headers.append("Set-Cookie", await signInCookie(tokens));
28
+ 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 });
32
+ } catch (error) {
33
+ return { error: loginErrorMessage(error) };
34
+ }
35
+ }
36
+
37
+ function loginErrorMessage(error: unknown): string {
38
+ if (error instanceof ApiError) {
39
+ if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
40
+ if (error.code === "ACCOUNT_LOCKED")
41
+ return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
42
+ if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
23
43
  }
44
+ return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
24
45
  }
25
46
 
26
47
  export default function Login({ actionData }: Route.ComponentProps) {
@@ -8,8 +8,8 @@ export function meta({ params }: Route.MetaArgs) {
8
8
  return [{ title: `주문 ${params.orderId}` }];
9
9
  }
10
10
 
11
- export async function loader({ request, params }: Route.LoaderArgs) {
12
- const accessToken = await readToken(request);
11
+ export async function loader({ request, params, context }: Route.LoaderArgs) {
12
+ const accessToken = readToken(context);
13
13
  if (!accessToken) throw redirect(`/login?redirectTo=/orders/${params.orderId}`);
14
14
  const api = apiFor(request, { accessToken });
15
15
  return { order: await api.myOrders.get(params.orderId) };
@@ -8,8 +8,8 @@ export function meta() {
8
8
  return [{ title: "주문 내역" }];
9
9
  }
10
10
 
11
- export async function loader({ request }: Route.LoaderArgs) {
12
- const accessToken = await readToken(request);
11
+ export async function loader({ request, context }: Route.LoaderArgs) {
12
+ const accessToken = readToken(context);
13
13
  if (!accessToken) throw redirect("/login?redirectTo=/orders");
14
14
  const api = apiFor(request, { accessToken });
15
15
  return { orders: await api.myOrders.list({ size: 20 }) };
@@ -22,12 +22,12 @@ export async function loader({ request, params }: Route.LoaderArgs) {
22
22
  * 장바구니 담기·바로구매를 서버에서 처리한다. 비회원 장바구니 토큰은 서버가 새로 발급할 수 있어
23
23
  * 응답 쿠키에 다시 심는다.
24
24
  */
25
- export async function action({ request, params }: Route.ActionArgs) {
25
+ export async function action({ request, params, context }: Route.ActionArgs) {
26
26
  const form = await request.formData();
27
27
  const optionId = String(form.get("optionId") ?? "") || undefined;
28
28
  const quantity = Number(form.get("quantity") ?? 1);
29
29
 
30
- const accessToken = await readToken(request);
30
+ const accessToken = readToken(context);
31
31
  let cartToken = await readCartToken(request);
32
32
  let issued: string | null = null;
33
33
  const api = apiFor(request, {