@sayren/store-sdk 0.30.7 → 0.30.9
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/index.d.mts +17663 -16393
- package/dist/index.mjs +1304 -460
- package/package.json +1 -1
- package/src/client.ts +57 -0
- package/src/index.ts +4 -0
- package/src/schemas/app-manifest.ts +407 -0
- package/src/schemas/apps.ts +310 -0
- package/src/schemas/misc.ts +15 -2
- package/src/schemas/payment.ts +4 -1
- package/src/schemas/scope-descriptions.ts +34 -0
- package/src/schemas/starter.ts +173 -0
- package/src/schemas/store.ts +11 -0
- package/src/schemas/storefront-hosting.ts +78 -2
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -21,6 +21,14 @@ import {
|
|
|
21
21
|
analyticsSourcesSchema,
|
|
22
22
|
} from "./schemas/analytics";
|
|
23
23
|
import { type ApiLogListOptions, apiLogPageSchema, apiLogSummarySchema } from "./schemas/api-log";
|
|
24
|
+
import {
|
|
25
|
+
type AppInstallationSettingsUpdate,
|
|
26
|
+
appInstallationSchema,
|
|
27
|
+
appInstallationSelfSchema,
|
|
28
|
+
appInstallationSettingsUpdateSchema,
|
|
29
|
+
appInstallRequestSchema,
|
|
30
|
+
appSessionSchema,
|
|
31
|
+
} from "./schemas/apps";
|
|
24
32
|
import {
|
|
25
33
|
type ApprovalDecisionRequest,
|
|
26
34
|
type ApprovalListOptions,
|
|
@@ -336,7 +344,10 @@ import {
|
|
|
336
344
|
storefrontVerifyReportSchema,
|
|
337
345
|
storefrontVersionCreatedSchema,
|
|
338
346
|
storefrontVersionSchema,
|
|
347
|
+
storeTemplateSchema,
|
|
348
|
+
type UpdateStoreTemplateRequest,
|
|
339
349
|
type UploadStorefrontSourceBlobsRequest,
|
|
350
|
+
updateStoreTemplateRequestSchema,
|
|
340
351
|
uploadStorefrontSourceBlobsRequestSchema,
|
|
341
352
|
type WriteStorefrontFilesRequest,
|
|
342
353
|
writeStorefrontFilesRequestSchema,
|
|
@@ -1419,6 +1430,45 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
1419
1430
|
),
|
|
1420
1431
|
},
|
|
1421
1432
|
|
|
1433
|
+
/** 설치 앱(#22, 매뉴얼 「앱 설치와 관리 › 관리 API」) — 설치·설정·제거·세션은 상점 플랫폼 OWNER·ADMIN만 */
|
|
1434
|
+
apps: {
|
|
1435
|
+
installations: {
|
|
1436
|
+
/** 설치 결과(accounts 동의 화면의 1회용 JWT)를 내 설치를 만든다 */
|
|
1437
|
+
install: (result: string) =>
|
|
1438
|
+
storeHttp.request("POST", "/apps/installations", appInstallationSchema, {
|
|
1439
|
+
body: appInstallRequestSchema.parse({ result }),
|
|
1440
|
+
}),
|
|
1441
|
+
list: () => storeHttp.request("GET", "/apps/installations", z.array(appInstallationSchema)),
|
|
1442
|
+
get: (installationId: string) =>
|
|
1443
|
+
storeHttp.request(
|
|
1444
|
+
"GET",
|
|
1445
|
+
path`/apps/installations/${installationId}`,
|
|
1446
|
+
appInstallationSchema,
|
|
1447
|
+
),
|
|
1448
|
+
/** 비밀 필드를 생략하면 현재 값을 유지한다. null은 비운다 */
|
|
1449
|
+
updateSettings: (installationId: string, body: AppInstallationSettingsUpdate) =>
|
|
1450
|
+
storeHttp.request(
|
|
1451
|
+
"PUT",
|
|
1452
|
+
path`/apps/installations/${installationId}/settings`,
|
|
1453
|
+
appInstallationSchema,
|
|
1454
|
+
{ body: appInstallationSettingsUpdateSchema.parse(body) },
|
|
1455
|
+
),
|
|
1456
|
+
remove: (installationId: string) =>
|
|
1457
|
+
storeHttp.requestVoid("DELETE", path`/apps/installations/${installationId}`),
|
|
1458
|
+
/** 앱 화면 iframe에 넘길 세션 토큰(5분) */
|
|
1459
|
+
session: (installationId: string) =>
|
|
1460
|
+
storeHttp.request(
|
|
1461
|
+
"POST",
|
|
1462
|
+
path`/apps/installations/${installationId}/session`,
|
|
1463
|
+
appSessionSchema,
|
|
1464
|
+
),
|
|
1465
|
+
},
|
|
1466
|
+
},
|
|
1467
|
+
/** 앱 설치 토큰 전용 — 자기 설치 */
|
|
1468
|
+
app: {
|
|
1469
|
+
installation: () => storeHttp.request("GET", "/app/installation", appInstallationSelfSchema),
|
|
1470
|
+
},
|
|
1471
|
+
|
|
1422
1472
|
webhooks: {
|
|
1423
1473
|
list: () => storeHttp.request("GET", "/webhooks", z.array(webhookSchema)),
|
|
1424
1474
|
/** 서명 시크릿 평문은 이 응답에서만 노출된다 */
|
|
@@ -1615,6 +1665,13 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
1615
1665
|
/** `GET /v1/storefront/templates` — 템플릿 카탈로그 */
|
|
1616
1666
|
listTemplates: () =>
|
|
1617
1667
|
storeHttp.request("GET", "/storefront/templates", z.array(storefrontTemplateSchema)),
|
|
1668
|
+
/** `GET /v1/storefront/template` — 템플릿 상점의 템플릿 정보(#123). 템플릿 상점이 아니면 404 `TEMPLATE_STORE_NOT_FOUND` */
|
|
1669
|
+
getStoreTemplate: () => storeHttp.request("GET", "/storefront/template", storeTemplateSchema),
|
|
1670
|
+
/** `PUT /v1/storefront/template` — 템플릿 이름·소개·썸네일을 바꾼다(템플릿 상점만) */
|
|
1671
|
+
updateStoreTemplate: (body: UpdateStoreTemplateRequest) =>
|
|
1672
|
+
storeHttp.request("PUT", "/storefront/template", storeTemplateSchema, {
|
|
1673
|
+
body: updateStoreTemplateRequestSchema.parse(body),
|
|
1674
|
+
}),
|
|
1618
1675
|
/** `GET /v1/storefront/site` — 스위치 상태와 현재 사이트(없으면 `site: null`) */
|
|
1619
1676
|
get: () => storeHttp.request("GET", "/storefront/site", storefrontHostingSchema),
|
|
1620
1677
|
/** `GET /v1/storefront/slug-availability?slug=` — 슬러그를 쓸 수 있는가 */
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export {
|
|
|
18
18
|
} from "./oauth";
|
|
19
19
|
export * from "./schemas/analytics";
|
|
20
20
|
export * from "./schemas/api-log";
|
|
21
|
+
export * from "./schemas/app-manifest";
|
|
22
|
+
export * from "./schemas/apps";
|
|
21
23
|
export * from "./schemas/audit";
|
|
22
24
|
export * from "./schemas/auth";
|
|
23
25
|
export * from "./schemas/category";
|
|
@@ -40,7 +42,9 @@ export * from "./schemas/promotion";
|
|
|
40
42
|
export * from "./schemas/refund-policy";
|
|
41
43
|
export * from "./schemas/saved-option-set";
|
|
42
44
|
export * from "./schemas/scope";
|
|
45
|
+
export * from "./schemas/scope-descriptions";
|
|
43
46
|
export * from "./schemas/shipping-policy";
|
|
47
|
+
export * from "./schemas/starter";
|
|
44
48
|
export * from "./schemas/stay";
|
|
45
49
|
export * from "./schemas/store";
|
|
46
50
|
export * from "./schemas/storefront-hosting";
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isStoreScope, STORE_SCOPE_RESOURCES, type StoreScopeResource } from "./scope";
|
|
3
|
+
import { describeScope } from "./scope-descriptions";
|
|
4
|
+
import { KNOWN_WEBHOOK_EVENT_TYPES, type WebhookEventType } from "./webhook-event";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 서드파티 앱 매니페스트 `sayren.app.json` (이슈 #22) — CLI(`sayren app`)·api(빌드 `VERIFY`·버전 판정·동의 화면 자료)·런타임이
|
|
8
|
+
* 같은 원천을 쓴다. 매니페스트는 정적 JSON이고 빌드 없이 읽는다.
|
|
9
|
+
*
|
|
10
|
+
* 기능 하나는 **capability** 하나다(`APP_CAPABILITIES`). capability는 매니페스트 조각·동의 문단·넓어짐 판정(재동의 조건)을 한 곳에 둔다.
|
|
11
|
+
* 새 기능(예약 작업·공개 HTTP·설치 저장소·외부 런타임)은 여기에 항목 하나를 더하고, 설치·버전·정지·감사·사용량 코드는 건드리지 않는다.
|
|
12
|
+
* 모르는 키는 거부한다(`strictObject`) — 아직 없는 capability를 쓴 매니페스트는 빌드가 분명히 실패한다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 앱 handle — 전역 유일, 앱 도메인의 라벨(`{handle}.{앱 도메인}`). 상점 코드와 같은 형식, 3~40자 */
|
|
16
|
+
export const APP_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$/;
|
|
17
|
+
/** handle로 쓸 수 없는 라벨 — 플랫폼 호스트·혼동되는 이름 */
|
|
18
|
+
export const APP_RESERVED_HANDLES: ReadonlySet<string> = new Set([
|
|
19
|
+
"www",
|
|
20
|
+
"api",
|
|
21
|
+
"app",
|
|
22
|
+
"apps",
|
|
23
|
+
"admin",
|
|
24
|
+
"accounts",
|
|
25
|
+
"store",
|
|
26
|
+
"mcp",
|
|
27
|
+
"docs",
|
|
28
|
+
"sayren",
|
|
29
|
+
"preview",
|
|
30
|
+
"runner",
|
|
31
|
+
"static",
|
|
32
|
+
"cdn",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const appHandleSchema = z
|
|
36
|
+
.string()
|
|
37
|
+
.regex(APP_HANDLE_PATTERN, "앱 handle은 소문자·숫자·하이픈 3~40자입니다")
|
|
38
|
+
.refine((value) => !APP_RESERVED_HANDLES.has(value) && !value.endsWith("-preview"), {
|
|
39
|
+
message: "쓸 수 없는 handle입니다",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 설치 앱에 열지 않는 스코프 — 구성원(초대로 권한 상승), 상점 설정 전반(`storefrontOrigins`로 소셜 로그인 code 가로채기), 스토어 웹훅
|
|
44
|
+
* (앱 구독으로 대체, 제거 뒤에도 남는 문제), 스토어프론트 편집·발행, 감사 로그. 커스텀 연동은 계정 액세스 토큰(#71)이 맡는다
|
|
45
|
+
*/
|
|
46
|
+
export const NON_INSTALLABLE_SCOPES: ReadonlySet<string> = new Set([
|
|
47
|
+
"member:r",
|
|
48
|
+
"member:rw",
|
|
49
|
+
"store:rw",
|
|
50
|
+
"webhook:r",
|
|
51
|
+
"webhook:rw",
|
|
52
|
+
"storefront:r",
|
|
53
|
+
"storefront:rw",
|
|
54
|
+
"audit:r",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** 앱이 요청할 수 있는 스코프 — 카탈로그 전체에서 위를 뺀 것 */
|
|
58
|
+
export const INSTALLABLE_SCOPES: readonly string[] = (
|
|
59
|
+
Object.entries(STORE_SCOPE_RESOURCES) as [StoreScopeResource, readonly string[]][]
|
|
60
|
+
)
|
|
61
|
+
.flatMap(([resource, accesses]) => accesses.map((access) => `${resource}:${access}`))
|
|
62
|
+
.filter((scope) => !NON_INSTALLABLE_SCOPES.has(scope));
|
|
63
|
+
|
|
64
|
+
export function isInstallableScope(scope: string): boolean {
|
|
65
|
+
return isStoreScope(scope) && !NON_INSTALLABLE_SCOPES.has(scope);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const appScopeSchema = z
|
|
69
|
+
.string()
|
|
70
|
+
.refine(isInstallableScope, { message: "앱이 요청할 수 없는 스코프입니다" });
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 앱 생명주기 이벤트 — 웹훅 카탈로그 밖이고 앱 코드만 받는다. `APP.UNINSTALLED`·`APP.STORE_REDACT`는 설치가 끝난 뒤라 토큰 없이 온다
|
|
74
|
+
* (`APP.STORE_REDACT`는 제거 48시간 뒤).
|
|
75
|
+
*/
|
|
76
|
+
export const APP_LIFECYCLE_EVENT_TYPES = [
|
|
77
|
+
"APP.INSTALLED",
|
|
78
|
+
"APP.UNINSTALLED",
|
|
79
|
+
"APP.SETTINGS_UPDATED",
|
|
80
|
+
"APP.SCOPES_UPDATED",
|
|
81
|
+
"APP.STORE_REDACT",
|
|
82
|
+
"APP.CUSTOMER_REDACT",
|
|
83
|
+
] as const;
|
|
84
|
+
export type AppLifecycleEventType = (typeof APP_LIFECYCLE_EVENT_TYPES)[number];
|
|
85
|
+
export type AppEventType = WebhookEventType | AppLifecycleEventType;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 이벤트를 받는 데 필요한 스코프 — 동의 스코프가 이것을 덮지 못하면 분배하지 않고, 매니페스트 `scopes`가 덮지 못하면 빌드가 실패한다.
|
|
89
|
+
* 생명주기 이벤트는 스코프가 없다(null). 새 웹훅 이벤트를 더하면 이 표도 더해야 테스트가 통과한다
|
|
90
|
+
*/
|
|
91
|
+
export const APP_EVENT_SCOPES: Record<AppEventType, string | null> = {
|
|
92
|
+
"ORDER.PAID": "order:r",
|
|
93
|
+
"ORDER.CONFIRMED": "order:r",
|
|
94
|
+
"ORDER.DISPATCHED": "order:r",
|
|
95
|
+
"ORDER.DELIVERED": "order:r",
|
|
96
|
+
"ORDER.PURCHASE_DECIDED": "order:r",
|
|
97
|
+
"ORDER.ADDRESS_CHANGE_REQUESTED": "order:r",
|
|
98
|
+
"ORDER.FULFILLED": "order:r",
|
|
99
|
+
"ORDER.CANCELED": "order:r",
|
|
100
|
+
"ORDER.FLOW_NOTIFICATION": "order:r",
|
|
101
|
+
"ORDER.FLOW_STATE_CHANGED": "order:r",
|
|
102
|
+
"CLAIM.CANCEL_REQUESTED": "claim:r",
|
|
103
|
+
"CLAIM.RETURN_REQUESTED": "claim:r",
|
|
104
|
+
"CLAIM.EXCHANGE_REQUESTED": "claim:r",
|
|
105
|
+
"CLAIM.COMPLETED": "claim:r",
|
|
106
|
+
"INQUIRY.CREATED": "inquiry:r",
|
|
107
|
+
"REVIEW.CREATED": "review:r",
|
|
108
|
+
"PRODUCT.OUT_OF_STOCK": "product:r",
|
|
109
|
+
"APP.INSTALLED": null,
|
|
110
|
+
"APP.UNINSTALLED": null,
|
|
111
|
+
"APP.SETTINGS_UPDATED": null,
|
|
112
|
+
"APP.SCOPES_UPDATED": null,
|
|
113
|
+
"APP.STORE_REDACT": null,
|
|
114
|
+
"APP.CUSTOMER_REDACT": null,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const APP_EVENT_TYPES: readonly AppEventType[] = [
|
|
118
|
+
...KNOWN_WEBHOOK_EVENT_TYPES,
|
|
119
|
+
...APP_LIFECYCLE_EVENT_TYPES,
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export const appEventTypeSchema = z.enum(APP_EVENT_TYPES as [AppEventType, ...AppEventType[]]);
|
|
123
|
+
|
|
124
|
+
/** 동의 스코프가 이벤트를 받을 수 있는가 — `rw`는 `r`을 함의한다 */
|
|
125
|
+
export function scopesCoverEvent(scopes: readonly string[], event: AppEventType): boolean {
|
|
126
|
+
const required = APP_EVENT_SCOPES[event];
|
|
127
|
+
if (required === null) return true;
|
|
128
|
+
const [resource] = required.split(":");
|
|
129
|
+
return scopes.includes(required) || scopes.includes(`${resource}:rw`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 외부 호출 호스트 — https·443·소문자 호스트명. 와일드카드는 한 단계(`*.example.com`)만. IP·`localhost`·sayren 플랫폼 호스트는
|
|
134
|
+
* 받지 않는다(내부 호스트 차단은 outbound Worker가 다시 본다 — 여기는 매니페스트 형식). 253자 이하, 라벨 2개 이상
|
|
135
|
+
*/
|
|
136
|
+
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
137
|
+
const TLD = /^(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/;
|
|
138
|
+
export const APP_NETWORK_DENIED_SUFFIXES: readonly string[] = [
|
|
139
|
+
"sayren.app",
|
|
140
|
+
"sayren.co",
|
|
141
|
+
"avarlabs.com",
|
|
142
|
+
"localhost",
|
|
143
|
+
"local",
|
|
144
|
+
"internal",
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
export function appNetworkHostProblem(raw: string): string | null {
|
|
148
|
+
const host = raw.trim();
|
|
149
|
+
if (host !== raw || host !== host.toLowerCase())
|
|
150
|
+
return "호스트는 소문자이고 앞뒤 공백이 없어야 합니다";
|
|
151
|
+
if (host.length > 253) return "호스트가 너무 깁니다";
|
|
152
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":")) return "IP 주소는 쓸 수 없습니다";
|
|
153
|
+
const labels = host.split(".");
|
|
154
|
+
const wildcard = labels[0] === "*";
|
|
155
|
+
const named = wildcard ? labels.slice(1) : labels;
|
|
156
|
+
if (named.length < 2) return "호스트는 라벨이 둘 이상이어야 합니다";
|
|
157
|
+
if (!named.every((label) => HOST_LABEL.test(label))) return "호스트 형식이 맞지 않습니다";
|
|
158
|
+
if (!TLD.test(named[named.length - 1] as string)) return "최상위 도메인 형식이 맞지 않습니다";
|
|
159
|
+
const apex = named.join(".");
|
|
160
|
+
for (const suffix of APP_NETWORK_DENIED_SUFFIXES) {
|
|
161
|
+
if (apex === suffix || apex.endsWith(`.${suffix}`))
|
|
162
|
+
return "sayren 플랫폼·내부 호스트는 쓸 수 없습니다";
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const appNetworkHostSchema = z.string().superRefine((value, ctx) => {
|
|
168
|
+
const problem = appNetworkHostProblem(value);
|
|
169
|
+
if (problem) ctx.addIssue({ code: "custom", message: problem });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** 셀러가 콘솔에서 채우는 설정 필드 — 콘솔이 폼을 그릴 수 있는 네 형식만 */
|
|
173
|
+
export const APP_SETTING_FIELD_TYPES = ["string", "number", "boolean", "select"] as const;
|
|
174
|
+
export type AppSettingFieldType = (typeof APP_SETTING_FIELD_TYPES)[number];
|
|
175
|
+
|
|
176
|
+
export const APP_SETTINGS_MAX_FIELDS = 30;
|
|
177
|
+
export const APP_SETTING_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/;
|
|
178
|
+
|
|
179
|
+
export const appSettingFieldSchema = z.strictObject({
|
|
180
|
+
type: z.enum(APP_SETTING_FIELD_TYPES),
|
|
181
|
+
label: z.string().trim().min(1).max(60),
|
|
182
|
+
description: z.string().trim().max(200).optional(),
|
|
183
|
+
required: z.boolean().optional(),
|
|
184
|
+
/** 비밀 값 — 암호화 저장, 응답에서 가린다, 에이전트·토큰으로 읽을 수 없다. `string`만 */
|
|
185
|
+
secret: z.boolean().optional(),
|
|
186
|
+
/** `select`의 선택지 */
|
|
187
|
+
options: z
|
|
188
|
+
.array(
|
|
189
|
+
z.strictObject({ value: z.string().min(1).max(60), label: z.string().trim().min(1).max(60) }),
|
|
190
|
+
)
|
|
191
|
+
.min(1)
|
|
192
|
+
.max(50)
|
|
193
|
+
.optional(),
|
|
194
|
+
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
195
|
+
});
|
|
196
|
+
export type AppSettingField = z.infer<typeof appSettingFieldSchema>;
|
|
197
|
+
|
|
198
|
+
export const APP_SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
199
|
+
export const APP_SECRETS_MAX = 20;
|
|
200
|
+
export const APP_EVENTS_MAX = 50;
|
|
201
|
+
export const APP_NETWORK_HOSTS_MAX = 20;
|
|
202
|
+
|
|
203
|
+
export const appManifestSchema = z.strictObject({
|
|
204
|
+
handle: appHandleSchema,
|
|
205
|
+
name: z.string().trim().min(1).max(60),
|
|
206
|
+
description: z.string().trim().max(500).optional(),
|
|
207
|
+
/** https 이미지 주소. 동의 화면·상점 플랫폼 목록에 보인다 */
|
|
208
|
+
iconUrl: z.url().startsWith("https://").optional(),
|
|
209
|
+
supportEmail: z.email().optional(),
|
|
210
|
+
/** 실행 위치 — A1은 코드 업로드 앱만. 외부 런타임(`external`)은 A3에서 연다 */
|
|
211
|
+
runtime: z.literal("code").default("code"),
|
|
212
|
+
/** 설치 때 전체 동의하는 스코프 */
|
|
213
|
+
scopes: z.array(appScopeSchema).max(32).default([]),
|
|
214
|
+
network: z
|
|
215
|
+
.strictObject({ hosts: z.array(appNetworkHostSchema).max(APP_NETWORK_HOSTS_MAX) })
|
|
216
|
+
.default({ hosts: [] }),
|
|
217
|
+
/** 이벤트 함수 구독 */
|
|
218
|
+
events: z.array(appEventTypeSchema).max(APP_EVENTS_MAX).default([]),
|
|
219
|
+
ui: z
|
|
220
|
+
.strictObject({
|
|
221
|
+
/** 상점 플랫폼 앱 화면(iframe) — 정적 SPA 진입 파일 */
|
|
222
|
+
adminPage: z.strictObject({ entry: z.string().regex(/^ui\/[A-Za-z0-9._/-]+\.html$/) }),
|
|
223
|
+
})
|
|
224
|
+
.optional(),
|
|
225
|
+
settings: z
|
|
226
|
+
.record(z.string().regex(APP_SETTING_KEY_PATTERN), appSettingFieldSchema)
|
|
227
|
+
.refine((fields) => Object.keys(fields).length <= APP_SETTINGS_MAX_FIELDS, {
|
|
228
|
+
message: `설정 필드는 ${APP_SETTINGS_MAX_FIELDS}개까지입니다`,
|
|
229
|
+
})
|
|
230
|
+
.default({}),
|
|
231
|
+
/** 개발자 비밀 값 이름 — 값은 CLI(`sayren app secret put`)로 따로 넣는다 */
|
|
232
|
+
secrets: z.array(z.string().regex(APP_SECRET_NAME_PATTERN)).max(APP_SECRETS_MAX).default([]),
|
|
233
|
+
});
|
|
234
|
+
export type AppManifest = z.infer<typeof appManifestSchema>;
|
|
235
|
+
export type AppManifestInput = z.input<typeof appManifestSchema>;
|
|
236
|
+
|
|
237
|
+
/** 소스 안 비밀 값 형식(매뉴얼 「빌드」 `SECRET_IN_SOURCE`) — 번들에 들어가면 막는다. CLI `app verify`와 api 빌드 `BUNDLE`이 같은 표를 쓴다 */
|
|
238
|
+
export const APP_SECRET_PATTERNS: readonly RegExp[] = [
|
|
239
|
+
/sy_pat_[A-Za-z0-9]{8,}/,
|
|
240
|
+
/\b(?:test|live)_sk_[A-Za-z0-9]{8,}/,
|
|
241
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
242
|
+
/\bsak_[a-z0-9]+_[0-9a-f]{32}\b/,
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
/** 서버 번들 gzip 상한(3MiB)·화면 정적 파일 상한 — 매뉴얼 「빌드 › 한도」 */
|
|
246
|
+
export const APP_SERVER_BUNDLE_MAX_GZIP_BYTES = 3 * 1024 * 1024;
|
|
247
|
+
export const APP_UI_MAX_BYTES = 25 * 1024 * 1024;
|
|
248
|
+
export const APP_UI_MAX_FILES = 2_000;
|
|
249
|
+
|
|
250
|
+
export interface AppManifestProblem {
|
|
251
|
+
path: string;
|
|
252
|
+
code:
|
|
253
|
+
| "EVENT_SCOPE_MISSING"
|
|
254
|
+
| "SELECT_OPTIONS_REQUIRED"
|
|
255
|
+
| "SECRET_FIELD_TYPE"
|
|
256
|
+
| "DEFAULT_TYPE_MISMATCH"
|
|
257
|
+
| "DUPLICATE";
|
|
258
|
+
message: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 스키마가 보지 못하는 필드 사이 규칙 — 빌드 `VERIFY`·CLI `app verify`가 같은 결과를 낸다. 비어 있으면 통과 */
|
|
262
|
+
export function appManifestProblems(manifest: AppManifest): AppManifestProblem[] {
|
|
263
|
+
const problems: AppManifestProblem[] = [];
|
|
264
|
+
const unique = (values: readonly string[], path: string, label: string) => {
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
for (const value of values) {
|
|
267
|
+
if (seen.has(value)) {
|
|
268
|
+
problems.push({ path, code: "DUPLICATE", message: `${label}이 중복됐습니다: ${value}` });
|
|
269
|
+
}
|
|
270
|
+
seen.add(value);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
unique(manifest.scopes, "scopes", "스코프");
|
|
274
|
+
unique(manifest.events, "events", "이벤트");
|
|
275
|
+
unique(manifest.network.hosts, "network.hosts", "호스트");
|
|
276
|
+
unique(manifest.secrets, "secrets", "비밀 값 이름");
|
|
277
|
+
for (const event of manifest.events) {
|
|
278
|
+
if (!scopesCoverEvent(manifest.scopes, event)) {
|
|
279
|
+
problems.push({
|
|
280
|
+
path: `events.${event}`,
|
|
281
|
+
code: "EVENT_SCOPE_MISSING",
|
|
282
|
+
message: `${event} 이벤트를 받으려면 ${APP_EVENT_SCOPES[event]} 스코프가 필요합니다`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const [key, field] of Object.entries(manifest.settings)) {
|
|
287
|
+
if (field.type === "select" && !field.options) {
|
|
288
|
+
problems.push({
|
|
289
|
+
path: `settings.${key}`,
|
|
290
|
+
code: "SELECT_OPTIONS_REQUIRED",
|
|
291
|
+
message: "select 필드에는 options가 필요합니다",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (field.secret && field.type !== "string") {
|
|
295
|
+
problems.push({
|
|
296
|
+
path: `settings.${key}`,
|
|
297
|
+
code: "SECRET_FIELD_TYPE",
|
|
298
|
+
message: "비밀 필드는 string만 됩니다",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (field.default !== undefined) {
|
|
302
|
+
const expected = field.type === "select" ? "string" : field.type;
|
|
303
|
+
const matchesOption =
|
|
304
|
+
field.type !== "select" || field.options?.some((o) => o.value === field.default);
|
|
305
|
+
if (typeof field.default !== expected || !matchesOption) {
|
|
306
|
+
problems.push({
|
|
307
|
+
path: `settings.${key}`,
|
|
308
|
+
code: "DEFAULT_TYPE_MISMATCH",
|
|
309
|
+
message: "default가 필드 형식과 맞지 않습니다",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return problems;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 판정용 요약 — `app_versions.capabilities`에 저장하고 분배·동의·넓어짐 판정이 매니페스트 전체 대신 읽는다. 매니페스트에서만 만든다
|
|
319
|
+
*/
|
|
320
|
+
export interface AppCapabilities {
|
|
321
|
+
scopes: string[];
|
|
322
|
+
events: AppEventType[];
|
|
323
|
+
networkHosts: string[];
|
|
324
|
+
adminPage: boolean;
|
|
325
|
+
/** 필수 설정 키 — 하나라도 비어 있으면 이벤트 함수를 부르지 않는다 */
|
|
326
|
+
requiredSettings: string[];
|
|
327
|
+
secretSettings: string[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function appCapabilitiesOf(manifest: AppManifest): AppCapabilities {
|
|
331
|
+
const sorted = (values: readonly string[]) => [...new Set(values)].sort();
|
|
332
|
+
return {
|
|
333
|
+
scopes: sorted(manifest.scopes),
|
|
334
|
+
events: sorted(manifest.events) as AppEventType[],
|
|
335
|
+
networkHosts: sorted(manifest.network.hosts),
|
|
336
|
+
adminPage: Boolean(manifest.ui?.adminPage),
|
|
337
|
+
requiredSettings: Object.entries(manifest.settings)
|
|
338
|
+
.filter(([, field]) => field.required)
|
|
339
|
+
.map(([key]) => key)
|
|
340
|
+
.sort(),
|
|
341
|
+
secretSettings: Object.entries(manifest.settings)
|
|
342
|
+
.filter(([, field]) => field.secret)
|
|
343
|
+
.map(([key]) => key)
|
|
344
|
+
.sort(),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* capability 레지스트리 — 키마다 동의 문단과 넓어짐 판정. 「넓어졌다」= 기존 설치가 재동의해야 새 버전으로 옮길 수 있다.
|
|
350
|
+
* 동의 문단은 accounts 설치 화면·상점 플랫폼 앱 상세·유저 문서가 같은 문장을 쓴다.
|
|
351
|
+
*/
|
|
352
|
+
export interface AppCapabilityDefinition {
|
|
353
|
+
key: "scopes" | "network" | "events" | "ui";
|
|
354
|
+
/** 동의 화면의 문단 제목 */
|
|
355
|
+
title: string;
|
|
356
|
+
/** 동의 화면에 보일 줄 — 없으면 문단을 그리지 않는다 */
|
|
357
|
+
describe(capabilities: AppCapabilities): string[];
|
|
358
|
+
/** 새 버전이 넓어졌는가 */
|
|
359
|
+
widens(previous: AppCapabilities, next: AppCapabilities): boolean;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const grew = (previous: readonly string[], next: readonly string[]) =>
|
|
363
|
+
next.some((value) => !previous.includes(value));
|
|
364
|
+
|
|
365
|
+
/** 스코프 집합이 넓어졌는가 — `r`→`rw`는 넓어짐, `rw`→`r`은 아님 */
|
|
366
|
+
function scopesWiden(previous: readonly string[], next: readonly string[]): boolean {
|
|
367
|
+
return next.some((scope) => {
|
|
368
|
+
if (previous.includes(scope)) return false;
|
|
369
|
+
const [resource, access] = scope.split(":");
|
|
370
|
+
return !(access === "r" && previous.includes(`${resource}:rw`));
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const APP_CAPABILITIES: readonly AppCapabilityDefinition[] = [
|
|
375
|
+
{
|
|
376
|
+
key: "scopes",
|
|
377
|
+
title: "이 앱이 할 수 있는 일",
|
|
378
|
+
describe: (c) => c.scopes.map((scope) => describeScope(scope) ?? scope),
|
|
379
|
+
widens: (p, n) => scopesWiden(p.scopes, n.scopes),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
key: "network",
|
|
383
|
+
title: "이 앱이 데이터를 보내는 곳",
|
|
384
|
+
describe: (c) => c.networkHosts,
|
|
385
|
+
widens: (p, n) => grew(p.networkHosts, n.networkHosts),
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
key: "events",
|
|
389
|
+
title: "이 앱이 받는 알림",
|
|
390
|
+
describe: (c) => c.events,
|
|
391
|
+
widens: (p, n) => grew(p.events, n.events),
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
key: "ui",
|
|
395
|
+
title: "상점 플랫폼 화면",
|
|
396
|
+
describe: (c) => (c.adminPage ? ["상점 플랫폼에 이 앱의 화면을 더합니다"] : []),
|
|
397
|
+
widens: (p, n) => !p.adminPage && n.adminPage,
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
/** 재동의가 필요한 capability 키 — 비어 있으면 release가 모든 설치에 곧바로 반영된다 */
|
|
402
|
+
export function appManifestWidens(
|
|
403
|
+
previous: AppCapabilities,
|
|
404
|
+
next: AppCapabilities,
|
|
405
|
+
): AppCapabilityDefinition["key"][] {
|
|
406
|
+
return APP_CAPABILITIES.filter((c) => c.widens(previous, next)).map((c) => c.key);
|
|
407
|
+
}
|