@gaonjs/cli 0.52.0 → 0.56.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 (51) hide show
  1. package/dist/commands/check.d.ts +2 -0
  2. package/dist/commands/check.js +43 -1
  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 +28 -4
  8. package/dist/db/journal.d.ts +4 -1
  9. package/dist/db/journal.js +37 -4
  10. package/dist/db/migrate.js +11 -11
  11. package/dist/db/replay.js +1 -1
  12. package/dist/db/resolve.d.ts +11 -1
  13. package/dist/db/resolve.js +24 -2
  14. package/dist/db/status.js +9 -6
  15. package/dist/db.js +26 -5
  16. package/dist/dev.js +2 -2
  17. package/dist/doctor/auth-wiring.js +5 -2
  18. package/dist/doctor/channel-collision.d.ts +9 -0
  19. package/dist/doctor/channel-collision.js +119 -0
  20. package/dist/doctor/fixers/index.d.ts +1 -1
  21. package/dist/doctor/fixers/index.js +6 -1
  22. package/dist/doctor/locale-parity.js +2 -2
  23. package/dist/doctor/types.d.ts +1 -1
  24. package/dist/doctor.d.ts +10 -2
  25. package/dist/doctor.js +45 -3
  26. package/dist/generate.d.ts +5 -0
  27. package/dist/generate.js +11 -3
  28. package/dist/i18n-config.d.ts +20 -0
  29. package/dist/i18n-config.js +87 -12
  30. package/dist/index.js +111 -26
  31. package/dist/mcp/tools.js +4 -0
  32. package/dist/messages-gen.js +11 -3
  33. package/dist/scaffold/controller.js +3 -1
  34. package/dist/scaffold/page.js +6 -4
  35. package/dist/templates/project/AGENTS.md.tpl +9 -5
  36. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  37. package/dist/templates/project/Dockerfile.tpl +11 -1
  38. package/dist/templates/project/agents/async.md.tpl +56 -14
  39. package/dist/templates/project/agents/data.md.tpl +146 -35
  40. package/dist/templates/project/agents/frontend.md.tpl +24 -10
  41. package/dist/templates/project/agents/i18n.md.tpl +32 -4
  42. package/dist/templates/project/agents/mail.md.tpl +6 -0
  43. package/dist/templates/project/agents/realtime.md.tpl +115 -16
  44. package/dist/templates/project/agents/seal.md.tpl +13 -4
  45. package/dist/templates/project/agents/security.md.tpl +29 -4
  46. package/dist/templates/project/agents/storage.md.tpl +57 -13
  47. package/dist/templates/project/agents/testing.md.tpl +58 -0
  48. package/dist/templates/project/agents/web.md.tpl +171 -15
  49. package/dist/work.d.ts +3 -0
  50. package/dist/work.js +4 -0
  51. package/package.json +7 -7
@@ -6,6 +6,58 @@
6
6
 
7
7
  ## 정본 규칙
8
8
 
