@gaonjs/config 0.20.0 → 0.22.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/types.d.ts +25 -1
- package/dist/wire.js +65 -0
- package/package.json +6 -6
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectionConfig } from '@gaonjs/data';
|
|
2
|
-
import type { SecurityOptions, ClientIpConfig } from '@gaonjs/web';
|
|
2
|
+
import type { SecurityOptions, AppSecurityOptions, ClientIpConfig } from '@gaonjs/web';
|
|
3
3
|
import type { AuthOptions, JwtAuthOptions, SharedPropsFn } from '@gaonjs/web';
|
|
4
4
|
/**
|
|
5
5
|
* DB 커넥션 맵 — 키는 커넥션 이름(§4.5), 값은 어댑터 설정. `main` 은 관례
|
|
@@ -33,6 +33,12 @@ export interface MailConfig {
|
|
|
33
33
|
readonly user?: string;
|
|
34
34
|
readonly pass?: string;
|
|
35
35
|
readonly secure?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* 결정 357: STARTTLS 를 **강제**한다(서버가 STARTTLS 를 광고하지 않으면 전송 실패).
|
|
38
|
+
* 기본(생략)은 기회적 STARTTLS — 운영 SMTP 에서 다운그레이드로 자격이 평문 노출될 수
|
|
39
|
+
* 있으니, 자격 있는 운영 전송은 `secure: true`(465) 또는 `requireTls: true` 를 켠다.
|
|
40
|
+
*/
|
|
41
|
+
readonly requireTls?: boolean;
|
|
36
42
|
/** from 헤더 기본값. */
|
|
37
43
|
readonly defaultFrom?: string;
|
|
38
44
|
}
|
|
@@ -98,6 +104,14 @@ export interface WebConfig {
|
|
|
98
104
|
* `{ proxy: 1 }`(신뢰 홉 수) · `{ header: 'cf-connecting-ip' }`(신뢰 헤더).
|
|
99
105
|
*/
|
|
100
106
|
readonly clientIp?: ClientIpConfig;
|
|
107
|
+
/**
|
|
108
|
+
* 결정 356: 업로드(multipart) 한도. 생략 시 파일당 10MB · 최대 10개.
|
|
109
|
+
* `false` 는 multipart 파싱 비활성. 한도 초과는 413 + 수리 안내로 응답된다.
|
|
110
|
+
*/
|
|
111
|
+
readonly uploads?: {
|
|
112
|
+
readonly maxFileSize?: number;
|
|
113
|
+
readonly maxFiles?: number;
|
|
114
|
+
} | false;
|
|
101
115
|
}
|
|
102
116
|
/** 루트 설정 — gaon.config.ts 의 defineConfig 인자. */
|
|
103
117
|
export interface GaonConfig {
|
|
@@ -190,6 +204,7 @@ export interface AppConfig {
|
|
|
190
204
|
*/
|
|
191
205
|
readonly seal?: boolean | {
|
|
192
206
|
readonly except?: readonly string[];
|
|
207
|
+
readonly strictQuery?: boolean;
|
|
193
208
|
};
|
|
194
209
|
/**
|
|
195
210
|
* 결정 150: 앱 전역 공유 prop 제공자. 요청마다 실행돼 반환 객체가 이 앱의 모든 렌더
|
|
@@ -198,6 +213,15 @@ export interface AppConfig {
|
|
|
198
213
|
* sharedProps: (ctx) => ({ locale: ctx.session?.locale ?? 'en' })
|
|
199
214
|
*/
|
|
200
215
|
readonly sharedProps?: SharedPropsFn;
|
|
216
|
+
/**
|
|
217
|
+
* 결정 339: 이 앱만의 보안 override — CORS·rate limit. 생략한 필드는 전역
|
|
218
|
+
* (`gaon.config.ts` 의 `web.security`)을 상속한다. API 앱만 크로스 오리진을 열고
|
|
219
|
+
* web 앱의 same-origin 기본은 유지하는 용도:
|
|
220
|
+
* security: { cors: { origin: 'https://app.example.com' }, rateLimit: { max: 600 } }
|
|
221
|
+
* `false` 는 이 앱에서 그 방어를 끈다(명시적으로만 · 규칙 8). 보안 응답 헤더는
|
|
222
|
+
* 전역 전용(앱 override 없음).
|
|
223
|
+
*/
|
|
224
|
+
readonly security?: AppSecurityOptions;
|
|
201
225
|
}
|
|
202
226
|
/** 타입 안전 헬퍼 — `export default defineAppConfig({...})`. */
|
|
203
227
|
export declare function defineAppConfig(config: AppConfig): AppConfig;
|
package/dist/wire.js
CHANGED
|
@@ -177,11 +177,19 @@ export async function configureNonDbBatteries(config, cwd = process.cwd(), opts
|
|
|
177
177
|
// mail — SMTP 전송(개발·테스트는 MailPit sink, 운영은 실 SMTP).
|
|
178
178
|
if (config.mail) {
|
|
179
179
|
const m = config.mail;
|
|
180
|
+
// 결정 357: user/pass 반쪽 설정은 fail-loud — 이전엔 `user && pass` 게이팅으로 auth 가
|
|
181
|
+
// 무신호 드랍돼(익명 전송 시도) "설정했는데 인증이 안 걸리는" 조용한 오배선이었다.
|
|
182
|
+
if (!!m.user !== !!m.pass) {
|
|
183
|
+
throw new Error(`mail 설정에 user/pass 가 한쪽만 있습니다 (auth 는 둘 다 필요) — 반쪽 설정은 인증 없이 전송을 시도합니다.\n` +
|
|
184
|
+
` → gaon.config.ts 의 mail 블록에 ${m.user ? 'pass' : 'user'} 를 함께 설정하거나(.env 의 SMTP_USER/SMTP_PASS),\n` +
|
|
185
|
+
` → 인증 없는 SMTP(MailPit 등)라면 둘 다 제거하세요.`);
|
|
186
|
+
}
|
|
180
187
|
configureMailer({
|
|
181
188
|
transport: smtpTransport({
|
|
182
189
|
host: m.host,
|
|
183
190
|
port: m.port,
|
|
184
191
|
secure: m.secure,
|
|
192
|
+
requireTls: m.requireTls,
|
|
185
193
|
auth: m.user && m.pass ? { user: m.user, pass: m.pass } : undefined,
|
|
186
194
|
}),
|
|
187
195
|
defaultFrom: m.defaultFrom,
|
|
@@ -217,6 +225,24 @@ export async function configureNonDbBatteries(config, cwd = process.cwd(), opts
|
|
|
217
225
|
const dir = config.i18n.dir ?? 'locales';
|
|
218
226
|
const abs = dir.startsWith('/') ? dir : join(root, dir);
|
|
219
227
|
const resources = loadLocales(abs);
|
|
228
|
+
// 결정 353: i18n 을 켰는데 카탈로그가 비면 fail-loud — 이전엔 무경고로 부팅해
|
|
229
|
+
// 모든 t() 가 원문 키를 화면에 노출했다(배포 이미지 locales 누락·dir 오타 무신호).
|
|
230
|
+
if (Object.keys(resources).length === 0) {
|
|
231
|
+
throw new Error(`i18n 이 설정됐지만 로케일 카탈로그가 비어 있습니다: ${abs}\n` +
|
|
232
|
+
` → ${dir}/${config.i18n.fallbackLng}.json 을 만들거나(최소 fallbackLng 카탈로그),\n` +
|
|
233
|
+
` → gaon.config.ts 의 i18n.dir 이 실제 카탈로그 폴더를 가리키는지 확인하세요.\n` +
|
|
234
|
+
` (이 상태로 부팅하면 모든 t() 가 번역 대신 원문 키를 노출합니다 — 결정 353)`);
|
|
235
|
+
}
|
|
236
|
+
// 결정 353: fallbackLng·supportedLngs 가 지목한 로케일 파일이 통째로 없으면 fail-loud —
|
|
237
|
+
// 이전엔 협상이 그 로케일을 골라 주고 전 키가 fallback 언어로 폴백돼, 로케일 하나가
|
|
238
|
+
// 조용히 다른 언어로 나갔다(locale-parity 는 존재 파일만 비교해 못 잡는 사각).
|
|
239
|
+
const missing = [config.i18n.fallbackLng, ...(config.i18n.supportedLngs ?? [])].filter((lng, i, arr) => arr.indexOf(lng) === i && !resources[lng]);
|
|
240
|
+
if (missing.length > 0) {
|
|
241
|
+
throw new Error(`i18n 설정이 지목한 로케일의 카탈로그 파일이 없습니다: ${missing.join(', ')}\n` +
|
|
242
|
+
` → ${dir}/ 에 ${missing.map((l) => `${l}.json`).join(' · ')} 을 추가하거나,\n` +
|
|
243
|
+
` → gaon.config.ts 의 fallbackLng/supportedLngs 에서 해당 로케일을 제거하세요.\n` +
|
|
244
|
+
` (파일이 없는 로케일은 전 화면이 fallback 언어로 조용히 대체됩니다 — 결정 353)`);
|
|
245
|
+
}
|
|
220
246
|
await configureI18n({
|
|
221
247
|
resources,
|
|
222
248
|
fallbackLng: config.i18n.fallbackLng,
|
|
@@ -325,6 +351,11 @@ async function assembleApp(config, root, domain, redis, nats) {
|
|
|
325
351
|
if (typeof ac?.sharedProps === 'function') {
|
|
326
352
|
spec = { ...spec, sharedProps: ac.sharedProps };
|
|
327
353
|
}
|
|
354
|
+
// 결정 339: app.config 의 앱 스코프 보안 override(CORS·rate limit)를 전달한다 —
|
|
355
|
+
// 생략 필드는 전역(web.security) 상속. createApp 이 앱 스코프 단위로 등록한다.
|
|
356
|
+
if (ac?.security && typeof ac.security === 'object') {
|
|
357
|
+
spec = { ...spec, security: ac.security };
|
|
358
|
+
}
|
|
328
359
|
if (ac?.session?.secret) {
|
|
329
360
|
// 결정 263: 세션이 구성됐는데(session.secret) Redis 가 없으면 fail-loud 한다.
|
|
330
361
|
// 세션 저장소는 Redis 가 필수인데(redisSessionStore), 이전엔 `&& redis` 게이팅으로
|
|
@@ -374,6 +405,12 @@ async function assembleApp(config, root, domain, redis, nats) {
|
|
|
374
405
|
security: config.web?.security,
|
|
375
406
|
// 결정 131: 스토리지 오리진 CSP 자동 배선(설정 있으면 img-src·connect-src 에 부착).
|
|
376
407
|
storageOrigins: storageCspOrigins(config.storage),
|
|
408
|
+
// 결정 355: 로컬 디스크 공개 경로(`/storage/<key>`)를 프레임웍이 직접 서빙 —
|
|
409
|
+
// url() 이 만드는 좌표를 아무도 응답하지 않던 조용한 404 를 없앤다.
|
|
410
|
+
localStorage: localStorageServeTargets(config.storage, root),
|
|
411
|
+
// 결정 356: 업로드 한도 config 표면(web.uploads) — 이전엔 createApp 옵션만 있고
|
|
412
|
+
// wireGaon 이 전달하지 않아 정본 경로(gaon.config.ts)로 한도를 올릴 방법이 없었다.
|
|
413
|
+
uploads: config.web?.uploads,
|
|
377
414
|
// 클라이언트 IP 정책(결정 120 · 생략 시 'direct'). 프록시 뒤 배치면 선언.
|
|
378
415
|
clientIp: config.web?.clientIp,
|
|
379
416
|
realtime: nats ? { nats, hubAddr: config.hub?.addr, heartbeatMs: config.hub?.heartbeatMs } : undefined,
|
|
@@ -468,6 +505,34 @@ void redisSessionStore;
|
|
|
468
505
|
* 표시 오리진(img-src). 로컬 디스크는 same-origin 상대 URL 이라 CSP 와 무관(제외). 오리진은
|
|
469
506
|
* `new URL(v).origin`(scheme+host+port) 만 — 경로·쿼리는 배제한다.
|
|
470
507
|
*/
|
|
508
|
+
/**
|
|
509
|
+
* 결정 355: 상대 publicUrl(기본 '/storage')을 가진 로컬 디스크 → 루트 서빙 대상.
|
|
510
|
+
* 절대 URL(별도 서버/CDN)은 앱이 서빙하지 않는다. 같은 경로에 다른 root 가 겹치면
|
|
511
|
+
* fail-loud(어느 디스크가 응답할지 비결정 — 조용한 오서빙 방지).
|
|
512
|
+
*/
|
|
513
|
+
function localStorageServeTargets(storage, root) {
|
|
514
|
+
if (!storage)
|
|
515
|
+
return [];
|
|
516
|
+
const out = [];
|
|
517
|
+
for (const [name, d] of Object.entries(storage.disks)) {
|
|
518
|
+
if (d.driver !== 'local')
|
|
519
|
+
continue;
|
|
520
|
+
const urlPrefix = d.publicUrl ?? '/storage';
|
|
521
|
+
if (!urlPrefix.startsWith('/'))
|
|
522
|
+
continue;
|
|
523
|
+
const abs = d.root.startsWith('/') ? d.root : join(root, d.root);
|
|
524
|
+
const dup = out.find((o) => o.urlPrefix === urlPrefix);
|
|
525
|
+
if (dup) {
|
|
526
|
+
if (dup.root !== abs) {
|
|
527
|
+
throw new Error(`storage 로컬 디스크 '${name}' 의 publicUrl('${urlPrefix}')이 다른 로컬 디스크와 겹칩니다(root 는 다름).\n` +
|
|
528
|
+
` → 디스크마다 publicUrl 을 다르게 지정하세요(예: '/storage-${name}').`);
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
out.push({ urlPrefix, root: abs });
|
|
533
|
+
}
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
471
536
|
function storageCspOrigins(storage) {
|
|
472
537
|
if (!storage)
|
|
473
538
|
return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Gaon 루트 설정 로더·자동 배선 (gaon.config.ts)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"ioredis": "^5.4.1",
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/adapter-mongo": "0.2.0",
|
|
33
|
+
"@gaonjs/adapter-mongo": "0.3.0",
|
|
35
34
|
"@gaonjs/core": "0.2.4",
|
|
35
|
+
"@gaonjs/async": "0.17.0",
|
|
36
|
+
"@gaonjs/data": "0.24.0",
|
|
36
37
|
"@gaonjs/i18n": "0.2.4",
|
|
37
|
-
"@gaonjs/
|
|
38
|
-
"@gaonjs/mail": "0.3.3",
|
|
38
|
+
"@gaonjs/web": "0.27.0",
|
|
39
39
|
"@gaonjs/storage": "0.1.5",
|
|
40
|
-
"@gaonjs/
|
|
40
|
+
"@gaonjs/mail": "0.4.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"fastify": "^5.0.0"
|