@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 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.20.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/async": "0.16.0",
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/data": "0.22.0",
38
- "@gaonjs/mail": "0.3.3",
38
+ "@gaonjs/web": "0.27.0",
39
39
  "@gaonjs/storage": "0.1.5",
40
- "@gaonjs/web": "0.24.1"
40
+ "@gaonjs/mail": "0.4.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "fastify": "^5.0.0"