9
+ ### 0. 라우트 DSL — `apps/<앱>/routes.ts` (`packages/web/src/routes.ts`)
10
+
11
+ 라우트는 앱마다 `routes.ts` 하나에 선언한다(`export default routes((r) => {...})`).
12
+ 빌더 `r` 의 표면은 **아래 7개가 전부**다 — 표에 없는 메서드(`r.namespace`·
13
+ `r.scope`·`r.match`·`r.root` 등)는 존재하지 않는다(AGENTS §0 "표에 없는 API 추측 금지").
14
+
15
+ | 호출 | 등록되는 것 |
16
+ |---|---|
17
+ | `r.get(path, '<컨트롤러>#<액션>')` | GET 한 건 |
18
+ | `r.post(path, '<컨트롤러>#<액션>')` | POST 한 건 |
19
+ | `r.put(path, '<컨트롤러>#<액션>')` | PUT 한 건 |
20
+ | `r.patch(path, '<컨트롤러>#<액션>')` | PATCH 한 건 |
21
+ | `r.delete(path, '<컨트롤러>#<액션>')` | DELETE 한 건 |
22
+ | `r.resources('posts')` | **복수** 리소스 7액션 |
23
+ | `r.resource('session')` | **단수** 리소스 5액션(`index`·`:id` 없음) |
24
+
25
+ ```ts
26
+ // apps/web/routes.ts
27
+ import { routes } from 'gaonjs/web'
28
+
29
+ export default routes((r) => {
30
+ r.resources('posts') // 표준 7액션 전개
31
+ r.resource('session') // 단수 리소스(로그인 세션)
32
+ r.get('/dashboard', 'dashboard#show') // 단건 라우트
33
+ r.post('/posts/:id/tagAdd', 'posts#tagAdd') // 액션명 = camelCase 메서드명(§1)
34
+ })
35
+ ```
36
+
37
+ `r.resources('posts')` 가 펴는 7 엔트리(Rails 관례):
38
+
39
+ | 메서드 | 경로 | 액션 |
40
+ |---|---|---|
41
+ | GET | `/posts` | `index` |
42
+ | GET | `/posts/new` | `new` |
43
+ | POST | `/posts` | `create` |
44
+ | GET | `/posts/:id` | `show` |
45
+ | GET | `/posts/:id/edit` | `edit` |
46
+ | PATCH | `/posts/:id` | `update` |
47
+ | DELETE | `/posts/:id` | `destroy` |
48
+
49
+ `r.resource('session')`(단수) 5 엔트리: GET `/session/new`→`new` ·
50
+ POST `/session`→`create` · GET `/session`→`show` · PATCH `/session`→`update` ·
51
+ DELETE `/session`→`destroy`.
52
+
53
+ - **`update` 는 PATCH 다** — `resources`/`resource` 는 PUT 을 등록하지 않는다.
54
+ PUT 이 필요하면 `r.put(...)` 로 직접 건다.
55
+ - **라우트 표는 후보다** — 컨트롤러에 그 액션이 구현돼 있지 않으면 조용히
56
+ 스킵된다(정상 경로). 반면 타깃 형식 불량(`r.get('/x', 'posts')` 처럼 `#`
57
+ 누락)은 **부팅 throw** 다(결정 293 · §알려진 함정).
58
+ - **경로는 앱 프리픽스 이전의 상대 경로**다 — admin 앱의 `r.get('/posts', …)`
59
+ 는 최종 `/admin/posts` 로 뜬다(§7 멀티앱 URL 관례).
60
+
9
61
  ### 1. JSON 액션 — 반환값이 곧 응답 (errata E-3 §3.1)
10
62
 
11
63
  컨트롤러 액션이 `this.render(...)` 나 `this.redirect(...)` 대신
@@ -415,8 +467,14 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
415
467
  빌드 없이 어느 환경에서나 설치가 확실하다.
416
468
  - **저장 위치** — 결과는 스키마의 hidden 컬럼(`passwordDigest: t.string().hidden()`)
417
469
  에 담는다 — 응답 경계에서 타입·런타임 양쪽으로 페이지 노출이 막힌다.
418
- - 회원 생성 로직은 컨트롤러가 아니라 **서비스**(`agents/data.md` §9 · registerUser)에
419
- 둔다 `const user = await RegisterUser.call({ name, email, password })`.
470
+ - **가입 컨트롤러는 이메일 중복을 pre-check 한다 (결정 256).** `gaon g auth` 스키마의
471
+ `email` `.unique()` DDL UNIQUE 중복을 막지만, 그것만 믿으면 중복 가입이 raw DB
472
+ 에러(500)로 터진다. 컨트롤러가 먼저 조회해 친절한 폼 에러로 마감하고, 유니크 제약은
473
+ TOCTOU 레이스의 backstop 으로 남긴다(아래 §정본 예시 = 스캐폴드와 같은 형태).
474
+ - 모델 여럿·트랜잭션·외부 API 가 얽히면 회원 생성 로직을 **서비스**(`domain/services/` ·
475
+ `service()` 시그니처·트랜잭션 계약은 `agents/data.md` §9)로 뺀다 —
476
+ `const user = await RegisterUser.call({ name, email, password })`. 스캐폴드 기본형은
477
+ 모델 하나뿐이라 컨트롤러에 둔다(§5.3 판단표 · 아래 §정본 예시).
420
478
 
421
479
  ### 6. 인증·세션 (v0.15 §7 · M5)
422
480
 
@@ -425,6 +483,10 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
425
483
  서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
426
484
  프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
427
485
  결정 142. 분리는 위 세 축으로 완성된다.)
486
+ - **세션 secret env 이름 = web 은 `SESSION_SECRET`, 그 외 앱은 `<APP>_SESSION_SECRET`**
487
+ (앱 이름 대문자화 · 예: admin → `ADMIN_SESSION_SECRET` · 결정 141). `gaon g auth --app <앱>`
488
+ 이 `.env`·`.env.example` 에 그 키를 시드하고 app.config 가 읽는다 — 운영에서는 앱마다
489
+ **다른** 값을 주입한다(운영 fail-loud 는 `agents/security.md` §2 · 결정 255).
428
490
  - **쿠키 secure·sameSite 는 app.config 의 session 에서 조정한다(결정 295).**
