@sayren/store-sdk 0.30.11 → 0.30.13

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.
@@ -233,6 +233,79 @@ export const APP_SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
233
233
  export const APP_SECRETS_MAX = 20;
234
234
  export const APP_EVENTS_MAX = 50;
235
235
  export const APP_NETWORK_HOSTS_MAX = 20;
236
+ export const APP_PUBLIC_HTTP_MAX = 20;
237
+ export const APP_SCHEDULES_MAX = 10;
238
+
239
+ /** HTTP 핸들러 키 — `"<메서드> <경로>"`(`@sayren/app` `defineApp({ http })`와 같은 형식) */
240
+ export const APP_HTTP_KEY_PATTERN = /^(GET|POST|PUT|PATCH|DELETE) (\/[A-Za-z0-9._~:/-]*)$/;
241
+ /** 예약 작업 이름 — 설정 키와 같은 형식 */
242
+ export const APP_SCHEDULE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/;
243
+ /** 예약 작업 간격 → 분 */
244
+ export const APP_SCHEDULE_INTERVALS = {
245
+ "15m": 15,
246
+ "30m": 30,
247
+ "1h": 60,
248
+ "3h": 180,
249
+ "6h": 360,
250
+ "12h": 720,
251
+ "1d": 1440,
252
+ } as const;
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>;
305
+ const APP_SCHEDULE_INTERVAL_KEYS = Object.keys(APP_SCHEDULE_INTERVALS) as [
306
+ AppScheduleInterval,
307
+ ...AppScheduleInterval[],
308
+ ];
236
309
 
237
310
  /** 앱 목록에 보이는 아이콘·README(#22 앱 목록 시안) — 앱 폴더의 파일을 `sayren app deploy`가 버전과 함께 올린다 */
238
311
  export const APP_ICON_MAX_BYTES = 256 * 1024;
