@gaonjs/cli 0.27.0 → 0.27.2
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.
- package/dist/templates/project/agents/data.md.tpl +37 -3
- package/dist/templates/project/agents/frontend.md.tpl +16 -0
- package/dist/templates/project/agents/security.md.tpl +36 -0
- package/dist/templates/project/agents/testing.md.tpl +39 -0
- package/dist/templates/project/agents/web.md.tpl +25 -0
- package/dist/templates/project/compose.prod.yaml.tpl +6 -0
- package/package.json +6 -6
|
@@ -138,6 +138,11 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
138
138
|
**타입 레벨로** 제외 (모든 타입에 확장). 페이지 props 에서 접근하면
|
|
139
139
|
컴파일 에러 — 유출이 타입 시스템에서 막힌다 (v0.15 §4.2 line
|
|
140
140
|
396–399 원문). 비밀번호 다이제스트가 대표: `passwordDigest: t.string().hidden()`.
|
|
141
|
+
런타임도 `serializeProps` 가 hidden 컬럼을 값에서 떨군다 —
|
|
142
|
+
**관계(`include`/지연) 로 로드한 대상 행에서도** 대상 테이블의 hidden 이
|
|
143
|
+
제외된다(결정 122). 즉 `Post.include('author')` 로 실은 `author`(User)를
|
|
144
|
+
render props 로 통째로 넘겨도 `passwordDigest` 는 나가지 않는다. hidden 값은
|
|
145
|
+
서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2).
|
|
141
146
|
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
142
147
|
- `.index()` — 컬럼 레벨 인덱스 (E-4).
|
|
143
148
|
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
|
|
@@ -178,7 +183,8 @@ export const posts = table('posts', {
|
|
|
178
183
|
|---|---|---|---|
|
|
179
184
|
| `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
|
|
180
185
|
| `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약 |
|
|
181
|
-
| `
|
|
186
|
+
| `whereAny` | `(cols, op, val?)` | `Chain` | **여러 컬럼에 같은 조건을 OR 로 묶어 괄호로 감쌈** = `(c1 op v OR c2 op v)` · 앞선 where 와 **AND 로 안전 결합**(결정 118). 다중 컬럼 검색의 정본 — `orWhere` 로 흩뜨리면 앞 조건이 샌다(아래 함정) |
|
|
187
|
+
| `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습). **다중 컬럼 검색엔 쓰지 말 것** — `whereAny` 를 쓴다(결정 118) |
|
|
182
188
|
| `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) · 정렬 뒤 PK 타이브레이커 자동 부가(결정 110) |
|
|
183
189
|
| `reorder` | `(col, dir?)` | `Chain` | 기존 정렬 전부 버리고 재지정 |
|
|
184
190
|
| `latest` | `()` | `Chain` | `orderBy('createdAt', 'desc')` 고정 축약 · id 타이브레이커로 결정적(결정 110) |
|
|
@@ -186,6 +192,7 @@ export const posts = table('posts', {
|
|
|
186
192
|
| `offset` | `(n)` | `Chain` | 페이지네이션 = `orderBy·offset·limit` 조합 |
|
|
187
193
|
| `first` | `()` | `Promise<Rec \| undefined>` | 자동 `limit 1` |
|
|
188
194
|
| `all` | `()` | `Promise<Rec[]>` | |
|
|
195
|
+
| `paginate` | `(page, perPage)` | `Promise<PaginatedResult<Rec>>` | **체인 종단 페이지네이션**(결정 119) — `{ rows, total, page, pageCount, perPage }` 를 한 번에. count·rows 를 내부 계산(호출자 쿼리 한 번). page/perPage **클램프 내장**(0·음수·초과 = 마지막 페이지 · total 0 = pageCount 1). total=**number**. **result 통째로 render props 안전**(rows 는 Serialized 경계). `latest()` 타이브레이커(결정 110)로 페이지 경계 결정적. 집계 그룹(GroupChain)엔 없음 |
|
|
189
196
|
| `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts:390`) |
|
|
190
197
|
| `exists` | `()` | `Promise<boolean>` | |
|
|
191
198
|
| `sum` · `avg` | `(col)` | `Promise<string \| null>` | numeric 정확성 · 대상 행 없으면 null |
|
|
@@ -426,11 +433,13 @@ export const Post = model(posts, {
|
|
|
426
433
|
// ❌ 컨트롤러 인라인 조립 (검색 교집합을 컨트롤러가 조립)
|
|
427
434
|
// const ids = await Post.where('title','ilike',p).orWhere('body','ilike',p).pluck('id')
|
|
428
435
|
// const rows = await Post.published().whereIn('id', ids).latest().all()
|
|
429
|
-
// ✅ 이름 붙인 스코프 — 컨트롤러는 한
|
|
436
|
+
// ✅ 이름 붙인 스코프 — 컨트롤러는 한 줄. 다중 컬럼 검색은 whereAny(결정 118)로
|
|
437
|
+
// 괄호로 묶어 published 가 안 새게 한다.
|
|
430
438
|
scopes: {
|
|
431
439
|
searchPublished: (q, term: string) =>
|
|
432
440
|
q.where('published', '=', true)
|
|
433
|
-
.
|
|
441
|
+
.whereAny(['title', 'body'], 'ilike', `%${term}%`),
|
|
442
|
+
// → WHERE published AND (title ILIKE .. OR body ILIKE ..) — 미발행 글이 검색에 안 샘.
|
|
434
443
|
}
|
|
435
444
|
// 컨트롤러: const rows = await Post.searchPublished(term).latest().all()
|
|
436
445
|
```
|
|
@@ -590,6 +599,15 @@ const page2 = await Post.latest().offset(20).limit(20).all()
|
|
|
590
599
|
// 부분 문자열 검색 · OR 조합
|
|
591
600
|
const found = await Post.where('title', 'like', '%gaon%').first()
|
|
592
601
|
const mine = await Post.where('published', '=', true).orWhere('authorId', '=', me.id).all()
|
|
602
|
+
// 다중 컬럼 검색은 whereAny — 앞 조건(published)과 AND 로 안전 결합(결정 118)
|
|
603
|
+
const hits = await Post.where('published', '=', true)
|
|
604
|
+
.whereAny(['title', 'body'], 'ilike', `%${term}%`).latest().all()
|
|
605
|
+
|
|
606
|
+
// 페이지네이션은 paginate — 체인 종단 · {rows,total,page,pageCount,perPage} 한 번에(결정 119)
|
|
607
|
+
const result = await Post.published().include('author').withCount('comments').latest().paginate(page, 20)
|
|
608
|
+
// result.rows → 이 페이지 (include·withCount 반영)
|
|
609
|
+
// result.total → 필터 반영 전체 개수(number) · result.pageCount → 전체 페이지(최소 1)
|
|
610
|
+
// 컨트롤러에서 this.render('Posts/Index', { page: result }) 로 통째로 넘겨도 안전.
|
|
593
611
|
|
|
594
612
|
// 집계
|
|
595
613
|
const total = await Post.where('published', '=', true).count() // bigint
|
|
@@ -675,6 +693,20 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
675
693
|
- **폼 변형은 `pick()` 뿐** (결정 104) — `Model.form.omit/extend/merge` 는
|
|
676
694
|
없다. 검증되는 부분 폼 = `pick()`, 스키마와 무관한 입력만 애드혹
|
|
677
695
|
`{ _row: {} as T }`(검증 없음 · `agents/web.md` §3).
|
|
696
|
+
- **페이지네이션을 손으로 조립하지 말 것** (결정 119) — "count 쿼리 + `orderBy·offset·limit`
|
|
697
|
+
목록 쿼리를 따로 조립하고, `count()`(bigint)를 number 로 캐스팅하고, `Math.ceil` 로 페이지 수를
|
|
698
|
+
계산하고, page 를 손으로 클램프" 하는 것은 **반정본**이다. 정본은 `chain.paginate(page, perPage)`
|
|
699
|
+
한 줄 — `{ rows, total, page, pageCount, perPage }` 를 한 번에 주고 클램프(0·음수·초과 = 마지막
|
|
700
|
+
페이지 · total 0 = pageCount 1)와 개수 number 변환을 내장한다. `include`·`withCount`·`latest`
|
|
701
|
+
뒤에 그대로 붙는다. result 는 통째로 render props 에 실어도 안전하고 UI 킷 `Pagination` 블록
|
|
702
|
+
(`:page`·`:pageCount`)에 필드가 그대로 맞는다. 집계 그룹(`groupBy`)에는 없다(행 목록 전용).
|
|
703
|
+
- **다중 컬럼 검색을 `orWhere` 로 흩뜨리면 앞 조건이 샌다** (결정 118) — `where('published',
|
|
704
|
+
'=', true).orWhere('title','ilike',t).orWhere('body','ilike',t)` 는 `published OR title OR
|
|
705
|
+
body` 로 접혀 **미발행 글이 검색에 새어 나온다**. 정본은 `whereAny(['title','body'], 'ilike',
|
|
706
|
+
t)` — 여러 컬럼을 괄호로 묶어 `published AND (title OR body)` 로 만든다. 컬럼 배열은 타입
|
|
707
|
+
안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
|
|
708
|
+
다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
|
|
709
|
+
그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
|
|
678
710
|
|
|
679
711
|
## 관련 결정 번호
|
|
680
712
|
|
|
@@ -694,4 +726,6 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
694
726
|
| 결정 110 | `latest()`·`orderBy` 정렬 뒤 PK 타이브레이커 자동 부가(결정적 페이지네이션 · §4) |
|
|
695
727
|
| 결정 114 | 여러 모델 조합 읽기 = 이름 붙임(모델 정적 메서드/`domain/services/`) · 컨트롤러는 스코프 체인 한 줄까지(§8 · §5.3) |
|
|
696
728
|
| 결정 115 | 원자 프리미티브 increment·decrement·touch·toggle(Rec) + incrementAll·decrementAll(Chain) · read-modify-write 금지(§4) |
|
|
729
|
+
| 결정 118 | `whereAny(cols, op, val)` — 다중 컬럼 동일 조건 OR 를 괄호로 묶어 AND 안전 결합(§8·정본 예시·함정) · 범용 그룹핑(whereGroup) 은 기각(복합 논리는 Kysely 탈출구) |
|
|
730
|
+
| 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
|
|
697
731
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -281,6 +281,21 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
281
281
|
한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
|
|
282
282
|
앱 라우트 지식(`api()`·`pageProps`). 데이터는 props 로 받는다(예 `Pagination` 은
|
|
283
283
|
`v-model:page` 로 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
|
|
284
|
+
- **`Pagination` 블록은 `paginate()` 결과에 바로 맞는다(결정 119·106)** — 컨트롤러가
|
|
285
|
+
`chain.paginate(page, perPage)` 로 만든 `{ rows, total, page, pageCount, perPage }` 를
|
|
286
|
+
통째로 넘기면, 블록의 `:page`·`:pageCount` 가 필드명 그대로 붙는다(매핑 보일러플레이트 0).
|
|
287
|
+
```vue
|
|
288
|
+
<script setup lang="ts">
|
|
289
|
+
import { Pagination } from '@shared/components/ui'
|
|
290
|
+
import { router } from 'gaonjs/vue'
|
|
291
|
+
const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
|
|
292
|
+
function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
|
|
293
|
+
</script>
|
|
294
|
+
<template>
|
|
295
|
+
<article v-for="post in props.page.rows" :key="post.id">…</article>
|
|
296
|
+
<Pagination :page="props.page.page" :page-count="props.page.pageCount" @update:page="goto" />
|
|
297
|
+
</template>
|
|
298
|
+
```
|
|
284
299
|
- **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
|
|
285
300
|
각 앱이 Tailwind 유틸을 받으려면 그 앱에 `style.css` 배선이 있어야 한다.
|
|
286
301
|
`gaon g app admin` 이 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
|
|
@@ -385,4 +400,5 @@ async function runSearch(q: string) {
|
|
|
385
400
|
| 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
|
|
386
401
|
| 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
|
|
387
402
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
|
|
403
|
+
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
388
404
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
`agents/web.md` §5)로만 다루고, 다이제스트는 hidden 컬럼
|
|
47
47
|
(`passwordDigest: t.string().hidden()`)에 저장한다 — 응답 경계에서
|
|
48
48
|
타입·런타임 양쪽으로 페이지 노출이 막힌다.
|
|
49
|
+
- **hidden 계약은 render props 로 나가는 모든 경로에 적용된다** — 직접
|
|
50
|
+
모델 행뿐 아니라 `include()`/지연 관계로 로드한 **대상 행**에서도 그
|
|
51
|
+
테이블의 hidden 이 제외된다(결정 122). 불변식: *페이지로 나가는 값은
|
|
52
|
+
`serializeProps` 를 통과하고, 그 결과 어디에도 hidden 컬럼명이 없다.*
|
|
53
|
+
민감 컬럼은 반드시 `.hidden()` 로 선언해야 이 보호가 작동한다.
|
|
49
54
|
|
|
50
55
|
### 4. 입력 안전 — `this.params` 고정 우선순위 (errata E-3 §5)
|
|
51
56
|
|
|
@@ -73,6 +78,26 @@
|
|
|
73
78
|
- **`presenceInfo`** — 접속자 목록은 채널 전원에게 공개된다. 공개 메타만
|
|
74
79
|
(`agents/realtime.md`).
|
|
75
80
|
|
|
81
|
+
### 6. 클라이언트 IP · 프록시 신뢰 (결정 120)
|
|
82
|
+
|
|
83
|
+
**헤더는 클라이언트 입력이다.** `X-Forwarded-For`·`CF-Connecting-IP` 같은 헤더의
|
|
84
|
+
신뢰는 "**바로 앞 홉이 신뢰 장비이고, 그 장비가 이 헤더를 덮어쓴다**"는 조건에서만
|
|
85
|
+
성립한다. 이 조건이 없으면 누구나 헤더를 위조해 rate limit 을 우회하고 로그를
|
|
86
|
+
오염시킬 수 있다. `gaon.config.ts` 의 `web.clientIp` 3모드가 조건을 어떻게 충족하는가:
|
|
87
|
+
|
|
88
|
+
| 모드 | 신뢰 조건 | 네트워크 전제 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `'direct'`(기본) | 헤더를 아예 안 믿음 — 소켓 피어만 | 서버가 인터넷에 직접 노출 |
|
|
91
|
+
| `{ proxy: n \| CIDR }` | 신뢰 홉/대역 안의 XFF 만 반영 · 밖은 위조로 무시 | 리버스 프록시/LB 가 XFF 를 올바로 덮어씀 |
|
|
92
|
+
| `{ header: '<이름>' }` | 지정 헤더를 정본으로 | **오리진 직접 접속 차단이 필수** — 방화벽/대역 allowlist 로 프록시(예 Cloudflare)만 오리진에 닿게 한다. 안 하면 위조로 우회. 상급: Cloudflare **Authenticated Origin Pulls**(mTLS)로 오리진이 CF 만 받게 강제 |
|
|
93
|
+
|
|
94
|
+
- **IP 는 약한 신호다.** NAT·공유 IP·모바일 캐리어·IPv6 프리픽스 회전으로 한 IP 가
|
|
95
|
+
여러 사람이거나 한 사람이 여러 IP 다. IP 는 **rate limit·어뷰즈 억제·로깅**까지만
|
|
96
|
+
쓴다 — **인가(authorization) 판단에 쓰지 않는다.** "특정 IP 면 관리자 허용" 같은
|
|
97
|
+
분기를 만들지 말 것(위조·회전으로 즉시 무너진다). 인가는 세션/JWT 신원으로만.
|
|
98
|
+
- 세 모드의 산출은 전부 `this.request.ip` 하나로 통일된다(별도 표면 없음 · `agents/web.md` §4.4).
|
|
99
|
+
운영 배치(프록시/Cloudflare 뒤)의 선언은 `compose.prod.yaml` 배치에 맞춘다.
|
|
100
|
+
|
|
76
101
|
## 정본 예시
|
|
77
102
|
|
|
78
103
|
```ts
|
|
@@ -97,6 +122,15 @@ const rows = await Post.query()
|
|
|
97
122
|
값은 바인딩.
|
|
98
123
|
- **시크릿 하드코딩 · `.env` 커밋 금지** — env 주입만.
|
|
99
124
|
- **JWT 를 세션 앱에 섞지 않는다** — JWT 는 API 앱 전용.
|
|
125
|
+
- **프록시 뒤인데 `clientIp` 미선언 = rate limit 무력화** (결정 120) — `web.clientIp` 를
|
|
126
|
+
안 두면 프록시/CF 뒤에서 전 사용자가 프록시 IP 한 버킷으로 묶인다. 배치 환경에 맞게
|
|
127
|
+
`{ proxy }`·`{ header }` 를 선언한다(§6).
|
|
128
|
+
- **IP 로 인가 판단 금지** (결정 120) — "특정 IP = 관리자" 류는 위조·회전에 무너진다.
|
|
129
|
+
IP 는 rate limit·로깅까지만, 인가는 신원(세션/JWT)으로.
|
|
130
|
+
- **관계 행을 render props 로 넘길 때 hidden 을 잊지 않는다** (결정 122) —
|
|
131
|
+
hidden 보호는 `serializeProps` 통과 시 자동 적용되고 관계(`include`/지연)
|
|
132
|
+
행에도 적용된다. 단, 민감 컬럼을 `.hidden()` 로 **선언하지 않으면** 어느
|
|
133
|
+
경로로도 막히지 않는다 — 토큰·다이제스트·개인정보는 선언 시점에 hidden.
|
|
100
134
|
|
|
101
135
|
## 관련 결정 번호
|
|
102
136
|
|
|
@@ -106,3 +140,5 @@ const rows = await Post.query()
|
|
|
106
140
|
| 결정 24 (E-3 §5) | `this.params` 고정 우선순위 — 파라미터 오염 차단 |
|
|
107
141
|
| §7 (v0.15) | 세션 앱별 분리 · JWT 는 API 앱 전용 |
|
|
108
142
|
| 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
|
|
143
|
+
| 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
|
|
144
|
+
| 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
|
|
@@ -123,6 +123,41 @@ BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감
|
|
|
123
123
|
그래서 격리는 service 가 실제로 커밋하는 운영 경로를 그대로 두고 매 테스트
|
|
124
124
|
뒤 truncate 로 비운다 — service 든 아니든 항상 안전하다.
|
|
125
125
|
|
|
126
|
+
### 6. 직렬화 경계 테스트 — 관계 경유 hidden 을 반드시 포함한다 (결정 122)
|
|
127
|
+
|
|
128
|
+
`.hidden()` 컬럼(예: `passwordDigest`)은 페이지 props 로 나가면 안 된다(§4.2).
|
|
129
|
+
이걸 검증하는 테스트는 **직접 모델 행만 보면 안 된다** — `include()`/지연 관계
|
|
130
|
+
접근자로 로드한 **관계 행**도 함께 렌더해 hidden 이 떨어지는지 봐야 한다.
|
|
131
|
+
관계 경유가 v1.4.1 에서 실제로 샜던 지점이고(결정 122), 직접 행만 보던
|
|
132
|
+
기존 테스트는 그 유출을 못 잡았다.
|
|
133
|
+
|
|
134
|
+
정본 = "직렬화 결과 어디에도 hidden 컬럼명이 없다" 를 **재귀로** 단언한다
|
|
135
|
+
(배열·중첩 관계 전부 훑는 헬퍼). 관계를 포함한 픽스처에 적용한다:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// 직렬화 결과(JSON-safe)에 hidden 컬럼명이 어느 깊이에도 없음을 확인.
|
|
139
|
+
function assertNoHiddenLeak(value: unknown, hidden: readonly string[]): void {
|
|
140
|
+
const walk = (v: unknown): void => {
|
|
141
|
+
if (v === null || typeof v !== 'object') return
|
|
142
|
+
if (Array.isArray(v)) return void v.forEach(walk)
|
|
143
|
+
for (const [k, child] of Object.entries(v)) {
|
|
144
|
+
if (hidden.includes(k)) throw new Error(`hidden 유출: ${k}`)
|
|
145
|
+
walk(child)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 관계를 포함해 렌더 — belongsTo·hasMany·hasOne·belongsToMany 를 커버.
|
|
152
|
+
const post = await Post.include('author').first() // author = User(passwordDigest hidden)
|
|
153
|
+
const props = serializeProps({ post })
|
|
154
|
+
assertNoHiddenLeak(props, ['passwordDigest']) // 관계 author 까지 재귀 확인
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에서만 제외 · §4.2) —
|
|
158
|
+
`serializeProps` 전에는 `user.passwordDigest` 가 정상 접근됨을 함께 단언해 계약을
|
|
159
|
+
양쪽으로 고정한다.
|
|
160
|
+
|
|
126
161
|
## 정본 예시
|
|
127
162
|
|
|
128
163
|
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본 예시이고,
|
|
@@ -139,12 +174,16 @@ BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감
|
|
|
139
174
|
소비한다. 큐·스트림 프리픽스를 테스트 전용으로.
|
|
140
175
|
- **워커 정리** — 직접 `runWorker` 를 쓰면 테스트 종료 전 `stop()` 을
|
|
141
176
|
보장하라 (`expectJobProcessed` 는 자동 정리).
|
|
177
|
+
- **직렬화 테스트가 직접 행만 봄** — hidden 검증을 `Model.create()` 결과
|
|
178
|
+
한 행에만 하면 관계 경유 유출(결정 122)을 못 잡는다. `include()`/지연
|
|
179
|
+
관계 행까지 렌더해 재귀로 확인한다(§6).
|
|
142
180
|
|
|
143
181
|
## 관련 결정 번호
|
|
144
182
|
|
|
145
183
|
| 결정 | 내용 |
|
|
146
184
|
|---|---|
|
|
147
185
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
|
|
186
|
+
| 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
|
|
148
187
|
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
149
188
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
150
189
|
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|
|
@@ -241,8 +241,31 @@ async create() {
|
|
|
241
241
|
```ts
|
|
242
242
|
// ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
|
|
243
243
|
// ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
|
|
244
|
+
// ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
|
|
245
|
+
// const page = await Post.searchPublished(term).latest().paginate(this.query('page') ?? 1, 20)
|
|
246
|
+
// return this.render('Posts/Index', { page }) // page 통째로 안전(rows Serialized · 나머지 number)
|
|
244
247
|
```
|
|
245
248
|
|
|
249
|
+
### 4.4 클라이언트 IP · 헤더는 `this.request` (FastifyRequest 탈출구 · 결정 120)
|
|
250
|
+
|
|
251
|
+
컨트롤러에서 IP·요청 헤더가 필요하면 `this.request`(FastifyRequest)로 내려간다 —
|
|
252
|
+
**`this.request.ip`** 가 클라이언트 IP 다. `this.clientIp` 같은 별도 표면은 없다.
|
|
253
|
+
|
|
254
|
+
**IP 는 프록시 뒤에서 반드시 설정을 선언해야 맞다.** `request.ip`·rate limit(기본
|
|
255
|
+
켬 · IP 기준)·구조화 로깅이 **같은 산출**을 쓰며, `gaon.config.ts` 의 `web.clientIp`
|
|
256
|
+
가 그 산출을 정한다(생략 시 `'direct'`).
|
|
257
|
+
|
|
258
|
+
| 모드 | 설정 | request.ip = | 언제 |
|
|
259
|
+
|---|---|---|---|
|
|
260
|
+
| direct(기본) | `web: { clientIp: 'direct' }` | 소켓 피어 주소 | 인터넷에 직접 노출 · XFF/CF 헤더 무시(위조 안 통함) |
|
|
261
|
+
| proxy | `{ clientIp: { proxy: 1 } }` | 신뢰 홉 내 `X-Forwarded-For` | 리버스 프록시/로드밸런서 뒤 · 홉 수 또는 CIDR/IP 로 신뢰 범위 지정 |
|
|
262
|
+
| header | `{ clientIp: { header: 'cf-connecting-ip' } }` | 지정 헤더의 첫 값 | Cloudflare 등 특정 헤더가 정본 · **오리진 직접 접속 차단이 전제**(agents/security.md) |
|
|
263
|
+
|
|
264
|
+
- **프록시/CF 뒤인데 direct 로 두면** 전 사용자가 프록시 IP 하나로 묶여 rate limit 이
|
|
265
|
+
무의미하고 로그의 IP 가 전부 프록시다 — 배치 환경에 맞게 선언한다(운영은 `agents/security.md`
|
|
266
|
+
위협 모델 · `compose.prod.yaml`).
|
|
267
|
+
- 잘못된 설정(빈 헤더 이름 등)은 **부팅 에러**로 즉시 잡힌다(→ 수리 안내 포함).
|
|
268
|
+
|
|
246
269
|
### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
|
|
247
270
|
|
|
248
271
|
회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
|
|
@@ -415,4 +438,6 @@ export default controller({
|
|
|
415
438
|
| 결정 114 | 여러 모델 조합 읽기는 이름 붙임(정적 메서드/서비스) · 컨트롤러는 스코프 체인 한 줄까지(§4.3 · `agents/data.md` §8) |
|
|
416
439
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `this.flash(k,v)` · 페이지는 `useShared()`(§4.2) |
|
|
417
440
|
| 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
|
|
441
|
+
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
442
|
+
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
418
443
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
# hub 를 별도 서비스로 띄운다(운영 프로세스 3종 · CLAUDE.md §2 · 결정 60). 인프라는
|
|
5
5
|
# 실 pg·redis·nats(목업 금지 §9). 시크릿은 env 로 주입한다 — 이 파일에 값을 박지 말 것.
|
|
6
6
|
#
|
|
7
|
+
# 리버스 프록시/로드밸런서/Cloudflare 뒤에 배치한다면 gaon.config.ts 의 web.clientIp 를
|
|
8
|
+
# 선언한다(결정 120) — 안 하면 rate limit(IP 기준·기본 켬)이 전 사용자를 프록시 IP 한
|
|
9
|
+
# 버킷으로 묶고 로그의 IP 가 전부 프록시가 된다. 프록시면 { proxy: 1 }, Cloudflare 등
|
|
10
|
+
# 특정 헤더가 정본이면 { header: 'cf-connecting-ip' }(오리진 직접 접속 차단 전제).
|
|
11
|
+
# 위협 모델·전제는 agents/security.md §6.
|
|
12
|
+
#
|
|
7
13
|
# 기동: docker compose -f compose.prod.yaml up -d --build
|
|
8
14
|
# 정지: docker compose -f compose.prod.yaml down
|
|
9
15
|
name: {{PROJECT_NAME}}-prod
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.2",
|
|
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/config": "0.
|
|
30
|
+
"@gaonjs/config": "0.8.1",
|
|
31
|
+
"@gaonjs/data": "0.13.1",
|
|
31
32
|
"@gaonjs/core": "0.2.1",
|
|
32
|
-
"@gaonjs/
|
|
33
|
-
"@gaonjs/web": "0.
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/async": "0.6.1"
|
|
33
|
+
"@gaonjs/async": "0.6.1",
|
|
34
|
+
"@gaonjs/web": "0.10.0",
|
|
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})\""
|