429
491
  `secure` 생략 시 운영(NODE_ENV=production)이면 켬 — 운영인데 비-TLS(사내
430
492
  내부망 http)로 서빙하면 Secure 쿠키가 안 실려 로그인이 조용히 실패하므로 그
@@ -459,7 +521,7 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
459
521
  세션이 없는 앱(세션 미구성·JWT/API)에서 `login`/`logout` 을 부르면 **즉시
460
522
  throw(500 + 수리 안내)** 한다(결정 296) — 이전엔 조용한 no-op 이라 부팅
461
523
  green·로그인만 영구 실패였다. 세션 앱이면 app.config 에 session 을 배선하고,
462
- JWT 앱이면 `this.jwt.issue`/`this.jwt.refresh` 를 쓴다.
524
+ JWT 앱이면 `this.jwt!.issue`/`this.jwt!.refresh` 를 쓴다.
463
525
 
464
526
  `login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
465
527
  디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
@@ -483,7 +545,10 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
483
545
  }
484
546
  ```
485
547
 
486
- API 앱(JWT)은 세션 대신 `this.jwt.issue(user)` / `this.jwt.refresh(token)` 를 쓴다.
548
+ API 앱(JWT)은 세션 대신 `this.jwt!.issue(user)` / `this.jwt!.refresh(token)` 를 쓴다 —
549
+ `this.jwt` 는 **optional 프로퍼티**(`readonly jwt?: JwtApi`)라 JWT 앱에서도 non-null
550
+ 단언 `!` 이 필요하다(세션 앱엔 없기 때문 · 스캐폴드 `jwt.session.controller` 동형).
551
+ `refresh` 는 토큰이 무효·만료면 `null` 을 돌려주므로 401 로 마감한다.
487
552
 
488
553
  - **JWT 하드닝 (결정 337 · 세션 결정 255 와 대칭).** JWT secret 은 **32자 이상**이
489
554
  부팅 요건이고(미만 = 부팅 에러), 운영(NODE_ENV=production)에서 dev 폴백/
@@ -492,13 +557,37 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
492
557
  통하지 않는다. **알려진 한계**: 토큰은 stateless 라 서버측 폐기(로그아웃·강제
493
558
  무효화) 수단이 없다 — 유출된 리프레시 토큰은 만료(기본 7d)까지 유효하므로
494
559
  민감한 앱은 `refreshTtl` 을 짧게 잡는다(서버측 폐기 목록은 v1 범위 밖).
560
+ **결정 391 보강**: ① `this.jwt.refresh` 는 재발급 전에 `loadUser(sub)` 로 사용자
561
+ 실존을 확인한다 — 삭제/정지된 계정(loadUser 가 falsy)은 리프레시 토큰이 만료
562
+ 전이어도 재발급이 거부된다(토큰 폐기가 아니라 부재 계정 차단 — stateless 한계는
563
+ 그대로). ② `Authorization` 의 Bearer 스킴은 대소문자 무관이다(RFC 7235 —
564
+ `bearer`/`BEARER` 클라이언트도 인증된다).
565
+
566
+ - **API(JWT) 앱 생성 The One Way = `gaon g auth --jwt --app <이름>` 단독** (결정 338 ·
567
+ `--app` 필수 · web 불가 — web 은 세션이 정본). **`gaon g app <이름>` 을 먼저 돌리지
568
+ 않는다** — 이 명령 하나가 앱 폴더째 낳는다:
569
+
570
+ ```bash
571
+ gaon g auth --jwt --app api # apps/api/ 가 이 한 줄로 생긴다(g app 선행 불필요)
572
+ ```
495
573
 
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 앱에 공개 가입 없음).
574
+ | 생성물 | 내용 |
575
+ |---|---|
576
+ | `domain/schema/users.ts` · `domain/models/User.ts` | 스키마·모델(세션 앱과 **공유** · 이미 있으면 skip) |
577
+ | `apps/<app>/routes.ts` | 토큰 라우트 3종(없으면 생성 · 있으면 패치) |
578
+ | `apps/<app>/controllers/session.ts` | 토큰 컨트롤러(JSON 전용) |
579
+ | `apps/<app>/auth.ts` | `loadUser`(JWT sub 사용자) + `GaonCurrentUser` 증강 |
580
+ | `apps/<app>/app.config.ts` | `auth: { strategy:'jwt', secret, loadUser }` 배선 |
581
+ | `.env`·`.env.example` | `<APP>_JWT_SECRET` 시드(예: `API_JWT_SECRET`) |
582
+
583
+ 라우트 3종은 `POST /session` 발급 · `POST /session/refresh` 재발급 ·
584
+ `GET /session` 현재 사용자(Bearer)다. 계정은 web 앱 가입 또는 seed 로 만든다
585
+ (API 앱에 공개 가입 없음 — `--public` 은 JWT 변형에서 거부된다).
586
+
587
+ - **`gaon g app` 을 먼저 돌리면 오히려 어긋난다** — `g app` 은 **프론트 앱**을 깔기
588
+ 때문에 `index.html`·`main.ts`·`pages/`·Tailwind 배선까지 함께 생긴다. API 앱엔 이
589
+ 파일들이 불필요하고, 남아 있으면 `gaon build`·`gaon check` 가 이 앱을 프론트 앱으로
590
+ 보고 빌드·검사한다. 이미 `g app` 으로 만들어 버렸다면 그 프론트 파일들을 지운다.
502
591
 
