@sayren/store-sdk 0.39.0 → 0.41.0

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/dist/server.d.mts CHANGED
@@ -23,8 +23,8 @@ export declare class MemberDelegationError extends Error {
23
23
  * `customer:rw`가 필요하다. `uid`(셀러 자체 인증의 사용자 id) 또는 `email`로 지목하고, 처음 보는 사용자면 구매자를 만든다.
24
24
  */
25
25
  export declare function delegateMember(options: {
26
- /** api 주소(예: `https://api.sayren.io`) */
27
- baseUrl: string;
26
+ /** api 오리진. 생략하면 운영(`https://api.sayren.io`) — 개발·로컬에서만 지정한다 */
27
+ baseUrl?: string;
28
28
  accessToken: string;
29
29
  storeId: string;
30
30
  uid?: string;
package/dist/server.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-ap_exsb9.mjs";
1
+ import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-D974dE4T.mjs";
2
2
  //#region src/member-delegation.ts
3
3
  /** 위임 실패 — `error`는 api 오류 코드(엔벨로프 `error.code`, 없으면 `http_{status}`) */
4
4
  var MemberDelegationError = class extends Error {
@@ -18,7 +18,7 @@ var MemberDelegationError = class extends Error {
18
18
  * `customer:rw`가 필요하다. `uid`(셀러 자체 인증의 사용자 id) 또는 `email`로 지목하고, 처음 보는 사용자면 구매자를 만든다.
19
19
  */
20
20
  async function delegateMember(options) {
21
- const res = await (options.fetch ?? globalThis.fetch)(`${options.baseUrl.replace(/\/+$/, "")}/v1/customers/delegations`, {
21
+ const res = await (options.fetch ?? globalThis.fetch)(`${(options.baseUrl ?? "https://api.sayren.io").replace(/\/+$/, "")}/v1/customers/delegations`, {
22
22
  method: "POST",
23
23
  headers: {
24
24
  "content-type": "application/json",
@@ -0,0 +1,359 @@
1
+ import { z } from "zod";
2
+ //#region src/schemas/audit.ts
3
+ /**
4
+ * 감사 로그와 승인 요청 — `/v1/audit-logs`, `/v1/approval-requests` (이슈 #36).
5
+ *
6
+ * 관리 API 쓰기(`/v1/**`의 GET 외 요청)는 모두 요청 단위 기록(`source: REQUEST`) 한 줄을 남긴다. 환불·직권취소 같은
7
+ * 도메인 동작은 그 안에서 변경 전후를 담은 기록(`source: DOMAIN`)을 따로 남기고 `requestId`로 요청 기록과 이어진다.
8
+ */
9
+ const auditActorTypeSchema = z.enum([
10
+ "USER",
11
+ "AGENT",
12
+ "APP",
13
+ "TOKEN",
14
+ "SYSTEM"
15
+ ]).describe("행위자 유형. `USER`=상점 플랫폼 사용자, `AGENT`=AI 에이전트(MCP·에이전트용 API 토큰), `APP`=앱(OAuth 클라이언트·M2M), `TOKEN`=API 토큰, `SYSTEM`=자동화(주기 잡)");
16
+ const auditActorSchema = z.object({
17
+ type: auditActorTypeSchema,
18
+ id: z.string().describe("행위자 ID. `USER`=userId, `TOKEN`·`AGENT`(API 토큰)=`pat:{tokenId}`, `APP`=`client:{clientId}`, `SYSTEM`=`system`. 사람이 앱에 위임한 호출은 `APP`이고 위임한 사람은 `metadata.onBehalfOf`에 있다"),
19
+ name: z.string().nullable().describe("기록 시점의 표시 이름. 알 수 없으면 null")
20
+ });
21
+ const auditLogSourceSchema = z.enum(["REQUEST", "DOMAIN"]).describe("기록 단위. `REQUEST`=관리 API 쓰기 요청 한 건, `DOMAIN`=환불·취소 같은 도메인 동작의 상세");
22
+ const auditLogOutcomeSchema = z.enum([
23
+ "SUCCEEDED",
24
+ "FAILED",
25
+ "PENDING_APPROVAL",
26
+ "DENIED",
27
+ "DRY_RUN"
28
+ ]).describe("요청 결과. `PENDING_APPROVAL`=승인 대기로 보류(202), `DENIED`=에이전트 정책으로 거부(403 `AGENT_OPERATION_DENIED`), `DRY_RUN`=드라이런(`x-sayren-dry-run: 1`)이라 실행하지 않음");
29
+ const auditLogSchema = z.object({
30
+ auditId: z.string().describe("감사 로그 ID"),
31
+ occurredAt: z.string().describe("기록 일시 (ISO 8601)"),
32
+ source: auditLogSourceSchema,
33
+ action: z.string().describe("동작. `REQUEST`는 오퍼레이션 ID(OpenAPI `operationId`, 예: `ProductsController_patch`), `DOMAIN`은 도메인 코드(예: `ORDER_ITEM_CANCELED_BY_SELLER`)"),
34
+ actor: auditActorSchema,
35
+ actorRole: z.string().nullable().describe("요청 시점의 상점 역할. 역할이 없는 토큰·자동화는 null"),
36
+ target: z.object({
37
+ type: z.string().describe("대상 리소스 유형 (예: `products`, `order-items`, `claim`)"),
38
+ id: z.string().nullable().describe("대상 리소스 ID. 목록 대상 요청처럼 하나로 정할 수 없으면 null")
39
+ }),
40
+ requestId: z.string().nullable().describe("요청 ID. 같은 요청에서 나온 `REQUEST`·`DOMAIN` 기록이 같은 값을 가진다. 자동화는 null"),
41
+ method: z.string().nullable().describe("HTTP 메서드 (`REQUEST`만)"),
42
+ path: z.string().nullable().describe("요청 경로와 쿼리 (`REQUEST`만). 민감한 쿼리 값은 `***`로 가린다"),
43
+ statusCode: z.number().int().nullable().describe("응답 HTTP 상태 코드 (`REQUEST`만)"),
44
+ outcome: auditLogOutcomeSchema.nullable().describe("요청 결과 (`REQUEST`만)"),
45
+ errorCode: z.string().nullable().describe("에러 응답의 `error.code` (실패한 요청만)"),
46
+ traceId: z.string().nullable().describe("추적 ID. 에러 응답의 `error.traceId`와 같다"),
47
+ idempotencyKey: z.string().nullable().describe("요청의 멱등키"),
48
+ approvalId: z.string().nullable().describe("승인 요청 ID. 승인 대기로 보류된 요청과 승인 뒤 실행된 요청에 실린다"),
49
+ metadata: z.record(z.string(), z.unknown()).describe("부가 정보. 도메인 기록은 변경 전후 값·사유를, 승인 실행 기록은 승인자(`approvedBy`)를 싣는다")
50
+ });
51
+ /** 쉼표 구분 목록 쿼리 → 배열 */
52
+ function commaList(item) {
53
+ return z.union([z.string(), z.array(z.string())]).transform((value) => (Array.isArray(value) ? value : [value]).flatMap((part) => part.split(",")).map((part) => part.trim()).filter((part) => part.length > 0)).pipe(z.array(item).min(1));
54
+ }
55
+ /** 감사 로그 조회 쿼리 — `GET /audit-logs` */
56
+ const auditLogListQuerySchema = z.object({
57
+ from: z.string().optional().describe("조회 시작 일시 (ISO 8601, 포함)"),
58
+ to: z.string().optional().describe("조회 종료 일시 (ISO 8601, 미포함)"),
59
+ actorType: commaList(auditActorTypeSchema).optional().describe("행위자 유형 필터. 여러 개는 쉼표로 구분한다 (예: `AGENT,APP`)"),
60
+ actorId: z.string().optional().describe("행위자 ID 필터 (정확히 일치)"),
61
+ action: z.string().optional().describe("동작 필터 (정확히 일치)"),
62
+ targetType: z.string().optional().describe("대상 유형 필터 (정확히 일치)"),
63
+ targetId: z.string().optional().describe("대상 ID 필터 (정확히 일치)"),
64
+ source: auditLogSourceSchema.optional(),
65
+ outcome: auditLogOutcomeSchema.optional(),
66
+ requestId: z.string().optional().describe("요청 ID 필터 — 한 요청의 요청·도메인 기록을 함께 본다"),
67
+ approvalId: z.string().optional().describe("승인 요청 ID 필터"),
68
+ cursor: z.string().optional().describe("이전 응답의 `nextCursor`. 생략하면 가장 최신부터"),
69
+ limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50)")
70
+ });
71
+ const auditLogPageSchema = z.object({
72
+ items: z.array(auditLogSchema),
73
+ nextCursor: z.string().nullable().describe("다음 페이지 커서. 마지막 페이지면 null")
74
+ });
75
+ const agentPolicyDecisionSchema = z.enum([
76
+ "ALLOW",
77
+ "APPROVAL_REQUIRED",
78
+ "DENIED"
79
+ ]).describe("에이전트 쓰기 판정. `ALLOW`=바로 실행, `APPROVAL_REQUIRED`=상점 플랫폼 승인 뒤 실행, `DENIED`=에이전트는 실행할 수 없음");
80
+ const previewValueSchema = z.union([
81
+ z.string(),
82
+ z.number(),
83
+ z.boolean(),
84
+ z.null()
85
+ ]);
86
+ const writePreviewSchema = z.object({
87
+ operationId: z.string().describe("오퍼레이션 ID"),
88
+ method: z.string(),
89
+ path: z.string(),
90
+ target: z.object({
91
+ type: z.string(),
92
+ id: z.string().nullable()
93
+ }),
94
+ targetName: z.string().nullable().optional().describe("대상 이름(상품명·쿠폰명·흐름 이름·구매자 이름 등). 읽을 수 없으면 null"),
95
+ changes: z.array(z.object({
96
+ field: z.string().describe("바뀌는 필드"),
97
+ before: z.unknown().describe("현재 값. 새로 만드는 요청이거나 읽을 수 없으면 null"),
98
+ after: z.unknown().describe("요청이 넣을 값")
99
+ })).describe("값이 달라지는 필드. 서버가 현재 값과 비교할 수 있는 오퍼레이션만 채운다"),
100
+ facts: z.record(z.string(), previewValueSchema).describe("영향 요약 (예: 취소 수량·주문상품 금액·예상 환불액). 오퍼레이션마다 다르다")
101
+ });
102
+ /** `x-sayren-dry-run: 1`을 실은 쓰기 요청의 응답(200) — 실행하지 않고 판정과 영향만 돌려준다 */
103
+ const writeDryRunResultSchema = z.object({
104
+ dryRun: z.literal(true),
105
+ decision: agentPolicyDecisionSchema,
106
+ reason: z.string().nullable().describe("승인 필요·거부 사유. `ALLOW`면 null"),
107
+ preview: writePreviewSchema
108
+ });
109
+ const approvalStatusSchema = z.enum([
110
+ "PENDING",
111
+ "EXECUTING",
112
+ "SUCCEEDED",
113
+ "FAILED",
114
+ "UNKNOWN",
115
+ "REJECTED",
116
+ "EXPIRED"
117
+ ]).describe("승인 요청 상태. `PENDING` 승인 대기 → 승인하면 `EXECUTING`(원 요청 실행 중) → `SUCCEEDED`·`FAILED`(원 요청의 응답 기준), 거절하면 `REJECTED`, 기한이 지나면 `EXPIRED`. 실행 응답을 받지 못했으면(타임아웃·서버 종료) `UNKNOWN`이다 — 서버가 감사 로그로 결과를 확정하면 `SUCCEEDED`·`FAILED`로 바뀌고, 확정하지 못하면 `UNKNOWN`에 남는다. `UNKNOWN`은 다시 실행하지 않는다(이미 실행됐을 수 있다)");
118
+ const approvalRequestSchema = z.object({
119
+ approvalId: z.string().describe("승인 요청 ID"),
120
+ status: approvalStatusSchema,
121
+ operationId: z.string().describe("보류된 오퍼레이션 ID"),
122
+ operationName: z.string().nullable().optional().describe("작업 이름(예: 흐름 게시, 구매자 적립금 지급·차감). API 문서의 요약과 같다. 모르면 null"),
123
+ method: z.string().describe("HTTP 메서드"),
124
+ path: z.string().describe("요청 경로와 쿼리"),
125
+ body: z.unknown().describe("요청 본문. 승인하면 이 본문 그대로 실행한다. 없으면 null"),
126
+ idempotencyKey: z.string().nullable().describe("원 요청의 멱등키"),
127
+ reason: z.string().describe("승인이 필요한 이유"),
128
+ actor: auditActorSchema.describe("요청한 행위자"),
129
+ preview: writePreviewSchema.nullable().describe("요청 시점에 서버가 계산한 영향 미리보기"),
130
+ createdAt: z.string().describe("요청 일시 (ISO 8601)"),
131
+ expiresAt: z.string().describe("승인 기한 (ISO 8601). 지나면 `EXPIRED`가 된다"),
132
+ decidedAt: z.string().nullable().describe("승인·거절 일시"),
133
+ decidedBy: z.object({
134
+ id: z.string(),
135
+ name: z.string().nullable()
136
+ }).nullable().describe("승인·거절한 상점 플랫폼 사용자"),
137
+ decisionNote: z.string().nullable().describe("승인·거절 메모"),
138
+ executedAt: z.string().nullable().describe("원 요청 실행을 마친 일시"),
139
+ result: z.object({
140
+ statusCode: z.number().int().describe("원 요청의 응답 상태 코드"),
141
+ data: z.unknown().describe("성공 응답의 `data` (큰 응답은 잘린다)"),
142
+ error: z.object({
143
+ code: z.string(),
144
+ message: z.string()
145
+ }).nullable().describe("실패 응답의 `error` 요약")
146
+ }).nullable().describe("실행 결과. `SUCCEEDED`·`FAILED`·`UNKNOWN`에서 값이 있다. 감사 로그로 확정한 결과는 `data`가 null이다"),
147
+ approvalUrl: z.string().nullable().describe("상점 플랫폼의 승인 화면 주소. 이 배포의 상점 플랫폼 주소를 알 수 없으면 null")
148
+ });
149
+ const approvalListQuerySchema = z.object({
150
+ status: commaList(approvalStatusSchema).optional().describe("상태 필터. 여러 개는 쉼표로 구분한다 (예: `PENDING,EXECUTING`)"),
151
+ cursor: z.string().optional().describe("이전 응답의 `nextCursor`. 생략하면 가장 최신부터"),
152
+ limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50)")
153
+ });
154
+ const approvalPageSchema = z.object({
155
+ items: z.array(approvalRequestSchema),
156
+ nextCursor: z.string().nullable().describe("다음 페이지 커서. 마지막 페이지면 null")
157
+ });
158
+ const approvalDecisionRequestSchema = z.object({ note: z.string().trim().max(500).optional().describe("승인·거절 메모 (최대 500자)") });
159
+ //#endregion
160
+ //#region src/http.ts
161
+ /** 표준 엔벨로프의 $.error 상세 */
162
+ const errorDetailSchema = z.object({
163
+ code: z.string(),
164
+ message: z.string(),
165
+ traceId: z.string().optional(),
166
+ /**
167
+ * 이 에러 코드를 설명하는 문서 URL(선택). 서버가 코드별 매핑을 가진 코드에만 실린다 —
168
+ * 없으면 필드가 아예 없으므로 소비측은 있을 때만 쓴다
169
+ */
170
+ docs: z.url().optional().catch(void 0),
171
+ /**
172
+ * 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`의
173
+ * `{ conditions: LiveGateCondition[] }`. 형태는 에러 코드마다 다르므로 소비측이 코드별 스키마로 다시 파싱한다
174
+ */
175
+ details: z.record(z.string(), z.unknown()).optional().catch(void 0)
176
+ });
177
+ /** 표준 응답 엔벨로프 — $.meta / $.data / $.error */
178
+ const envelopeSchema = z.object({
179
+ meta: z.object({
180
+ status: z.number(),
181
+ code: z.string(),
182
+ message: z.string(),
183
+ isSuccess: z.boolean()
184
+ }),
185
+ data: z.unknown().optional(),
186
+ /** 구형 필드 — data로 개명되기 전 응답과의 전환기 호환 */
187
+ content: z.unknown().optional(),
188
+ error: errorDetailSchema.nullable()
189
+ });
190
+ /** OAuth 프로토콜 표면의 평면 에러 (RFC 6749 §5.2, RFC 7591 §3.2.2) */
191
+ const oauthErrorSchema = z.object({
192
+ error: z.string(),
193
+ error_description: z.string().optional()
194
+ });
195
+ /** 표준 엔벨로프면 $.data(구형 $.content)를, 아니면(평면 응답) 본문 전체를 반환 */
196
+ function unwrapData(json) {
197
+ const envelope = envelopeSchema.safeParse(json);
198
+ if (!envelope.success) return json;
199
+ return envelope.data.data ?? envelope.data.content ?? null;
200
+ }
201
+ var ApiError = class extends Error {
202
+ status;
203
+ code;
204
+ traceId;
205
+ body;
206
+ details;
207
+ docs;
208
+ constructor(status, code, message, traceId, body, details, docs) {
209
+ super(message);
210
+ this.status = status;
211
+ this.code = code;
212
+ this.traceId = traceId;
213
+ this.body = body;
214
+ this.details = details;
215
+ this.docs = docs;
216
+ this.name = "ApiError";
217
+ }
218
+ };
219
+ /** 에이전트 쓰기가 승인 대기로 보류됐다(202) */
220
+ const APPROVAL_REQUIRED_CODE = "APPROVAL_REQUIRED";
221
+ /** 같은 멱등키의 승인 요청이 이미 끝났다(200) — 결과는 승인 요청의 `status`·`result`다 */
222
+ const APPROVAL_ALREADY_DECIDED_CODE = "APPROVAL_ALREADY_DECIDED";
223
+ /**
224
+ * 쓰기 요청이 실행되지 않고 승인 흐름에 걸렸다 (이슈 #36). 응답 `data`가 요청한 리소스가 아니라 승인 요청이라
225
+ * 성공 값으로 돌려주지 않는다 — 돌려주면 호출부가 승인 요청을 상품·주문으로 읽는다.
226
+ *
227
+ * - `code = APPROVAL_REQUIRED`(202): 에이전트의 고위험 쓰기가 보류됐다. 아직 아무것도 바뀌지 않았다.
228
+ * 셀러가 상점 플랫폼(`approval.approvalUrl`)에서 승인하면 서버가 원 요청을 실행한다. 같은 요청을 다시 보내지 말고
229
+ * `approvalRequests.get(approvalId)`로 결과를 확인한다.
230
+ * - `code = APPROVAL_ALREADY_DECIDED`(200): 같은 멱등키로 보낸 요청의 승인이 이미 끝났다. `status`가
231
+ * `SUCCEEDED`·`FAILED`·`UNKNOWN`이면 실행 결과(`approval.result`)를 보고, `REJECTED`·`EXPIRED`면 새 멱등키로 다시 요청한다.
232
+ */
233
+ var ApprovalPendingError = class extends Error {
234
+ code;
235
+ httpStatus;
236
+ approval;
237
+ approvalId;
238
+ /** 승인 요청 상태 */
239
+ status;
240
+ constructor(code, httpStatus, approval, message) {
241
+ super(message);
242
+ this.code = code;
243
+ this.httpStatus = httpStatus;
244
+ this.approval = approval;
245
+ this.name = "ApprovalPendingError";
246
+ this.approvalId = approval.approvalId;
247
+ this.status = approval.status;
248
+ }
249
+ };
250
+ /** 성공 엔벨로프가 승인 흐름 응답이면 ApprovalPendingError. 그 밖(다른 202 등)은 null */
251
+ function approvalErrorOf(status, json) {
252
+ const envelope = envelopeSchema.safeParse(json);
253
+ if (!envelope.success) return null;
254
+ const { code, message } = envelope.data.meta;
255
+ if (code !== "APPROVAL_REQUIRED" && code !== "APPROVAL_ALREADY_DECIDED") return null;
256
+ const approval = approvalRequestSchema.safeParse(envelope.data.data);
257
+ if (!approval.success) return null;
258
+ return new ApprovalPendingError(code, status, approval.data, message);
259
+ }
260
+ /**
261
+ * 에러 응답 본문 → ApiError 구성 (에러 형태의 단일 원천).
262
+ * 표준 엔벨로프($.error) 우선, 구형 평면 에러({ code, message }), OAuth 평면 에러
263
+ * ({ error, error_description } → code=error, message=error_description) 순으로 폴백하고,
264
+ * 모두 아니면 UNKNOWN. createHttp의 send와 세션(userinfo 등 raw fetch 경로)이 동일한 에러 모양을 내도록 공유한다.
265
+ */
266
+ function toApiError(status, raw) {
267
+ const envelope = envelopeSchema.safeParse(raw);
268
+ const detail = envelope.success ? envelope.data.error : errorDetailSchema.safeParse(raw).data ?? null;
269
+ if (detail) return new ApiError(status, detail.code, detail.message, detail.traceId, raw, detail.details, detail.docs);
270
+ const oauth = envelope.success ? null : oauthErrorSchema.safeParse(raw).data;
271
+ if (oauth) return new ApiError(status, oauth.error, oauth.error_description ?? oauth.error, void 0, raw);
272
+ return new ApiError(status, "UNKNOWN", `HTTP ${status}`, void 0, raw);
273
+ }
274
+ /**
275
+ * 경로 템플릿 태그 — 보간 값(경로 파라미터)마다 encodeURIComponent를 적용한다.
276
+ * `/`·`?`·`#` 등이 섞인 id가 다른 경로·쿼리로 해석되지 않게 한다. 예: path`/products/${id}`
277
+ */
278
+ function path(strings, ...params) {
279
+ return strings.reduce((acc, part, i) => acc + part + (i < params.length ? encodeURIComponent(String(params[i])) : ""), "");
280
+ }
281
+ function resolveToken(source) {
282
+ return (typeof source === "function" ? source() : source) ?? null;
283
+ }
284
+ function buildQuery(query) {
285
+ if (!query) return "";
286
+ const params = new URLSearchParams();
287
+ for (const [key, value] of Object.entries(query)) if (value !== void 0) params.set(key, String(value));
288
+ const qs = params.toString();
289
+ return qs ? `?${qs}` : "";
290
+ }
291
+ /** 운영 관리 API 주소(버전 경로 포함) — 클라이언트·세션의 `baseUrl` 기본값. 개발·로컬에서만 지정한다 */
292
+ const SAYREN_API_BASE_URL = "https://api.sayren.io/v1";
293
+ /** 운영 api 오리진 — 경로를 스스로 붙이는 함수(`delegateMember`)의 `baseUrl` 기본값 */
294
+ const SAYREN_API_ORIGIN = "https://api.sayren.io";
295
+ function createHttp(options) {
296
+ const fetchFn = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
297
+ async function send(method, path, request, retried = false) {
298
+ const url = `${options.baseUrl}${path}${buildQuery(request?.query)}`;
299
+ const headers = {
300
+ ...request?.body !== void 0 ? { "content-type": "application/json" } : {},
301
+ ...options.headers?.(),
302
+ ...request?.headers
303
+ };
304
+ const response = await fetchFn(url, {
305
+ method,
306
+ headers,
307
+ body: request?.body !== void 0 ? JSON.stringify(request.body) : void 0
308
+ });
309
+ options.onResponse?.(response);
310
+ if (response.status === 401 && !retried && options.refresh) {
311
+ if (await options.refresh()) return send(method, path, request, true);
312
+ }
313
+ if (!response.ok) {
314
+ if (response.status === 401) options.onUnauthorized?.();
315
+ const raw = await response.json().catch(() => null);
316
+ throw toApiError(response.status, raw);
317
+ }
318
+ return response;
319
+ }
320
+ return {
321
+ async request(method, path, schema, request) {
322
+ const response = await send(method, path, request);
323
+ const json = await response.json();
324
+ const pending = approvalErrorOf(response.status, json);
325
+ if (pending) throw pending;
326
+ return schema.parse(unwrapData(json));
327
+ },
328
+ async requestVoid(method, path, request) {
329
+ const response = await send(method, path, request);
330
+ if (response.status === 204) return;
331
+ const json = await response.json().catch(() => null);
332
+ const pending = approvalErrorOf(response.status, json);
333
+ if (pending) throw pending;
334
+ }
335
+ };
336
+ }
337
+ //#endregion
338
+ //#region src/webhook-headers.ts
339
+ /** 웹훅 발송 요청 헤더 상수 (공개 계약). zod 의존 없이 서버 엔트리포인트에서도 가볍게 쓴다 */
340
+ /** 서명 헤더. 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]` */
341
+ const WEBHOOK_SIGNATURE_HEADER = "Sayren-Signature";
342
+ /** 이벤트 ID 헤더. 본문 `id`와 같다. 중복 제거 키 */
343
+ const WEBHOOK_EVENT_ID_HEADER = "Sayren-Event-Id";
344
+ /** 이벤트 타입 헤더. 본문 `type`과 같다 */
345
+ const WEBHOOK_EVENT_TYPE_HEADER = "Sayren-Event-Type";
346
+ /** 전송 기록 ID 헤더. 전송 기록의 `deliveryId`와 같다 */
347
+ const WEBHOOK_DELIVERY_ID_HEADER = "Sayren-Delivery-Id";
348
+ /** 발송 요청의 `User-Agent` 값 */
349
+ const WEBHOOK_USER_AGENT = "Sayren-Webhooks/1.0";
350
+ /** 수신 측 권장 타임스탬프 허용 오차(초) */
351
+ const WEBHOOK_DEFAULT_TOLERANCE_SEC = 300;
352
+ const WEBHOOK_HEADERS = {
353
+ signature: WEBHOOK_SIGNATURE_HEADER,
354
+ eventId: WEBHOOK_EVENT_ID_HEADER,
355
+ eventType: WEBHOOK_EVENT_TYPE_HEADER,
356
+ deliveryId: WEBHOOK_DELIVERY_ID_HEADER
357
+ };
358
+ //#endregion
359
+ export { auditLogPageSchema as A, approvalPageSchema as C, auditActorTypeSchema as D, auditActorSchema as E, auditLogSourceSchema as M, writeDryRunResultSchema as N, auditLogListQuerySchema as O, writePreviewSchema as P, approvalListQuerySchema as S, approvalStatusSchema as T, resolveToken as _, WEBHOOK_HEADERS as a, agentPolicyDecisionSchema as b, APPROVAL_ALREADY_DECIDED_CODE as c, ApprovalPendingError as d, SAYREN_API_BASE_URL as f, path as g, createHttp as h, WEBHOOK_EVENT_TYPE_HEADER as i, auditLogSchema as j, auditLogOutcomeSchema as k, APPROVAL_REQUIRED_CODE as l, buildQuery as m, WEBHOOK_DELIVERY_ID_HEADER as n, WEBHOOK_SIGNATURE_HEADER as o, SAYREN_API_ORIGIN as p, WEBHOOK_EVENT_ID_HEADER as r, WEBHOOK_USER_AGENT as s, WEBHOOK_DEFAULT_TOLERANCE_SEC as t, ApiError as u, toApiError as v, approvalRequestSchema as w, approvalDecisionRequestSchema as x, unwrapData as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { z } from "zod";
2
- import { ApiError, createHttp, path, type Query, resolveToken, type TokenSource } from "./http";
2
+ import {
3
+ ApiError,
4
+ createHttp,
5
+ path,
6
+ type Query,
7
+ resolveToken,
8
+ SAYREN_API_BASE_URL,
9
+ type TokenSource,
10
+ } from "./http";
3
11
  import {
4
12
  aiConversationFileChangesSchema,
5
13
  aiConversationSchema,
@@ -463,8 +471,8 @@ import {
463
471
  import type { AuthorizeOptions, StoreSession } from "./session";
464
472
 
465
473
  export interface StoreClientOptions {
466
- /** 예: https://api.example.com/v1 */
467
- baseUrl: string;
474
+ /** 관리 API 주소(버전 경로 포함). 생략하면 운영 주소(`SAYREN_API_BASE_URL`, `https://api.sayren.io/v1`) — 개발·로컬에서만 지정한다 */
475
+ baseUrl?: string;
468
476
  /**
469
477
  * 계정 토큰(accounts가 발급한 JWT — 상점 플랫폼 로그인, 개인 액세스 토큰, 연결된 앱의 토큰).
470
478
  * 계정 API(`/me/*`)와 상점 API 모두에 Bearer로 싣는다. `session`을 주면 생략해도 세션 토큰을 쓴다.
@@ -485,7 +493,9 @@ export interface StoreClientOptions {
485
493
  }
486
494
 
487
495
  export function createStoreClient(options: StoreClientOptions) {
488
- const fetchFn = options.fetch ?? globalThis.fetch;
496
+ // 주입이 없으면 부를 때마다 지금의 전역 fetch를 쓴다 — 모듈을 불러올 때 만든 싱글턴도 나중에 fetch를 감싼 도구(MSW·계측)를 따른다
497
+ const fetchFn: typeof globalThis.fetch =
498
+ options.fetch ?? ((input, init) => globalThis.fetch(input, init));
489
499
  const { session } = options;
490
500
 
491
501
  // session이 있으면 토큰/refresh/onUnauthorized의 기본값을 세션에서 배선한다. 개별 옵션을 명시하면 그쪽이 우선한다.
@@ -501,7 +511,7 @@ export function createStoreClient(options: StoreClientOptions) {
501
511
 
502
512
  /** 계정 API(`/me/*`) — 상점을 고르지 않는다 */
503
513
  const userHttp = createHttp({
504
- baseUrl: options.baseUrl,
514
+ baseUrl: options.baseUrl ?? SAYREN_API_BASE_URL,
505
515
  fetch: fetchFn,
506
516
  headers: bearer,
507
517
  refresh,
@@ -510,7 +520,7 @@ export function createStoreClient(options: StoreClientOptions) {
510
520
 
511
521
  /** 상점 API — 같은 계정 토큰 + `X-Store-Id` */
512
522
  const storeHttp = createHttp({
513
- baseUrl: options.baseUrl,
523
+ baseUrl: options.baseUrl ?? SAYREN_API_BASE_URL,
514
524
  fetch: fetchFn,
515
525
  headers: () => {
516
526
  const storeId = resolveToken(options.storeId);
@@ -2271,7 +2281,7 @@ export function createStoreClient(options: StoreClientOptions) {
2271
2281
  removeDomain: (domainId: string) =>
2272
2282
  storeHttp.requestVoid("DELETE", path`/storefront/site/domains/${domainId}`),
2273
2283
  /** `POST /v1/storefront/site/disable` — 호스팅을 끈다(사이트 `CLOSED`, 슬러그 90일 보류). 다시 만들 수 있다 */
2274
- disable: (body: DisableStorefrontHostingRequest) =>
2284
+ disable: (body: DisableStorefrontHostingRequest = {}) =>
2275
2285
  storeHttp.request("POST", "/storefront/site/disable", storefrontSiteSchema, {
2276
2286
  body: disableStorefrontHostingRequestSchema.parse(body),
2277
2287
  }),
package/src/http.ts CHANGED
@@ -204,8 +204,15 @@ export interface Http {
204
204
  requestVoid(method: string, path: string, options?: RequestOptions): Promise<void>;
205
205
  }
206
206
 
207
+ /** 운영 관리 API 주소(버전 경로 포함) — 클라이언트·세션의 `baseUrl` 기본값. 개발·로컬에서만 지정한다 */
208
+ export const SAYREN_API_BASE_URL = "https://api.sayren.io/v1";
209
+ /** 운영 api 오리진 — 경로를 스스로 붙이는 함수(`delegateMember`)의 `baseUrl` 기본값 */
210
+ export const SAYREN_API_ORIGIN = "https://api.sayren.io";
211
+
207
212
  export function createHttp(options: HttpOptions): Http {
208
- const fetchFn = options.fetch ?? globalThis.fetch;
213
+ // 주입이 없으면 부를 때마다 지금의 전역 fetch를 쓴다 — 클라이언트를 만든 뒤 fetch를 감싸는 도구(MSW·계측)도 적용된다
214
+ const fetchFn: typeof globalThis.fetch =
215
+ options.fetch ?? ((input, init) => globalThis.fetch(input, init));
209
216
 
210
217
  async function send(
211
218
  method: string,
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export {
7
7
  ApiError,
8
8
  ApprovalPendingError,
9
9
  buildQuery,
10
+ SAYREN_API_BASE_URL,
11
+ SAYREN_API_ORIGIN,
10
12
  } from "./http";
11
13
  export {
12
14
  buildAuthorizeUrl,
@@ -1,3 +1,4 @@
1
+ import { SAYREN_API_ORIGIN } from "./http";
1
2
  /**
2
3
  * 구매자 위임 — 자체 회원 시스템을 가진 셀러 서버가 로그인한 사용자를 sayren 구매자 토큰으로 바꾼다.
3
4
  * 계정 토큰(개인 액세스 토큰 등)을 쓰므로 **서버에서만** 부른다.
@@ -28,8 +29,8 @@ export class MemberDelegationError extends Error {
28
29
  * `customer:rw`가 필요하다. `uid`(셀러 자체 인증의 사용자 id) 또는 `email`로 지목하고, 처음 보는 사용자면 구매자를 만든다.
29
30
  */
30
31
  export async function delegateMember(options: {
31
- /** api 주소(예: `https://api.sayren.io`) */
32
- baseUrl: string;
32
+ /** api 오리진. 생략하면 운영(`https://api.sayren.io`) — 개발·로컬에서만 지정한다 */
33
+ baseUrl?: string;
33
34
  accessToken: string;
34
35
  storeId: string;
35
36
  uid?: string;
@@ -39,22 +40,25 @@ export async function delegateMember(options: {
39
40
  fetch?: typeof globalThis.fetch;
40
41
  }): Promise<DelegatedMemberToken> {
41
42
  const doFetch = options.fetch ?? globalThis.fetch;
42
- const res = await doFetch(`${options.baseUrl.replace(/\/+$/, "")}/v1/customers/delegations`, {
43
- method: "POST",
44
- headers: {
45
- "content-type": "application/json",
46
- authorization: `Bearer ${options.accessToken}`,
47
- "x-store-id": options.storeId,
43
+ const res = await doFetch(
44
+ `${(options.baseUrl ?? SAYREN_API_ORIGIN).replace(/\/+$/, "")}/v1/customers/delegations`,
45
+ {
46
+ method: "POST",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ authorization: `Bearer ${options.accessToken}`,
50
+ "x-store-id": options.storeId,
51
+ },
52
+ body: JSON.stringify({
53
+ ...(options.uid ? { uid: options.uid } : {}),
54
+ ...(options.email ? { email: options.email } : {}),
55
+ ...(options.name ? { name: options.name } : {}),
56
+ ...(options.authenticatedAt
57
+ ? { authenticatedAt: options.authenticatedAt.toISOString() }
58
+ : {}),
59
+ }),
48
60
  },
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
- }),
57
- });
61
+ );
58
62
  const body = (await res.json().catch(() => ({}))) as {
59
63
  data?: { accessToken?: unknown; expiresIn?: unknown; memberId?: unknown };
60
64
  error?: { code?: unknown; message?: unknown };