@gaonjs/cli 0.47.0 → 0.52.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 (49) hide show
  1. package/dist/commands/check.d.ts +1 -1
  2. package/dist/commands/check.js +1 -1
  3. package/dist/commands/test.js +16 -3
  4. package/dist/db/journal.d.ts +4 -3
  5. package/dist/db/journal.js +21 -10
  6. package/dist/db/migrate.d.ts +3 -1
  7. package/dist/db/migrate.js +3 -3
  8. package/dist/db/replay.js +2 -2
  9. package/dist/db/status.js +13 -3
  10. package/dist/dev.d.ts +6 -4
  11. package/dist/dev.js +9 -4
  12. package/dist/doctor/fixers/index.d.ts +1 -1
  13. package/dist/doctor/fixers/index.js +6 -1
  14. package/dist/doctor/locale-parity.js +4 -1
  15. package/dist/doctor/render-return.d.ts +11 -0
  16. package/dist/doctor/render-return.js +143 -0
  17. package/dist/doctor/types.d.ts +1 -1
  18. package/dist/doctor.d.ts +3 -2
  19. package/dist/doctor.js +16 -5
  20. package/dist/generate.d.ts +20 -1
  21. package/dist/generate.js +120 -21
  22. package/dist/hub.js +2 -0
  23. package/dist/i18n-config.d.ts +12 -0
  24. package/dist/i18n-config.js +95 -0
  25. package/dist/index.js +41 -12
  26. package/dist/mcp/tools.d.ts +1 -1
  27. package/dist/mcp/tools.js +9 -6
  28. package/dist/messages-gen.d.ts +1 -1
  29. package/dist/messages-gen.js +5 -2
  30. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  31. package/dist/templates/auth/Login.vue.tpl +3 -5
  32. package/dist/templates/auth/Signup.vue.tpl +3 -5
  33. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  34. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  35. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  36. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  37. package/dist/templates/project/AGENTS.md.tpl +3 -2
  38. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  39. package/dist/templates/project/agents/async.md.tpl +35 -8
  40. package/dist/templates/project/agents/data.md.tpl +113 -49
  41. package/dist/templates/project/agents/frontend.md.tpl +15 -4
  42. package/dist/templates/project/agents/i18n.md.tpl +5 -2
  43. package/dist/templates/project/agents/mail.md.tpl +2 -1
  44. package/dist/templates/project/agents/realtime.md.tpl +16 -6
  45. package/dist/templates/project/agents/seal.md.tpl +6 -3
  46. package/dist/templates/project/agents/security.md.tpl +37 -19
  47. package/dist/templates/project/agents/storage.md.tpl +6 -5
  48. package/dist/templates/project/agents/web.md.tpl +40 -22
  49. package/package.json +6 -6
@@ -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,21 @@ 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
+
496
+ - **JWT 스캐폴드 = `gaon g auth --jwt --app <api>`** (결정 338 · `--app` 필수 ·
497
+ web 불가 — web 은 세션이 정본). 페이지·회원가입 없이 스키마/모델(공유) +
498
+ 토큰 컨트롤러(JSON 전용: `POST /session` 발급 · `POST /session/refresh` 재발급 ·
499
+ `GET /session` 현재 사용자[Bearer]) + `auth: { strategy:'jwt', secret, loadUser }`
500
+ 배선 + `.env` 에 `<APP>_JWT_SECRET` 시드를 깐다. 계정은 web 앱 가입 또는
501
+ seed 로 만든다(API 앱에 공개 가입 없음).
502
+
492
503
  - **API 앱은 프론트엔드가 없다(JSON 전용).** `apps/<app>/app.config.ts`(`auth: { strategy:'jwt', … }`)
493
504
  + `routes.ts` + `controllers/` 만 두면 된다 — `index.html`·`main.ts`·`pages/` 는 만들지 않는다.
494
505
  앱 발견은 `routes.ts` 기준이라 `gaon serve` 가 이 앱을 `/<app>` 프리픽스로 정상 마운트하고,
@@ -568,6 +579,8 @@ export default controller({
568
579
  - **액션이 아무것도 반환하지 않으면 204 No Content 다** — `this.render(...)`
569
580
  를 호출만 하고 `return` 을 빼먹으면 컴파일은 통과하고 페이지가 조용히
570
581
  빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
582
+ `gaon doctor` 의 **render-return** 검사가 이 패턴(호출만 하고 return 누락)을
583
+ 경고로 잡는다(결정 340 · 의도된 204 는 응답 호출 없이 그냥 return).
571
584
  - **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
572
585
  처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
573
586
  이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
@@ -607,7 +620,8 @@ export default controller({
607
620
  | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps → 모든 렌더 자동 주입 · 코어 3종 예약(덮으면 throw) · 선언 병합 타입 · hidden 미유출 · useShared 로 읽기(§4.2) |
608
621
  | 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
609
622
  | 결정 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 + 수리 안내) |
623
+ | 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 검사는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내 · 부착은 결정 342 로 자동화) |
624
+ | 결정 342 | CSRF 부착 The One Way — `useForm`/`router` 상태 변경 visit 에 프레임웍이 `X-CSRF-Token` 자동 부착(라이브 페이지 props 출처 · 결정 341 · api() 와 단일 출처) · 수동 `_csrf` 바디/헤더 보일러플레이트 제거(스캐폴드 동기) · 명시 헤더는 존중(탈출구) |
611
625
  | 결정 64 | 폼 API 는 `gaonjs/vue` 의 `useForm`·`router` 뿐 — 로그아웃 등 DELETE 는 `router.delete()`(`@inertiajs/vue3` 직접 import 금지 · `Inertia.post()` 유령 API 아님) |
612
626
  | 결정 122 | 관계·hidden 값이 render 경계 `serializeProps` 를 넘어 새지 않는다 — hidden 컬럼 제외 유지(§4.2) |
613
627
  | 결정 165 | 세션/CSRF 실패·415(지원 안 되는 Content-Type)를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 으로 앱을 깨지 않는다(§4.1 · 지원 타입 `application/json`·`multipart/form-data`) |
@@ -620,6 +634,10 @@ export default controller({
620
634
  | 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw(조용한 no-op 금지 · §6) |
621
635
  | 결정 297 | 초기 HTML 문서에도 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — CDN/공유 캐시가 사용자별 data-page(csrf·currentUser)를 캐시하지 못하게 |
622
636
  | 결정 298 | render props 순환 참조 = 스택 오버플로 대신 수리 안내 에러(같은 객체의 형제 중복(DAG)은 정상) |
637
+ | 결정 337 | JWT 하드닝 — secret 32자 하한(부팅) · 운영 dev 폴백 거부 · 검증 alg HS256 고정 · stateless 폐기 불가 한계 명기(§6) |
638
+ | 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
639
+ | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
640
+ | 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
623
641
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
624
642
 
625
643
  ## `@gaonjs/seal` 켠 앱
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.47.0",
3
+ "version": "0.52.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/async": "0.17.0",
36
+ "@gaonjs/config": "0.22.0",
36
37
  "@gaonjs/core": "0.2.4",
37
- "@gaonjs/async": "0.16.0",
38
+ "@gaonjs/data": "0.24.0",
38
39
  "@gaonjs/i18n": "0.2.4",
39
- "@gaonjs/mail": "0.3.3",
40
- "@gaonjs/data": "0.22.0",
41
- "@gaonjs/web": "0.24.1"
40
+ "@gaonjs/mail": "0.4.0",
41
+ "@gaonjs/web": "0.27.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})\""