503
592
  - **API 앱은 프론트엔드가 없다(JSON 전용).** `apps/<app>/app.config.ts`(`auth: { strategy:'jwt', … }`)
504
593
  + `routes.ts` + `controllers/` 만 두면 된다 — `index.html`·`main.ts`·`pages/` 는 만들지 않는다.
@@ -517,6 +606,20 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
517
606
  }
518
607
  ```
519
608
 
609
+ **스키마에 컬럼을 더하면 이 증강도 함께 갱신한다.** 증강은 자동 생성이 아니라
610
+ 스캐폴드가 한 번 심는 손 선언이라 스키마와 자동 동기되지 않는다 — 예컨대 역할
611
+ 게이트(`this.authorize(this.currentUser?.role === 'admin')` · `agents/security.md` §2)
612
+ 를 쓰려고 `domain/schema/users.ts` 에 `role` 컬럼을 추가했다면, `apps/<app>/auth.ts`
613
+ 의 `GaonCurrentUser` 에도 `role: string` 을 더해야 한다. 안 그러면 그 접근이
614
+ **TS2339**(`role` 없음)로 컴파일에서 막힌다.
615
+
616
+ ```ts
617
+ // apps/<app>/auth.ts — role 컬럼을 추가했다면 증강도 함께
618
+ declare module 'gaonjs/web' {
619
+ interface GaonCurrentUser { id: bigint; name: string; email: string; role: string }
620
+ }
621
+ ```
622
+
520
623
  - **인증 배선 = `apps/<app>/app.config.ts` — 로그인 기능의 필수 구성 요소다**
521
624
  (결정 59). 컨트롤러·페이지만 만들면 컴파일은 통과하지만, 이 배선이 없으면
522
625
  세션에 로그인해도 요청마다 사용자를 로드할 길이 없어 `this.currentUser`/
@@ -543,23 +646,62 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
543
646
  await User.where('id', '=', BigInt(String(id))).first()
544
647
  ```
545
648
 
