@gaonjs/cli 0.47.0 → 0.55.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.
Files changed (59) hide show
  1. package/dist/commands/check.d.ts +3 -1
  2. package/dist/commands/check.js +44 -2
  3. package/dist/commands/db.js +9 -0
  4. package/dist/commands/gen.d.ts +2 -0
  5. package/dist/commands/gen.js +3 -1
  6. package/dist/commands/new.js +13 -0
  7. package/dist/commands/test.js +16 -3
  8. package/dist/db/journal.d.ts +8 -4
  9. package/dist/db/journal.js +58 -14
  10. package/dist/db/migrate.d.ts +3 -1
  11. package/dist/db/migrate.js +14 -14
  12. package/dist/db/replay.js +3 -3
  13. package/dist/db/resolve.d.ts +11 -1
  14. package/dist/db/resolve.js +24 -2
  15. package/dist/db/status.js +17 -4
  16. package/dist/db.js +26 -5
  17. package/dist/dev.d.ts +6 -4
  18. package/dist/dev.js +9 -4
  19. package/dist/doctor/fixers/index.d.ts +1 -1
  20. package/dist/doctor/fixers/index.js +6 -1
  21. package/dist/doctor/locale-parity.js +4 -1
  22. package/dist/doctor/render-return.d.ts +11 -0
  23. package/dist/doctor/render-return.js +143 -0
  24. package/dist/doctor/types.d.ts +1 -1
  25. package/dist/doctor.d.ts +3 -2
  26. package/dist/doctor.js +16 -5
  27. package/dist/generate.d.ts +20 -1
  28. package/dist/generate.js +120 -21
  29. package/dist/hub.js +2 -0
  30. package/dist/i18n-config.d.ts +32 -0
  31. package/dist/i18n-config.js +170 -0
  32. package/dist/index.js +141 -29
  33. package/dist/mcp/tools.d.ts +1 -1
  34. package/dist/mcp/tools.js +13 -6
  35. package/dist/messages-gen.d.ts +1 -1
  36. package/dist/messages-gen.js +16 -5
  37. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  38. package/dist/templates/auth/Login.vue.tpl +3 -5
  39. package/dist/templates/auth/Signup.vue.tpl +3 -5
  40. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  41. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  42. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  43. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  44. package/dist/templates/project/AGENTS.md.tpl +3 -2
  45. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  46. package/dist/templates/project/Dockerfile.tpl +11 -1
  47. package/dist/templates/project/agents/async.md.tpl +70 -14
  48. package/dist/templates/project/agents/data.md.tpl +173 -52
  49. package/dist/templates/project/agents/frontend.md.tpl +19 -4
  50. package/dist/templates/project/agents/i18n.md.tpl +20 -2
  51. package/dist/templates/project/agents/mail.md.tpl +8 -1
  52. package/dist/templates/project/agents/realtime.md.tpl +25 -6
  53. package/dist/templates/project/agents/seal.md.tpl +8 -3
  54. package/dist/templates/project/agents/security.md.tpl +50 -19
  55. package/dist/templates/project/agents/storage.md.tpl +39 -6
  56. package/dist/templates/project/agents/web.md.tpl +54 -22
  57. package/dist/work.d.ts +3 -0
  58. package/dist/work.js +4 -0
  59. package/package.json +7 -7
