@gaonjs/cli 0.42.2 → 0.42.3

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.
@@ -2,14 +2,14 @@
2
2
  * @gaonjs/cli · `gaon test` — vitest wrapper (M9-G · v0.15 §13.5)
3
3
  *
4
4
  * The One Way — 하나의 명령이 vitest 를 얇게 감싼다. 사용자는
5
- * `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --unit`
5
+ * `gaon test` 로 전체를, `gaon test posts` 로 필터를, `gaon test --scope unit`
6
6
  * 으로 단위만 실행한다. 나머지는 전부 vitest 에 그대로 위임 — 우리가
7
7
  * 관례를 재발명하지 않는다.
8
8
  *
9
- * 스코프 필터(§9 실 인프라 관례):
10
- * --unit *.test.ts (통합 제외)
11
- * --integration *.integration.test.ts 만
12
- * (기본) 둘 다 실행
9
+ * 스코프 필터(§9 실 인프라 관례 · `--scope <값>`):
10
+ * --scope unit *.test.ts (통합 제외)
11
+ * --scope integration *.integration.test.ts 만
12
+ * --scope all / (기본) 둘 다 실행
13
13
  *
14
14
  * 실행 경로 우선순위:
15
15
  * 1) 사용자 package.json 의 `test` 스크립트가 있으면 `<pm> run test`(결정 170 ·
@@ -115,7 +115,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
115
115
 
116
116
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
117
117
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
118
- 3. `dependency-direction` — 의존 방향 4규칙 위반
118
+ 3. `dependency-direction` — 의존 방향 4규칙 위반 (domain→shared 를 값으로 import 한 경우만 `--fix` 지원 = `import`→`import type` AST 삽입 · 나머지 방향(domain→app·app→app·shared→app)은 파일 위치 판단이 필요해 수동 · `doctor/fixers/dependency-direction`)
119
119
  4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
120
120
  5. `migration-diff` — 스키마 vs DB 상태 불일치
121
121
  6. `shared-purity` — shared/ 전체(.ts·.vue)가 `api`/`pageProps`·domain 값을 import (컴포저블·컴포넌트 통일 · 결정 25·217)
@@ -210,7 +210,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
210
210
  | `gaon new <name>` | 프로젝트 스캐폴드 |
211
211
  | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
212
212
  | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
213
- | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` |
213
+ | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
214
214
  | `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
215
215
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
216
216
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -242,6 +242,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
242
242
  | 패키지 | 역할 |
243
243
  |---|---|
244
244
  | `gaonjs` | 파사드(설치 단위) · CLI `gaon` |
245
+ | `create-gaon` | `npm create gaon <name>` 진입점 · `gaon new` 에 위임 |
245
246
  | `@gaonjs/cli` | 제너레이터·스캐폴딩·명령 라우팅 |
246
247
  | `@gaonjs/data` | 스키마 DSL · 모델 · 마이그레이션 |
247
248
  | `@gaonjs/config` | `gaon.config.ts`·`app.config.ts` |
@@ -79,9 +79,12 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
79
79
  - **시그니처** — `job(handler, options?)`. 첫 인자는 평범한 async
80
80
  함수(`(...args) => Promise<void> | void`) — `defineJob` 이나
81
81
  `{ perform }` 객체 형태가 아니다.
82
- - **이름** — `options.name` 으로 명시하거나, 생략하면 `domain/jobs/`
83
- 파일 로더가 **파일명**으로 채운다(`assignName`). 이름을 얻기 전까지
84
- `.later()` 등을 호출하면 에러 파일로 두거나 `name` 을 직접 준다.
82
+ - **이름** — `job()` **정의 시점에 스스로** 이름을 잡는다: `options.name` 이
83
+ 있으면 그것, 없으면 자신을 정의한 **파일의 파일명**을 스택에서 유추한다
84
+ (`inferNameFromCaller`). 그래서 `domain/jobs/` 파일에 두기만 하면 로더를 기다리지
85
+ 않고 곧바로 `.later()` 를 호출할 수 있다. `domain/jobs/` 파일 로더의 `assignName`
86
+ 은 폴백일 뿐(이미 이름이 있으면 멱등). 스택에서 파일명을 못 얻는 특수 환경에서만
87
+ `options.name` 을 명시한다(이름을 못 얻은 채 발행하면 에러).
85
88
  - **파일당 잡 하나(결정 271)** — 같은 파일에 `job()` 을 둘 이상 두면 파일명
86
89
  유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
87
90
  핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
@@ -185,7 +185,7 @@ export const posts = table('posts', {
185
185
  | 메서드 | 시그니처 | 반환 | 비고 |
186
186
  |---|---|---|---|
187
187
  | `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
188
- | `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약 |
188
+ | `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약. **빈 배열(`[]`)은 단락**(결정 99) — 단독이면 DB 무접촉으로 빈 결과(`count`=0·`exists`=false), 다른 조건과 `or` 로 섞이면 `1 = 0` 으로 상수 폴딩(`in ()` 문법 오류 방지) |
189
189
  | `whereAny` | `(cols, op, val?)` | `Chain` | **여러 컬럼에 같은 조건을 OR 로 묶어 괄호로 감쌈** = `(c1 op v OR c2 op v)` · 앞선 where 와 **AND 로 안전 결합**(결정 118). 다중 컬럼 검색의 정본 — `orWhere` 로 흩뜨리면 앞 조건이 샌다(아래 함정) |
190
190
  | `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습). **다중 컬럼 검색엔 쓰지 말 것** — `whereAny` 를 쓴다(결정 118) |
191
191
  | `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) · 정렬 뒤 PK 타이브레이커 자동 부가(결정 110) |
@@ -203,9 +203,9 @@ export const posts = table('posts', {
203
203
  | `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
204
204
  | `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
205
205
  | `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
206
- | `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
206
+ | `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy·**distinct** 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
207
207
  | `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
208
- | `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy 있으면 throw)은 updateAll 과 동일 |
208
+ | `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy/distinct 있으면 throw)은 updateAll 과 동일 |
209
209
  | `decrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 감소**(결정 115) — `SET col = col - by`. incrementAll 의 대칭 |
210
210
 
211
211
  **집계·조인 그룹** (`Chain` · M2E · 결정 34):
@@ -214,7 +214,7 @@ export const posts = table('posts', {
214
214
  |---|---|---|---|
215
215
  | `groupBy` | `(col \| col[])` | `GroupChain` | 그룹 집계로 **분기** — 종단은 집계 함수 하나(`count`/`sum`/`avg`/`min`/`max`)이고 결과는 `Rec[]` 이 아니라 **`그룹 키 + 집계값` 행 배열**(`GroupRow[]`). `Post.groupBy('authorId').count()` → `{ authorId, count: bigint }[]` |
216
216
  | `having` | `('count', op, val)` · `('sum'\|'avg'\|'min'\|'max', col, op, val)` | `GroupChain` | groupBy 뒤 **집계값** 필터 (그룹 키 필터는 `where`). `.having('count', '>', 2)` · `.having('sum', 'price', '>=', 1000)` |
217
- | `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(집계·벌크 쓰기 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)` |
217
+ | `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(읽기 집계 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)`. **벌크 쓰기로는 이어지지 않는다** — distinct 걸린 체인의 `updateAll`/`deleteAll` 등은 throw(위 벌크 계약) |
218
218
  | `withCount` | `(...rels)` | `IncludedChain` | 관계별 개수를 **상관 서브쿼리**로 얹는다 — `withCount('comments')` → 각 Rec 에 `commentsCount: bigint`. 조인이 아니라 행이 안 늘어 `limit` 과 함께 써도 개수가 정확. **hasMany·hasOne·belongsToMany 만**(belongsTo 는 항상 0/1 이라 throw). `include` 와 같은 체인에 실린다(`include('author').withCount('comments')`) |
219
219
  | `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). **자기 테이블 컬럼은 한정 없이 그대로** 쓴다 — `t.timestamps()`·`t.id()` 로 양 테이블이 `createdAt`·`id` 를 공유해도 조인 시 자기 테이블로 자동 한정돼 `where('createdAt', ..)` 가 안전하다(ambiguous column 방지). **조인 테이블** 조건만 한정 이름(`where('users.name', '=', ...)`)으로 쓴다. 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
220
220
  | `leftJoin` | `(table, 'table.col', 'self.col')` | `JoinChain` | LEFT OUTER JOIN — 짝 없는 자기 행도 남는다. "짝 없는 것만" = `.where('posts.id', 'is null')` |
@@ -304,7 +304,8 @@ methods: {
304
304
  - `groupBy` 이후는 `GroupChain` — 결과가 그룹 행이라 `first`/`all` 대신
305
305
  집계 함수가 종단이고, 레코드가 아니라 `include`·`select` 도 없다.
306
306
  - `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라 `include`·집계 그룹은
307
- 없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라 집계·
307
+ 없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라 집계는
308
+ **`count`·`exists` 두 종만**(sum/avg/min/max 는 JoinChain 에 없다 — 필요하면 §5 `Post.query()`)·
308
309
  `select`(자기 컬럼)·`pluck`·`first`/`all`/**`paginate`** 는 이어진다. 그래서 **텍스트
309
310
  검색(whereAny)+관계 필터(join)+페이지네이션을 한 체인으로** 조립할 수 있다(읽기 조합).
310
311
  - `select()` 이후엔 `include` 도 없다 (부분 행에 관계를 붙이지 않는다).
@@ -800,8 +801,8 @@ const titles = await Post.pluck('title') // string[]
800
801
  const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'title'>[]
801
802
 
802
803
  // 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
803
- const post = await Post.find(id)
804
- await post.delete()
804
+ const doomed = await Post.find(id)
805
+ await doomed.delete()
805
806
  const removed = await Post.where('published', '=', false).deleteAll() // number
806
807
  const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
807
808
 
@@ -820,10 +821,15 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
820
821
  · 결정 119). 단건 삭제는 `rec.delete()`, 벌크는 `deleteAll()` (결정 31).
821
822
  - **`find(id)` 는 없으면 throw** — undefined 를 원하면
822
823
  `where('id', '=', id).first()`.
824
+ - **`find` 과 `where('id', …)` 의 id 타입은 비대칭** — `find(id)` 는
825
+ `bigint | string` 을 받는다(문자열은 그대로 드라이버에 넘긴다 · 별도 코드에서
826
+ `BigInt()` 강제 안 함). 반면 `where('id', '=', v)` 는 컬럼 타입 `Row['id']`(=bigint)를
827
+ 요구해 **문자열을 넘기면 컴파일 에러**다. 라우트 파라미터(문자열)로 조회할 땐
828
+ `find(this.params.id)` 를 쓰거나, `where` 로는 `BigInt(this.params.id)` 로 감싼다.
823
829
  - **관계 대상은 문자열 테이블명** — 모델 객체를 넘기면 순환 참조.
824
830
  - **불규칙 복수**(`people`·`media` 등)는 단수화 관례가 못 잡는다 —
825
831
  `foreignKey`/`otherKey` 를 명시한다.
826
- - **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
832
+ - **`updateAll`/`deleteAll` 에 limit·offset·orderBy·distinct 가 걸려 있으면 throw** —
827
833
  행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
828
834
  - **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
829
835
  다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
@@ -885,4 +891,8 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
885
891
  | 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
886
892
  | 결정 148 | 캐시 헬퍼 `cache.remember`/`forget`·쿼리 `.withCache(ttl)` — 명시 TTL 만 · **자동 무효화 없음**(쓰기 자동 퍼지 기각 · 조용한 stale 방지) · Redis 기본·메모리 폴백(§8.2) |
887
893
  | 결정 153 | `Model.form` 컬럼 제약(`.max`·enum) 쓰기 전 서버측 검증 → 422(폼 에러) · DB 제약 위반 raw 500 방지(§8.1 · `this.params`) |
894
+ | 결정 220 | 마이그레이션 diff 확장 — 기존 컬럼의 `.unique()`·`.index()`·`.default()`·`.check()` 추가/제거를 diff 로 잡음(§10) |
895
+ | 결정 221 | 크로스 커넥션 트랜잭션 런타임 가드 — tx 안 다른 커넥션 **쓰기**(INSERT/UPDATE/DELETE) throw · **읽기는 예외** · 중첩 서비스/afterCommit/tx:false 는 자기 경계로 통과(§5 · 규칙 9) |
896
+ | 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
897
+ | 결정 265 | 조인 시 자기 테이블 컬럼 자동 한정 — `where`/`whereAny`/`orderBy` 가 자기 테이블로 한정돼 ambiguous column 방지(§4 join) |
888
898
  | E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
@@ -55,6 +55,11 @@ const props = pageProps<'web:posts#index'>()
55
55
  로 서버가 번역해 흘려보낸다(`agents/i18n.md` §5 · 결정 213).
56
56
  `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
57
57
  `useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
58
+ - **구조분해 금지 — `pageProps` 와 같은 함정** (`packages/vue/src/shared.ts`). `useShared()`
59
+ 는 매 접근마다 `usePage().props` 를 다시 읽는 **Proxy** 를 돌려준다. `const { csrf } =
60
+ useShared()` 처럼 구조분해하면 그 순간 값을 한 번 스냅샷해 **반응성이 끊긴다**(리다이렉트·
61
+ partial reload 후 flash·currentUser 갱신이 안 보인다). 항상 `const shared = useShared()`
62
+ 로 받아 `shared.csrf`·`shared.flash` 로 접근한다.
58
63
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
59
64
  로 import 하지 않는다.
60
65
  - **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
@@ -71,9 +76,19 @@ const props = pageProps<'web:posts#index'>()
71
76
  `router.visit(url)`. **외부 URL(`https://…`)·`target="_blank"` 만 `<a>`** 를
72
77
  유지한다. 내부 경로 일반 앵커는 doctor **internal-anchor** 가 잡는다(결정 96).
73
78
  - **페이지 제목 = `Head`(결정 271)** — 문서 `<title>` 은 `gaonjs/vue` 의 `Head` 로
74
- 설정한다: `<Head title="글 목록" />`. `createGaonApp({ title })` 조합자가 이 값을
75
- 받아 `글 목록 · 사이트명` 처럼 꾸민다. `document.title` 수동 조작·`@inertiajs`
76
- 직접 import 금지(결정 64) 정본 표면은 `Head` 뿐이다.
79
+ 설정한다. `createGaonApp({ title })` 조합자가 이 값을 받아 `글 목록 · 사이트명` 처럼
80
+ 꾸민다. `document.title` 수동 조작·`@inertiajs` 직접 import 금지(결정 64) — 정본
81
+ 표면은 `Head`(`gaonjs/vue` 재수출) 뿐이다. 페이지 `<template>` 최상단에 둔다:
82
+ ```vue
83
+ <script setup lang="ts">
84
+ import { Head, pageProps } from 'gaonjs/vue'
85
+ const props = pageProps<'web:posts#index'>()
86
+ </script>
87
+ <template>
88
+ <Head title="글 목록" />
89
+ <!-- 이하 페이지 본문 -->
90
+ </template>
91
+ ```
77
92
  - **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
78
93
  금지 (라우트를 모른다는 순수 규칙).
79
94
 
@@ -31,8 +31,9 @@ t('nav.home') // 중첩은 점 표기
31
31
  |---|---|---|
32
32
  | 번역 | `t(key, params?)` | 키는 카탈로그에서 타입 검사(아래 §4) · params 는 `{{name}}` 보간 |
33
33
  | 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
34
- | 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
34
+ | 지원 언어 | `languages(): readonly string[]` | 설정된 supportedLngs(읽기 전용) |
35
35
  | 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
36
+ | 고정 번역기 | `translator(lng)` | 언어를 고정한 번역 함수 `(key, params?) => string` 를 돌려준다(테스트·비요청 경로) |
36
37
 
37
38
  ### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
38
39
 
@@ -142,6 +143,7 @@ nav 라벨·레이아웃 문구처럼 앱의 **모든** 페이지가 쓰는 chro
142
143
 
143
144
  ```ts
144
145
  // apps/web/app.config.ts
146
+ import { defineAppConfig } from 'gaonjs/config'
145
147
  import { t } from 'gaonjs/i18n'
146
148
  export default defineAppConfig({
147
149
  sharedProps: () => ({
@@ -10,7 +10,9 @@
10
10
 
11
11
  메일은 `domain/mails/<이름>.ts` 에 `mail()` 로 정의한다(모델·잡과 같은 함수/객체
12
12
  스타일 · 데코레이터 금지). 파일을 두면 등록이고 파일명이 곧 이름이다. build 함수는
13
- 데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`)를 만든다.
13
+ 데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`·`cc?`·`bcc?`·`replyTo?`)를
14
+ 만든다. `to`·`cc`·`bcc` 는 `string | readonly string[]`(여러 수신자), `replyTo` 는 단일
15
+ `string` 이다. build 함수는 동기·비동기(`async`) 둘 다 된다(`MailMessage | Promise<MailMessage>`).
14
16
 
15
17
  ```ts
16
18
  // domain/mails/welcome.ts
@@ -26,9 +28,9 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
26
28
 
27
29
  | 표면 | 시그니처 | 비고 |
28
30
  |---|---|---|
29
- | 정의 | `mail<T>((data) => MailMessage)` | 파일명 = 이름 |
30
- | 발송 | `def.deliver(data, { locale?, to? })` | 설정된 SMTP 로 보냄 |
31
- | 미리보기 | `def.render(data, { locale?, to? })` | 발송 없이 메시지만(테스트·미리보기) · `to` 는 `deliver` 와 대칭 |
31
+ | 정의 | `mail<T>((data) => MailMessage \| Promise<MailMessage>)` | 파일명 = 이름 · build 는 async 가능 |
32
+ | 발송 | `def.deliver(data, { locale?, to? }): Promise<SentInfo>` | 설정된 SMTP 로 보냄(`await`) |
33
+ | 미리보기 | `def.render(data, { locale?, to? }): Promise<MailMessage>` | 발송 없이 메시지만(테스트·미리보기 · `await`) · `to` 는 `deliver` 와 대칭 |
32
34
 
33
35
  ### 2. 로케일 메일 — `deliver(data, { locale })` (결정 160)
34
36
 
@@ -60,7 +60,7 @@ export default channel({
60
60
 
61
61
  | 훅 | 시점 | 반환 |
62
62
  | --- | --- | --- |
63
- | `authorize(ctx)` | 소켓 attach 전 | `boolean` — false 면 연결 거부 |
63
+ | `authorize(ctx)` | 소켓 attach 전 | `boolean \| Promise<boolean>` — false 면 연결 거부(4401 close) · async 가능 |
64
64
  | `presenceInfo(ctx)` | attach 전 | 접속자 목록에 실을 공개 메타 |
65
65
  | `onJoin(ctx)` | 연결·프레즌스 등록 후 | — |
66
66
  | `onMessage(ctx, data)` | 클라이언트 메시지 | — |
@@ -80,11 +80,15 @@ export default channel({
80
80
 
81
81
  멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
82
82
 
83
- **핸들러가 throw 하면 연결만 닫힌다(결정 257).** `onMessage`/`onLeave` 안에서
84
- 예외(코드 결함·DB 순단)가 나도 **서버는 죽지 않는다** — HTTP 액션이 500 으로
85
- 마감되는 것과 대칭으로, 연결에 에러 프레임을 보내고 닫는다(다른 연결·서버는
86
- 그대로 산다). 즉 `onMessage` 예외는 전역 장애가 아니라 연결 단위 장애다. 재시도가
87
- 필요한 로직은 핸들러 안에서 try/catch 감싸 직접 통제한다.
83
+ **핸들러가 throw 해도 서버는 죽지 않는다(결정 257).** `onMessage`/`onLeave` 안에서
84
+ 예외(코드 결함·DB 순단)가 나도 예외는 **연결 단위로 격리**된다 — HTTP 액션이 500
85
+ 으로 마감되는 것과 대칭이다(다른 연결·서버는 그대로 산다). **두 훅의 마감이 다르다**:
86
+ - `onMessage` throw 연결에 **에러 프레임(`{ t:'error' }`)을 보내고 `1011` 로 종료**한다
87
+ (요청 실패지만 서버는 생존 = HTTP 500 대칭).
88
+ - `onLeave` throw → 연결이 **이미 닫히는 중**이라 통지할 곳이 없다. **로그만 남기고 정리(프레즌스
89
+ 해제 등)를 계속**한다(추가 종료·에러 프레임 없음).
90
+
91
+ 재시도가 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
88
92
 
89
93
  ### 2.5 서버 개시 broadcast (결정 126)
90
94
 
@@ -332,7 +336,7 @@ export default channel({
332
336
  | 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
333
337
  | 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
334
338
  | 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
335
- | 결정 257 | 채널 `onMessage`/`onLeave` throw 는 연결만 마감(§2.4) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭 · 에러 프레임 + 연결 종료) |
339
+ | 결정 257 | 채널 `onMessage`/`onLeave` throw 는 연결 단위로 격리(§2) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭) · `onMessage` = 에러 프레임 + `1011` 종료 · `onLeave` = 이미 닫히는 중이라 로그만·정리 계속 |
336
340
  | 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
337
341
  | 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
338
342
  | 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
@@ -59,7 +59,11 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
59
59
  | CSRF · 무차별 요청(DoS) | ❌ | 세션 CSRF · rate limit |
60
60
  | 작정한 공격자의 봉인 위조 | ❌(클라에 규약 있음) | 위 서버 검증 전부 |
61
61
 
62
- ## 1. 켜는 법 — The One Way (결정 121·124)
62
+ ## 정본 규칙
63
+
64
+ (§0 은 포지셔닝 프리앰블 — 켜기 전에 반드시 읽는다. 아래 §1~§5 가 실제 정본 규칙이다.)
65
+
66
+ ### 1. 켜는 법 — The One Way (결정 121·124)
63
67
 
64
68
  ```bash
65
69
  1) npm i @gaonjs/seal # 선택 플러그인 · 기본 스캐폴드 미포함
@@ -88,7 +92,7 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
88
92
  - 미설치로 `seal: true` 를 켜면 **부팅 에러**(수리 안내). `masterSecret` 설정 표면은 없다 — 미끼 literal
89
93
  이라 설정할 이유가 없다(비밀 착시·랜덤화 사고 방지).
90
94
 
91
- ## 2. 무엇이 봉인되나
95
+ ### 2. 무엇이 봉인되나
92
96
 
93
97
  - **요청/응답 JSON**: 클라 `installClientSeal()` 이 Inertia XHR 인터셉터(`XMLHttpRequest.prototype`) +
94
98
  `api()`/`fetch` 봉인을 설치한다. 서버는 Fastify **4-stage 훅**(`plugin.ts` · onRequest 분류/fail-closed →
@@ -112,17 +116,18 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
112
116
  - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
113
117
  직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
114
118
  모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
115
- - **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
116
- **403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
119
+ - **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
120
+ **403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
121
+ (`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
117
122
  fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단).
118
123
 
119
- ## 3. CSP (결정 124)
124
+ ### 3. CSP (결정 124)
120
125
 
121
126
  seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입**한다(wasm 컴파일만 허용 ·
122
127
  `'unsafe-eval'` 보다 좁음). **전역 완화 금지** — 비-seal 앱은 strict CSP(`script-src 'self'`) 그대로다.
123
128
  요청 단위 판별(seal 스코프가 요청을 표시 · 보안 헤더 훅이 그 응답에만 보정).
124
129
 
125
- ## 4. 아키텍처 경계 (AI 가 넘지 말 것)
130
+ ### 4. 아키텍처 경계 (AI 가 넘지 말 것)
126
131
 
127
132
  - **`@gaonjs/vue` 는 seal 무지 유지 · 비-seal 앱 번들에 wasm 0.** 두 번째 http/ws 클라이언트를 이식하지
128
133
  않는다 — 봉인/개봉은 기존 전송 경로(Inertia·`api()`·`useChannel`) **경계 인터셉터**가 한다.
@@ -152,7 +157,7 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
152
157
  - **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
153
158
  UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
154
159
 
155
- ## 5. 게이트 — seal 검증은 **실 브라우저가 blocking** (결정 124)
160
+ ### 5. 게이트 — seal 검증은 **실 브라우저가 blocking** (결정 124)
156
161
 
157
162
  - 정본 게이트는 **실 vite 프로덕션 빌드 + 실 chromium + 실 wasm** e2e 다:
158
163
  `test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
@@ -168,6 +173,29 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
168
173
  (Playwright `page.waitForResponse(...).text()`·`page.on('response')` · 외부 `curl`)로 **시그널 헤더 + 암호문**을
169
174
  직접 봐야 드러난다. 정본 게이트 ⑨(결정 125)가 이 방식으로 네비게이션 wire 봉인을 정면 단언한다.
170
175
 
176
+ ## 정본 예시
177
+
178
+ 봉인은 **켜는 것**이 전부다 — 컨트롤러·`this.params`·`api()`·페이지 코드는 한 줄도 안 바뀐다(§2 wire 봉인은 훅/인터셉터가 담당).
179
+
180
+ ```ts
181
+ // apps/web/app.config.ts — 앱 wire 전체 봉인.
182
+ export default defineAppConfig({ seal: true })
183
+ ```
184
+ ```ts
185
+ // apps/web/main.ts — seal 클라이언트 정적 import 를 createGaonApp 에 주입(결정 124 · doctor 가 강제·fixer 자동 배선).
186
+ import { createGaonApp } from 'gaonjs/vue'
187
+ import * as sealClient from '@gaonjs/seal/client'
188
+ void createGaonApp({ pages, layouts, /* ... */ sealClient })
189
+ ```
190
+ ```ts
191
+ // 컨트롤러는 그대로 — seal 을 전혀 모른다(요청/응답 JSON·최초 문서 data-page·WS 프레임이 자동 봉인).
192
+ export default controller({
193
+ async index() {
194
+ return this.render('Posts/Index', { posts: await Post.latest().all() })
195
+ },
196
+ })
197
+ ```
198
+
171
199
  ## 알려진 함정
172
200
 
173
201
  1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고 `gaon doctor --fix --yes` 가 배선. 런타임도 수리 안내 throw.
@@ -94,6 +94,11 @@
94
94
  한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
95
95
  라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
96
96
  `csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
97
+ - **CSRF 를 끄는 유일한 스위치는 `session: { csrf: false }` (결정 271 · 규칙 8).**
98
+ `app.config.ts` 의 `session` 에 `csrf: false` 를 명시할 때만 그 앱의 CSRF 검증이
99
+ 꺼진다 — 생략·`undefined` 는 켬(보안 기본값). 커스텀 헤더 인증 등 세션 앱이면서
100
+ CSRF 를 꺼야 하는 특수 경로용 탈출구이고, 끄면 상태 변경 요청이 무방비가 되므로
101
+ 이유를 남긴다. wire 가 이 값을 `SessionOptions.csrf` 로 그대로 전달한다.
97
102
  - **CSRF/세션 실패는 코어가 Inertia-네이티브로 마감한다 (결정 165).** 세션 만료·
98
103
  secret 로테이션·장시간 탭으로 CSRF 가 실패하면, Inertia 요청은 raw JSON 403 이 아니라
99
104
  **409 + `X-Inertia-Location` 풀 리로드**(새 세션 쿠키+새 토큰) + `flash.error` 안내로
@@ -241,3 +246,6 @@ const rows = await Post.query()
241
246
  | 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
242
247
  | 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
243
248
  | 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
249
+ | 결정 165 | 세션/CSRF 실패·415 를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 무 · 비-Inertia 는 JSON 유지(§2.3) |
250
+ | 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§2.3 · `this.auth`) |
251
+ | 결정 271 | CSRF 를 끄는 유일 스위치 = `session: { csrf: false }` — 생략은 켬(보안 기본값) · wire 가 `SessionOptions.csrf` 로 전달(§2.3) |
@@ -19,13 +19,16 @@
19
19
  | 조회 | `Storage.get(key): Promise<Buffer \| null>` | 없으면 null |
