@sayren/store-sdk 0.30.12 → 0.30.14

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.
@@ -8,7 +8,7 @@ import {
8
8
  } from "./webhook-event";
9
9
 
10
10
  /**
11
- * 서드파티 앱 매니페스트 `sayren.app.json` (이슈 #22) — CLI(`sayren app`)·api(빌드 `VERIFY`·버전 판정·동의 화면 자료)·런타임이
11
+ * 서드파티 앱 매니페스트 `manifest.json` (이슈 #22) — CLI(`sayren app`)·api(빌드 `VERIFY`·버전 판정·동의 화면 자료)·런타임이
12
12
  * 같은 원천을 쓴다. 매니페스트는 정적 JSON이고 빌드 없이 읽는다.
13
13
  *
14
14
  * 기능 하나는 **capability** 하나다(`APP_CAPABILITIES`). capability는 매니페스트 조각·동의 문단·넓어짐 판정(재동의 조건)을 한 곳에 둔다.
@@ -251,6 +251,57 @@ export const APP_SCHEDULE_INTERVALS = {
251
251
  "1d": 1440,
252
252
  } as const;
253
253
  export type AppScheduleInterval = keyof typeof APP_SCHEDULE_INTERVALS;
254
+
255
+ /** 흐름 알림 이름 — 흐름 알림 채널 `app.<handle>.<이름>`의 마지막 마디(소문자·숫자·하이픈) */
256
+ export const APP_FLOW_NOTIFICATION_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
257
+ /** 흐름 사건 이름 — 사건 키 `app.<handle>.<이름>`의 마지막 마디 */
258
+ export const APP_FLOW_EVENT_PATTERN = /^[A-Z][A-Z0-9_]{0,39}$/;
259
+ export const APP_FLOW_ITEMS_MAX = 20;
260
+
261
+ /** 흐름 알림 매개변수 — 셀러가 편집기에서 식으로 값을 연결한다 */
262
+ export const appFlowParamSchema = z.strictObject({
263
+ type: z.enum(["string", "number", "boolean"]),
264
+ label: z.string().trim().min(1).max(60),
265
+ required: z.boolean().optional(),
266
+ });
267
+ export type AppFlowParam = z.infer<typeof appFlowParamSchema>;
268
+
269
+ export const appFlowNotificationSchema = z.strictObject({
270
+ label: z.string().trim().min(1).max(60),
271
+ description: z.string().trim().max(200).optional(),
272
+ params: z
273
+ .record(z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/), appFlowParamSchema)
274
+ .refine((params) => Object.keys(params).length <= APP_FLOW_ITEMS_MAX, {
275
+ message: `매개변수는 ${APP_FLOW_ITEMS_MAX}개까지입니다`,
276
+ })
277
+ .default({}),
278
+ });
279
+ export type AppFlowNotification = z.infer<typeof appFlowNotificationSchema>;
280
+
281
+ export const appFlowEventSchema = z.strictObject({
282
+ label: z.string().trim().min(1).max(60),
283
+ description: z.string().trim().max(200).optional(),
284
+ });
285
+ export type AppFlowEvent = z.infer<typeof appFlowEventSchema>;
286
+
287
+ /** 에이전트 도구 이름 — 소문자·숫자·하이픈 */
288
+ export const APP_TOOL_NAME_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
289
+ export const appToolInputSchema = z.strictObject({
290
+ type: z.enum(["string", "number", "boolean"]),
291
+ description: z.string().trim().max(200).optional(),
292
+ required: z.boolean().optional(),
293
+ });
294
+ export type AppToolInput = z.infer<typeof appToolInputSchema>;
295
+ export const appToolSchema = z.strictObject({
296
+ description: z.string().trim().min(1).max(500),
297
+ input: z
298
+ .record(z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/), appToolInputSchema)
299
+ .refine((input) => Object.keys(input).length <= APP_FLOW_ITEMS_MAX, {
300
+ message: `입력은 ${APP_FLOW_ITEMS_MAX}개까지입니다`,
301
+ })
302
+ .default({}),
303
+ });
304
+ export type AppTool = z.infer<typeof appToolSchema>;
254
305
  const APP_SCHEDULE_INTERVAL_KEYS = Object.keys(APP_SCHEDULE_INTERVALS) as [
255
306
  AppScheduleInterval,
256
307
  ...AppScheduleInterval[],
@@ -306,8 +357,21 @@ export const appManifestSchema = z.strictObject({
306
357
  .regex(APP_FOLDER_FILE)
307
358
  .refine((path) => /\.md$/i.test(path), { message: "README는 .md 파일입니다" })
308
359
  .optional(),
309
- /** 실행 위치 — A1은 코드 업로드 앱만. 외부 런타임(`external`)은 A3에서 연다 */
310
- runtime: z.literal("code").default("code"),
360
+ /** 실행 위치 — `code`(sayren이 실행) 또는 `external`(개발자 서버, 매뉴얼 「개발자 서버에서 실행」) */
361
+ runtime: z.enum(["code", "external"]).default("code"),
362
+ /** `runtime: external`의 호출 주소 */
363
+ external: z.strictObject({ url: z.url().startsWith("https://") }).optional(),
364
+ /** AI 에이전트 도구(매뉴얼 「에이전트 도구」) */
365
+ mcp: z
366
+ .strictObject({
367
+ tools: z
368
+ .record(z.string().regex(APP_TOOL_NAME_PATTERN), appToolSchema)
369
+ .refine((tools) => Object.keys(tools).length <= APP_FLOW_ITEMS_MAX, {
370
+ message: `도구는 ${APP_FLOW_ITEMS_MAX}개까지입니다`,
371
+ })
372
+ .default({}),
373
+ })
374
+ .optional(),
311
375
  /** 설치 때 전체 동의하는 스코프 */
312
376
  scopes: z.array(appScopeSchema).max(32).default([]),
313
377
  network: z
@@ -337,6 +401,23 @@ export const appManifestSchema = z.strictObject({
337
401
  message: `예약 작업은 ${APP_SCHEDULES_MAX}개까지입니다`,
338
402
  })
339
403
  .default({}),
404
+ /** 주문 흐름 연동(매뉴얼 「주문 흐름에서 앱 쓰기」) — 흐름 알림(흐름 → 앱)과 흐름 사건(앱 → 흐름) */
405
+ flow: z
406
+ .strictObject({
407
+ notifications: z
408
+ .record(z.string().regex(APP_FLOW_NOTIFICATION_PATTERN), appFlowNotificationSchema)
409
+ .refine((items) => Object.keys(items).length <= APP_FLOW_ITEMS_MAX, {
410
+ message: `흐름 알림은 ${APP_FLOW_ITEMS_MAX}개까지입니다`,
411
+ })
412
+ .default({}),
413
+ events: z
414
+ .record(z.string().regex(APP_FLOW_EVENT_PATTERN), appFlowEventSchema)
415
+ .refine((items) => Object.keys(items).length <= APP_FLOW_ITEMS_MAX, {
416
+ message: `흐름 사건은 ${APP_FLOW_ITEMS_MAX}개까지입니다`,
417
+ })
418
+ .default({}),
419
+ })
420
+ .optional(),
340
421
  settings: z
341
422
  .record(z.string().regex(APP_SETTING_KEY_PATTERN), appSettingFieldSchema)
342
423
  .refine((fields) => Object.keys(fields).length <= APP_SETTINGS_MAX_FIELDS, {
@@ -369,7 +450,10 @@ export interface AppManifestProblem {
369
450
  | "SELECT_OPTIONS_REQUIRED"
370
451
  | "SECRET_FIELD_TYPE"
371
452
  | "DEFAULT_TYPE_MISMATCH"
372
- | "DUPLICATE";
453
+ | "DUPLICATE"
454
+ | "EXTERNAL_URL_REQUIRED"
455
+ | "EXTERNAL_NETWORK_HOSTS"
456
+ | "EXTERNAL_URL_INVALID";
373
457
  message: string;
374
458
  }
375
459
 
@@ -390,6 +474,28 @@ export function appManifestProblems(manifest: AppManifest): AppManifestProblem[]
390
474
  unique(manifest.network.hosts, "network.hosts", "호스트");
391
475
  unique(manifest.secrets, "secrets", "비밀 값 이름");
392
476
  unique(manifest.http?.public ?? [], "http.public", "공개 HTTP 핸들러");
477
+ // 외부 런타임 — 주소가 있어야 하고, 내보내는 곳을 sayren이 막을 수 없어 network.hosts를 받지 않는다
478
+ if ((manifest.runtime === "external") !== Boolean(manifest.external)) {
479
+ problems.push({
480
+ path: "external",
481
+ code: "EXTERNAL_URL_REQUIRED",
482
+ message: "runtime이 external이면 external.url이 필요하고, code면 external을 적지 않습니다",
483
+ });
484
+ }
485
+ if (manifest.runtime === "external" && manifest.network.hosts.length > 0) {
486
+ problems.push({
487
+ path: "network.hosts",
488
+ code: "EXTERNAL_NETWORK_HOSTS",
489
+ message: "외부 런타임 앱은 network.hosts를 적지 않습니다",
490
+ });
491
+ }
492
+ if (manifest.external) {
493
+ const host = new URL(manifest.external.url).hostname;
494
+ const hostProblem = appNetworkHostProblem(host);
495
+ if (hostProblem) {
496
+ problems.push({ path: "external.url", code: "EXTERNAL_URL_INVALID", message: hostProblem });
497
+ }
498
+ }
393
499
  for (const event of manifest.events) {
394
500
  if (!scopesCoverEvent(manifest.scopes, event)) {
395
501
  problems.push({
@@ -442,6 +548,13 @@ export interface AppCapabilities {
442
548
  publicHttp?: string[];
443
549
  /** 예약 작업 이름 → 간격. 옛 버전 행에는 없다 — 읽을 때 `?? {}` */
444
550
  schedules?: Record<string, AppScheduleInterval>;
551
+ /** 흐름 알림(이름 → 표시 이름·매개변수)·흐름 사건(이름 → 표시 이름). 옛 버전 행에는 없다 — 읽을 때 `?? {}` */
552
+ flowNotifications?: Record<string, AppFlowNotification>;
553
+ flowEvents?: Record<string, AppFlowEvent>;
554
+ /** 외부 런타임의 호출 호스트(동의 화면). 코드 앱·옛 버전 행은 없다 */
555
+ externalHost?: string | null;
556
+ /** 에이전트 도구. 옛 버전 행에는 없다 — 읽을 때 `?? {}` */
557
+ tools?: Record<string, AppTool>;
445
558
  /** 필수 설정 키 — 하나라도 비어 있으면 이벤트 함수를 부르지 않는다 */
446
559
  requiredSettings: string[];
447
560
  secretSettings: string[];
@@ -455,6 +568,10 @@ export function appCapabilitiesOf(manifest: AppManifest): AppCapabilities {
455
568
  networkHosts: sorted(manifest.network.hosts),
456
569
  adminPage: Boolean(manifest.ui?.adminPage),
457
570
  publicHttp: sorted(manifest.http?.public ?? []),
571
+ flowNotifications: manifest.flow?.notifications ?? {},
572
+ flowEvents: manifest.flow?.events ?? {},
573
+ externalHost: manifest.external ? new URL(manifest.external.url).hostname : null,
574
+ tools: manifest.mcp?.tools ?? {},
458
575
  schedules: Object.fromEntries(
459
576
  Object.entries(manifest.schedules)
460
577
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
@@ -476,7 +593,7 @@ export function appCapabilitiesOf(manifest: AppManifest): AppCapabilities {
476
593
  * 동의 문단은 accounts 설치 화면·상점 플랫폼 앱 상세·유저 문서가 같은 문장을 쓴다.
477
594
  */
478
595
  export interface AppCapabilityDefinition {
479
- key: "scopes" | "network" | "events" | "ui" | "publicHttp" | "schedules";
596
+ key: "scopes" | "network" | "events" | "ui" | "publicHttp" | "schedules" | "runtime" | "tools";
480
597
  /** 동의 화면의 문단 제목 */
481
598
  title: string;
482
599
  /** 동의 화면에 보일 줄 — 없으면 문단을 그리지 않는다 */
@@ -545,6 +662,19 @@ export const APP_CAPABILITIES: readonly AppCapabilityDefinition[] = [
545
662
  );
546
663
  },
547
664
  },
665
+ {
666
+ key: "runtime",
667
+ title: "이 앱이 실행되는 곳",
668
+ describe: (c) => (c.externalHost ? [`개발자 서버 ${c.externalHost}`] : []),
669
+ widens: (p, n) => Boolean(n.externalHost) && n.externalHost !== p.externalHost,
670
+ },
671
+ {
672
+ key: "tools",
673
+ title: "AI 에이전트 도구",
674
+ describe: (c) =>
675
+ Object.entries(c.tools ?? {}).map(([name, tool]) => `${name} · ${tool.description}`),
676
+ widens: (p, n) => grew(Object.keys(p.tools ?? {}), Object.keys(n.tools ?? {})),
677
+ },
548
678
  ];
549
679
 
550
680
  /** 요청(메서드·경로)이 HTTP 핸들러 키에 맞는가 — `:이름`은 경로 한 마디에 맞는다(`@sayren/app`의 핸들러 고르기와 같은 규칙) */
@@ -567,6 +697,48 @@ export function appScheduleListOf(
567
697
  .map(([name, every]) => ({ name, every }));
568
698
  }
569
699
 
700
+ /** 응답용 흐름 알림·사건 목록(이름순) — 옛 버전 행은 빈 목록 */
701
+ export function appFlowSummaryOf(
702
+ capabilities: Pick<AppCapabilities, "flowNotifications" | "flowEvents">,
703
+ ): {
704
+ flowNotifications: Array<{
705
+ name: string;
706
+ label: string;
707
+ params: Array<{ name: string; type: AppFlowParam["type"]; label: string; required: boolean }>;
708
+ }>;
709
+ flowEvents: Array<{ name: string; label: string }>;
710
+ } {
711
+ const byName = <T>(entries: [string, T][]) =>
712
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
713
+ return {
714
+ flowNotifications: byName(Object.entries(capabilities.flowNotifications ?? {})).map(
715
+ ([name, notice]) => ({
716
+ name,
717
+ label: notice.label,
718
+ params: byName(Object.entries(notice.params ?? {})).map(([param, field]) => ({
719
+ name: param,
720
+ type: field.type,
721
+ label: field.label,
722
+ required: field.required ?? false,
723
+ })),
724
+ }),
725
+ ),
726
+ flowEvents: byName(Object.entries(capabilities.flowEvents ?? {})).map(([name, event]) => ({
727
+ name,
728
+ label: event.label,
729
+ })),
730
+ };
731
+ }
732
+
733
+ /** 응답용 도구 목록(이름순) — 옛 버전 행은 빈 목록 */
734
+ export function appToolListOf(
735
+ capabilities: Pick<AppCapabilities, "tools">,
736
+ ): Array<{ name: string; description: string }> {
737
+ return Object.entries(capabilities.tools ?? {})
738
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
739
+ .map(([name, tool]) => ({ name, description: tool.description }));
740
+ }
741
+
570
742
  /** 예약 간격의 표시 이름 — 동의 화면·상점 플랫폼이 같이 쓴다 */
571
743
  export function appScheduleIntervalLabel(every: AppScheduleInterval): string {
572
744
  const minutes = APP_SCHEDULE_INTERVALS[every];
@@ -76,11 +76,38 @@ const publicHttpField = z
76
76
  .array(z.string())
77
77
  .default([])
78
78
  .describe("외부에서 부를 수 있는 HTTP 핸들러(`GET /track/:id`)");
79
+ /** 흐름 알림·사건(매뉴얼 「주문 흐름에서 앱 쓰기」) — 흐름 편집기가 알림 채널·매개변수와 사건 이름을 고른다 */
80
+ export const appFlowNotificationSummarySchema = z.object({
81
+ name: z.string(),
82
+ label: z.string(),
83
+ params: z.array(
84
+ z.object({
85
+ name: z.string(),
86
+ type: z.enum(["string", "number", "boolean"]),
87
+ label: z.string(),
88
+ required: z.boolean(),
89
+ }),
90
+ ),
91
+ });
92
+ export type AppFlowNotificationSummary = z.infer<typeof appFlowNotificationSummarySchema>;
93
+ export const appFlowEventSummarySchema = z.object({ name: z.string(), label: z.string() });
94
+ export type AppFlowEventSummary = z.infer<typeof appFlowEventSummarySchema>;
95
+
79
96
  const schedulesField = z
80
97
  .array(appScheduleSummarySchema)
81
98
  .default([])
82
99
  .describe("주기적으로 실행되는 작업");
83
100
 
101
+ const toolsField = z
102
+ .array(z.object({ name: z.string(), description: z.string() }))
103
+ .default([])
104
+ .describe("AI 에이전트 도구");
105
+ const externalHostField = z
106
+ .string()
107
+ .nullable()
108
+ .default(null)
109
+ .describe("개발자 서버에서 실행하는 앱의 호출 호스트. sayren이 실행하면 null");
110
+
84
111
  export const appInstallationSchema = z.object({
85
112
  installationId: z.string(),
86
113
  storeId: z.string(),
@@ -100,6 +127,8 @@ export const appInstallationSchema = z.object({
100
127
  events: z.array(appEventTypeSchema),
101
128
  publicHttp: publicHttpField,
102
129
  schedules: schedulesField,
130
+ tools: toolsField,
131
+ externalHost: externalHostField,
103
132
  })
104
133
  .nullable()
105
134
  .optional()
@@ -110,6 +139,16 @@ export const appInstallationSchema = z.object({
110
139
  adminPage: z.boolean().describe("상점 플랫폼 앱 화면이 있는가"),
111
140
  publicHttp: publicHttpField,
112
141
  schedules: schedulesField,
142
+ tools: toolsField,
143
+ externalHost: externalHostField,
144
+ flowNotifications: z
145
+ .array(appFlowNotificationSummarySchema)
146
+ .default([])
147
+ .describe("주문 흐름에서 이 앱으로 보낼 수 있는 알림"),
148
+ flowEvents: z
149
+ .array(appFlowEventSummarySchema)
150
+ .default([])
151
+ .describe("이 앱이 주문 흐름에 보내는 사건(`app.<handle>.<이름>`)"),
113
152
  /** 설치 결과의 캐시가 아니라 실행 버전 매니페스트의 요약 */
114
153
  settings: appInstallationSettingsSchema,
115
154
  installedBy: z.object({ userId: z.string(), name: z.string().nullable() }).nullable(),
@@ -197,6 +236,8 @@ export const storeAppDetailSchema = storeAppSchema.extend({
197
236
  networkHosts: z.array(z.string()).describe("앱이 데이터를 보내는 외부 호스트"),
198
237
  publicHttp: publicHttpField,
199
238
  schedules: schedulesField,
239
+ tools: toolsField,
240
+ externalHost: externalHostField,
200
241
  settings: z.record(z.string(), appSettingFieldSchema).describe("설치 때 채우는 설정 필드"),
201
242
  updatedAt: z.string().nullable().describe("현재 버전을 release한 시각"),
202
243
  });
@@ -222,6 +263,33 @@ export const appInstallationSelfSchema = z.object({
222
263
  });
223
264
  export type AppInstallationSelf = z.infer<typeof appInstallationSelfSchema>;
224
265
 
266
+ /** 에이전트 도구(매뉴얼 「에이전트 도구」) — `GET /v1/apps/tools` 한 줄 */
267
+ export const appToolSummarySchema = z.object({
268
+ installationId: z.string(),
269
+ app: z.object({ appId: z.string(), handle: z.string(), name: z.string() }),
270
+ name: z.string(),
271
+ description: z.string(),
272
+ input: z.array(
273
+ z.object({
274
+ name: z.string(),
275
+ type: z.enum(["string", "number", "boolean"]),
276
+ description: z.string().nullable(),
277
+ required: z.boolean(),
278
+ }),
279
+ ),
280
+ });
281
+ export type AppToolSummary = z.infer<typeof appToolSummarySchema>;
282
+
283
+ export const appToolCallSchema = z.object({
284
+ input: z.record(z.string(), z.unknown()).default({}),
285
+ });
286
+ export type AppToolCall = z.input<typeof appToolCallSchema>;
287
+
288
+ export const appToolResultSchema = z.object({
289
+ result: z.unknown().describe("앱 도구가 돌려준 값(JSON)"),
290
+ });
291
+ export type AppToolResult = z.infer<typeof appToolResultSchema>;
292
+
225
293
  /** 설치 저장소(`ctx.storage`, 매뉴얼 「앱 코드 › 저장소」) — 설치 토큰 전용 */
226
294
  export const APP_STORAGE_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
227
295
  export const APP_STORAGE_MAX_KEYS = 1_000;
@@ -377,7 +445,7 @@ export type DeveloperAppCreate = z.infer<typeof developerAppCreateSchema>;
377
445
 
378
446
  /** `PUT /platform/v1/developer/apps/{appId}/versions` — CLI `app deploy`가 올리는 번들 */
379
447
  export const appArtifactUploadSchema = z.object({
380
- manifest: z.unknown().describe("sayren.app.json 원문(JSON)"),
448
+ manifest: z.unknown().describe("manifest.json 원문(JSON)"),
381
449
  packageJson: z.string().max(256 * 1024),
382
450
  lockfile: z
383
451
  .string()
@@ -464,7 +532,7 @@ export const appInvocationSchema = z.object({
464
532
  storeName: z.string().nullable(),
465
533
  installationId: z.string(),
466
534
  versionNumber: z.number().int().nullable(),
467
- kind: z.enum(["event", "http", "schedule"]),
535
+ kind: z.enum(["event", "http", "schedule", "flow", "tool"]),
468
536
  eventType: z.string().nullable(),
469
537
  outcome: z.enum(["OK", "ERROR", "LIMIT_EXCEEDED", "REJECTED"]),
470
538
  errorMessage: z.string().nullable(),
@@ -6,8 +6,14 @@ import { z } from "zod";
6
6
  * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
7
7
  * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
8
8
  * - `flow-attachment`: 주문 흐름 행동의 첨부(시안 등, 이슈 #115). 구매자에게 보이므로 정지 이미지와 PDF만 받는다.
9
+ * - `ai-attachment`: 상점 플랫폼 AI 채팅에 붙이는 자료(이슈 #124). 모델이 읽는 정지 이미지와 PDF만 받는다.
9
10
  */
10
- export const uploadPurposeSchema = z.enum(["product-image", "description", "flow-attachment"]);
11
+ export const uploadPurposeSchema = z.enum([
12
+ "product-image",
13
+ "description",
14
+ "flow-attachment",
15
+ "ai-attachment",
16
+ ]);
11
17
 
12
18
  export type UploadPurpose = z.infer<typeof uploadPurposeSchema>;
13
19
 
@@ -39,6 +45,11 @@ export const UPLOAD_PURPOSE_LIMITS: Record<UploadPurpose, UploadPurposeLimit> =
39
45
  extensions: ["jpg", "jpeg", "png", "webp", "avif", "gif", "pdf"],
40
46
  maxBytes: 20 * MB,
41
47
  },
48
+ // 모델이 받는 형식(jpeg·png·webp·gif, PDF)만 받는다
49
+ "ai-attachment": {
50
+ extensions: ["jpg", "jpeg", "png", "webp", "gif", "pdf"],
51
+ maxBytes: 10 * MB,
52
+ },
42
53
  };
43
54
 
44
55
  /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */