@sayren/mcp 0.1.3 → 0.1.5

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.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sayren-mcp": "./dist/index.mjs"
@@ -13,7 +13,7 @@
13
13
  "@modelcontextprotocol/sdk": "^1.22.0",
14
14
  "zod": "^4.6.5",
15
15
  "@sayren/store-sdk": "^0.3.0",
16
- "@sayren/storefront-sdk": "^0.2.1"
16
+ "@sayren/storefront-sdk": "^0.3.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "versions": {
10
10
  "@sayren/store-sdk": "0.3.0",
11
- "@sayren/storefront-sdk": "0.2.1",
11
+ "@sayren/storefront-sdk": "0.3.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -26,7 +26,9 @@ pnpm dev
26
26
  `app/lib/analytics.ts`가 브라우저에서 방문 분석을 시작한다. 페이지뷰·체류는 자동으로 세고, 상품 조회·목록 노출·검색은
27
27
  화면에서 `useTrack`으로 남긴다. 장바구니·결제·구매는 `app/lib/api.server.ts`가 방문자 쿠키를 API 요청 헤더에 실어
28
28
  서버가 기록한다. localhost에서는 보내지 않는다. 개발 중에 확인하려면 `SAYREN_ANALYTICS_DEBUG=1`을 준다.
29
- 쿠키 동의 배너를 붙인다면 `consent`를 `"pending"`으로 바꾸고 배너에서 `setConsent`를 부른다.
29
+ 동의 기본값은 `granted`(바로 수집)다. 개인정보를 저장하지 않는 1st-party 쿠키라 국내 쇼핑몰 기준으로 정했다.
30
+ EU 등 분석 쿠키에 사전 동의가 필요한 지역의 구매자를 받는다면 `app/lib/analytics.ts`의 `consent`를 `"pending"`으로 바꾸고,
31
+ 쿠키 동의 배너에서 `setConsent("granted" | "denied")`를 부른다. 동의 전에는 쿠키를 만들지 않고 이벤트를 모아 두었다가 동의하면 보낸다.
30
32
 
31
33
  ## 확장 지점
32
34
 
@@ -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,8 +9,8 @@ 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) {
@@ -20,10 +20,10 @@ export async function loader({ request }: Route.LoaderArgs) {
20
20
  return { cart: await api.cart.get() };
21
21
  }
22
22
 
23
- export async function action({ request }: Route.ActionArgs) {
23
+ export async function action({ request, context }: Route.ActionArgs) {
24
24
  const form = await request.formData();
25
25
  const intent = String(form.get("intent") ?? "");
26
- const accessToken = await readToken(request);
26
+ const accessToken = readToken(context);
27
27
  const cartToken = await readCartToken(request);
28
28
  let issued: string | null = null;
29
29
  const api = apiFor(request, {
@@ -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 });
@@ -14,11 +14,11 @@ export function meta() {
14
14
  return [{ title: "주문서" }];
15
15
  }
16
16
 
17
- export async function loader({ request }: Route.LoaderArgs) {
17
+ export async function loader({ request, context }: Route.LoaderArgs) {
18
18
  const intent = checkoutIntentFrom(new URL(request.url).searchParams);
19
19
  if (!intent) throw redirect("/cart");
20
20
 
21
- const accessToken = await readToken(request);
21
+ const accessToken = readToken(context);
22
22
  const cartToken = await readCartToken(request);
23
23
  const api = apiFor(request, { accessToken, cartToken });
24
24
  // 주문서 세션은 진입할 때마다 새로 만든다 — 조회 API가 없고, 가격·재고를 매번 다시 계산한다
@@ -34,10 +34,10 @@ export async function loader({ request }: Route.LoaderArgs) {
34
34
  }
35
35
 
36
36
  /** 결제 요청은 서버에서 한다 — 토큰이 브라우저 JS에 노출되지 않는다 */
37
- export async function action({ request }: Route.ActionArgs) {
37
+ export async function action({ request, context }: Route.ActionArgs) {
38
38
  const form = await request.formData();
39
39
  const checkoutId = String(form.get("checkoutId") ?? "");
40
- const accessToken = await readToken(request);
40
+ const accessToken = readToken(context);
41
41
  const cartToken = await readCartToken(request);
42
42
  const api = apiFor(request, { accessToken, cartToken });
43
43
 
@@ -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, {