@gaonjs/cli 0.18.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/doctor/csrf-wiring.d.ts +5 -0
  2. package/dist/doctor/csrf-wiring.js +72 -0
  3. package/dist/doctor/internal-anchor.d.ts +6 -0
  4. package/dist/doctor/internal-anchor.js +122 -0
  5. package/dist/doctor/method-override.d.ts +5 -0
  6. package/dist/doctor/method-override.js +75 -0
  7. package/dist/doctor/route-registration.d.ts +5 -0
  8. package/dist/doctor/route-registration.js +77 -0
  9. package/dist/doctor/static-collision.d.ts +5 -0
  10. package/dist/doctor/static-collision.js +84 -0
  11. package/dist/doctor/types.d.ts +1 -1
  12. package/dist/doctor/types.js +2 -4
  13. package/dist/doctor.d.ts +2 -0
  14. package/dist/doctor.js +24 -2
  15. package/dist/generate.d.ts +8 -0
  16. package/dist/generate.js +51 -7
  17. package/dist/index.d.ts +6 -2
  18. package/dist/index.js +33 -23
  19. package/dist/serve.d.ts +10 -0
  20. package/dist/serve.js +97 -2
  21. package/dist/templates/auth/Login.vue.tpl +2 -2
  22. package/dist/templates/auth/Signup.vue.tpl +2 -2
  23. package/dist/templates/auth/app.ts.tpl +29 -0
  24. package/dist/templates/auth/server.ts.tpl +14 -0
  25. package/dist/templates/project/.dockerignore.tpl +12 -0
  26. package/dist/templates/project/AGENTS.md.tpl +18 -7
  27. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  28. package/dist/templates/project/Dockerfile.tpl +30 -0
  29. package/dist/templates/project/agents/async.md.tpl +4 -0
  30. package/dist/templates/project/agents/data.md.tpl +37 -15
  31. package/dist/templates/project/agents/frontend.md.tpl +33 -1
  32. package/dist/templates/project/agents/realtime.md.tpl +24 -17
  33. package/dist/templates/project/agents/security.md.tpl +18 -2
  34. package/dist/templates/project/agents/web.md.tpl +47 -0
  35. package/dist/templates/project/apps/web/app.config.ts.tpl +14 -0
  36. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +6 -2
  37. package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
  38. package/dist/templates/project/compose.prod.yaml.tpl +98 -0
  39. package/dist/templates/project/package.json.tpl +1 -0
  40. package/dist/work.js +2 -0
  41. package/package.json +7 -7