20
20
  | 삭제 | `Storage.delete(key)` | 멱등 |
21
21
  | 존재 | `Storage.exists(key): Promise<boolean>` | |
22
- | URL | `Storage.url(key, { expiresIn? }): Promise<string>` | 공개 버킷/CDN = 공개 URL · 아니면 presigned |
22
+ | URL | `Storage.url(key, { expiresIn? }): Promise<string>` | 드라이버별로 다름(아래) 로컬=공개 경로 · s3=공개 URL 또는 presigned |
23
23
  | 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
24
24
 
25
- - **URL 은 `Storage.url()` 한 곳**이다 `publicUrl`(공개 버킷·CDN·R2 public)이
26
- 있으면 `${publicUrl}/${key}`, 없으면 만료 있는 **presigned URL** 만든다.
27
- `expiresIn`(초)로 만료를 조절한다. 존재하지 않는 `Attachment.urlFor` 같은
28
- 헬퍼를 만들지 표면은 `Storage.url()` 뿐이다.
25
+ - **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
26
+ - **로컬 디스크**: 항상 `${baseUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시).
27
+ `baseUrl` 기본값은 `/storage`(웹이 경로를 정적 서빙·라우트로 노출) — presigned 개념이 없다.
28
+ - **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public) 있으면 `${publicUrl}/${key}`,
29
+ 없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
30
+ 존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
31
+ 표면은 `Storage.url()` 뿐이다.
29
32
  - **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
30
33
  - **`contentType` 은 어댑터별로 다르게 반영된다**(결정 236): `s3` 는 객체
31
34
  메타데이터로 저장해 다운로드·presign 시 그대로 나가고, **로컬**은 저장하지
@@ -55,7 +58,8 @@ storage: process.env.STORAGE_ENDPOINT
55
58
 
56
59
  - dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
57
60
  (결정 132 · zero-config). `cp .env.example .env && gaon dev` → 우회 0.
58
- - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/uploads' }`.
61
+ - 로컬 디스크: `{ driver: 'local', root: 'storage', baseUrl?: '/storage' }` — `url(key)`
62
+ = `baseUrl + '/' + key`(공개 경로 · `baseUrl` 생략 시 `/storage`).
59
63
  - 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