649
+ ### 7. 멀티앱 URL 관례 (`packages/web/src/dispatch.ts` · `host-router.ts`)
650
+
651
+ 앱이 둘 이상이면 URL 이 앱 경계를 표현한다. 규칙은 셋이다:
652
+
653
+ 1. **앱 폴더명 = URL 프리픽스 · web 만 `/`** — `prefixFor(name)` = `web` → `/`,
654
+ 그 외 → `/<name>`. `apps/admin/routes.ts` 의 `r.get('/posts', 'posts#index')`
655
+ 는 최종 `/admin/posts` 로 뜬다(라우트 경로는 프리픽스 **이전**의 상대 경로 · §0).
656
+ 2. **개발 모드는 `<앱>.localhost` 매핑도 함께 동작** — `admin.localhost:3000/posts`
657
+ 요청은 URL 이 `/admin/posts` 로 재작성돼 같은 프리픽스 라우팅에 도달한다(새 라우팅
658
+ 층이 아니라 재작성 한 겹). 이미 프리픽스가 붙어 있으면 재작성하지 않고(중복 방지),
659
+ web 앱(프리픽스 `/`)은 재작성 대상이 아니다. 운영 도메인은 `app.config.ts` 의
660
+ `hosts: ['admin.example.com']` 로 같은 경로를 탄다.
661
+ 3. **프리픽스는 그 앱의 예약 경로다** — admin 앱이 있으면 web 앱이 `/admin/...` 을
662
+ 라우팅하지 않는다. 기계적 강제(doctor 검사)는 없는 **관례**라 어기면 이렇게 갈린다:
663
+ 최종 URL 이 **정확히 겹치면 부팅 실패**(`FST_ERR_DUPLICATED_ROUTE` · 2026-08-05 실측),
664
+ 부분만 겹치면 **둘 다 조용히 살아** 앱 경계가 흐려진다. 후자가 더 나쁘므로 관례를 지킨다.
665
+
666
+ **앱 안의 절대 경로는 항상 풀 프리픽스로 쓴다.** 리다이렉트·폼 action·`Link href`
667
+ 가 앱 안 상대 경로가 아니라 **최종 URL** 이라서, admin 앱의 대시보드 리다이렉트는
668
+ `/dashboard` 가 아니라 **`/admin/dashboard`** 다. `gaon g auth --app admin` 스캐폴드가
669
+ 이 형태로 생성한다(`loginRedirect: '/admin/session/new'` · `this.redirect('/admin/dashboard')` ·
670
+ `form.post('/admin/session')`). 프리픽스를 빼면 web 앱 경로로 새 나가 404 나 엉뚱한 앱에
671
+ 도달한다.
672
+
673
+ - `api()` 라우트 키도 앱을 포함한다 — `api('admin:posts#search', …)`(`<app>:<컨트롤러>#<액션>` ·
674
+ `agents/frontend.md` §1·§2). URL 은 매니페스트가 프리픽스까지 채워 주므로 손으로 붙이지 않는다.
675
+ - 채널 WS 접속 경로도 앱 프리픽스를 탄다(`/admin/gaon/ws/<채널>`) — `useChannel` 이 자동
676
+ 주입한다. 단 **채널 이름 자체는 전역 네임스페이스**다(`agents/realtime.md` §2).
677
+ - 앱별 세션 분리(쿠키 이름 `<app>_sid`·secret·Redis prefix)는 §6, 앱 스코프 보안
678
+ override 는 `agents/security.md` §1.
679
+
546
680
  ## 정본 예시
547
681
 