@@ -193,8 +193,8 @@ export const posts = table('posts', {
193
193
  | `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
194
194
  | `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
195
195
  | `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
196
- | `updateAll` | `(patch)` | `Promise<bigint>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수 반환. limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
- | `deleteAll` | `()` | `Promise<bigint>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
196
+ | `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
+ | `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
198
198
 
199
199
  **집계·조인 그룹** (`Chain` · M2E · 결정 34):
200
200
 
@@ -218,18 +218,35 @@ export const posts = table('posts', {
218
218
  | 메서드 | 시그니처 | 반환 | 비고 |
219
219
  |---|---|---|---|
220
220
  | `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
221
- | `batchInsert` | `(rows)` | `Promise<Rec[]>` | **벌크 삽입** (M2F · 결정 35) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · 넣은 순서대로 Rec 반환. 빈 배열은 DB 무접촉 `[]`. beforeCreate 훅은 각 row 에 적용 |
222
- | `insertOrIgnore` | `(row \| rows)` | `Promise<Rec[]>` | **멱등 삽입** (M2F) — `ON CONFLICT DO NOTHING` · UNIQUE/PK 충돌 행은 건너뛰고 **실제로 삽입된 Rec 만** 반환. 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
223
- | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<Rec[]>` | **있으면 갱신 없으면 삽입** (M2F) — `ON CONFLICT DO UPDATE`. `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 `excluded` 로 덮음 · 갱신 대상 없으면 DO NOTHING |
221
+ | `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용 |
222
+ | `insertOrIgnore` | `(row \| rows)` | `Promise<BulkResult>` | **멱등 삽입** (M2F) — 충돌 행은 건너뛰고 `count` = **실제로 삽입된 수**(PG=`ON CONFLICT DO NOTHING` · MySQL=`INSERT IGNORE`). 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
223
+ | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<BulkResult>` | **있으면 갱신 없으면 삽입** (M2F) — PG=`ON CONFLICT DO UPDATE` · MySQL=`ON DUPLICATE KEY UPDATE`. `count` = 처리된 입력 행 수(삽입+갱신 · 갱신 대상 없으면 실 삽입 수). `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 덮음 |
224
224
  | `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
225
225
  | `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
226
226
 
227
- > 벌크 삽입 3종(`batchInsert`·`insertOrIgnore`·`upsert`) **RETURNING
228
- > 지원하는 커넥션(Postgres)** 에서만 된다 삽입/무시/갱신된집합을
229
- > 정확히 복원할 있는 방언이 RETURNING 뿐이라, RETURNING 커넥션에서는
230
- > 어림하지 않고 수리 안내와 함께 **throw** 한다(결정 35 · §7.5.3). 삽입 계열은
227
+ > 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`) 돌려준다(결정 90 ·
228
+ > 원안의 "비 RETURNING 방언 throw" 폐기). `count` **처리된 입력수**로 방언
229
+ > 무관 같은 의미다 MySQL upsert 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
230
+ > 그대로 노출하지 않고 정규화한다. 방언 원시값이 필요하면 `meta`(PG `returnedIds` ·
231
+ > MySQL `firstInsertId`·`affectedRows`)를 쓴다. **행 자체가 필요하면**: 소량은
232
+ > `create()`(단건 · 전 방언 Rec 반환), 대량은 유니크 키로 재조회 — **id 범위 추정
233
+ > 재조회는 금지**(MySQL auto-increment 는 동시 삽입에서 연속 보장이 없다). 삽입 계열은
231
234
  > `create` 처럼 루트 전용 — `where` 필터와 무관하므로 체인 종단이 아니다.
232
235
 
236
+ **개수·집계 반환 타입 — 영역별로 다르다** (결정 90·91 · 헷갈리지 말 것):
237
+
238
+ | 영역 | 반환 타입 | 근거 |
239
+ |---|---|---|
240
+ | 쓰기 개수 (`BulkResult.count` · `updateAll` · `deleteAll`) | `number` | 처리 행 수 — 2^53 초과 실무 없음 · JSON 직렬화 |
241
+ | 읽기 집계 `count()` | `bigint` | E-4 정본 |
242
+ | 읽기 집계 `sum()` · `avg()` | `string` | 무손실(정밀도 보존) |
243
+ | 읽기 집계 `min()` · `max()` | 컬럼 타입 따름 | E-4 정본 |
244
+
245
+ > **왜 sum/avg 가 `number` 가 아닌가**: 양 방언 실측 결과 집계 체인은 이미 방언
246
+ > 일관이라(silent split 없음) 고칠 문제가 없고, `string→number` 강제는 2^53 초과
247
+ > 합계에서 **정밀도를 잃는**(lossy) breaking 이라 "어림 금지" 원칙에 어긋나 기각했다
248
+ > (결정 91). 쓰기 개수(number)와 읽기 집계(string/bigint)는 축이 다르다.
249
+
233
250
  **레코드(`Rec`) 내장** (`model.ts:47-52`):
234
251
 
235
252
  - `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
@@ -546,14 +563,15 @@ const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'titl
546
563
  // 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
547
564
  const post = await Post.find(id)
548
565
  await post.delete()
549
- const removed = await Post.where('published', '=', false).deleteAll() // bigint
566
+ const removed = await Post.where('published', '=', false).deleteAll() // number
550
567
  const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
551
568
 
552
- // 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35)
553
- const seeded = await Post.batchInsert(rows) // 단일 INSERT · Rec[] (순서 보존)
554
- const fresh = await Post.insertOrIgnore(rows) // 충돌 건너뜀 · 실 삽입만
555
- await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
569
+ // 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35 → BulkResult 결정 90 · 전 방언 통일)
570
+ const seeded = await Post.batchInsert(rows) // { count } (삽입 )
571
+ const fresh = await Post.insertOrIgnore(rows) // { count } (실 삽입만 · 충돌 건너뜀)
572
+ const { count } = await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
556
573
  await Post.upsert({ id, title, body }) // onConflict 생략 = 기본 키(id)
574
+ // 삽입된 행이 필요하면: 소량은 create(), 대량은 유니크 키로 재조회 (id 범위 추정 금지)
557
575
  ```
558
576
 
559
577
  ## 알려진 함정
@@ -568,6 +586,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
568
586
  `foreignKey`/`otherKey` 를 명시한다.
569
587
  - **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
570
588
  행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
589
+ - **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
590
+ 다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
591
+ `throw` 하던 옛 계약(비 RETURNING 방언)은 폐기 — MySQL 도 그냥 `count` 를 준다.
571
592
  - **loop 안 관계 lazy 호출 = N+1** — doctor **n-plus-one** 검사가 잡는다.
572
593
  목록은 `include()` 로.
573
594
  - **스키마 파일명은 camelCase** — 테이블 `posts_tags` → 파일 `postsTags.ts`
@@ -588,8 +609,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
588
609
  | 결정 31 | `rec.delete()` 명명(destroy 아님) · 파라미터 스코프 (M2C) |
589
610
  | 결정 33 | 관계 선언 위치(컬럼 + relations) · 문자열 테이블명 (M2D) |
590
611
  | 결정 34 | 집계·조인 그룹(groupBy·having·distinct·withCount·join) (M2E) |
591
- | 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) |
612
+ | 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) → **결정 90 으로 개정** |
592
613
  | 결정 39 | 마이그레이션 합성형(파일 replay → 스키마 diff · no-auto-drop) |