60
64
  만 env 로 바꾼다.
61
65
 
@@ -99,6 +103,19 @@ export default controller({
99
103
  `<bucket>-test`, 로컬 root 는 `<root>-test` 로 격리된다(`<db>_test` 대칭 ·
100
104
  `agents/testing.md`). 스토리지 잡 테스트는 스캐폴드 `test/setup.ts` 그대로 통과한다.
101
105
 
106
+ ## 정본 예시
107
+
108
+ ```ts
109
+ // 저장 → 공개/서명 URL 얻기(드라이버 무관 · 같은 코드). url() 은 async.
110
+ const key = `avatars/${user.id}.png`
111
+ await Storage.put(key, buffer, { contentType: 'image/png' })
112
+ const src = await Storage.url(key) // 로컬=`/storage/avatars/<id>.png` · s3=공개 URL 또는 presigned
113
+ // 만료 있는 서명 URL(s3 · 로컬은 expiresIn 무시):
114
+ const tempLink = await Storage.url(key, { expiresIn: 600 })
115
+ ```
116
+
117
+ 업로드(멀티파트) 수신·저장의 정본은 §3(`this.file('avatar')` → `Storage.put`).
118
+
102
119
  ## 알려진 함정
103
120
 
104
121
  - **존재하지 않는 API 를 상상하지 말 것** — 파일 URL 은 `Storage.url()`,
@@ -25,8 +25,9 @@
25
25
  - `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
26
26
  테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
27
27
  `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
28
- (스케줄러 리스 `gaon_lease`·프레즌스·허브)에도 적용돼(결정 203) `test_gaon_lease`
29
- 처럼 격리된다 스트림뿐 아니라 KV 도 개발·운영 스택과 안 겹친다. 그래서
28
+ (프레즌스·허브·역할별 리스)에도 적용돼(결정 203) 격리된다 — 리스 버킷은 결정 260 으로
29
+ 역할마다 분리돼 `gaon_lease_<역할>`(예 `gaon_lease_hub_leader`) 이고, 테스트에선
30
+ `test_gaon_lease_hub_leader` 처럼 접두가 붙는다. 스트림뿐 아니라 KV 도 개발·운영 스택과 안 겹친다. 그래서
30
31
  개발용 `gaon work` 가 떠 있어도 테스트 잡을 훔치지 않고 스케줄러 리더 경합도
31
32
  안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
32
33
  워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
@@ -42,6 +43,14 @@
42
43
  - 통합 테스트는 `test/integration/<이름>.integration.test.ts` — 러너는
43
44
  vitest (`gaon test --scope integration`).
44
45
  - 단위 테스트는 소스 옆 `<이름>.test.ts` (`--scope unit`).
46
+ - **필터·인자 전달** — `gaon test <필터>`(예 `gaon test posts`)는 `posts` 를 vitest
47
+ 파일명 필터로 그대로 넘긴다. `gaon test -- <vitest 인자>`(예 `gaon test -- --reporter=dot`)
48
+ 로 vitest 플래그를 통과시킨다 — `--scope`/`--json` 만 gaon 이 소비하고 나머지는 전부
49
+ vitest 에 그대로 전달된다.
50
+ - **MCP `run_tests` 도 같은 하네스를 탄다(결정 270)** — 내장 MCP 서버의 `run_tests` 도구는
51
+ vitest 를 직접 spawn 하지 않고 `gaon test`(`runTestCommand`)에 위임한다. 그래서 AI 가
52
+ MCP 로 돌려도 테스트 DB 프로비저닝·`GAON_STREAM_PREFIX` 격리·사용자 `test` 스크립트
53
+ 우선이 그대로 적용된다(필터·`--scope` 인자도 동일).
45
54
  - `vi.mock('gaonjs/async')` · `vi.mock('nats')` · `vi.mock('@nats-io/…')`
46
55
  같은 프레임웍·전송 목업은 금지다 (§9) — 실 접속으로 검증한다.
47
56
 
@@ -168,9 +168,11 @@ export default controller({
168
168
  `this.file()` 업로드(멀티파트)의 CSRF 토큰은 **`x-csrf-token` 헤더**로 보낸다.
169
169
  멀티파트는 `parts()` 스트리밍이라 CSRF 검사(preHandler) 시점에 **바디가 아직
170
170
  파싱되지 않아** 폼 필드 `_csrf` 가 검사에 잡히지 않는다(구조적 한계 · 디스패처가
171
- handler 안에서 파싱). 일반 폼(JSON/urlencoded)의 `_csrf` 바디 폴백은 멀티파트엔
171
+ handler 안에서 파싱). 일반 JSON 폼의 `_csrf` 바디 폴백은 멀티파트엔
172
172
  통하지 않는다. 파일이 있으면 `useForm` 이 자동으로 multipart 로 보내므로, 업로드
173
- 제출은 **반드시 헤더**로 토큰을 실어야 한다.
173
+ 제출은 **반드시 헤더**로 토큰을 실어야 한다. (참고: 지원 Content-Type 은
174
+ `application/json` · `multipart/form-data` 뿐이라 `x-www-form-urlencoded` 로 폼을
175
+ 보내면 `_csrf` 폴백에 닿기 전에 415 로 거부된다 · `inertia.ts` · §아래 415.)
174
176
 
175
177
  ```vue
176
178
  <script setup lang="ts">
@@ -576,10 +578,16 @@ export default controller({
576
578
  | 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
577
579
  | 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
578
580
  | 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
581
+ | 결정 64 | 폼 API 는 `gaonjs/vue` 의 `useForm`·`router` 뿐 — 로그아웃 등 DELETE 는 `router.delete()`(`@inertiajs/vue3` 직접 import 금지 · `Inertia.post()` 유령 API 아님) |
582
+ | 결정 122 | 관계·hidden 값이 render 경계 `serializeProps` 를 넘어 새지 않는다 — hidden 컬럼 제외 유지(§4.2) |
583
+ | 결정 165 | 세션/CSRF 실패·415(지원 안 되는 Content-Type)를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 으로 앱을 깨지 않는다(§4.1 · 지원 타입 `application/json`·`multipart/form-data`) |
584
+ | 결정 183 | 검증 사유 로케일화 — 안정 코드 + 예약 namespace `validation.<code>` 로 요청 로케일 번역(미제공 시 내장 fallback · §4.1) |
585
+ | 결정 253 | hidden 마커 = 열거 가능한 심볼 → `render(page, { ...user })` spread 우회로도 hidden 값이 안 샌다(§4.2) |
586
+ | 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§4.4 auth) |
579
587
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
580
588
 
581
589
  ## `@gaonjs/seal` 켠 앱
582
590
 
583
591
  `app.config seal: true` 면 그 앱의 wire(요청/응답 JSON + 최초 문서 data-page)가 봉인된다 — 컨트롤러·라우트·
584
592
  `this.params`·`api()` 코드는 한 줄도 안 바뀐다. **정본은 `agents/seal.md`**(켜는 법·main.ts 배선·`except`·
585
- 자동 제외·fail-closed 403).
593
+ 자동 제외·fail-closed = 평문 통과 금지 · 기본 403 · 과대 페이로드는 413).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.42.2",
3
+ "version": "0.42.3",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,12 +28,12 @@
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
30
  "@gaonjs/async": "0.15.3",
31
- "@gaonjs/config": "0.18.0",
31
+ "@gaonjs/config": "0.18.1",
32
32
  "@gaonjs/core": "0.2.4",
33
- "@gaonjs/i18n": "0.2.4",
34
33
  "@gaonjs/data": "0.17.3",
34
+ "@gaonjs/i18n": "0.2.4",
35
35
  "@gaonjs/mail": "0.3.3",
36
- "@gaonjs/web": "0.20.4"
36
+ "@gaonjs/web": "0.20.5"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""