@@ -284,8 +357,21 @@ export const appManifestSchema = z.strictObject({
284
357
  .regex(APP_FOLDER_FILE)
285
358
  .refine((path) => /\.md$/i.test(path), { message: "README는 .md 파일입니다" })
286
359
  .optional(),
287
- /** 실행 위치 — A1은 코드 업로드 앱만. 외부 런타임(`external`)은 A3에서 연다 */
288
- 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(),
289
375
  /** 설치 때 전체 동의하는 스코프 */
290
376
  scopes: z.array(appScopeSchema).max(32).default([]),
291
377
  network: z
@@ -299,6 +385,39 @@ export const appManifestSchema = z.strictObject({
299
385
  adminPage: z.strictObject({ entry: z.string().regex(/^ui\/[A-Za-z0-9._/-]+\.html$/) }),
300
386
  })
301
387
  .optional(),
388
+ /** 공개 HTTP — 여기 적은 핸들러만 `https://{handle}.{앱 도메인}/hooks/{설치 id}/…`로 외부에서 부를 수 있다 */
389
+ http: z
390
+ .strictObject({
391
+ public: z.array(z.string().regex(APP_HTTP_KEY_PATTERN)).max(APP_PUBLIC_HTTP_MAX),
392
+ })
393
+ .optional(),
394
+ /** 예약 작업 — 이름마다 실행 간격 */
395
+ schedules: z
396
+ .record(
397
+ z.string().regex(APP_SCHEDULE_NAME_PATTERN),
398
+ z.strictObject({ every: z.enum(APP_SCHEDULE_INTERVAL_KEYS) }),
399
+ )
400
+ .refine((schedules) => Object.keys(schedules).length <= APP_SCHEDULES_MAX, {
401
+ message: `예약 작업은 ${APP_SCHEDULES_MAX}개까지입니다`,
402
+ })
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(),
302
421
  settings: z
303
422
  .record(z.string().regex(APP_SETTING_KEY_PATTERN), appSettingFieldSchema)
304
423
  .refine((fields) => Object.keys(fields).length <= APP_SETTINGS_MAX_FIELDS, {
@@ -331,7 +450,10 @@ export interface AppManifestProblem {
331
450
  | "SELECT_OPTIONS_REQUIRED"
332
451
  | "SECRET_FIELD_TYPE"
333
452
  | "DEFAULT_TYPE_MISMATCH"
334
- | "DUPLICATE";
453
+ | "DUPLICATE"
454
+ | "EXTERNAL_URL_REQUIRED"
455
+ | "EXTERNAL_NETWORK_HOSTS"
456
+ | "EXTERNAL_URL_INVALID";
335
457
  message: string;
336
458
  }
337
459
 
@@ -351,6 +473,29 @@ export function appManifestProblems(manifest: AppManifest): AppManifestProblem[]
351
473
  unique(manifest.events, "events", "이벤트");
352
474
  unique(manifest.network.hosts, "network.hosts", "호스트");
353
475
  unique(manifest.secrets, "secrets", "비밀 값 이름");
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
+ }
354
499
  for (const event of manifest.events) {
355
500
  if (!scopesCoverEvent(manifest.scopes, event)) {
356
501
  problems.push({
@@ -399,6 +544,17 @@ export interface AppCapabilities {
399
544
  events: AppEventType[];
400
545
  networkHosts: string[];
401
546
  adminPage: boolean;
547
+ /** 공개 HTTP 핸들러 키(`"GET /track/:id"`). 옛 버전 행에는 없다 — 읽을 때 `?? []` */
548
+ publicHttp?: string[];
549
+ /** 예약 작업 이름 → 간격. 옛 버전 행에는 없다 — 읽을 때 `?? {}` */
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>;
402
558
  /** 필수 설정 키 — 하나라도 비어 있으면 이벤트 함수를 부르지 않는다 */
403
559
  requiredSettings: string[];
404
560
  secretSettings: string[];
@@ -411,6 +567,16 @@ export function appCapabilitiesOf(manifest: AppManifest): AppCapabilities {
411
567
  events: sorted(manifest.events) as AppEventType[],
412
568
  networkHosts: sorted(manifest.network.hosts),
413
569
  adminPage: Boolean(manifest.ui?.adminPage),
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 ?? {},
575
+ schedules: Object.fromEntries(
576
+ Object.entries(manifest.schedules)
577
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
578
+ .map(([name, schedule]) => [name, schedule.every]),
579
+ ),
414
580
  requiredSettings: Object.entries(manifest.settings)
415
581
  .filter(([, field]) => field.required)
416
582
  .map(([key]) => key)
@@ -427,7 +593,7 @@ export function appCapabilitiesOf(manifest: AppManifest): AppCapabilities {
427
593
  * 동의 문단은 accounts 설치 화면·상점 플랫폼 앱 상세·유저 문서가 같은 문장을 쓴다.
428
594
  */
429
595
  export interface AppCapabilityDefinition {
430
- key: "scopes" | "network" | "events" | "ui";
596
+ key: "scopes" | "network" | "events" | "ui" | "publicHttp" | "schedules" | "runtime" | "tools";
431
597
  /** 동의 화면의 문단 제목 */
432
598
  title: string;
433
599
  /** 동의 화면에 보일 줄 — 없으면 문단을 그리지 않는다 */
@@ -473,8 +639,114 @@ export const APP_CAPABILITIES: readonly AppCapabilityDefinition[] = [
473
639
  describe: (c) => (c.adminPage ? ["상점 플랫폼에 이 앱의 화면을 더합니다"] : []),
474
640
  widens: (p, n) => !p.adminPage && n.adminPage,
475
641
  },
642
+ {
643
+ key: "publicHttp",
644
+ title: "외부에서 이 앱을 부르는 주소",
645
+ describe: (c) => c.publicHttp ?? [],
646
+ widens: (p, n) => grew(p.publicHttp ?? [], n.publicHttp ?? []),
647
+ },
648
+ {
649
+ key: "schedules",
650
+ title: "주기적으로 실행되는 작업",
651
+ describe: (c) =>
652
+ Object.entries(c.schedules ?? {}).map(
653
+ ([name, every]) => `${name} · ${appScheduleIntervalLabel(every)}마다`,
654
+ ),
655
+ widens: (p, n) => {
656
+ const previous = p.schedules ?? {};
657
+ return Object.entries(n.schedules ?? {}).some(
658
+ ([name, every]) =>
659
+ !previous[name] ||
660
+ APP_SCHEDULE_INTERVALS[every] <
661
+ APP_SCHEDULE_INTERVALS[previous[name] as AppScheduleInterval],
662
+ );
663
+ },
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
+ },
476
678
  ];
477
679
 
680
+ /** 요청(메서드·경로)이 HTTP 핸들러 키에 맞는가 — `:이름`은 경로 한 마디에 맞는다(`@sayren/app`의 핸들러 고르기와 같은 규칙) */
681
+ export function appHttpKeyMatches(key: string, method: string, path: string): boolean {
682
+ const match = APP_HTTP_KEY_PATTERN.exec(key.trim().replace(/\s+/g, " "));
683
+ if (!match || match[1] !== method.toUpperCase()) return false;
684
+ const pattern = (match[2] as string).split("/");
685
+ const actual = (path.split("?")[0] ?? path).split("/");
686
+ if (pattern.length !== actual.length) return false;
687
+ return pattern.every((part, i) => (part.startsWith(":") ? actual[i] !== "" : part === actual[i]));
688
+ }
689
+
690
+ /** 응답용 예약 작업 목록(이름순) — 옛 버전 행(`schedules` 없음)은 빈 목록 */
691
+ export function appScheduleListOf(
692
+ capabilities: Pick<AppCapabilities, "schedules">,
693
+ ): Array<{ name: string; every: AppScheduleInterval }> {
694
+ // jsonb는 키를 길이순으로 저장한다 — 이름순으로 맞춘다
695
+ return Object.entries(capabilities.schedules ?? {})
696
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
697
+ .map(([name, every]) => ({ name, every }));
698
+ }
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
+
742
+ /** 예약 간격의 표시 이름 — 동의 화면·상점 플랫폼이 같이 쓴다 */
743
+ export function appScheduleIntervalLabel(every: AppScheduleInterval): string {
744
+ const minutes = APP_SCHEDULE_INTERVALS[every];
745
+ if (minutes < 60) return `${minutes}분`;
746
+ if (minutes < 1440) return `${minutes / 60}시간`;
747
+ return "하루";
748
+ }
749
+
478
750
  /** 재동의가 필요한 capability 키 — 비어 있으면 release가 모든 설치에 곧바로 반영된다 */
479
751
  export function appManifestWidens(
480
752
  previous: AppCapabilities,
@@ -29,6 +29,10 @@ export const appInstallationStatusSchema = z.enum(["ACTIVE", "UNINSTALLED", "SUS
29
29
  export type AppInstallationStatus = z.infer<typeof appInstallationStatusSchema>;
30
30
 
31
31
  /** 설치 앱의 앱 정보 — 상점이 보는 범위만(개발자 비밀 값·소스·빌드는 없다) */
32
+ /** 상점이 보는 앱 공개 범위 — 개발 중(개발자 조직) · 이 상점 전용 · 공개 */
33
+ export const appVisibilitySchema = z.enum(["DEVELOPMENT", "PRIVATE", "PUBLIC"]);
34
+ export type AppVisibility = z.infer<typeof appVisibilitySchema>;
35
+
32
36
  export const appInstallationAppSchema = z.object({
33
37
  appId: z.string(),
34
38
  handle: z.string().describe("앱 handle. 앱 화면 주소의 라벨"),
@@ -61,6 +65,49 @@ export const appInstallationSettingsUpdateSchema = z.object({
61
65
  });
62
66
  export type AppInstallationSettingsUpdate = z.infer<typeof appInstallationSettingsUpdateSchema>;
63
67
 
68
+ /** 예약 작업 한 줄 — 매니페스트 `schedules`의 이름과 간격 */
69
+ export const appScheduleSummarySchema = z.object({
70
+ name: z.string(),
71
+ every: z.enum(["15m", "30m", "1h", "3h", "6h", "12h", "1d"]),
72
+ });
73
+ export type AppScheduleSummary = z.infer<typeof appScheduleSummarySchema>;
74
+
75
+ const publicHttpField = z
76
+ .array(z.string())
77
+ .default([])
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
+
96
+ const schedulesField = z
97
+ .array(appScheduleSummarySchema)
98
+ .default([])
99
+ .describe("주기적으로 실행되는 작업");
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
+
64
111
  export const appInstallationSchema = z.object({
65
112
  installationId: z.string(),
66
113
  storeId: z.string(),
@@ -78,6 +125,10 @@ export const appInstallationSchema = z.object({
78
125
  scopes: z.array(z.string()),
79
126
  networkHosts: z.array(z.string()),
80
127
  events: z.array(appEventTypeSchema),
128
+ publicHttp: publicHttpField,
129
+ schedules: schedulesField,
130
+ tools: toolsField,
131
+ externalHost: externalHostField,
81
132
  })
82
133
  .nullable()
83
134
  .optional()
@@ -86,6 +137,18 @@ export const appInstallationSchema = z.object({
86
137
  grantedNetworkHosts: z.array(z.string()).describe("동의한 외부 호출 호스트"),
87
138
  events: z.array(appEventTypeSchema).describe("이 설치가 받는 이벤트"),
88
139
  adminPage: z.boolean().describe("상점 플랫폼 앱 화면이 있는가"),
140
+ publicHttp: publicHttpField,
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>.<이름>`)"),
89
152
  /** 설치 결과의 캐시가 아니라 실행 버전 매니페스트의 요약 */
90
153
  settings: appInstallationSettingsSchema,
91
154
  installedBy: z.object({ userId: z.string(), name: z.string().nullable() }).nullable(),
@@ -158,6 +221,9 @@ export const storeAppSchema = z.object({
158
221
  developerName: z.string(),
159
222
  versionNumber: z.number().int().describe("현재 버전"),
160
223
  installationId: z.string().nullable().describe("이 상점에 설치돼 있으면 설치 id"),
224
+ visibility: appVisibilitySchema
225
+ .default("DEVELOPMENT")
226
+ .describe("공개 범위 — 개발 중(심사 전) · 이 상점 전용 · 공개(심사 통과)"),
161
227
  });
162
228
  export type StoreApp = z.infer<typeof storeAppSchema>;
163
229
 
@@ -168,6 +234,10 @@ export const storeAppDetailSchema = storeAppSchema.extend({
168
234
  scopes: z.array(z.string()).describe("설치 때 동의하는 스코프(Scope)"),
169
235
  events: z.array(appEventTypeSchema).describe("받는 이벤트(Hook)"),
170
236
  networkHosts: z.array(z.string()).describe("앱이 데이터를 보내는 외부 호스트"),
237
+ publicHttp: publicHttpField,
238
+ schedules: schedulesField,
239
+ tools: toolsField,
240
+ externalHost: externalHostField,
171
241
  settings: z.record(z.string(), appSettingFieldSchema).describe("설치 때 채우는 설정 필드"),
172
242
  updatedAt: z.string().nullable().describe("현재 버전을 release한 시각"),
173
243
  });
@@ -193,6 +263,60 @@ export const appInstallationSelfSchema = z.object({
193
263
  });
194
264
  export type AppInstallationSelf = z.infer<typeof appInstallationSelfSchema>;
195
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
+
293
+ /** 설치 저장소(`ctx.storage`, 매뉴얼 「앱 코드 › 저장소」) — 설치 토큰 전용 */
294
+ export const APP_STORAGE_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
295
+ export const APP_STORAGE_MAX_KEYS = 1_000;
296
+ /** 설치당 전체 크기(키 + JSON 값, UTF-8 바이트) */
297
+ export const APP_STORAGE_MAX_BYTES = 1024 * 1024;
298
+
299
+ export const appStorageEntrySchema = z.object({
300
+ key: z.string(),
301
+ value: z.unknown().describe("저장한 JSON 값"),
302
+ updatedAt: z.string(),
303
+ });
304
+ export type AppStorageEntry = z.infer<typeof appStorageEntrySchema>;
305
+
306
+ export const appStorageKeysSchema = z.object({
307
+ keys: z.array(z.string()).describe("키 목록(이름순)"),
308
+ });
309
+ export type AppStorageKeys = z.infer<typeof appStorageKeysSchema>;
310
+
311
+ export const appStoragePutSchema = z.object({
312
+ value: z.unknown().refine((value) => value !== undefined, { message: "value가 필요합니다" }),
313
+ });
314
+ export type AppStoragePut = z.infer<typeof appStoragePutSchema>;
315
+
316
+ export const appStorageListQuerySchema = z.object({
317
+ prefix: z.string().max(200).optional(),
318
+ });
319
+
196
320
  export type { AppCapabilities };
197
321
 
198
322
  // ─── 개발자 API (플랫폼 플레인 `/platform/v1/developer/**`, 공개 문서 제외 — CLI·상점 플랫폼 개발자 화면이 쓴다) ───
@@ -249,6 +373,8 @@ export const appVersionSchema = z.object({
249
373
  events: z.array(z.string()),
250
374
  networkHosts: z.array(z.string()),
251
375
  adminPage: z.boolean(),
376
+ publicHttp: z.array(z.string()).optional(),
377
+ schedules: z.record(z.string(), z.string()).optional(),
252
378
  requiredSettings: z.array(z.string()),
253
379
  secretSettings: z.array(z.string()),
254
380
  }),
@@ -264,6 +390,19 @@ export const appVersionSchema = z.object({
264
390
  });
265
391
  export type AppVersion = z.infer<typeof appVersionSchema>;
266
392
 
393
+ /** 앱 공개 심사(#22, 매뉴얼 「앱 공개 범위」) — 개발자가 신청하고 운영자가 승인·반려한다 */
394
+ export const appListingReviewStatusSchema = z.enum(["PENDING", "APPROVED", "REJECTED"]);
395
+ export type AppListingReviewStatus = z.infer<typeof appListingReviewStatusSchema>;
396
+ export const appListingReviewSchema = z.object({
397
+ reviewId: z.string(),
398
+ status: appListingReviewStatusSchema,
399
+ versionNumber: z.number().int().describe("신청 때 현재 버전"),
400
+ reason: z.string().nullable().describe("반려 사유"),
401
+ requestedAt: z.string(),
402
+ reviewedAt: z.string().nullable(),
403
+ });
404
+ export type AppListingReview = z.infer<typeof appListingReviewSchema>;
405
+
267
406
  export const developerAppSchema = z.object({
268
407
  appId: z.string(),
269
408
  orgId: z.string(),
@@ -277,6 +416,15 @@ export const developerAppSchema = z.object({
277
416
  status: appStatusSchema,
278
417
  suspendedReason: z.string().nullable(),
279
418
  currentVersionNumber: z.number().int().nullable(),
419
+ privateStoreId: z
420
+ .string()
421
+ .nullable()
422
+ .default(null)
423
+ .describe("비공개 앱이 묶인 상점. 개발 중·퍼블릭 앱은 null"),
424
+ listingReview: appListingReviewSchema
425
+ .nullable()
426
+ .default(null)
427
+ .describe("마지막 공개 신청. 없으면 null"),
280
428
  installations: z.object({
281
429
  active: z.number().int(),
282
430
  pendingReconsent: z.number().int(),
@@ -290,6 +438,8 @@ export const developerAppCreateSchema = z.object({
290
438
  handle: appHandleSchema,
291
439
  name: z.string().trim().min(1).max(60),
292
440
  description: z.string().trim().max(500).optional(),
441
+ /** 비공개 앱 — 이 상점 하나에만 설치된다. 만드는 사람이 그 상점의 OWNER·ADMIN이어야 한다 */
442
+ privateStoreId: z.string().min(1).optional(),
293
443
  });
294
444
  export type DeveloperAppCreate = z.infer<typeof developerAppCreateSchema>;
295
445
 
@@ -345,6 +495,15 @@ export const devStoreSchema = z.object({
345
495
  });
346
496
  export type DevStore = z.infer<typeof devStoreSchema>;
347
497
 
498
+ /** `GET /platform/v1/developer/stores` — 비공개 앱을 묶을 수 있는 상점(내가 소유자·관리자, 운영 중) */
499
+ export const developerStoreSchema = z.object({
500
+ storeId: z.string(),
501
+ name: z.string(),
502
+ role: z.enum(["OWNER", "ADMIN"]),
503
+ development: z.boolean().describe("개발 상점인가"),
504
+ });
505
+ export type DeveloperStore = z.infer<typeof developerStoreSchema>;
506
+
348
507
  /** 개발자가 보는 설치 한 줄 */
349
508
  export const developerInstallationSchema = z.object({
350
509
  installationId: z.string(),
@@ -373,7 +532,7 @@ export const appInvocationSchema = z.object({
373
532
  storeName: z.string().nullable(),
374
533
  installationId: z.string(),
375
534
  versionNumber: z.number().int().nullable(),
376
- kind: z.enum(["event", "http", "schedule"]),
535
+ kind: z.enum(["event", "http", "schedule", "flow", "tool"]),
377
536
  eventType: z.string().nullable(),
378
537
  outcome: z.enum(["OK", "ERROR", "LIMIT_EXCEEDED", "REJECTED"]),
379
538
  errorMessage: z.string().nullable(),