614
+ | 결정 90 | 벌크 3종 `BulkResult`(count+meta) 전 방언 통일 · updateAll/deleteAll bigint→number (W14) |
593
615
  | 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
594
616
  | 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
595
617
  | 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
@@ -36,6 +36,19 @@ const props = pageProps<'web:posts#index'>()
36
36
  액션 → `'web:posts#index'`.
37
37
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
38
38
  로 import 하지 않는다.
39
+ - **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
40
+ 라우팅**이라 클라이언트 라우터 라이브러리가 없다. `vue-router`·`react-router`
41
+ 를 install/import 하지 말 것(`createRouter`·`useRoute`·`useRouter`·`<RouterLink>`
42
+ 전부 없음 · CLAUDE.md 규칙 12). 페이지 전환은 `gaonjs/vue` 의 `router`
43
+ (`router.visit(url)`·`router.get/post/delete`), 폼은 `useForm(...)` (결정 64).
44
+ 라우트 정의는 서버의 `apps/<앱>/routes.ts` 뿐이다.
45
+ - **앱 내부 이동 = `Link`(선언적) 또는 `router.visit`(프로그램적) — 내부 경로
46
+ 일반 `<a>` 금지** (결정 96). 내부 경로로 가는 `<a href="/...">` 는 클릭마다
47
+ 전체 문서를 다시 로드해 Inertia SPA 상태(스크롤·폼 입력·채널 구독)가 초기화된다
48
+ (첫 실사용 블로그에서 "메인 경로" 풀 리로드로 관측). 선언적 링크는 `gaonjs/vue`
49
+ 의 `Link`(`<Link href="/posts">글 목록</Link>`), 코드에서의 이동은
50
+ `router.visit(url)`. **외부 URL(`https://…`)·`target="_blank"` 만 `<a>`** 를
51
+ 유지한다. 내부 경로 일반 앵커는 doctor **internal-anchor** 가 잡는다(결정 96).
39
52
  - **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
40
53
  금지 (라우트를 모른다는 순수 규칙).
41
54
 
@@ -169,7 +182,8 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
169
182
  scoped-CSS 미편입 방침은 결정 74 로 뒤집혔다).
170
183
  - **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
171
184
  랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
172
- 의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
185
+ 의 nav 에 `<Link href="/session/new">로그인</Link>` 을 직접 추가한다(내부 이동은
186
+ `Link` · 일반 `<a>` 는 풀 리로드 · 결정 96).
173
187
  - **auth 페이지 경로 = `pages/Auth/`(PascalCase)** — `gaon g auth` 는 `Auth/Login.vue`