@@ -90,7 +90,8 @@ export default defineConfig({
90
90
  | `host` | `string` | SMTP 호스트(dev = MailPit `127.0.0.1`) |
91
91
  | `port` | `number` | SMTP 포트(dev MailPit = 1025) |
92
92
  | `secure?` | `boolean` | TLS(운영). 생략 = false |
93
- | `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능) |
93
+ | `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능 · **둘 다 또는 둘 다 없음** — 한쪽만 있으면 부팅 fail-loud · 결정 357) |
94
+ | `requireTls?` | `boolean` | STARTTLS **강제**(결정 357). 기본 STARTTLS 는 기회적이라 서버가 광고 안 하면 평문 진행 — 자격 있는 운영 전송은 `secure: true`(465) 또는 이걸 켠다 |
94
95
  | `defaultFrom` | `string` | 발신자 기본값 — 메시지에 `from` 이 없을 때. `configureMailer({ defaultFrom })` 와 동치 |
95
96
 
96
97
  ## 정본 예시
@@ -115,6 +116,10 @@ export const SendWelcome = job(async (userId: bigint) => {
115
116
  으로 뺀다(`agents/async.md` 판단표 · doctor `async-offload`).
116
117
  - **메일 로케일 ≠ 요청 로케일** — 메일은 요청과 다른 컨텍스트(잡)에서 나갈 수 있다.
117
118
  `deliver(data, { locale: recipient.locale })` 로 **명시**한다(결정 160).
119
+ - **`secure: true`(465)면 `requireTls` 는 무시된다** — implicit TLS 라 STARTTLS 협상이
120
+ 없다(부팅 경고 · 결정 404). STARTTLS 강제가 목적이면 `secure` 를 끄고 587 + `requireTls`.
121
+ - **발송 로그의 수신자는 마스킹된다**(`h***@example.com` · 결정 404) — 원문 주소가 필요하면
122
+ 앱이 자기 감사 로그에 남긴다(프레임웍 로그는 PII 저장소가 아니다).
118
123
  - **from 이 없으면 발송 에러** — 메시지에 `from` 을 주거나 `configureMailer({ defaultFrom })`
119
124
  (스캐폴드 config 의 `MAIL_FROM`)를 설정한다.
120
125
  - **레이아웃 상속은 v1 에 없다** — 공통 레이아웃은 v1.1 백로그(결정 160). v1 은 각
@@ -126,3 +131,5 @@ export const SendWelcome = job(async (userId: bigint) => {
126
131
  |---|---|
127
132
  | §7 (v0.15) | 메일 배터리 · `domain/mails/` · MailPit dev sink |
128
133
  | 결정 160 (13차 W3) | `deliver(data, { locale, to })` 로케일 인지 · 스캐폴드 mail 블록 · 레이아웃 v1.1 백로그 |
134
+ | 결정 357 | SMTP user/pass 반쪽 설정 부팅 fail-loud · `requireTls`(STARTTLS 강제) 표면 |
135
+ | 결정 404 | `secure: true` + `requireTls` 무의미 조합 부팅 경고 · 발송 로그 수신자 마스킹 |
@@ -183,7 +183,10 @@ const members = await ctx.presence()
183
183
  URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
184
184
  감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
185
185
  을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
186
- 자동 인증, JWT 앱은 `params: { access_token }`.
186
+ 자동 인증, JWT 앱은 `params: () => ({ access_token: token.value })` — **함수형으로
187
+ 넘겨라**(결정 344). params 는 접속·재접속 시점마다 평가되므로 함수형이면 회전한
188
+ 토큰이 재연결에 반영된다(고정 객체는 최초 값 고정 — 토큰 만료 후 드롭 시 4401
189
+ 영구 종료). room=42 같은 불변 값은 고정 객체로 넘겨도 된다.
187
190
 
188
191
  **앱 프리픽스는 자동이다(결정 154).** 서버는 채널 WS 를 `<앱 프리픽스>/gaon/ws/:channel`
189
192
  에 등록하고, `useChannel` 은 그 앱 번들의 `import.meta.env.BASE_URL`(= vite base = 앱
@@ -267,10 +270,21 @@ export function useRoom(roomId: number) {
267
270
  `127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
268
271
  자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
269
272
  (예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(`docs/guides/operations.md`).
270
- - **허브 TCP 포트는 내부망 전용이다(결정 311).** 허브 명령 채널에는 인증이 없다 —
271
- 포트(기본 4001)는 방화벽/보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반
272
- 백스톱으로 라인 길이 상한(1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed ·
273
- 개행 없는 스트림의 메모리 증식 차단).
273
+ - **허브 TCP 포트는 내부망 전용이다(결정 311).** 포트(기본 4001)는 방화벽/
274
+ 보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반 백스톱으로 라인 길이 상한
275
+ (1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed · 개행 없는 스트림의
276
+ 메모리 증식 차단). 개행이 **있는** 초과 라인도 동일하게 fail-closed 다
277
+ (결정 400 — 종전엔 그 명령만 조용히 버려져 로스터 불일치로만 관측됐다).
278
+ - **공유 토큰 인증(선택 · 결정 350).** `GAON_HUB_TOKEN` 을 허브·웹서버 양쪽에
279
+ 설정하면 웹서버 소켓의 첫 명령이 올바른 `auth` 여야 하고, 미인증 명령·오토큰은
280
+ 즉시 종료된다(fail-closed · 도달 가능한 임의 피어의 로스터 위조 방어). 미설정
281
+ 이면 종전(무인증 · 내부망 가정). 토큰 없는 허브는 auth 를 무시하므로 웹서버에
282
+ 먼저 설정해 둬도 무해하다(무중단 롤아웃: 웹서버 → 허브 순).
283
+ - **연결 실패는 웹서버 쪽에서도 보인다(결정 399).** 허브 접속이 반복 실패하면
284
+ (토큰 불일치로 허브가 즉시 끊음 · 허브 미기동 · `GAON_HUB_ADVERTISE` 오설정)
285
+ 프레즌스 클라이언트가 스트릭당 1회 `log.warn` 으로 원인 후보와 함께 신호한다 —
286
+ 종전엔 무한 재접속 루프가 완전 무로그라 허브 프로세스 로그에만 흔적이 남았다.
287
+ 건강한 연결이 서면 리셋돼 재발 시 다시 경고한다.
274
288
 
275
289
  ## 정본 예시
276
290
 
@@ -361,9 +375,14 @@ export default channel({
361
375
  | 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
362
376
  | 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
363
377
  | 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
378
+ | 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 값 고정이라 만료 후 재연결이 4401 영구 종료되던 갭 봉합 |
364
379
  | 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
365
380
  | 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
366
- | 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 인증 없음 = 방화벽으로 내부망 한정 |
381
+ | 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
382
+ | 결정 350 | 허브 공유 토큰 인증(§5 · 선택) — `GAON_HUB_TOKEN` 설정 시 첫 명령 = `auth` 강제(타이밍 세이프 비교) · 미인증/오토큰 즉시 종료 · 토큰 없는 허브는 auth 무시(혼재 롤아웃 호환) |
383
+ | 결정 395 | 리스 사임 CAS 삭제 — `stop()` 이 자기 revision 에서만 리더 키 삭제(`previousSeq`) · stale 리더 종료가 활성 리더 키를 지우던 재선출 순단 봉합(endpoint 결정 259 동형 · 허브·스케줄러 공통) |
384
+ | 결정 399 | 프레즌스 클라 연결 실패 warn(§5) — 단명 연결·리더 미발견 연속 시 스트릭당 1회 log.warn(토큰 불일치·허브 부재 안내) · 건강한 연결에 리셋 · 종전 무로그 재접속 루프 봉합 |
385
+ | 결정 400 | P3 청소(실시간 축) — 허브 KV 복원이 오염 키에 throw 해 전 인스턴스 crash-loop 하던 것을 try/continue 방어(presenceStats 와 대칭) · 라인 디코더 완결 초과 라인도 onOverflow(fail-closed 통일) |
367
386
 
368
387
  ## `@gaonjs/seal` 켠 앱의 채널
369
388
 
@@ -71,7 +71,7 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
71
71
  ```ts
72
72
  // 2) apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
73
73
  export default defineAppConfig({
74
- seal: true, // 또는 { except: ['/webhooks/*'] } — 외부가 seal 을 모르는 경로만 평문 통과
74
+ seal: true, // 또는 { except: ['/webhooks/*'], strictQuery: true } — except = 외부가 seal 을 모르는 경로만 평문 통과 · strictQuery = 평문 쿼리도 거부(결정 354)
75
75
  })
76
76
  ```
77
77
  ```ts
@@ -115,7 +115,8 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
115
115
  소비해 봉인이 깨진다. 클라 개봉 실패는 조용히 무시하지 않고 소켓을 **4500 종료**(`useChannel` · 아래 fail-closed).
116
116
  - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
117
117
  직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
118
- 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
118
+ 모르는 경로는 `seal: { except: ['/webhooks/*'] }`. except 글롭·기본 헬스 제외는 **앱 상대 경로**로
119
+ 매칭된다(prefix 앱도 정본 예시 그대로 동작 · 전체 경로 매칭도 병행 — 결정 354).
119
120
  - **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
120
121
  **403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
121
122
  (`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
@@ -209,8 +210,9 @@ export default controller({
209
210
  6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
210
211
  7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
211
212
  8. **리버스 프록시의 Host 재작성 금지** — 서버 키 시드는 `Host` 헤더, 브라우저는 `location.hostname` 을 쓴다. 프록시가 Host 를 upstream 이름으로 바꾸면 키가 갈려 data-page 개봉 실패(blank)·전 요청 403 이 된다. 프록시는 원 Host 를 보존해야 한다(`proxy_set_header Host $host` 류 · `x-forwarded-host` 는 참조하지 않는다).
212
- 9. **쿼리 봉인은 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 인바운드(직접 URL )의 쿼리는 평문일 있다 "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것.
213
+ 9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`). **strictQuery 를 켰을 때 실제로 깨지는 정당 경로는 쿼리 실린 redirect 다**(결정 416) — POST→303→GET 을 브라우저 XHR 이 투명 추종할 때 Location 의 쿼리는 클라 인터셉터를 평문으로 도착한다. 그런 대상 경로는 `except` 로 빼거나 redirect 에 쿼리를 싣지 말 것. (주소창 직접 접근은 `Accept: text/html` + X-Inertia 부재라 애초에 seal-target 이 아니다 — 옛 서술 정정.)
213
214
  10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
215
+ 11. **클라 인터셉터는 except 를 모른다** — `gaonjs/vue` 인터셉터는 모든 same-origin 요청의 쿼리를 `?q=` 로 봉인하는데, 서버는 excluded 경로에서 개봉을 건너뛴다. seal 앱 **자신의 브라우저 코드가 except 경로를 쿼리와 함께 호출**하면 핸들러가 `q=<암호문>` 을 받고 실 파라미터는 소실된다(무에러 오동작). except 경로는 외부 호출자 전용으로 두고 앱 자신은 호출하지 말 것(클라 except 전파는 백로그 DEFER).
214
216
 
215
217
  ## 관련 결정 번호
216
218
 
@@ -221,4 +223,7 @@ export default controller({
221
223
  - **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
222
224
  - **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
223
225
  - **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
226
+ - **결정 354** — seal 백로그 2건: ① **prefix 앱 except 무력** 수정 — except 글롭·기본 헬스 제외를 **앱 상대 경로**로도 매칭(전체 경로 매칭 병행 · 하위 호환). 배선부(web)가 앱 prefix 를 normalizeSealConfig 로 전달. ② **strictQuery 옵션** 신설 — 봉인 강제 요청의 평문 쿼리를 403 `plaintext_query` 로 거부(기본 off — 직접 URL 인바운드가 흔해 기본 강제는 정당한 요청을 깬다). 클라 인터셉터 except 전파는 DEFER(함정 11).
227
+ - **결정 416** — 함정 9 정정: strictQuery 의 파손 클래스는 "직접 URL" 이 아니라 **쿼리 실린 redirect**(클라 인터셉터가 못 타는 홉)다. docstring·문서를 실제 경로로 교체.
228
+ - **결정 417** — seal 위생 3건: `isExcluded` prefix **경계 검사**(`/apiv2` 가 `/api` 앱 제외로 새던 과확장 차단 · standalone 표면) · 봉인 응답 `Cache-Control: no-store` + `Vary: User-Agent`(공유 캐시가 per-request 키 응답을 재사용하면 개봉 실패 = 가용성 사고).
224
229
  - **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).
@@ -33,9 +33,28 @@
33
33
  — `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
34
34
  `helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
35
35
  - **rate limit 은 앱 라우트뿐 아니라 정적 에셋(`/assets/*`)·static 폴백까지 전
36
- 라우트에 적용된다**(기본 100 req/분/IP · 전역 등록). 에셋이 많은 페이지를 여러
36
+ 라우트에 적용된다**(기본 100 req/분/IP). 에셋이 많은 페이지를 여러
37
37
  사용자가 한 IP(NAT·사내망) 뒤에서 열면 기본치에 닿을 수 있다 — 그 경우
38
38
  `web.security.rateLimit: { max: ... }` 로 상향한다(끄지 말고 조정).
39
+ - **CORS·rate limit 은 앱 스코프 단위로 override 할 수 있다 (결정 339).** 전역
40
+ (`gaon.config.ts` 의 `web.security`)이 기본이고, 앱이 `app.config.ts` 의
41
+ `security: { cors, rateLimit }` 로 자기 것만 바꾼다 — **API 앱만 크로스 오리진을
42
+ 열어도 web 앱의 same-origin 기본은 그대로다**(앱별 세션 분리와 같은 축):
43
+ ```ts
44
+ // apps/api/app.config.ts
45
+ export default defineAppConfig({
46
+ security: { cors: { origin: ['https://app.example.com'] }, rateLimit: { max: 600 } },
47
+ })
48
+ ```
49
+ 생략한 필드는 전역 상속 · `false` 는 그 앱에서만 끔(명시적으로만 · 규칙 8).
50
+ **override 객체는 전역과 필드 단위로 병합된다(결정 389)** — `rateLimit: {
51
+ timeWindow: '10 minutes' }` 처럼 일부 필드만 줘도 `max` 는 전역(없으면 코어
52
+ 기본 100)을 상속한다(객체 전체 치환이면 전역 max 600 이 조용히 100 으로
53
+ 하향되는 무신호 파단이었다). CORS 는 앱이 `origin` 을 명시하지 않으면 전역/
54
+ 코어 기본(`origin:false`)이 유지된다 — **부분 override 로 CORS 가 열리지
55
+ 않는다**(fail-closed). rate limit 카운터는 **앱 단위 버킷**이다(override
56
+ 여부와 무관 — 한 클라이언트가 web·api 를 함께 써도 상한은 앱별로 센다).
57
+ 보안 응답 헤더는 전역 전용(앱 override 없음).
39
58
  - **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
40
59
  기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
41
60
  될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
@@ -85,24 +104,17 @@
85
104
  - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
86
105
  **토큰은 `<meta>` 태그가 아니라 data-page 공유 prop 으로 온다 (결정 116).**
87
106
  프레임웍 Inertia 셸은 `<meta name="csrf-token">` 을 **넣지 않는다** — csrf 는
88
- 모든 렌더에 자동 주입되는 공유 prop 이라 페이지에서 `useShared().csrf` 로 읽는다
89
- (`packages/vue/src/shared.ts`). 폼은 값을 실어 보낸다 `useForm({ ..., _csrf:
90
- shared.csrf })` 또는 HTML 폼이 보내는 메서드는 헤더로: `router.delete(url, {
91
- headers: { 'x-csrf-token': shared.csrf } })` (스캐폴드 Login/Signup/Dashboard
92
- 관례를 그대로 깐다).
93
- - `api()` 클라이언트도 **같은 data-page csrf 를 자동으로 붙인다 (결정 166).** 세션
94
- 앱에서 상태 변경 JSON 액션(`api('web:posts#tagAdd', ...)` 등)을 불러도 손수 토큰을
95
- 넘길 필요가 없다 `api()` data-page `props.csrf`(`useShared().csrf` 와 같은
96
- 단일 출처) 읽어 `X-CSRF-Token` 실어 준다(`packages/vue/src/api.ts`). data-page
97
- 가 없거나(비-Inertia) 봉인(seal)이면 레거시 `<meta name="csrf-token">` 폴백한다.
98
- JWT/API 앱은 토큰 인증이라 CSRF 대상이 아니다.
99
- - **알려진 한계**: 로그인(`this.auth.login`)은 세션을 재생성하므로(결정 254)
100
- 풀 리로드 없이 로그인한 직후에는 최초 문서의 data-page 토큰이 stale 이 돼
101
- `api()` 상태 변경 호출이 403 이 날 수 있다(useForm 은 렌더마다 갱신되는
102
- `shared.csrf` 를 쓰므로 무관). 로그인 성공 후 `api()` 변이를 이어가야 하면
103
- 풀 리로드(서버 redirect 는 SPA 네비게이션이라 불충분)를 거치거나 `useShared().csrf`
104
- 를 `opts.headers['X-CSRF-Token']` 으로 직접 넘긴다 — 근본 수정(라이브 페이지
105
- props 우선 읽기)은 후속 결정.
107
+ 모든 렌더에 자동 주입되는 공유 prop 이다(`packages/vue/src/shared.ts`).
108
+ **부착은 프레임웍 자동이다(결정 341·342 · The One Way)**: `useForm`/`router`
109
+ 상태 변경 제출과 `api()` 상태 변경 호출 전부에 **라이브 Inertia 페이지 props
110
+ csrf**(`useShared().csrf` 같은 단일 출처 · `packages/vue/src/csrf.ts`)
111
+ `X-CSRF-Token` 헤더로 자동 실린다 — 페이지가 `_csrf` 바디·수동 헤더를 싣지
112
+ 않는다(스캐폴드 Login/Signup/Dashboard 동기). 라이브 출처라 로그인(세션 재생성 ·
113
+ 결정 254) 직후에도 항상 최신 토큰이다 종전의 "최초 문서 스냅샷 stale → 로그인
114
+ api() 403" 한계는 결정 341 로 봉합됐다(최초 문서 data-page·레거시 meta
115
+ 부팅 전 폴백으로만 남는다). `useForm`/`router`/`api()` 우회하는 커스텀 전송은
116
+ `readCsrfToken()`(gaonjs/vue)으로 토큰을 읽어 직접 실어라. JWT/API 앱은 토큰
117
+ 인증이라 CSRF 대상이 아니다(토큰 없으면 아무것도 붙는 무회귀 경로).
106
118
  - **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
107
119
  세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
108
120
  `app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
@@ -121,6 +133,16 @@
121
133
  같은 핸들러가 Inertia-네이티브 에러+수리 안내로 마감한다. 비-Inertia(API/JWT)는 종전
122
134
  JSON 유지(회귀 없음). 상세는 `agents/web.md` §4.1. 앱은 아무것도 안 한다.
123
135
  - JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
136
+ - **JWT 하드닝 (결정 337)**: secret **32자 미만 = 부팅 에러**(항상) · 운영에서
137
+ dev 폴백/플레이스홀더 secret = 부팅 확정 종료(세션 결정 255 와 대칭) · 검증은
138
+ **HS256 alg 고정**(algorithm confusion 방어). 스캐폴드는 `gaon g auth --jwt
139
+ --app <api>`(결정 338) — `.env` 의 `<APP>_JWT_SECRET` 으로 주입한다.
140
+ - **폐기 한계**: 토큰은 stateless — 서버측 폐기(로그아웃·강제 무효화)가 없다.
141
+ 유출 리프레시 토큰은 만료까지 유효 · 민감 앱은 `refreshTtl` 단축(v1 범위 밖).
142
+ - **결정 391 보강**: `this.jwt.refresh` 는 재발급 전 `loadUser(sub)` 실존 확인 —
143
+ 삭제/정지된 계정은 리프레시 토큰이 살아 있어도 재발급이 거부된다(토큰 폐기가
144
+ 아니라 부재 계정 차단 — stateless 한계는 그대로). Bearer 스킴은 RFC 7235 대로
145
+ 대소문자 무관 매칭이다.
124
146
  - **인증·인가는 3층이다** (결정 145 · 149):
125
147
  - **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
126
148
  로그인 페이지 리다이렉트).
@@ -267,3 +289,12 @@ const rows = await Post.query()
267
289
  | 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 으로 조정(wire 전달) · `sameSite:'none'`+secure 미충족 = 부팅 에러(브라우저 조용한 쿠키 거부 방지) |
268
290
  | 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw — 조용한 no-op(부팅 green·로그인 영구 실패) 금지 |
269
291
  | 결정 297 | Inertia HTML/JSON 응답에 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — 공유 캐시가 사용자별 페이지(csrf·currentUser)를 저장·교차 서빙하지 못하게 |
292
+ | 결정 337 | JWT 하드닝 — secret 32자 하한 · 운영 dev 폴백 부팅 거부 · 검증 alg HS256 고정 · stateless 폐기 한계 명기(§2) |
293
+ | 결정 338 | `gaon g auth --jwt` — API 앱 토큰 스캐폴드(`<APP>_JWT_SECRET` 시드 · 공개 가입 없음 · `agents/web.md` §6) |
294
+ | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }` · 전역 상속 · 앱 단위 rate limit 버킷 · 보안 헤더는 전역 전용(§1) |
295
+ | 결정 341 | CSRF 토큰 출처 = **라이브 Inertia 페이지 props**(최초 문서 data-page·meta 는 부팅 전 폴백) — 로그인 세션 재생성(결정 254) 후 stale 403 봉합(`packages/vue/src/csrf.ts` · §2) |
296
+ | 결정 342 | CSRF 부착 The One Way — `useForm`/`router`/`api()` 상태 변경에 `X-CSRF-Token` 자동 부착 · 수동 `_csrf` 바디/헤더 제거(스캐폴드 동기) · 우회 전송은 `readCsrfToken()` 탈출구(§2) |
297
+ | 결정 388 | `api()` 도 명시 `X-CSRF-Token` 헤더를 대소문자 무관으로 존중(무조건 덮어쓰기·소문자 공존 콤마 병합 403 봉합 — 인터셉터와 대칭) |
298
+ | 결정 389 | 앱 스코프 보안 override = 전역과 **필드 병합**(부분 override 의 조용한 코어 기본 리셋 봉합) · CORS origin 미명시 = fail-closed(§1) |
299
+ | 결정 391 | JWT 보강 — `refresh` 사용자 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§2) |
300
+ | 결정 393 | 멀티파트 CSRF 403 안내 = 자동 부착 실태 + `readCsrfToken()` 탈출구로 갱신(종전 useForm 수동 헤더 예시는 결정 342 와 모순) |
@@ -24,10 +24,20 @@
24
24
 
25
25
  - **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
26
26
  - **로컬 디스크**: 항상 `${publicUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시 ·
27
- `publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **주의: 프레임웍이 이 경로를 자동 서빙하지
28
- 않는다** 브라우저에 보여 주려면 앱이 경로를 직접 노출해야 한다(예: 컨트롤러 라우트에서
29
- `Storage.get(key)` 로 읽어 응답). 자동 서빙 없이 `url()` 만 렌더하면 조용한 404 다. 개발·표시가
30
- 목적이면 **s3 디스크(dev = compose MinIO · zero-config)가 정본 경로**다.
27
+ `publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **프레임웍이 이 경로를 직접 서빙한다**
28
+ (결정 355 · `gaon serve`/`gaon dev` 루트에 자동 등록 상대 publicUrl 만 · 폴더 이탈 차단).
29
+ 업로드→`url()` 렌더→표시가 zero-config 흐른다. publicUrl 절대 URL(별도 서버/CDN)이면
30
+ 서빙하지 않는다( 서버 ).
31
+ - **공개 범위는 `publicPrefix` 아래로 한정된다**(결정 401 · 기본 `'public/'`). 서빙되는 키는
32
+ `public/...` 뿐이고 그 밖의 키는 파일이 있어도 **404**(수리 안내 포함)다 — 로컬 디스크는
33
+ 한 폴더에 공개·비공개가 섞이므로 접두사가 유일한 경계다. 따라서 **표시할 파일은
34
+ `public/` 아래에 저장한다**: `Storage.put('public/avatars/1.png', body)` →
35
+ `url('public/avatars/1.png')` = `/storage/public/avatars/1.png`.
36
+ - 접근 제어가 필요한 파일은 `public/` 밖에 두고(예 `private/...`) 컨트롤러 라우트로
37
+ 권한을 검사해 `Storage.get()` 으로 내보낸다. 디스크 전체를 공개하려면
38
+ `publicPrefix: ''` 로 **명시 옵트인**한다(비공개 파일이 없을 때만).
39
+ - 업로드된 `text/html`·`image/svg+xml` 은 `Content-Disposition: attachment` 로 내려간다
40
+ (결정 402) — 사용자가 올린 문서가 앱과 같은 오리진에서 렌더되지 않게 한다.
31
41
  - **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
32
42
  없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
33
43
  존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
@@ -62,8 +72,9 @@ storage: process.env.STORAGE_ENDPOINT
62
72
  - dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
63
73
  (결정 132 · zero-config). `.env` 는 `gaon new` 가 자동 생성하므로(결정 198)
64
74
  `gaon dev` 만으로 우회 0.
65
- - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` — `url(key)`
66
- = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 서빙은 앱 몫 — §2 주의).
75
+ - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage', publicPrefix?: 'public/' }` —
76
+ `url(key)` = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동
77
+ 서빙 — 결정 355). `publicPrefix`(기본 `'public/'`)는 **서빙되는 키 범위**다(결정 401).
67
78
  config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
68
79
  쓰면 컴파일 에러).
69
80
  - 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
@@ -92,6 +103,19 @@ export default controller({
92
103
  - **멀티파트 폼의 CSRF 는 `x-csrf-token` 헤더 전용**이다(결정 133 · 구조적).
93
104
  `useForm(...).post(url, { headers: { 'x-csrf-token': shared.csrf } })` 로 보낸다 —
94
105
  바디 `_csrf` 는 멀티파트에서 안 걸린다(상세는 `agents/web.md` §3).
106
+ - **업로드 한도는 `web.uploads`** 다(결정 356 · 기본 파일당 10MB · 최대 10개):
107
+
108
+ ```ts
109
+ // gaon.config.ts
110
+ export default defineConfig({
111
+ web: { uploads: { maxFileSize: 50 * 1024 * 1024, maxFiles: 5 } },
112
+ // web: { uploads: false }, // 멀티파트 자체를 끔
113
+ })
114
+ ```
115
+
116
+ 한도를 넘으면 **413** 이 나가고 응답에 어느 설정을 올리라는 안내가 담긴다. Inertia
117
+ 요청(`useForm().post()`)도 같은 통로로 마감된다(결정 403 · CSRF 409·415 와 동형).
118
+ 파일은 메모리에 버퍼링되므로 `maxFileSize × maxFiles` 가 요청당 메모리 상한이다.
95
119
 
96
120
  ### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
97
121
 
@@ -132,6 +156,10 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
132
156
  인프라가 버킷을 만든다. 프레임웍은 런타임에 버킷을 만들지 않는다(결정 132).
133
157
  - **멀티파트 업로드를 일반 폼처럼 `_csrf` 바디 필드로 보내면 403** — 헤더로
134
158
  옮긴다(결정 133).
159
+ - **`public/` 밖 키는 `url()` 이 만들어도 404** — 서빙 범위는 `publicPrefix`(기본
160
+ `'public/'`)로 한정된다(결정 401). 표시할 파일은 `public/` 아래에 저장한다.
161
+ - **업로드 파일명 확장자를 신뢰하지 말 것** — 사용자가 올린 `.html`·`.svg` 는 첨부로
162
+ 내려가지만(결정 402), 그 밖의 처리(썸네일·파싱)는 앱이 직접 검증해야 한다.
135
163
 
136
164
  ## 관련 결정 번호
137
165
 
@@ -140,3 +168,8 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
140
168
  - 결정 133 — 멀티파트 CSRF = `x-csrf-token` 헤더 전용(구조적).
141
169
  - 결정 129 — `gaon work` 도 `wireDomain` 으로 스토리지·메일 배선(운영 워커).
142
170
  - 결정 136 — `gaon test` 하네스가 스토리지·메일을 테스트 격리 값으로 배선.
171
+ - 결정 355 — 로컬 디스크 공개 경로를 프레임웍이 직접 서빙(조용한 404 제거).
172
+ - 결정 401 — 로컬 자동 서빙 범위를 `publicPrefix`(기본 `'public/'`)로 한정.
173
+ - 결정 402 — 업로드된 렌더 가능 타입(html·svg)은 `Content-Disposition: attachment`.
174
+ - 결정 356·403 — `web.uploads` 한도 표면 · 413 수리 안내(Inertia 동형 마감).
175
+ - 결정 405 — 서빙 root 계산을 디스크 배선과 공유(좌표 표류 방지) · 심링크 이탈 차단.
@@ -168,35 +168,32 @@ export default controller({
168
168
  ①이 검증까지 주므로 **모델이 있으면 ①을 먼저 고른다**. ②는 로그인 폼처럼
169
169
  전용 테이블이 없는 입력의 탈출구다.
170
170
 
171
- **멀티파트 업로드 + CSRF — 토큰은 `x-csrf-token` 헤더로만 (결정 133):**
171
+ **멀티파트 업로드 + CSRF — 서버 검사는 `x-csrf-token` 헤더로만 (결정 133 · 부착은 자동 · 결정 342):**
172
172
 
173
- `this.file()` 업로드(멀티파트)의 CSRF 토큰은 **`x-csrf-token` 헤더**로 보낸다.
173
+ `this.file()` 업로드(멀티파트)의 CSRF 검사는 **`x-csrf-token` 헤더**만 본다.
174
174
  멀티파트는 `parts()` 스트리밍이라 CSRF 검사(preHandler) 시점에 **바디가 아직
175
175
  파싱되지 않아** 폼 필드 `_csrf` 가 검사에 잡히지 않는다(구조적 한계 · 디스패처가
176
- handler 안에서 파싱). 일반 JSON 폼의 `_csrf` 바디 폴백은 멀티파트엔
177
- 통하지 않는다. 파일이 있으면 `useForm` 자동으로 multipart 보내므로, 업로드
178
- 제출은 **반드시 헤더**로 토큰을 실어야 한다. (참고: 지원 Content-Type
179
- `application/json` · `multipart/form-data` 뿐이라 `x-www-form-urlencoded` 로 폼을
180
- 보내면 `_csrf` 폴백에 닿기 전에 415 로 거부된다 · `inertia.ts` · §아래 415.)
176
+ handler 안에서 파싱). **결정 342 이후 헤더 부착은 프레임웍 자동**이다 —
177
+ `useForm`/`router` 상태 변경 제출(멀티파트 포함)에 최신 토큰이
178
+ `X-CSRF-Token` 으로 자동 실리므로 업로드 폼도 손으로 헤더를 넘기지 않는다.
179
+ (참고: 지원 Content-Type 은 `application/json` · `multipart/form-data` 뿐이라
180
+ `x-www-form-urlencoded` 폼을 보내면 415 로 거부된다 · `inertia.ts` · §아래 415.)
181
181
 
182
182
  ```vue
183
183
  <script setup lang="ts">
184
- import { useForm, useShared } from 'gaonjs/vue'
185
- const shared = useShared() // csrf 는 자동 주입 공유 prop (결정 116)
184
+ import { useForm } from 'gaonjs/vue'
186
185
  const form = useForm({ avatar: null as File | null })
187
186
 
188
187
  function submit() {
189
- // 파일이 있으면 multipart — csrf x-csrf-token 헤더로(바디 _csrf 는 안 걸림).
190
- form.post('/uploads', { headers: { 'x-csrf-token': shared.csrf } })
188
+ form.post('/uploads') // 파일이 있으면 multipart — CSRF 헤더는 자동(결정 342)
191
189
  }
192
190
  </script>
193
191
  ```
194
192
 
195
- - **알려진 함정:** 업로드 폼을 일반 폼처럼 `useForm({ avatar, _csrf: shared.csrf })`
196
- 짜면 `_csrf` 멀티파트 필드로 들어가 **검사 시점에 없어 403** 이 난다. 서버는
197
- 경우 "→ x-csrf-token 헤더로 보내라" 수리 안내와 함께 403 을 돌려준다.
198
- - 비멀티파트 폼은 지금처럼 `_csrf: shared.csrf` 바디 필드로 그대로 보낸다(§4 로그인
199
- 예시). 멀티파트일 때만 헤더가 유일 경로다.
193
+ - **알려진 함정:** 업로드 폼에 `useForm({ avatar, _csrf: ... })` 처럼 `_csrf` 를
194
+ 바디 필드로 넣어도 멀티파트에선 **검사 시점에 없어 무의미**하다(자동 헤더가
195
+ 실질 경로). 커스텀 fetch 업로더처럼 `useForm`/`router` 우회하면 자동 부착이
196
+ 없다 `readCsrfToken()`(gaonjs/vue)으로 토큰을 읽어 헤더에 직접 실어라.
200
197
 
201
198
  ### 4. 데이터 경로 판단 — 루트 판단표가 정본
202
199
 
@@ -213,14 +210,13 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
213
210
  ```ts
214
211
  // 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
215
212
  // pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
216
- // csrf 자동 주입 공유 prop useShared() 읽는다(결정 116).
213
+ // CSRF 토큰은 프레임웍이 자동 부착한다(결정 342)_csrf 바디도, 수동 헤더도 없다.
217
214
  const props = pageProps<'web:session#new'>()
218
- const shared = useShared()
219
- const form = useForm({ email: '', password: '', _csrf: shared.csrf })
215
+ const form = useForm({ email: '', password: '' })
220
216
  // <form @submit.prevent="form.post('/session')"> · 실패 시 {{ props.error }} 가 반응형으로 갱신
221
217
 
222
- // HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다.
223
- router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
218
+ // HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다(CSRF 자동).
219
+ router.delete('/session')
224
220
  ```
225
221
 
226
222
  `?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
@@ -489,6 +485,26 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
489
485
 
490
486
  API 앱(JWT)은 세션 대신 `this.jwt.issue(user)` / `this.jwt.refresh(token)` 를 쓴다.
491
487
 
488
+ - **JWT 하드닝 (결정 337 · 세션 결정 255 와 대칭).** JWT secret 은 **32자 이상**이
489
+ 부팅 요건이고(미만 = 부팅 에러), 운영(NODE_ENV=production)에서 dev 폴백/
490
+ 플레이스홀더(`dev-only-…`·`change-me…`)가 남아 있으면 부팅이 확정 종료된다.
491
+ 검증은 **HS256 으로 alg 고정** — 같은 키의 HS384/512 서명·헤더 alg 조작은
492
+ 통하지 않는다. **알려진 한계**: 토큰은 stateless 라 서버측 폐기(로그아웃·강제
493
+ 무효화) 수단이 없다 — 유출된 리프레시 토큰은 만료(기본 7d)까지 유효하므로
494
+ 민감한 앱은 `refreshTtl` 을 짧게 잡는다(서버측 폐기 목록은 v1 범위 밖).
495
+ **결정 391 보강**: ① `this.jwt.refresh` 는 재발급 전에 `loadUser(sub)` 로 사용자
496
+ 실존을 확인한다 — 삭제/정지된 계정(loadUser 가 falsy)은 리프레시 토큰이 만료
497
+ 전이어도 재발급이 거부된다(토큰 폐기가 아니라 부재 계정 차단 — stateless 한계는
498
+ 그대로). ② `Authorization` 의 Bearer 스킴은 대소문자 무관이다(RFC 7235 —
499
+ `bearer`/`BEARER` 클라이언트도 인증된다).
500
+
501
+ - **JWT 스캐폴드 = `gaon g auth --jwt --app <api>`** (결정 338 · `--app` 필수 ·
502
+ web 불가 — web 은 세션이 정본). 페이지·회원가입 없이 스키마/모델(공유) +
503
+ 토큰 컨트롤러(JSON 전용: `POST /session` 발급 · `POST /session/refresh` 재발급 ·
504
+ `GET /session` 현재 사용자[Bearer]) + `auth: { strategy:'jwt', secret, loadUser }`
505
+ 배선 + `.env` 에 `<APP>_JWT_SECRET` 시드를 깐다. 계정은 web 앱 가입 또는
506
+ seed 로 만든다(API 앱에 공개 가입 없음).
507
+
492
508
  - **API 앱은 프론트엔드가 없다(JSON 전용).** `apps/<app>/app.config.ts`(`auth: { strategy:'jwt', … }`)
493
509
  + `routes.ts` + `controllers/` 만 두면 된다 — `index.html`·`main.ts`·`pages/` 는 만들지 않는다.
494
510
  앱 발견은 `routes.ts` 기준이라 `gaon serve` 가 이 앱을 `/<app>` 프리픽스로 정상 마운트하고,
@@ -568,6 +584,8 @@ export default controller({
568
584
  - **액션이 아무것도 반환하지 않으면 204 No Content 다** — `this.render(...)`
569
585
  를 호출만 하고 `return` 을 빼먹으면 컴파일은 통과하고 페이지가 조용히
570
586
  빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
587
+ `gaon doctor` 의 **render-return** 검사가 이 패턴(호출만 하고 return 누락)을
588
+ 경고로 잡는다(결정 340 · 의도된 204 는 응답 호출 없이 그냥 return).
571
589
  - **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
572
590
  처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
573
591
  이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
@@ -582,6 +600,10 @@ export default controller({
582
600
  - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
583
601
  - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
584
602
  (결정 37 · 상세는 `agents/frontend.md`).
603
+ - **render/JSON props 에 `Map`/`Set` 을 넘기지 말 것 (결정 392)** — JSON 직렬화
604
+ 대응이 하나가 아니라 프레임웍이 자동 변환하지 않고 **수리 안내 에러**로 막는다
605
+ (이전엔 조용히 `{}` 가 됐다 — 무신호 파손). `Object.fromEntries(map)`·
606
+ `[...map.entries()]`·`[...set]` 으로 변환해 넘긴다.
585
607
  - **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
586
608
  (결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
587
609
  라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
@@ -607,7 +629,8 @@ export default controller({
607
629
  | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps → 모든 렌더 자동 주입 · 코어 3종 예약(덮으면 throw) · 선언 병합 타입 · hidden 미유출 · useShared 로 읽기(§4.2) |
608
630
  | 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
609
631
  | 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
610
- | 결정 133 | 멀티파트 업로드(`this.file()`) CSRF `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
632
+ | 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 검사는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내 · 부착은 결정 342 로 자동화) |
633
+ | 결정 342 | CSRF 부착 The One Way — `useForm`/`router` 상태 변경 visit 에 프레임웍이 `X-CSRF-Token` 자동 부착(라이브 페이지 props 출처 · 결정 341 · api() 와 단일 출처) · 수동 `_csrf` 바디/헤더 보일러플레이트 제거(스캐폴드 동기) · 명시 헤더는 존중(탈출구) |
611
634
  | 결정 64 | 폼 API 는 `gaonjs/vue` 의 `useForm`·`router` 뿐 — 로그아웃 등 DELETE 는 `router.delete()`(`@inertiajs/vue3` 직접 import 금지 · `Inertia.post()` 유령 API 아님) |
612
635
  | 결정 122 | 관계·hidden 값이 render 경계 `serializeProps` 를 넘어 새지 않는다 — hidden 컬럼 제외 유지(§4.2) |
613
636
  | 결정 165 | 세션/CSRF 실패·415(지원 안 되는 Content-Type)를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 으로 앱을 깨지 않는다(§4.1 · 지원 타입 `application/json`·`multipart/form-data`) |
@@ -620,6 +643,15 @@ export default controller({
620
643
  | 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw(조용한 no-op 금지 · §6) |
621
644
  | 결정 297 | 초기 HTML 문서에도 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — CDN/공유 캐시가 사용자별 data-page(csrf·currentUser)를 캐시하지 못하게 |
622
645
  | 결정 298 | render props 순환 참조 = 스택 오버플로 대신 수리 안내 에러(같은 객체의 형제 중복(DAG)은 정상) |
646
+ | 결정 337 | JWT 하드닝 — secret 32자 하한(부팅) · 운영 dev 폴백 거부 · 검증 alg HS256 고정 · stateless 폐기 불가 한계 명기(§6) |
647
+ | 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
648
+ | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
649
+ | 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
650
+ | 결정 389 | 앱 스코프 보안 override 는 전역과 **필드 병합** — 부분 override 가 나머지 필드를 코어 기본으로 리셋하지 않음 · CORS 는 origin 미명시 시 fail-closed(`agents/security.md` §1) |
651
+ | 결정 390 | Inertia 렌더의 `Vary: X-Inertia` 는 기존 Vary(CORS `Origin` 등)에 **병합**(치환 아님) |
652
+ | 결정 391 | JWT 보강 — `refresh` 가 `loadUser(sub)` 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§6) |
653
+ | 결정 392 | render/JSON props 의 `Map`/`Set` = 수리 안내 에러(조용한 `{}` 봉합 · 함정 §알려진 함정) |
654
+ | 결정 393 | 멀티파트 CSRF 403 안내문 = 결정 342 실태(자동 부착 · 커스텀 전송은 `readCsrfToken()`) 로 갱신 |
623
655
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
624
656
 
625
657
  ## `@gaonjs/seal` 켠 앱
package/dist/work.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface WorkCommandOptions {
19
19
  readonly outboxPurgeIntervalMs?: number;
20
20
  /** 아웃박스 릴레이 폴링 주기(ms). 생략 시 GAON_OUTBOX_RELAY_POLL_MS(결정 312). */
21
21
  readonly relayPollMs?: number;
22
+ /** 아웃박스 claim 리스(ms · dedupe 창보다 작아야 함 — 결정 396). 생략 시 GAON_OUTBOX_CLAIM_TIMEOUT_MS. */
23
+ readonly outboxClaimTimeoutMs?: number;
22
24
  /** 시그널 등록·해제(테스트 주입). 기본 process. */
23
25
  readonly signals?: {
24
26
  on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
@@ -35,6 +37,7 @@ export declare function outboxTuningFromEnv(env?: Record<string, string | undefi
35
37
  outboxRetentionMs?: number;
36
38
  outboxPurgeIntervalMs?: number;
37
39
  relayPollMs?: number;
40
+ outboxClaimTimeoutMs?: number;
38
41
  };
39
42
  /** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
40
43
  * 오류·리스너 폐기·워커 인프라 오류)가 기본(human) 모드에서 0 신호이던 갭을 닫는다. */
package/dist/work.js CHANGED
@@ -47,6 +47,9 @@ export function outboxTuningFromEnv(env = process.env) {
47
47
  outboxRetentionMs: readInt('GAON_OUTBOX_RETENTION_MS'),
48
48
  outboxPurgeIntervalMs: readInt('GAON_OUTBOX_PURGE_INTERVAL_MS'),
49
49
  relayPollMs: readInt('GAON_OUTBOX_RELAY_POLL_MS'),
50
+ // 결정 396: claim 리스 운영 튜닝 표면 — dedupe 창(기본 120s)보다 작아야 하며,
51
+ // 위반은 runWork 부팅이 fail-loud 로 잡는다.
52
+ outboxClaimTimeoutMs: readInt('GAON_OUTBOX_CLAIM_TIMEOUT_MS'),
50
53
  };
51
54
  }
52
55
  /** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
@@ -164,6 +167,7 @@ export async function runWorkCommand(opts = {}) {
164
167
  outboxRetentionMs: opts.outboxRetentionMs ?? outboxEnv.outboxRetentionMs,
165
168
  outboxPurgeIntervalMs: opts.outboxPurgeIntervalMs ?? outboxEnv.outboxPurgeIntervalMs,
166
169
  relayPollMs: opts.relayPollMs ?? outboxEnv.relayPollMs,
170
+ outboxClaimTimeoutMs: opts.outboxClaimTimeoutMs ?? outboxEnv.outboxClaimTimeoutMs,
167
171
  onEvent: emit,
168
172
  });
169
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.47.0",
3
+ "version": "0.55.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
- "@gaonjs/config": "0.20.0",
35
+ "@gaonjs/config": "0.23.0",
36
+ "@gaonjs/async": "0.18.0",
36
37
  "@gaonjs/core": "0.2.4",
37
- "@gaonjs/async": "0.16.0",
38
- "@gaonjs/i18n": "0.2.4",
39
- "@gaonjs/mail": "0.3.3",
40
- "@gaonjs/data": "0.22.0",
41
- "@gaonjs/web": "0.24.1"
38
+ "@gaonjs/i18n": "0.3.0",
39
+ "@gaonjs/data": "0.25.0",
40
+ "@gaonjs/mail": "0.5.0",
41
+ "@gaonjs/web": "0.29.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""