548
682
  ```ts
549
- // apps/web/controllers/registration.ts — 회원 가입 (E-3 · Inertia SPA)
550
- import { controller } from 'gaonjs/web'
551
- import { RegisterUser } from '../../../domain/services/registerUser.js'
683
+ // apps/web/controllers/registration.ts — 회원 가입 (E-3 · Inertia SPA · gaon g auth 스캐폴드 형태)
684
+ import { controller, hashPassword } from 'gaonjs/web'
685
+ import { User } from '../../../domain/models/User.js'
552
686
  import { SendWelcomeMail } from '../../../domain/jobs/sendWelcomeMail.js'
553
687
 
554
688
  export default controller({
555
689
  async new() {
556
- return this.render('Auth/Signup', {})
690
+ return this.render('Auth/Signup', { error: null as string | null })
557
691
  },
558
692
  async create() {
559
693
  const { name, email, password } = this.params({
560
694
  _row: {} as { name: string; email: string; password: string },
561
695
  })
562
- const user = await RegisterUser.call({ name, email, password })
696
+ // 결정 256: 이메일 중복은 스키마 .unique() 강제하지만(무결성 backstop),
697
+ // pre-check 로 raw DB 500 대신 폼 에러를 준다. 폼 액션이라 실패=render·성공=redirect
698
+ // 혼용이 허용된다(§2 결정 57 보완).
699
+ if (await User.where('email', '=', email).first()) {
700
+ return this.render('Auth/Signup', { error: '이미 사용 중인 이메일입니다.' })
701
+ }
702
+ const passwordDigest = await hashPassword(password) // §5 · bcrypt 직접 import 금지
703
+ const user = await User.create({ name, email, passwordDigest })
704
+ await this.auth.login(user) // 세션 ID 재생성 + 로그인 확정(결정 254)
563
705
  await SendWelcomeMail.later(user.id) // 잡 발행 위치는 결정 32 — 서비스 afterCommit 도 정합
564
706
  return this.redirect('/dashboard')
565
707
  },
@@ -595,6 +737,10 @@ export default controller({
595
737
  - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
596
738
  - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
597
739
  (결정 37 · 상세는 `agents/frontend.md`).
740
+ - **render/JSON props 에 `Map`/`Set` 을 넘기지 말 것 (결정 392)** — JSON 직렬화
741
+ 대응이 하나가 아니라 프레임웍이 자동 변환하지 않고 **수리 안내 에러**로 막는다
742
+ (이전엔 조용히 `{}` 가 됐다 — 무신호 파손). `Object.fromEntries(map)`·
743
+ `[...map.entries()]`·`[...set]` 으로 변환해 넘긴다.
598
744
  - **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
599
745
  (결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
600
746
  라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
@@ -605,11 +751,16 @@ export default controller({
605
751
 
606
752
  | 결정 | 내용 |
607
753
  |---|---|
754
+ | §5.1 (v0.17) | 라우트 DSL 표면 7종 — `get/post/put/patch/delete` + `resources`(7액션)·`resource`(5액션) · `update`=PATCH · 경로는 프리픽스 이전 상대 경로(§0) |
755
+ | §3.3 (v0.16) | 멀티앱 URL — 앱 폴더명 = 프리픽스(web `/`) · 개발 `<앱>.localhost` 매핑(URL 재작성 한 겹) · 프리픽스 = 그 앱 예약 경로(관례 · 완전 중복은 부팅 실패) (§7) |
608
756
  | 결정 23 (E-3) | 앱 내 JSON 액션 — 반환값 = 응답 |
609
757
  | 결정 24 (E-3 §5) | `this.params` 안전 규칙 (라우트 > body > query · 중복 키 · body/query 탈출구) |
610
758
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 — `agents/async.md`) |
611
759
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
760
+ | 결정 58 | `this.auth.user`/`requireAuth()` 사용자 타입 = 앱의 `GaonCurrentUser` 선언 병합 증강(`apps/<app>/auth.ts`) — 스키마 컬럼(role 등) 추가 시 증강도 함께 갱신(미갱신 = TS2339 · §6) |
612
761
  | 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
762
+ | 결정 141 | 앱별 세션 secret env — web=`SESSION_SECRET` · 그 외=`<APP>_SESSION_SECRET` · 스캐폴드가 앱 프리픽스 URL(`/admin/...`)로 리다이렉트·폼 경로를 생성(§6·§7) |
763
+ | 결정 256 | `gaon g auth` email `.unique()` + 가입 컨트롤러 이메일 pre-check(raw DB 500 대신 폼 에러 · 유니크는 TOCTOU backstop · §5·§정본 예시) |
613
764
  | 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
614
765
  | 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
615
766
  | 결정 108 | 정적 default 컬럼 빈 입력 채움(coerceParams) · 동적 default 는 DB 위임 |
@@ -638,6 +789,11 @@ export default controller({
638
789
  | 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
639
790
  | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
640
791
  | 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
792
+ | 결정 389 | 앱 스코프 보안 override 는 전역과 **필드 병합** — 부분 override 가 나머지 필드를 코어 기본으로 리셋하지 않음 · CORS 는 origin 미명시 시 fail-closed(`agents/security.md` §1) |
793
+ | 결정 390 | Inertia 렌더의 `Vary: X-Inertia` 는 기존 Vary(CORS `Origin` 등)에 **병합**(치환 아님) |
794
+ | 결정 391 | JWT 보강 — `refresh` 가 `loadUser(sub)` 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§6) |
795
+ | 결정 392 | render/JSON props 의 `Map`/`Set` = 수리 안내 에러(조용한 `{}` 봉합 · 함정 §알려진 함정) |
796
+ | 결정 393 | 멀티파트 CSRF 403 안내문 = 결정 342 실태(자동 부착 · 커스텀 전송은 `readCsrfToken()`) 로 갱신 |
641
797
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
642
798
 
643
799
  ## `@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.52.0",
3
+ "version": "0.56.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/async": "0.17.0",
36
- "@gaonjs/config": "0.22.0",
37
35
  "@gaonjs/core": "0.2.4",
38
- "@gaonjs/data": "0.24.0",
39
- "@gaonjs/i18n": "0.2.4",
40
- "@gaonjs/mail": "0.4.0",
41
- "@gaonjs/web": "0.27.0"
36
+ "@gaonjs/config": "0.24.0",
37
+ "@gaonjs/data": "0.25.1",
38
+ "@gaonjs/mail": "0.5.0",
39
+ "@gaonjs/async": "0.18.0",
40
+ "@gaonjs/web": "0.29.0",
41
+ "@gaonjs/i18n": "0.3.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})\""