174
188
  ·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
175
189
  소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
@@ -259,6 +273,23 @@ async function runSearch(q: string) {
259
273
  shared-composable-purity 위반. 데이터는 props/인자로.
260
274
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
261
275
  `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
276
+ - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
277
+ 로드해 SPA 상태가 초기화된다. 앱 내부 이동은 `gaonjs/vue` 의 `Link`
278
+ (`<Link href="/...">`) 또는 `router.visit(...)`, 외부 URL·`target="_blank"`
279
+ 만 `<a>` (결정 96 · doctor **internal-anchor**).
280
+ - **`vue-router` import 금지** — Gaon 은 클라이언트 라우터가 없다(Inertia =
281
+ SPA + 서버 라우팅). `import { useRouter } from 'vue-router'` 는 존재하지 않는
282
+ 의존을 끌어와 컴파일 실패한다 — 전환은 `gaonjs/vue` 의 `router`, 라우트 정의는
283
+ `apps/<앱>/routes.ts`(§1).
284
+ - **로그아웃은 `router.delete('/session')`** — `?_method=DELETE` 폼 override(Rails/
285
+ Laravel 관례)는 Gaon 에서 안 통한다(Inertia 는 실 DELETE 를 보낸다 · 컴파일은
286
+ 통과해도 런타임 조용히 파손). 로그아웃 링크/버튼은 `router.delete(...)` 또는
287
+ `useForm(...).delete(...)` 로 한다(결정 64 · 절대 규칙 6). `_method` 사용은 doctor
288
+ **method-override** 가 잡는다(결정 89).
289
+ - **채널 구독은 `useChannel()`** — 실시간 클라이언트는 `gaonjs/vue` 의
290
+ `useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
291
+ URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
292
+ 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
262
293
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
263
294
  - **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
264
295
  Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
@@ -281,4 +312,5 @@ async function runSearch(q: string) {
281
312
  | 결정 74 | Tailwind 스캐폴드 편입(tailwind.config.ts·postcss.config.js·style.css · 결정 69 scoped-CSS 방침 뒤집기) |
282
313
  | 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
283
314
  | 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
315
+ | 결정 96 | 앱 내부 이동 = `Link`(선언적)/`router.visit`(프로그램적) · 내부 경로 일반 `<a>` 금지(풀 리로드) · `Link` 재수출 · doctor internal-anchor |
284
316
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -89,29 +89,36 @@ const members = await ctx.presence()
89
89
  - leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
90
90
  **즉시** 정리한다 (TCP `close`).
91
91
 
92
- ### 4. 클라이언트 (WebSocket)
92
+ ### 4. 클라이언트 (`useChannel` · 결정 87)
93
93
 
94
- 브라우저는 표준 WebSocket 으로 채널에 접속한다. 세션 앱은 세션 쿠키로,
95
- JWT 앱은 쿼리(`access_token`)로 인증한다.
96
-
97
- **경로는 `<앱 프리픽스>/gaon/ws/<채널명>`** 이고, 보내는 프레임은 `{ t: 'msg',
98
- data }` **봉투** 페이로드를 그대로 보내면 서버가 `onMessage` 로
99
- 흘리지 않는다. 받는 프레임도 같은 모양(`t` 로 종류를 가른다).
94
+ **정본 = `gaonjs/vue` `useChannel(name, opts)`.** 채널 구독의 One Way 다 —
95
+ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
96
+ 감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
97
+ 손으로 짜지 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
98
+ 자동 인증, JWT 앱은 `params: { access_token }`.
100
99
 
101
100
  ```ts
102
- // 브라우저측채널 구독 래핑은 컴포저블에 (agents/frontend.md)
103
- // web 앱(프리픽스 /)의 room 채널 = /gaon/ws/room. admin 앱이면 /admin/gaon/ws/room.
104
- const ws = new WebSocket(`wss://example.com/gaon/ws/room?room=42`)
105
- ws.onmessage = (ev) => {
106
- const frame = JSON.parse(ev.data) // { t: 'msg' | 'presence' | ... , data }
107
- if (frame.t === 'msg') {
108
- // 서버가 broadcast/send 데이터
109
- }
101
+ // apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §3.2)
102
+ import { useChannel } from 'gaonjs/vue'
103
+
104
+ export function useRoom(roomId: number) {
105
+ // messages(반응형)·status·send·connect·close 돌려준다. 마운트에 접속.
106
+ const { messages, status, send } = useChannel('room', {
107
+ params: { room: roomId },
108
+ onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
109
+ onPresence: (delta) => { /* 접속자 join/leave */ },
110
+ })
111
+ return { messages, status, send }
110
112
  }
111
- // 보낼 때도 봉투로 감싼다 — 이래야 채널의 onMessage 가 호출된다.
112
- ws.send(JSON.stringify({ t: 'msg', data: { text: '안녕하세요' } }))
113
113
  ```
114
114
 
115
+ - **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
116
+ 정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
117
+ - **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출, `presence` 는
118
+ `onPresence`. 그 외는 `onFrame`.
119
+ - **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
120
+ 을 직접 쓸 수 있다(봉투·라이프사이클을 스스로 책임진다). 기본 경로는 `useChannel`.
121
+
115
122
  ### 5. 허브 프로세스 (`gaon hub`)
116
123
 
117
124
  허브는 접속자 목록의 단일 권위이자 서버 간 중계다.
@@ -7,12 +7,21 @@
7
7
 
8
8
  ### 1. 보안 기본값 = fail-closed (v0.15 §2.5.1)
9
9
 
10
- **CORS · rate limit · CSRF 코어에서 기본 켬.** 끄는 것은 명시적
11
- 설정으로만 (v0.15 §2.5.1 원문 근거).
10
+ **CORS · rate limit · CSRF · 보안 응답 헤더는 코어에서 기본 켬.** 끄는 것은
11
+ 명시적 설정으로만 (v0.15 §2.5.1 원문 근거).
12
12
 
13
13
  보안 결함 = 보안이 선택 설치면 설치 안 한 앱의 기본값이 무방비가
14
14
  된다. Rails/Laravel 이 증명한 원칙이다 (§2.5.1).
15
15
 
16
+ - **보안 응답 헤더 (결정 86)** — 코어가 모든 응답에 CSP·HSTS(HTTPS 한정)·
17
+ `X-Content-Type-Options: nosniff`·`X-Frame-Options: SAMEORIGIN`·
18
+ `Referrer-Policy: strict-origin-when-cross-origin`·`Cross-Origin-Opener-Policy`
19
+ 를 자동으로 붙인다. 기본 CSP 는 Inertia SPA + vite 스택에서 안 깨지게 튜닝돼
20
+ 있다(`script-src 'self'`·인라인 스타일 허용·`connect-src ... ws: wss:` 로
21
+ realtime 허용). 끄거나 조정은 `createApp({ security: { securityHeaders: … } })`
22
+ — `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
23
+ `helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
24
+
16
25
  ### 2. 세션·CSRF·JWT
17
26
 
18
27
  - 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
@@ -21,6 +30,12 @@
21
30
  - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
22
31
  강제. `api()` 클라이언트는 `<meta name="csrf-token">` 을 자동으로
23
32
  읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
33
+ - **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
34
+ 세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
35
+ `app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
36
+ 한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
37
+ 라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
38
+ `csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
24
39
  - JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
25
40
 
26
41
  ### 3. 시크릿
@@ -90,3 +105,4 @@ const rows = await Post.query()
90
105
  | §2.5.1 (v0.15) | 보안 기본값 fail-closed (CORS·rate limit·CSRF 기본 켬) |
91
106
  | 결정 24 (E-3 §5) | `this.params` 고정 우선순위 — 파라미터 오염 차단 |
92
107
  | §7 (v0.15) | 세션 앱별 분리 · JWT 는 API 앱 전용 |
108
+ | 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
@@ -100,6 +100,47 @@ export default controller({
100
100
  - 기본 경로는 여전히 `this.params` 하나(The One Way) — 스캐폴드·문서·
101
101
  기본 예시는 `this.params` 만 쓴다 (E-3 §5.3 원문).
102
102
 
103
+ **폼 모양 두 가지 — 스키마 파생 vs 애드혹 (라우트 파라미터는 둘 다 자동 병합 · 결정 95):**
104
+
105
+ 라우트 파라미터·body·query 병합은 폼 모양과 무관하게 항상 일어난다 —
106
+ `this.params(...)` 에 넘기는 폼은 **결과 타입**만 정한다. 그래서 `/posts/:id`
107
+ 같은 라우트 파라미터와 폼 필드를 **한 번의 `this.params` 로 함께** 받는다.
108
+
109
+ - **① 스키마 파생 폼 `this.params(Model.form)`** — 정본. 모델 컬럼 타입으로
110
+ 런타임 강제 변환·검증(대량 할당 차단)까지 한다. 라우트 파라미터는 **키 이름이
111
+ 컬럼과 같으면** 자동으로 그 자리에 들어간다. 그래서 라우트를 컬럼명에 맞춰
112
+ 짓는 게 관례다:
113
+
114
+ ```ts
115
+ // routes: r.post('/posts/:postId/comments', 'comments#create')
116
+ // 스키마 comments 에 postId·author·body 컬럼이 있으면:
117
+ async create() {
118
+ // :postId 는 라우트에서, author·body 는 body 에서 — 한 번에 검증까지.
119
+ const data = this.params(Comment.form) // { postId, author, body, ... } (검증됨)
120
+ const comment = await Comment.create(data)
121
+ return this.redirect(`/posts/${String(data.postId)}`)
122
+ }
123
+ ```
124
+
125
+ - **② 애드혹 폼 `this.params({ _row: {} as { ... } })`** — 전용 모델이 없거나
126
+ 받을 필드를 **정확히** 고를 때. 라우트 파라미터·폼 필드를 자유롭게 섞어 타입을
127
+ 못박는다. 단 **런타임 스키마 검증은 없다**(타입만 · 컬럼 정의가 없어 coerce
128
+ 스킵) — 필요하면 값 검사를 직접 하거나 ①로 간다:
129
+
130
+ ```ts
131
+ // routes: r.post('/posts/:id/comments', 'comments#create')
132
+ async create() {
133
+ // :id(라우트) + author·body(폼) 를 한 폼 모양으로.
134
+ const { id, author, body } = this.params({
135
+ _row: {} as { id: string; author: string; body: string },
136
+ })
137
+ // ...
138
+ }
139
+ ```
140
+
141
+ ①이 검증까지 주므로 **모델이 있으면 ①을 먼저 고른다**. ②는 로그인 폼처럼
142
+ 전용 테이블이 없는 입력의 탈출구다.
143
+
103
144
  ### 4. 데이터 경로 판단 — 루트 판단표가 정본
104
145
 
105
146
  데이터가 필요할 때는 루트 `AGENTS.md` 의 데이터 경로 4종 판단표를 따른다
@@ -275,6 +316,11 @@ export default controller({
275
316
  - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
276
317
  - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
277
318
  (결정 37 · 상세는 `agents/frontend.md`).
319
+ - **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
320
+ (결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
321
+ 라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
322
+ (doctor **static-collision** 가 경고). 정적 서빙에 `@fastify/static` 을 따로
323
+ 깔지 말 것(코어 내장).
278
324
 
279
325
  ## 관련 결정 번호
280
326
 
@@ -285,4 +331,5 @@ export default controller({
285
331
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 — `agents/async.md`) |
286
332
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
287
333
  | 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
334
+ | 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
288
335
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -0,0 +1,14 @@
1
+ // 앱 설정 — apps/web/app.config.ts. 보안 기본값(세션 + CSRF)을 표준 부팅
2
+ // (gaon dev / gaon serve)에 배선한다. 잘 지은 app.config 는 거의 비어 있어야
3
+ // 하지만(§1.1), 세션은 CSRF 토큰 저장소의 토대라 기본으로 켠다.
4
+ //
5
+ // 규칙 8(§2.5.1): CORS·rate limit·CSRF 는 코어에서 **기본 켬** — 끄는 것은
6
+ // 명시적 설정으로만. 세션이 있어야 상태 변경 요청(POST/PUT/PATCH/DELETE)에
7
+ // CSRF 토큰 검증이 걸린다. 세션 스토어는 루트 gaon.config.ts 의 redis(REDIS_URL)
8
+ // 를 wireGaon 이 주입한다(결정 93).
9
+ import { defineAppConfig } from 'gaonjs/config'
10
+
11
+ export default defineAppConfig({
12
+ // secret 은 32자 이상 — .env 의 SESSION_SECRET 로 주입한다(운영은 반드시 교체).
13
+ session: { secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me-now!!' },
14
+ })
@@ -9,6 +9,10 @@
9
9
  // 다크 헤더 + 라이트 본문(결정 69). 스타일은 Tailwind 유틸(결정 74) — 다크
10
10
  // 헤더는 본문 테마와 무관하게 항상 어둡게 두려고 브랜드 색을 명시값으로 박는다.
11
11
  // 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
12
+ //
13
+ // 결정 96: 앱 내부 이동은 `Link`(선언적) — `<a href="/">` 는 전체 문서
14
+ // 리로드라 SPA 가 깨진다. 외부 URL 만 `<a>`(문서·GitHub).
15
+ import { Link } from 'gaonjs/vue'
12
16
 
13
17
  // package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
14
18
  const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
@@ -19,13 +23,13 @@ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
19
23
  <header
20
24
  class="flex items-center justify-between gap-4 border-b border-[#21262d] bg-[#0d1117] px-6 py-3.5 text-[#e6edf3]"
21
25
  >
22
- <a href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
26
+ <Link href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
23
27
  <span
24
28
  class="inline-block rounded-md bg-gradient-to-br from-[#4f8cff] to-[#7c5cff] px-1.5 py-0.5 text-xs font-bold tracking-wide text-white"
25
29
  >가온</span>
26
30
  <span class="text-[0.95rem] font-bold tracking-[0.08em]">GAONJS</span>
27
31
  <span class="text-xs tabular-nums text-[#8b949e]">v{{ version }}</span>
28
- </a>
32
+ </Link>
29
33
  <nav class="flex gap-[1.1rem] text-sm">
30
34
  <a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">문서</a>
31
35
  <a href="https://github.com/gaonjs" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">GitHub</a>
@@ -0,0 +1,4 @@
1
+ # {{PROJECT_NAME}} · 정적 파일 폴더 (결정 85) — 이 폴더의 파일은 앱 루트로 서빙된다.
2
+ # 예: 이 robots.txt 는 /robots.txt 로, static/img/logo.png 은 /img/logo.png 으로.
3
+ User-agent: *
4
+ Disallow:
@@ -0,0 +1,98 @@
1
+ # {{PROJECT_NAME}} 운영 스택 — 웹·워커·허브 3종 프로세스 + 실 인프라.
2
+ #
3
+ # 개발 스택(docker-compose.yaml)과 달리 앱 이미지(Dockerfile)를 빌드해 serve·work·
4
+ # hub 를 별도 서비스로 띄운다(운영 프로세스 3종 · CLAUDE.md §2 · 결정 60). 인프라는
5
+ # 실 pg·redis·nats(목업 금지 §9). 시크릿은 env 로 주입한다 — 이 파일에 값을 박지 말 것.
6
+ #
7
+ # 기동: docker compose -f compose.prod.yaml up -d --build
8
+ # 정지: docker compose -f compose.prod.yaml down
9
+ name: {{PROJECT_NAME}}-prod
10
+
11
+ x-app-env: &app-env
12
+ DATABASE_URL: postgres://{{PROJECT_NAME}}:${DB_PASSWORD:?set DB_PASSWORD}@postgres:5432/{{PROJECT_NAME}}
13
+ REDIS_URL: redis://redis:6379
14
+ NATS_URL: nats://nats:4222
15
+ COOKIE_SECRET: ${COOKIE_SECRET:?set COOKIE_SECRET (32+ chars)}
16
+ NODE_ENV: production
17
+
18
+ services:
19
+ # 웹 프로세스(gaon serve). node:cluster 워커 수는 WEB_CONCURRENCY 로(기본 1).
20
+ web:
21
+ build: .
22
+ command: ["pnpm", "serve"]
23
+ environment:
24
+ <<: *app-env
25
+ PORT: "3000"
26
+ WEB_CONCURRENCY: "${WEB_CONCURRENCY:-1}"
27
+ ports:
28
+ - "3000:3000"
29
+ depends_on:
30
+ postgres: { condition: service_healthy }
31
+ redis: { condition: service_healthy }
32
+ nats: { condition: service_healthy }
33
+ restart: unless-stopped
34
+
35
+ # 워커 프로세스(gaon work) — 잡·리스너·스케줄러·아웃박스 릴레이.
36
+ worker:
37
+ build: .
38
+ command: ["pnpm", "work"]
39
+ environment: *app-env
40
+ depends_on:
41
+ postgres: { condition: service_healthy }
42
+ nats: { condition: service_healthy }
43
+ restart: unless-stopped
44
+
45
+ # 허브 프로세스(gaon hub) — 리더만 bind, 프레즌스 단일 권위(결정 60 · §7).
46
+ hub:
47
+ build: .
48
+ command: ["pnpm", "hub"]
49
+ environment:
50
+ <<: *app-env
51
+ GAON_HUB_PORT: "4001"
52
+ GAON_HUB_ADVERTISE: "hub:4001"
53
+ depends_on:
54
+ nats: { condition: service_healthy }
55
+ restart: unless-stopped
56
+
57
+ postgres:
58
+ image: postgres:16-alpine
59
+ environment:
60
+ POSTGRES_USER: {{PROJECT_NAME}}
61
+ POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
62
+ POSTGRES_DB: {{PROJECT_NAME}}
63
+ volumes:
64
+ - pgdata:/var/lib/postgresql/data
65
+ healthcheck:
66
+ test: ["CMD-SHELL", "pg_isready -U {{PROJECT_NAME}} -d {{PROJECT_NAME}}"]
67
+ interval: 5s
68
+ timeout: 3s
69
+ retries: 30
70
+ restart: unless-stopped
71
+
72
+ redis:
73
+ image: redis:7-alpine
74
+ volumes:
75
+ - redisdata:/data
76
+ healthcheck:
77
+ test: ["CMD", "redis-cli", "ping"]
78
+ interval: 5s
79
+ timeout: 3s
80
+ retries: 30
81
+ restart: unless-stopped
82
+
83
+ nats:
84
+ image: nats:2.10-alpine
85
+ command: ["-js", "-sd", "/data", "-m", "8222"]
86
+ volumes:
87
+ - natsdata:/data
88
+ healthcheck:
89
+ test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"]
90
+ interval: 5s
91
+ timeout: 3s
92
+ retries: 30
93
+ restart: unless-stopped
94
+
95
+ volumes:
96
+ pgdata:
97
+ redisdata:
98
+ natsdata:
@@ -3,6 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.27.0",
6
7
  "engines": {
7
8
  "node": ">=22"
8
9
  },
package/dist/work.js CHANGED
@@ -44,6 +44,8 @@ function emitHuman(e) {
44
44
  return undefined;
45
45
  case 'relay':
46
46
  return ` ↪ 아웃박스 릴레이 — ${e.count}건 발행`;
47
+ case 'purge':
48
+ return ` 🧹 아웃박스 정리 — ${e.count}건 삭제(보존 기간 초과)`;
47
49
  default:
48
50
  return undefined;
49
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.18.0",
3
+ "version": "0.23.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.5.0",
31
- "@gaonjs/core": "0.2.0",
32
- "@gaonjs/config": "0.4.1",
33
- "@gaonjs/web": "0.6.1",
34
- "@gaonjs/mail": "0.1.1",
35
- "@gaonjs/data": "0.8.4"
30
+ "@gaonjs/async": "0.6.1",
31
+ "@gaonjs/data": "0.9.1",
32
+ "@gaonjs/web": "0.7.1",
33
+ "@gaonjs/core": "0.2.1",
34
+ "@gaonjs/config": "0.5.2",
35
+ "@gaonjs/mail": "0.1.3"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""