@gaonjs/config 0.20.0 → 0.23.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
  }
@@ -41,6 +47,12 @@ export type StorageDiskConfig = {
41
47
  readonly driver: 'local';
42
48
  readonly root: string;
43
49
  readonly publicUrl?: string;
50
+ /**
51
+ * 결정 401: 공개 서빙 범위. 이 접두사 아래 키만 `publicUrl` 경로로 응답하고
52
+ * 나머지는 404 다(생략 시 'public/'). 로컬 디스크는 한 폴더에 공개·비공개가
53
+ * 섞이므로 접두사가 유일한 경계다 — `''` 로 두면 디스크 전체가 무인증 공개.
54
+ */
55
+ readonly publicPrefix?: string;
44
56
  } | {
45
57
  readonly driver: 's3';
46
58
  readonly bucket: string;
@@ -98,6 +110,14 @@ export interface WebConfig {
98
110
  * `{ proxy: 1 }`(신뢰 홉 수) · `{ header: 'cf-connecting-ip' }`(신뢰 헤더).
99
111
  */
100
112
  readonly clientIp?: ClientIpConfig;
113
+ /**
114
+ * 결정 356: 업로드(multipart) 한도. 생략 시 파일당 10MB · 최대 10개.
115
+ * `false` 는 multipart 파싱 비활성. 한도 초과는 413 + 수리 안내로 응답된다.
116
+ */
117
+ readonly uploads?: {
118
+ readonly maxFileSize?: number;
119
+ readonly maxFiles?: number;
120
+ } | false;
101
121
  }
102
122
  /** 루트 설정 — gaon.config.ts 의 defineConfig 인자. */
103
123
  export interface GaonConfig {
@@ -190,6 +210,7 @@ export interface AppConfig {
190
210
  */
191
211
  readonly seal?: boolean | {
192
212
  readonly except?: readonly string[];
213
+ readonly strictQuery?: boolean;
193
214
  };
194
215
  /**
195
216
  * 결정 150: 앱 전역 공유 prop 제공자. 요청마다 실행돼 반환 객체가 이 앱의 모든 렌더
@@ -198,6 +219,20 @@ export interface AppConfig {
198
219
  * sharedProps: (ctx) => ({ locale: ctx.session?.locale ?? 'en' })
199
220
  */
200
221
  readonly sharedProps?: SharedPropsFn;
222
+ /**
223
+ * 결정 339: 이 앱만의 보안 override — CORS·rate limit. 생략한 필드는 전역
224
+ * (`gaon.config.ts` 의 `web.security`)을 상속한다. API 앱만 크로스 오리진을 열고
225
+ * web 앱의 same-origin 기본은 유지하는 용도:
226
+ * security: { cors: { origin: 'https://app.example.com' }, rateLimit: { max: 600 } }
227
+ * `false` 는 이 앱에서 그 방어를 끈다(명시적으로만 · 규칙 8). 보안 응답 헤더는
228
+ * 전역 전용(앱 override 없음).
229
+ *
230
+ * 결정 389: override 객체는 전역과 **필드 단위 병합**이다 — `rateLimit: {
231
+ * timeWindow: '10 minutes' }` 처럼 일부만 줘도 `max` 는 전역(없으면 코어 기본)을
232
+ * 상속한다. CORS 는 `origin` 미명시 시 전역/코어 기본(`origin:false`)이 유지된다
233
+ * (fail-closed — 부분 override 로 CORS 가 열리지 않는다).
234
+ */
235
+ readonly security?: AppSecurityOptions;
201
236
  }
202
237
  /** 타입 안전 헬퍼 — `export default defineAppConfig({...})`. */
203
238
  export declare function defineAppConfig(config: AppConfig): AppConfig;
package/dist/wire.js CHANGED
@@ -177,11 +177,26 @@ 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
+ }
187
+ // 결정 404: secure(implicit TLS · 465)면 nodemailer 가 requireTLS 를 무시한다 —
188
+ // 연결은 이미 암호화라 위험은 없지만, 켠 설정이 아무 일도 안 하는 걸 모르면
189
+ // "STARTTLS 를 강제했다" 는 오해가 남는다. 부팅 경고로만 알린다(정당한 설정이라 throw 아님).
190
+ if (m.secure && m.requireTls) {
191
+ log.warn('mail.requireTls 는 secure: true 에서 무시됩니다 — secure(465)는 이미 암호화 연결로 시작합니다. ' +
192
+ 'STARTTLS 강제가 목적이면 secure 를 끄고 requireTls 만 켜세요(587).');
193
+ }
180
194
  configureMailer({
181
195
  transport: smtpTransport({
182
196
  host: m.host,
183
197
  port: m.port,
184
198
  secure: m.secure,
199
+ requireTls: m.requireTls,
185
200
  auth: m.user && m.pass ? { user: m.user, pass: m.pass } : undefined,
186
201
  }),
187
202
  defaultFrom: m.defaultFrom,
@@ -192,10 +207,7 @@ export async function configureNonDbBatteries(config, cwd = process.cwd(), opts
192
207
  const disks = {};
193
208
  for (const [name, d] of Object.entries(config.storage.disks)) {
194
209
  if (d.driver === 'local') {
195
- // root 프로젝트 상대 경로면 cwd 로 절대화.
196
- const base = d.root.startsWith('/') ? d.root : join(root, d.root);
197
- const root2 = opts.localRootFor ? opts.localRootFor(base) : base;
198
- disks[name] = localDisk({ root: root2, baseUrl: d.publicUrl });
210
+ disks[name] = localDisk({ root: localDiskRoot(d.root, root, opts.localRootFor), baseUrl: d.publicUrl });
199
211
  }
200
212
  else {
201
213
  disks[name] = s3Disk({
@@ -217,6 +229,24 @@ export async function configureNonDbBatteries(config, cwd = process.cwd(), opts
217
229
  const dir = config.i18n.dir ?? 'locales';
218
230
  const abs = dir.startsWith('/') ? dir : join(root, dir);
219
231
  const resources = loadLocales(abs);
232
+ // 결정 353: i18n 을 켰는데 카탈로그가 비면 fail-loud — 이전엔 무경고로 부팅해
233
+ // 모든 t() 가 원문 키를 화면에 노출했다(배포 이미지 locales 누락·dir 오타 무신호).
234
+ if (Object.keys(resources).length === 0) {
235
+ throw new Error(`i18n 이 설정됐지만 로케일 카탈로그가 비어 있습니다: ${abs}\n` +
236
+ ` → ${dir}/${config.i18n.fallbackLng}.json 을 만들거나(최소 fallbackLng 카탈로그),\n` +
237
+ ` → gaon.config.ts 의 i18n.dir 이 실제 카탈로그 폴더를 가리키는지 확인하세요.\n` +
238
+ ` (이 상태로 부팅하면 모든 t() 가 번역 대신 원문 키를 노출합니다 — 결정 353)`);
239
+ }
240
+ // 결정 353: fallbackLng·supportedLngs 가 지목한 로케일 파일이 통째로 없으면 fail-loud —
241
+ // 이전엔 협상이 그 로케일을 골라 주고 전 키가 fallback 언어로 폴백돼, 로케일 하나가
242
+ // 조용히 다른 언어로 나갔다(locale-parity 는 존재 파일만 비교해 못 잡는 사각).
243
+ const missing = [config.i18n.fallbackLng, ...(config.i18n.supportedLngs ?? [])].filter((lng, i, arr) => arr.indexOf(lng) === i && !resources[lng]);
244
+ if (missing.length > 0) {
245
+ throw new Error(`i18n 설정이 지목한 로케일의 카탈로그 파일이 없습니다: ${missing.join(', ')}\n` +
246
+ ` → ${dir}/ 에 ${missing.map((l) => `${l}.json`).join(' · ')} 을 추가하거나,\n` +
247
+ ` → gaon.config.ts 의 fallbackLng/supportedLngs 에서 해당 로케일을 제거하세요.\n` +
248
+ ` (파일이 없는 로케일은 전 화면이 fallback 언어로 조용히 대체됩니다 — 결정 353)`);
249
+ }
220
250
  await configureI18n({
221
251
  resources,
222
252
  fallbackLng: config.i18n.fallbackLng,
@@ -325,6 +355,11 @@ async function assembleApp(config, root, domain, redis, nats) {
325
355
  if (typeof ac?.sharedProps === 'function') {
326
356
  spec = { ...spec, sharedProps: ac.sharedProps };
327
357
  }
358
+ // 결정 339: app.config 의 앱 스코프 보안 override(CORS·rate limit)를 전달한다 —
359
+ // 생략 필드는 전역(web.security) 상속. createApp 이 앱 스코프 단위로 등록한다.
360
+ if (ac?.security && typeof ac.security === 'object') {
361
+ spec = { ...spec, security: ac.security };
362
+ }
328
363
  if (ac?.session?.secret) {
329
364
  // 결정 263: 세션이 구성됐는데(session.secret) Redis 가 없으면 fail-loud 한다.
330
365
  // 세션 저장소는 Redis 가 필수인데(redisSessionStore), 이전엔 `&& redis` 게이팅으로
@@ -374,6 +409,14 @@ async function assembleApp(config, root, domain, redis, nats) {
374
409
  security: config.web?.security,
375
410
  // 결정 131: 스토리지 오리진 CSP 자동 배선(설정 있으면 img-src·connect-src 에 부착).
376
411
  storageOrigins: storageCspOrigins(config.storage),
412
+ // 결정 355: 로컬 디스크 공개 경로(`/storage/<key>`)를 프레임웍이 직접 서빙 —
413
+ // url() 이 만드는 좌표를 아무도 응답하지 않던 조용한 404 를 없앤다.
414
+ // 결정 405: root 계산은 디스크 배선과 **같은 헬퍼**(localDiskRoot)를 쓴다 — serve
415
+ // 경로엔 테스트 격리 훅이 없지만(wireGaon 표면에 없음), 계산이 한 곳이라 갈라지지 않는다.
416
+ localStorage: localStorageServeTargets(config.storage, root),
417
+ // 결정 356: 업로드 한도 config 표면(web.uploads) — 이전엔 createApp 옵션만 있고
418
+ // wireGaon 이 전달하지 않아 정본 경로(gaon.config.ts)로 한도를 올릴 방법이 없었다.
419
+ uploads: config.web?.uploads,
377
420
  // 클라이언트 IP 정책(결정 120 · 생략 시 'direct'). 프록시 뒤 배치면 선언.
378
421
  clientIp: config.web?.clientIp,
379
422
  realtime: nats ? { nats, hubAddr: config.hub?.addr, heartbeatMs: config.hub?.heartbeatMs } : undefined,
@@ -468,6 +511,45 @@ void redisSessionStore;
468
511
  * 표시 오리진(img-src). 로컬 디스크는 same-origin 상대 URL 이라 CSP 와 무관(제외). 오리진은
469
512
  * `new URL(v).origin`(scheme+host+port) 만 — 경로·쿼리는 배제한다.
470
513
  */
514
+ /**
515
+ * 결정 355: 상대 publicUrl(기본 '/storage')을 가진 로컬 디스크 → 루트 서빙 대상.
516
+ * 절대 URL(별도 서버/CDN)은 앱이 서빙하지 않는다. 같은 경로에 다른 root 가 겹치면
517
+ * fail-loud(어느 디스크가 응답할지 비결정 — 조용한 오서빙 방지).
518
+ */
519
+ function localStorageServeTargets(storage, root, localRootFor) {
520
+ if (!storage)
521
+ return [];
522
+ const out = [];
523
+ for (const [name, d] of Object.entries(storage.disks)) {
524
+ if (d.driver !== 'local')
525
+ continue;
526
+ const urlPrefix = d.publicUrl ?? '/storage';
527
+ if (!urlPrefix.startsWith('/'))
528
+ continue;
529
+ // 결정 405: 디스크 배선과 **같은 헬퍼**로 root 를 계산한다 — 테스트 격리
530
+ // (localRootFor)가 한쪽에만 걸리면 업로드 위치와 서빙 위치가 어긋난다.
531
+ const abs = localDiskRoot(d.root, root, localRootFor);
532
+ const dup = out.find((o) => o.urlPrefix === urlPrefix);
533
+ if (dup) {
534
+ if (dup.root !== abs) {
535
+ throw new Error(`storage 로컬 디스크 '${name}' 의 publicUrl('${urlPrefix}')이 다른 로컬 디스크와 겹칩니다(root 는 다름).\n` +
536
+ ` → 디스크마다 publicUrl 을 다르게 지정하세요(예: '/storage-${name}').`);
537
+ }
538
+ continue;
539
+ }
540
+ out.push({ urlPrefix, root: abs, publicPrefix: d.publicPrefix });
541
+ }
542
+ return out;
543
+ }
544
+ /**
545
+ * 로컬 디스크의 실 저장 루트 — 상대 경로는 프로젝트 루트 기준으로 절대화하고,
546
+ * 테스트 격리 훅(localRootFor)을 적용한다. 디스크 배선과 공개 서빙이 이 한 지점을
547
+ * 공유해야 두 좌표가 갈라지지 않는다(결정 405).
548
+ */
549
+ function localDiskRoot(diskRoot, root, localRootFor) {
550
+ const base = diskRoot.startsWith('/') ? diskRoot : join(root, diskRoot);
551
+ return localRootFor ? localRootFor(base) : base;
552
+ }
471
553
  function storageCspOrigins(storage) {
472
554
  if (!storage)
473
555
  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.23.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.4.0",
34
+ "@gaonjs/async": "0.18.0",
35
35
  "@gaonjs/core": "0.2.4",
36
- "@gaonjs/i18n": "0.2.4",
37
- "@gaonjs/data": "0.22.0",
38
- "@gaonjs/mail": "0.3.3",
36
+ "@gaonjs/i18n": "0.3.0",
37
+ "@gaonjs/mail": "0.5.0",
38
+ "@gaonjs/data": "0.25.0",
39
39
  "@gaonjs/storage": "0.1.5",
40
- "@gaonjs/web": "0.24.1"
40
+ "@gaonjs/web": "0.29.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "fastify": "^5.0.0"