@sayren/store-sdk 0.23.0 → 0.28.1

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.
@@ -1,9 +1,8 @@
1
1
  /**
2
- * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다
3
- * (token-exchange의 `member-uid`·`member-ref` 확장, auth-spec §12-10). 클라이언트 시크릿을 쓰므로 **서버에서만** 부른다.
2
+ * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다.
3
+ * 계정 토큰(개인 액세스 토큰 등)을 쓰므로 **서버에서만** 부른다.
4
4
  *
5
- * 스토어에 바인딩된 confidential 클라이언트 + `member:delegate` 스코프가 필요하다. 응답은 구매자 액세스 토큰(30분)이고
6
- * refresh는 없다 — 만료되면 같은 호출로 다시 받는다.
5
+ * 응답은 구매자 액세스 토큰(30분)이고 refresh는 없다 — 만료되면 같은 호출로 다시 받는다.
7
6
  */
8
7
 
9
8
  export interface DelegatedMemberToken {
@@ -12,87 +11,62 @@ export interface DelegatedMemberToken {
12
11
  memberId: string;
13
12
  }
14
13
 
15
- interface DelegationOptions {
16
- /** api 주소(예: `https://api.sayren.app`) — `/v1/oauth2/token`을 붙인다 */
17
- baseUrl: string;
18
- clientId: string;
19
- clientSecret: string;
20
- fetch?: typeof globalThis.fetch;
21
- }
22
-
23
- /** 셀러 자체 인증의 사용자 id로 지목한다. 처음 보는 id면 구매자를 만든다(이메일·이름은 그때만 쓴다) */
24
- export function delegateMemberByUid(
25
- options: DelegationOptions & {
26
- uid: string;
27
- email?: string;
28
- name?: string;
29
- /**
30
- * 사용자가 내 서비스에서 실제로 로그인한 시각. 주면 구매자 토큰의 인증 시각이 되고(탈퇴처럼 최근 인증이 필요한 동작에
31
- * 쓴다), 주지 않으면 인증 시각이 없는 토큰이 나온다
32
- */
33
- authenticatedAt?: Date;
34
- },
35
- ): Promise<DelegatedMemberToken> {
36
- return delegate(options, {
37
- subject_token_type: "urn:sayren:params:oauth:token-type:member-uid",
38
- subject_token: options.uid,
39
- ...(options.authenticatedAt
40
- ? { subject_auth_time: String(Math.floor(options.authenticatedAt.getTime() / 1000)) }
41
- : {}),
42
- ...(options.email ? { subject_email: options.email } : {}),
43
- ...(options.name ? { subject_name: options.name } : {}),
44
- });
45
- }
46
-
47
- /** 이메일로 지목한다. 처음 보는 이메일이면 구매자를 만든다 */
48
- export function delegateMemberByEmail(
49
- options: DelegationOptions & { email: string; name?: string },
50
- ): Promise<DelegatedMemberToken> {
51
- return delegate(options, {
52
- subject_token_type: "urn:sayren:params:oauth:token-type:member-ref",
53
- subject_token: options.email,
54
- ...(options.name ? { subject_name: options.name } : {}),
55
- });
14
+ /** 위임 실패 — `error`는 api 오류 코드(엔벨로프 `error.code`, 없으면 `http_{status}`) */
15
+ export class MemberDelegationError extends Error {
16
+ constructor(
17
+ readonly error: string,
18
+ readonly description: string,
19
+ readonly status: number,
20
+ ) {
21
+ super(description ? `${error}: ${description}` : error);
22
+ this.name = "MemberDelegationError";
23
+ }
56
24
  }
57
25
 
58
- async function delegate(
59
- options: DelegationOptions,
60
- subject: Record<string, string>,
61
- ): Promise<DelegatedMemberToken> {
26
+ /**
27
+ * 계정 토큰으로 구매자 위임(`POST /v1/customers/delegations`) — 계정 토큰(개인 액세스 토큰 등)과 상점 id를 쓴다.
28
+ * `customer:rw`가 필요하다. `uid`(셀러 자체 인증의 사용자 id) 또는 `email`로 지목하고, 처음 보는 사용자면 구매자를 만든다.
29
+ */
30
+ export async function delegateMember(options: {
31
+ /** api 주소(예: `https://api.sayren.app`) */
32
+ baseUrl: string;
33
+ accessToken: string;
34
+ storeId: string;
35
+ uid?: string;
36
+ email?: string;
37
+ name?: string;
38
+ authenticatedAt?: Date;
39
+ fetch?: typeof globalThis.fetch;
40
+ }): Promise<DelegatedMemberToken> {
62
41
  const doFetch = options.fetch ?? globalThis.fetch;
63
- const basic = btoa(`${options.clientId}:${options.clientSecret}`);
64
- const res = await doFetch(`${options.baseUrl.replace(/\/+$/, "")}/v1/oauth2/token`, {
42
+ const res = await doFetch(`${options.baseUrl.replace(/\/+$/, "")}/v1/customers/delegations`, {
65
43
  method: "POST",
66
44
  headers: {
67
- "content-type": "application/x-www-form-urlencoded",
68
- authorization: `Basic ${basic}`,
45
+ "content-type": "application/json",
46
+ authorization: `Bearer ${options.accessToken}`,
47
+ "x-store-id": options.storeId,
69
48
  },
70
- body: new URLSearchParams({
71
- grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
72
- ...subject,
73
- }).toString(),
49
+ body: JSON.stringify({
50
+ ...(options.uid ? { uid: options.uid } : {}),
51
+ ...(options.email ? { email: options.email } : {}),
52
+ ...(options.name ? { name: options.name } : {}),
53
+ ...(options.authenticatedAt
54
+ ? { authenticatedAt: options.authenticatedAt.toISOString() }
55
+ : {}),
56
+ }),
74
57
  });
75
- const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
76
- if (!res.ok || typeof body.access_token !== "string") {
77
- const error = typeof body.error === "string" ? body.error : `http_${res.status}`;
78
- const description = typeof body.error_description === "string" ? body.error_description : "";
79
- throw new MemberDelegationError(error, description, res.status);
58
+ const body = (await res.json().catch(() => ({}))) as {
59
+ data?: { accessToken?: unknown; expiresIn?: unknown; memberId?: unknown };
60
+ error?: { code?: unknown; message?: unknown };
61
+ };
62
+ if (!res.ok || typeof body.data?.accessToken !== "string") {
63
+ const code = typeof body.error?.code === "string" ? body.error.code : `http_${res.status}`;
64
+ const message = typeof body.error?.message === "string" ? body.error.message : "";
65
+ throw new MemberDelegationError(code, message, res.status);
80
66
  }
81
67
  return {
82
- accessToken: body.access_token,
83
- expiresIn: Number(body.expires_in),
84
- memberId: String(body.memberId),
68
+ accessToken: body.data.accessToken,
69
+ expiresIn: Number(body.data.expiresIn),
70
+ memberId: String(body.data.memberId),
85
71
  };
86
72
  }
87
-
88
- /** 위임 실패 — `error`는 OAuth 오류 코드(`invalid_client`·`invalid_scope`·`invalid_request` 등) */
89
- export class MemberDelegationError extends Error {
90
- constructor(
91
- readonly error: string,
92
- readonly description: string,
93
- readonly status: number,
94
- ) {
95
- super(description ? `${error}: ${description}` : error);
96
- this.name = "MemberDelegationError";
97
- }
98
- }
package/src/oauth.ts CHANGED
@@ -4,10 +4,11 @@ import { unwrapData } from "./http";
4
4
 
5
5
  /**
6
6
  * OAuth 2.1 브라우저 플로우 헬퍼 (authorization_code + PKCE).
7
+ * 발급자는 Sayren 계정(accounts)이다 — 엔드포인트 주소는 호출부가 준다.
7
8
  * Web Crypto만 사용 — 브라우저/Node 모두에서 동작하며 **시크릿을 다루지 않는다**.
8
9
  */
9
10
 
10
- /** RFC 6749 §5.1 토큰 응답 (+ RFC 8693 확장 멤버 허용) */
11
+ /** RFC 6749 §5.1 토큰 응답 */
11
12
  export const oauthTokenResponseSchema = z
12
13
  .object({
13
14
  access_token: z.string(),
@@ -29,19 +30,23 @@ export async function createPkcePair(): Promise<{ verifier: string; challenge: s
29
30
  }
30
31
 
31
32
  export interface AuthorizeUrlOptions {
32
- /** 예: https://api.sayren.app/v1 (베이스 URL — /oauth2/authorize가 덧붙는다) */
33
- baseUrl: string;
33
+ /** authorize 엔드포인트 전체 주소(예: `https://accounts.sayren.app/api/auth/oauth2/authorize`) */
34
+ authorizeUrl: string;
34
35
  clientId: string;
35
36
  redirectUri: string;
36
37
  state: string;
37
38
  codeChallenge: string;
38
- /** 생략 시 서버 기본(stores:read). refresh가 필요하면 offline_access 포함 */
39
+ /** 생략 시 발급자 기본. refresh가 필요하면 offline_access 포함 */
39
40
  scope?: string;
41
+ /** RFC 8707 리소스 지시자 — 주면 JWT access token(`aud` = 리소스)을 받는다 */
42
+ resource?: string;
43
+ /** OIDC `prompt` — `login`(재인증)·`create`(가입)·`consent`(동의 다시 받기) */
44
+ prompt?: "login" | "create" | "consent";
40
45
  }
41
46
 
42
47
  /** authorize 엔드포인트 URL 구성 — 브라우저는 이 URL로 이동(top-level navigation) */
43
48
  export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
44
- const url = new URL(`${options.baseUrl}/oauth2/authorize`);
49
+ const url = new URL(options.authorizeUrl);
45
50
  url.searchParams.set("response_type", "code");
46
51
  url.searchParams.set("client_id", options.clientId);
47
52
  url.searchParams.set("redirect_uri", options.redirectUri);
@@ -49,15 +54,18 @@ export function buildAuthorizeUrl(options: AuthorizeUrlOptions): string {
49
54
  url.searchParams.set("code_challenge", options.codeChallenge);
50
55
  url.searchParams.set("code_challenge_method", "S256");
51
56
  if (options.scope) url.searchParams.set("scope", options.scope);
57
+ if (options.resource) url.searchParams.set("resource", options.resource);
58
+ if (options.prompt) url.searchParams.set("prompt", options.prompt);
52
59
  return url.toString();
53
60
  }
54
61
 
55
62
  async function tokenRequest(
56
- baseUrl: string,
63
+ endpoint: string,
57
64
  form: Record<string, string>,
58
65
  fetchFn: typeof globalThis.fetch = globalThis.fetch,
59
66
  ): Promise<OAuthTokenResponse> {
60
- const response = await fetchFn(`${baseUrl}/oauth2/token`, {
67
+ // 자격 증명(쿠키)을 싣지 않는다 — public 클라이언트 + PKCE·refresh로만 인증한다(다른 origin 발급자도 같다)
68
+ const response = await fetchFn(endpoint, {
61
69
  method: "POST",
62
70
  headers: { "content-type": "application/x-www-form-urlencoded" },
63
71
  body: new URLSearchParams(form).toString(),
@@ -70,23 +78,27 @@ async function tokenRequest(
70
78
  return oauthTokenResponseSchema.parse(unwrapData(json));
71
79
  }
72
80
 
73
- /** 콜백에서 받은 code를 userToken으로 교환 (PKCE verifier 동반) */
81
+ /** 콜백에서 받은 code를 액세스 토큰으로 교환 (PKCE verifier 동반) */
74
82
  export async function exchangeAuthorizationCode(options: {
75
- baseUrl: string;
83
+ /** 토큰 엔드포인트 전체 주소 */
84
+ tokenUrl: string;
76
85
  clientId: string;
77
86
  redirectUri: string;
78
87
  code: string;
79
88
  codeVerifier: string;
89
+ /** RFC 8707 리소스 지시자 */
90
+ resource?: string;
80
91
  fetch?: typeof globalThis.fetch;
81
92
  }): Promise<OAuthTokenResponse> {
82
93
  return tokenRequest(
83
- options.baseUrl,
94
+ options.tokenUrl,
84
95
  {
85
96
  grant_type: "authorization_code",
86
97
  code: options.code,
87
98
  redirect_uri: options.redirectUri,
88
99
  client_id: options.clientId,
89
100
  code_verifier: options.codeVerifier,
101
+ ...(options.resource ? { resource: options.resource } : {}),
90
102
  },
91
103
  options.fetch,
92
104
  );
@@ -94,38 +106,21 @@ export async function exchangeAuthorizationCode(options: {
94
106
 
95
107
  /** refresh grant (rotation — 응답의 새 refresh_token으로 반드시 교체 저장할 것) */
96
108
  export async function refreshUserToken(options: {
97
- baseUrl: string;
109
+ /** 토큰 엔드포인트 전체 주소 */
110
+ tokenUrl: string;
98
111
  clientId: string;
99
112
  refreshToken: string;
113
+ /** RFC 8707 리소스 지시자 */
114
+ resource?: string;
100
115
  fetch?: typeof globalThis.fetch;
101
116
  }): Promise<OAuthTokenResponse> {
102
117
  return tokenRequest(
103
- options.baseUrl,
118
+ options.tokenUrl,
104
119
  {
105
120
  grant_type: "refresh_token",
106
121
  refresh_token: options.refreshToken,
107
122
  client_id: options.clientId,
108
- },
109
- options.fetch,
110
- );
111
- }
112
-
113
- /** userToken → storeToken 교환 (RFC 8693 token-exchange grant) */
114
- export async function exchangeStoreTokenViaOAuth(options: {
115
- baseUrl: string;
116
- clientId: string;
117
- userToken: string;
118
- storeId: string;
119
- fetch?: typeof globalThis.fetch;
120
- }): Promise<OAuthTokenResponse & { storeId?: string; role?: string }> {
121
- return tokenRequest(
122
- options.baseUrl,
123
- {
124
- grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
125
- subject_token: options.userToken,
126
- subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
127
- audience: options.storeId,
128
- client_id: options.clientId,
123
+ ...(options.resource ? { resource: options.resource } : {}),
129
124
  },
130
125
  options.fetch,
131
126
  );
@@ -1,14 +1,16 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  /**
4
- * API 로그 — `/v1/api-logs`. 스토어가 해석된 관리 API(`/v1/**`) 요청을 7일간 보관한다.
5
- * 인증에 실패해 스토어를 알 수 없는 요청(401 등)은 기록되지 않는다.
4
+ * API 로그 — `/v1/api-logs`. 상점이 해석된 관리 API(`/v1/**`) 요청을 7일간 보관한다.
5
+ * 인증에 실패해 상점을 알 수 없는 요청(401 등)은 기록되지 않는다.
6
6
  * Authorization·쿠키 헤더, 요청 본문, 개인정보는 저장하지 않는다.
7
7
  */
8
8
 
9
9
  export const apiLogPrincipalTypeSchema = z
10
10
  .enum(["USER", "PAT", "CLIENT"])
11
- .describe("호출 주체 유형. `USER`=콘솔 사용자, `PAT`=API 토큰, `CLIENT`=앱(OAuth 클라이언트)");
11
+ .describe(
12
+ "호출 주체 유형. `USER`=상점 플랫폼 사용자, `PAT`=API 토큰, `CLIENT`=앱(OAuth 클라이언트)",
13
+ );
12
14
  export type ApiLogPrincipalType = z.infer<typeof apiLogPrincipalTypeSchema>;
13
15
 
14
16
  export const apiLogPrincipalSchema = z.object({
@@ -100,7 +102,7 @@ export type ApiLogPage = z.infer<typeof apiLogPageSchema>;
100
102
 
101
103
  /**
102
104
  * API 로그 요약 — `GET /api-logs/summary`. 연동 트래픽(호출 주체 `PAT`·`CLIENT`)만 집계하며
103
- * 콘솔 사용자(`USER`) 요청은 제외한다
105
+ * 상점 플랫폼 사용자(`USER`) 요청은 제외한다
104
106
  */
105
107
  export const apiLogSummarySchema = z.object({
106
108
  windowHours: z.number().int().describe("집계 구간(시간). 최근 24시간"),
@@ -108,7 +110,7 @@ export const apiLogSummarySchema = z.object({
108
110
  .number()
109
111
  .int()
110
112
  .min(0)
111
- .describe("구간 내 연동 요청 수 (호출 주체 `PAT`·`CLIENT`, 콘솔 사용자 제외)"),
113
+ .describe("구간 내 연동 요청 수 (호출 주체 `PAT`·`CLIENT`, 상점 플랫폼 사용자 제외)"),
112
114
  errors: z.number().int().min(0).describe("구간 내 연동 요청 중 4xx·5xx 응답 수"),
113
115
  serverErrors: z.number().int().min(0).describe("구간 내 연동 요청 중 5xx 응답 수"),
114
116
  });
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  export const auditActorTypeSchema = z
11
11
  .enum(["USER", "AGENT", "APP", "TOKEN", "SYSTEM"])
12
12
  .describe(
13
- "행위자 유형. `USER`=콘솔 사용자, `AGENT`=AI 에이전트(MCP·에이전트용 API 토큰), `APP`=앱(OAuth 클라이언트·M2M), " +
13
+ "행위자 유형. `USER`=상점 플랫폼 사용자, `AGENT`=AI 에이전트(MCP·에이전트용 API 토큰), `APP`=앱(OAuth 클라이언트·M2M), " +
14
14
  "`TOKEN`=API 토큰, `SYSTEM`=자동화(주기 잡)",
15
15
  );
16
16
  export type AuditActorType = z.infer<typeof auditActorTypeSchema>;
@@ -55,7 +55,7 @@ export const auditLogSchema = z.object({
55
55
  actorRole: z
56
56
  .string()
57
57
  .nullable()
58
- .describe("요청 시점의 스토어 역할. 역할이 없는 토큰·자동화는 null"),
58
+ .describe("요청 시점의 상점 역할. 역할이 없는 토큰·자동화는 null"),
59
59
  target: z.object({
60
60
  type: z.string().describe("대상 리소스 유형 (예: `products`, `order-items`, `claim`)"),
61
61
  id: z
@@ -144,7 +144,7 @@ export type AuditLogPage = z.infer<typeof auditLogPageSchema>;
144
144
  export const agentPolicyDecisionSchema = z
145
145
  .enum(["ALLOW", "APPROVAL_REQUIRED", "DENIED"])
146
146
  .describe(
147
- "에이전트 쓰기 판정. `ALLOW`=바로 실행, `APPROVAL_REQUIRED`=콘솔 승인 뒤 실행, `DENIED`=에이전트는 실행할 수 없음",
147
+ "에이전트 쓰기 판정. `ALLOW`=바로 실행, `APPROVAL_REQUIRED`=상점 플랫폼 승인 뒤 실행, `DENIED`=에이전트는 실행할 수 없음",
148
148
  );
149
149
  export type AgentPolicyDecision = z.infer<typeof agentPolicyDecisionSchema>;
150
150
 
@@ -208,7 +208,7 @@ export const approvalRequestSchema = z.object({
208
208
  decidedBy: z
209
209
  .object({ id: z.string(), name: z.string().nullable() })
210
210
  .nullable()
211
- .describe("승인·거절한 콘솔 사용자"),
211
+ .describe("승인·거절한 상점 플랫폼 사용자"),
212
212
  decisionNote: z.string().nullable().describe("승인·거절 메모"),
213
213
  executedAt: z.string().nullable().describe("원 요청 실행을 마친 일시"),
214
214
  result: z
@@ -227,7 +227,7 @@ export const approvalRequestSchema = z.object({
227
227
  approvalUrl: z
228
228
  .string()
229
229
  .nullable()
230
- .describe("셀러 콘솔의 승인 화면 주소. 이 배포의 콘솔 주소를 알 수 없으면 null"),
230
+ .describe("상점 플랫폼의 승인 화면 주소. 이 배포의 상점 플랫폼 주소를 알 수 없으면 null"),
231
231
  });
232
232
  export type ApprovalRequest = z.infer<typeof approvalRequestSchema>;
233
233
 
@@ -4,35 +4,6 @@ export const storeRoleSchema = z.enum(["OWNER", "ADMIN", "STAFF"]);
4
4
 
5
5
  export type StoreRole = z.infer<typeof storeRoleSchema>;
6
6
 
7
- export const userTokenResponseSchema = z.object({
8
- access_token: z.string(),
9
- token_type: z.literal("Bearer"),
10
- expires_in: z.number().int(),
11
- refresh_token: z.string().optional(),
12
- });
13
-
14
- export type UserTokenResponse = z.infer<typeof userTokenResponseSchema>;
15
-
16
- /** client_credentials 응답 (RFC 6749 §4.4.3) — storeToken 직발급, refresh 없음 */
17
- export const clientCredentialsTokenResponseSchema = z.object({
18
- access_token: z.string(),
19
- token_type: z.literal("Bearer"),
20
- expires_in: z.number().int(),
21
- scope: z.string(),
22
- });
23
- export type ClientCredentialsTokenResponse = z.infer<typeof clientCredentialsTokenResponseSchema>;
24
-
25
- export const storeTokenResponseSchema = z.object({
26
- access_token: z.string(),
27
- token_type: z.literal("Bearer"),
28
- expires_in: z.number().int(),
29
- storeId: z.string(),
30
- role: storeRoleSchema,
31
- scopes: z.array(z.string()),
32
- });
33
-
34
- export type StoreTokenResponse = z.infer<typeof storeTokenResponseSchema>;
35
-
36
7
  export const myStoreSchema = z.object({
37
8
  storeId: z.string(),
38
9
  storeName: z.string(),
@@ -52,38 +23,3 @@ export const myAccountSchema = z.object({
52
23
  });
53
24
 
54
25
  export type MyAccount = z.infer<typeof myAccountSchema>;
55
-
56
- /** API 토큰(PAT) — 목록/조회 뷰 (평문·해시 미포함) */
57
- export const apiTokenSchema = z.object({
58
- tokenId: z.string(),
59
- name: z.string(),
60
- prefix: z.string(),
61
- scopes: z.array(z.string()),
62
- createdBy: z.string().describe("발급자 userId"),
63
- createdByName: z.string().nullable().describe("발급자 이름 (표시용). 계정이 없으면 null"),
64
- createdByEmail: z.string().nullable().describe("발급자 이메일 (표시용). 계정이 없으면 null"),
65
- createdAt: z.string(),
66
- expiresAt: z.string().nullable(),
67
- lastUsedAt: z.string().nullable(),
68
- agent: z
69
- .boolean()
70
- .default(false)
71
- .describe(
72
- "AI 에이전트(MCP)용 토큰인가. true면 이 토큰의 쓰기는 행위자 `AGENT`로 기록되고 에이전트 쓰기 정책(승인 필요·거부)을 따른다",
73
- ),
74
- });
75
- export type ApiToken = z.infer<typeof apiTokenSchema>;
76
-
77
- /** 생성 응답 — 평문 토큰은 이 응답에서 단 한 번만 노출된다 */
78
- export const apiTokenCreatedSchema = apiTokenSchema.extend({ token: z.string() });
79
- export type ApiTokenCreated = z.infer<typeof apiTokenCreatedSchema>;
80
-
81
- export const apiTokenCreateRequestSchema = z.object({
82
- name: z.string().min(1).max(50),
83
- scopes: z.array(z.string()).min(1),
84
- /** 일 단위 만료. null = 무기한, 생략 = 90일 */
85
- expiresInDays: z.number().int().nullable().optional(),
86
- /** AI 에이전트(MCP)용 토큰. 생략 = false. 발급 뒤에는 바꿀 수 없다 */
87
- agent: z.boolean().optional(),
88
- });
89
- export type ApiTokenCreateRequest = z.infer<typeof apiTokenCreateRequestSchema>;
@@ -31,10 +31,10 @@ export const claimReasonSchema = z.enum([
31
31
  export type ClaimReason = z.infer<typeof claimReasonSchema>;
32
32
 
33
33
  /**
34
- * 반품지 (이슈 #30) — 반품·교환 수거가 안내하는 주소다. 스토어 주소록의 기본 반품지이거나,
34
+ * 반품지 (이슈 #30) — 반품·교환 수거가 안내하는 주소다. 상점 주소록의 기본 반품지이거나,
35
35
  * 상품이 반품지를 따로 지정했으면 그 주소다.
36
36
  *
37
- * 취소 클레임은 보낼 물건이 없어 null이고, 반품지를 등록하지 않은 스토어도 null이다.
37
+ * 취소 클레임은 보낼 물건이 없어 null이고, 반품지를 등록하지 않은 상점도 null이다.
38
38
  * 구매자가 직접 보내는 수거(`BUYER_SEND`)에 이 주소가 필요하다.
39
39
  */
40
40
  export const claimReturnAddressSchema = z.object({
@@ -102,8 +102,8 @@ export const claimSchema = z.object({
102
102
  returnAddress: claimReturnAddressSchema
103
103
  .nullable()
104
104
  .describe(
105
- "수거 안내에 쓸 반품지(이슈 #30). 상품이 지정한 반품지, 없으면 스토어 기본 반품지다. " +
106
- "취소 클레임과 반품지를 등록하지 않은 스토어는 null",
105
+ "수거 안내에 쓸 반품지(이슈 #30). 상품이 지정한 반품지, 없으면 상점 기본 반품지다. " +
106
+ "취소 클레임과 반품지를 등록하지 않은 상점은 null",
107
107
  ),
108
108
  });
109
109
 
@@ -45,7 +45,7 @@ const limitField = (description: string) =>
45
45
  const descriptions = {
46
46
  name: "구매자에게 보이는 쿠폰 이름. 1~60자",
47
47
  description: "구매자에게 보이는 설명. 300자까지",
48
- code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 스토어 안에서 유일하다",
48
+ code: "쿠폰 코드 — 발급 방식이 `CODE`일 때 필수. 영문·숫자·`-`·`_` 6~30자, 대소문자 구분 없음(대문자로 저장). 상점 안에서 유일하다",
49
49
  startsAt: "사용 시작 시각(ISO 8601, 시간대 포함). 생략하면 바로 쓸 수 있다",
50
50
  endsAt:
51
51
  "사용 종료 시각(ISO 8601, 시간대 포함). 이 시각부터 못 쓴다. 생략하면 제한이 없다. " +
@@ -247,7 +247,7 @@ export const issueCouponResultSchema = z.object({
247
247
  code: z
248
248
  .string()
249
249
  .describe(
250
- "실패 사유. `CUSTOMER_NOT_FOUND`(이 스토어 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·" +
250
+ "실패 사유. `CUSTOMER_NOT_FOUND`(이 상점 구매자가 아님)·`COUPON_LIMIT_REACHED`(1인 발급 한도)·" +
251
251
  "`COUPON_EXHAUSTED`(총 발급 한도)",
252
252
  ),
253
253
  message: z.string(),
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 구매자 위임 REST(`POST /v1/customers/delegations`, #71) — 계정 토큰(또는 관리 API 토큰) + `X-Store-Id`로 부른다(`customer:rw`).
5
+ * 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰(30분, refresh 없음)으로 바꾼다. 옛 token-exchange
6
+ * (`member-uid`·`member-ref`)를 대신한다. 에이전트 요청은 거부된다(`x-agent-policy` DENIED).
7
+ */
8
+
9
+ const uidPattern = /^[A-Za-z0-9._:@|-]{1,128}$/;
10
+
11
+ export const customerDelegationRequestSchema = z
12
+ .object({
13
+ uid: z
14
+ .string()
15
+ .regex(uidPattern)
16
+ .optional()
17
+ .describe(
18
+ "셀러 자체 인증의 사용자 id(1~128자, 영문·숫자·`._:@|-`). 처음 보는 id면 구매자를 만든다",
19
+ ),
20
+ email: z
21
+ .email()
22
+ .max(254)
23
+ .optional()
24
+ .describe("uid가 없으면 이메일로 지목한다. uid와 함께 주면 처음 만들 때의 이메일이다"),
25
+ name: z.string().max(50).optional().describe("처음 만들 때의 이름"),
26
+ authenticatedAt: z.iso
27
+ .datetime({ offset: true })
28
+ .optional()
29
+ .describe(
30
+ "사용자가 셀러 서비스에서 실제로 로그인한 시각(uid 위임만). 주면 구매자 토큰의 인증 시각이 된다",
31
+ ),
32
+ })
33
+ .refine((value) => Boolean(value.uid || value.email), {
34
+ message: "uid 또는 email이 필요합니다",
35
+ path: ["uid"],
36
+ })
37
+ .describe("구매자 위임 요청");
38
+ export type CustomerDelegationRequest = z.infer<typeof customerDelegationRequestSchema>;
39
+
40
+ export const customerDelegationSchema = z
41
+ .object({
42
+ accessToken: z.string().describe("구매자 액세스 토큰(스토어프론트 API Bearer)"),
43
+ tokenType: z.literal("Bearer"),
44
+ expiresIn: z.number().int().describe("수명(초)"),
45
+ memberId: z.string(),
46
+ })
47
+ .describe("구매자 위임 결과");
48
+ export type CustomerDelegation = z.infer<typeof customerDelegationSchema>;
@@ -88,7 +88,7 @@ export const customerConsentSchema = z.object({
88
88
  kind: consentKindSchema,
89
89
  /** 동의한 약관 버전. 마케팅은 버전이 없어 null */
90
90
  version: z.string().nullable(),
91
- /** 동의 시점의 약관 문서 주소. 스토어가 주소를 정하지 않았거나 마케팅이면 null */
91
+ /** 동의 시점의 약관 문서 주소. 상점이 주소를 정하지 않았거나 마케팅이면 null */
92
92
  documentUrl: z.string().nullable(),
93
93
  action: consentActionSchema,
94
94
  source: consentSourceSchema,
@@ -102,9 +102,9 @@ export const customerConsentStateSchema = z.object({
102
102
  agreed: z.boolean(),
103
103
  /** 동의한 약관 버전. 마케팅이거나 이력이 없으면 null */
104
104
  agreedVersion: z.string().nullable(),
105
- /** 스토어의 현재 약관 버전. 마케팅은 null */
105
+ /** 상점의 현재 약관 버전. 마케팅은 null */
106
106
  currentVersion: z.string().nullable(),
107
- /** 스토어가 정한 현재 약관 문서 주소. 정하지 않았거나 마케팅이면 null */
107
+ /** 상점이 정한 현재 약관 문서 주소. 정하지 않았거나 마케팅이면 null */
108
108
  currentDocumentUrl: z.string().nullable(),
109
109
  updatedAt: z.string().nullable(),
110
110
  /** 약관이 개정돼 다시 받아야 하는가. 마케팅은 항상 false */
@@ -107,11 +107,11 @@ const KNOWN_FULFILLMENT_TYPES: readonly string[] = fulfillmentTypeSchema.options
107
107
 
108
108
  const ADDRESS_ID_DESCRIPTIONS = {
109
109
  shipping:
110
- "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 등록에서 생략하면 그 시점 스토어 기본 출고지를 저장한다. " +
111
- "`null`이면 비워 둔다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
110
+ "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 등록에서 생략하면 그 시점 상점 기본 출고지를 저장한다. " +
111
+ "`null`이면 비워 둔다(상점 기본 출고지를 따른다). 이 상점의 `SHIPPING` 주소가 아니면 거부한다",
112
112
  return:
113
- "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 등록에서 생략하면 그 시점 스토어 기본 반품지를 저장한다. " +
114
- "`null`이면 비워 둔다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다",
113
+ "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 등록에서 생략하면 그 시점 상점 기본 반품지를 저장한다. " +
114
+ "`null`이면 비워 둔다(상점 기본 반품지를 따른다). 이 상점의 `RETURN` 주소가 아니면 거부한다",
115
115
  } as const;
116
116
 
117
117
  /**
@@ -161,7 +161,7 @@ const shippingCreateSchema = z
161
161
  .min(0)
162
162
  .optional()
163
163
  .describe(
164
- "배송비. 원 단위. 생략하면 스토어 기본 배송비(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 저장한다. FREE면 0이거나 생략한다",
164
+ "배송비. 원 단위. 생략하면 상점 기본 배송비(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 저장한다. FREE면 0이거나 생략한다",
165
165
  ),
166
166
  conditionalFreeAmount: z
167
167
  .number()
@@ -176,7 +176,7 @@ const shippingCreateSchema = z
176
176
  .superRefine(refineShippingConsistency);
177
177
 
178
178
  /**
179
- * 상품 이행 — 등록(POST). 배송 상품의 `shipping`을 생략하면 기본값(유료 배송·스토어 기본 배송비·기본 출고지·반품지)이다.
179
+ * 상품 이행 — 등록(POST). 배송 상품의 `shipping`을 생략하면 기본값(유료 배송·상점 기본 배송비·기본 출고지·반품지)이다.
180
180
  */
181
181
  export const productFulfillmentCreateSchema = z.discriminatedUnion("type", [
182
182
  z.strictObject({ type: z.literal("SHIPPING"), shipping: shippingCreateSchema.optional() }),
@@ -235,11 +235,11 @@ export const productFulfillmentSchema = z.discriminatedUnion("type", [
235
235
  shippingAddressId: z
236
236
  .string()
237
237
  .nullable()
238
- .describe("출고지 주소 id. null이면 스토어 기본 출고지를 따른다"),
238
+ .describe("출고지 주소 id. null이면 상점 기본 출고지를 따른다"),
239
239
  returnAddressId: z
240
240
  .string()
241
241
  .nullable()
242
- .describe("반품지 주소 id. null이면 스토어 기본 반품지를 따른다"),
242
+ .describe("반품지 주소 id. null이면 상점 기본 반품지를 따른다"),
243
243
  }),
244
244
  }),
245
245
  z.object({ type: z.literal("MANUAL") }),
@@ -306,9 +306,7 @@ export const orderItemFulfillmentSnapshotSchema = z.discriminatedUnion("type", [
306
306
  returnAddressId: z
307
307
  .string()
308
308
  .nullable()
309
- .describe(
310
- "주문 시점 반품지 주소 id. null이거나 지워진 주소면 스토어 기본 반품지로 안내한다",
311
- ),
309
+ .describe("주문 시점 반품지 주소 id. null이거나 지워진 주소면 상점 기본 반품지로 안내한다"),
312
310
  }),
313
311
  }),
314
312
  z.object({ type: z.literal("MANUAL") }),
@@ -328,7 +328,7 @@ export const dispatchRequestSchema = z
328
328
  * 발송 시각. ISO 8601이고 시간대(`Z` 또는 `+09:00`)를 포함해야 한다. 생략하면 현재 시각이다.
329
329
  *
330
330
  * 전에는 `z.string()`이라 아무 문자열이나 그대로 저장됐다. 이 값은 배송 정보 jsonb에 들어가
331
- * 자동 배송완료·추적 동기화 잡의 대상 선별 조건이 된다 — 잘못된 한 행이 스토어 구분 없이 도는
331
+ * 자동 배송완료·추적 동기화 잡의 대상 선별 조건이 된다 — 잘못된 한 행이 상점 구분 없이 도는
332
332
  * 잡 전체를 멈출 수 있어 입력에서 좁혔다. 검증을 통과한 값도 저장 전에 UTC ISO로 정규화한다.
333
333
  */
334
334
  dispatchedAt: z.iso.datetime({ offset: true }).optional(),