@gaonjs/cli 0.5.0 → 0.10.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.
- package/dist/commands/check.d.ts +21 -2
- package/dist/commands/check.js +70 -7
- package/dist/commands/db.d.ts +3 -1
- package/dist/commands/db.js +8 -2
- package/dist/commands/g.d.ts +1 -1
- package/dist/commands/g.js +27 -3
- package/dist/commands/mcp.d.ts +15 -0
- package/dist/commands/mcp.js +78 -0
- package/dist/db/diff.js +5 -0
- package/dist/db/journal.d.ts +34 -0
- package/dist/db/journal.js +71 -0
- package/dist/db/migrate.d.ts +6 -1
- package/dist/db/migrate.js +120 -102
- package/dist/db/replay.d.ts +49 -0
- package/dist/db/replay.js +148 -0
- package/dist/db/status.d.ts +12 -0
- package/dist/db/status.js +61 -0
- package/dist/dev/index.d.ts +2 -0
- package/dist/dev/index.js +2 -0
- package/dist/dev/vite.d.ts +67 -0
- package/dist/dev/vite.js +126 -0
- package/dist/dev.d.ts +18 -0
- package/dist/dev.js +15 -0
- package/dist/doctor/agents-doc-index.d.ts +4 -0
- package/dist/doctor/agents-doc-index.js +80 -0
- package/dist/doctor/fixers/dependency-direction.d.ts +9 -0
- package/dist/doctor/fixers/dependency-direction.js +98 -0
- package/dist/doctor/fixers/index.d.ts +15 -0
- package/dist/doctor/fixers/index.js +66 -0
- package/dist/doctor/fixers/schema-filename.d.ts +14 -0
- package/dist/doctor/fixers/schema-filename.js +104 -0
- package/dist/doctor/fixers/types.d.ts +59 -0
- package/dist/doctor/fixers/types.js +15 -0
- package/dist/doctor/schema-filename.d.ts +6 -0
- package/dist/doctor/schema-filename.js +81 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +49 -0
- package/dist/doctor.js +179 -5
- package/dist/generate.js +2 -2
- package/dist/hub.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +50 -10
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/server.d.ts +50 -0
- package/dist/mcp/server.js +102 -0
- package/dist/mcp/tools.d.ts +109 -0
- package/dist/mcp/tools.js +485 -0
- package/dist/scaffold/app.d.ts +5 -0
- package/dist/scaffold/app.js +172 -0
- package/dist/scaffold/controller.js +2 -2
- package/dist/scaffold/index.d.ts +2 -1
- package/dist/scaffold/index.js +2 -1
- package/dist/scaffold/job.d.ts +5 -0
- package/dist/scaffold/job.js +35 -0
- package/dist/scaffold/model.js +8 -8
- package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
- package/dist/templates/auth/registration.controller.ts.tpl +1 -1
- package/dist/templates/auth/session.controller.ts.tpl +1 -1
- package/dist/templates/auth/user.model.ts.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +210 -0
- package/dist/templates/project/agents/async.md.tpl +218 -0
- package/dist/templates/project/agents/data.md.tpl +532 -0
- package/dist/templates/project/agents/frontend.md.tpl +201 -0
- package/dist/templates/project/agents/realtime.md.tpl +157 -0
- package/dist/templates/project/agents/security.md.tpl +92 -0
- package/dist/templates/project/agents/testing.md.tpl +101 -0
- package/dist/templates/project/agents/web.md.tpl +177 -0
- package/dist/templates/project/apps/web/index.html.tpl +18 -0
- package/dist/templates/project/apps/web/main.ts.tpl +24 -0
- package/dist/templates/project/package.json.tpl +5 -2
- package/dist/templates/project/vite.config.ts.tpl +23 -0
- package/dist/tsResolve.js +1 -1
- package/dist/work.d.ts +2 -2
- package/dist/work.js +3 -1
- package/package.json +13 -11
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +0 -12
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +0 -7
- package/dist/__fixtures__/db-minimal/gaon.config.d.ts +0 -2
- package/dist/__fixtures__/db-minimal/gaon.config.js +0 -11
- package/dist/check.d.ts +0 -29
- package/dist/check.js +0 -92
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# agents/frontend.md — 프론트 (페이지 · 컴포넌트 · 컴포저블 · 레이아웃 · api())
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
+
> 대상 패키지: `@gaonjs/vue` (파사드 import 는 `gaonjs/vue`).
|
|
6
|
+
|
|
7
|
+
## 정본 규칙
|
|
8
|
+
|
|
9
|
+
### 1. 페이지(Vue) — `pageProps<'ctrl#action'>()` 로 컨트롤러 props 수신
|
|
10
|
+
|
|
11
|
+
컨트롤러의 `this.render('Posts/Index', {...})` 가 넘긴 render props 를 페이지가 받는
|
|
12
|
+
방식은 **하나뿐** — `gaonjs/vue` 의 `pageProps<K>()` 헬퍼. 라우트 키 `K` 로부터
|
|
13
|
+
컨트롤러 반환 타입이 자동 흘러들어온다 (§6.2 타입 브리지). `usePage()`(Inertia
|
|
14
|
+
원본) · `defineProps<T>()` 로 대체하지 않는다 — pageProps 는 Serialized 경계
|
|
15
|
+
(Date→string, bigint→string, hidden 제외) 를 강제하는 지점이라 우회하면 타입 안전이 무너진다.
|
|
16
|
+
|
|
17
|
+
```vue
|
|
18
|
+
<!-- apps/web/pages/Posts/Index.vue -->
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { pageProps } from 'gaonjs/vue' // 파사드 · 서브패스 X
|
|
21
|
+
|
|
22
|
+
// 라우트 posts#index 의 컨트롤러 render props 가 Serialized 로 흘러들어온다.
|
|
23
|
+
const props = pageProps<'posts#index'>()
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div>
|
|
28
|
+
<h1>Posts</h1>
|
|
29
|
+
<pre>{{ props }}</pre>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **라우트 키** = `<controller>#<action>` (컨트롤러 파일명 stem · 소문자 복수).
|
|
35
|
+
`apps/web/controllers/posts.ts` 의 `index` 액션 → `'posts#index'`.
|
|
36
|
+
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
37
|
+
로 import 하지 않는다.
|
|
38
|
+
- **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
|
|
39
|
+
금지 (라우트를 모른다는 순수 규칙).
|
|
40
|
+
|
|
41
|
+
### 2. API 클라이언트 (`api()`) 호출 (errata E-3 §C)
|
|
42
|
+
|
|
43
|
+
페이지와 무관한 JSON 액션(루트 데이터 경로 판단표 3행)은 `gaonjs/vue` 의 `api()` 로
|
|
44
|
+
부른다. `.gaon/routes.d.ts` 브리지를 `pageProps` 와 그대로 재사용하므로
|
|
45
|
+
추가 생성 파일이 없다 — 라우트 키 하나로 액션 반환 타입이 그대로
|
|
46
|
+
흘러들어온다.
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<!-- apps/web/pages/Posts/Index.vue -->
|
|
50
|
+
<script setup lang="ts">
|
|
51
|
+
import { api } from 'gaonjs/vue' // 파사드 · 서브패스 X
|
|
52
|
+
|
|
53
|
+
async function search(q: string) {
|
|
54
|
+
// 첫 인자 = 'controller#action' 라우트 키, 두 번째 = 라우트 파라미터 + 쿼리/바디
|
|
55
|
+
const { results } = await api('posts#search', { q })
|
|
56
|
+
return results
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
|
|
62
|
+
붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
|
|
63
|
+
반환 타입을 결정한다 (`packages/vue/src/api.ts:72`).
|
|
64
|
+
- **params** — 라우트에 `:id` 같은 자리표시자가 있으면 거기서 채우고,
|
|
65
|
+
남는 값은 GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 실린다
|
|
66
|
+
(서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
|
|
67
|
+
- **반환 타입** = 액션 반환의 `Serialized<>`. JSON 액션이 객체를
|
|
68
|
+
반환하면 그 타입 그대로, render 액션이면 render props 타입이 온다.
|
|
69
|
+
- **실패** — 4xx/5xx 는 예외로 던진다. 422(`ValidationError`)는
|
|
70
|
+
`err.body` 에 검증 이슈가 실려 있다.
|
|
71
|
+
- **CSRF** — 세션 앱은 `<meta name="csrf-token">` 값을 자동으로
|
|
72
|
+
`X-CSRF-Token` 헤더에 붙인다 (`agents/security.md`).
|
|
73
|
+
|
|
74
|
+
### 3. bigint PK 식별자 — 컨트롤러에서 `String()` 정규화 (결정 37)
|
|
75
|
+
|
|
76
|
+
bigint PK(`t.id()`)를 페이지로 흘릴 때는 **컨트롤러 render props 에서 `String(id)`
|
|
77
|
+
로 정규화한다** — The One Way (Rails/Django 의 boundary 직렬화 관례). 컨트롤러
|
|
78
|
+
가 앱 경계이므로 식별자를 여기서 문자열화하면 페이지(Vue)는 항상 string id 를 받아
|
|
79
|
+
`:key`(`PropertyKey`)에 그대로 쓸 수 있고, 템플릿마다 `String()`/`toString()` 방어를
|
|
80
|
+
심을 필요가 없다.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// apps/web/controllers/products.ts
|
|
84
|
+
async index() {
|
|
85
|
+
const list = await Product.orderBy('createdAt', 'desc').limit(20).all()
|
|
86
|
+
return this.render('Products/Index', {
|
|
87
|
+
products: list.map((p) => ({ id: String(p.id), name: p.name, price: p.price })),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **정규화 지점은 하나** — 컨트롤러. Vue 템플릿에서 `:key="String(p.id)"` 로 방어
|
|
93
|
+
하지 않는다(방어가 페이지마다 산재하면 경계가 흐려진다).
|
|
94
|
+
- **안전** — 응답 경계의 `serializeProps`/`Serialized<>`(M4)가 bigint→string
|
|
95
|
+
을 이미 수행하므로 컨트롤러 `String()` 은 문자열에 멱등이다. 자동 직렬화 유무와
|
|
96
|
+
무관하게 식별자가 string 임을 컨트롤러에서 못박아 관례를 하나로 만든다.
|
|
97
|
+
|
|
98
|
+
### 4. 컴포저블·컴포넌트 대칭 표 (errata E-5 §2.2 원문 · 결정 25)
|
|
99
|
+
|
|
100
|
+
프론트 로직 배치 3규칙은 루트 `AGENTS.md` 판단표가 정본이다 (컴포저블 =
|
|
101
|
+
프론트 전용 로직만 · 비즈니스 로직은 서버 `domain/` · 한 컴포넌트 전용 상태는
|
|
102
|
+
컴포저블로 뽑지 않는다). 파일 하나에 컴포저블 하나, 파일명 = 컴포저블명
|
|
103
|
+
(`use` 접두사 필수) — Vue 커뮤니티 표준 관례 그대로 (E-5 §2.1).
|
|
104
|
+
|
|
105
|
+
배치:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
apps/web/composables/useSearch.ts # 이 앱 전용 컴포저블
|
|
109
|
+
shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로직)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`shared/composables/` 는 shared 컴포넌트의 "props 로만" 규칙과 정확히
|
|
113
|
+
같은 구도를 따른다 — 따로 외울 규칙을 만들지 않기 위한 의도적 대칭
|
|
114
|
+
(E-5 §2.2).
|
|
115
|
+
|
|
116
|
+
| | 앱 전용 (`apps/<앱>/`) | 공용 (`shared/`) |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| 컴포넌트 | 자유 (pageProps·api 사용 가능) | props 로만 받는 순수 UI |
|
|
119
|
+
| 컴포저블 | 자유 (api·채널 래핑 가능) | 인자로만 받는 순수 로직 |
|
|
120
|
+
|
|
121
|
+
**shared 컴포저블 제약:**
|
|
122
|
+
|
|
123
|
+
- **라우트를 몰라야 한다** — `api()`·`pageProps()` 호출 금지.
|
|
124
|
+
`.gaon/routes.d.ts` 는 앱별 생성이므로 구조적으로도 불가능하다.
|
|
125
|
+
- 필요한 데이터·호출 함수는 **인자로 받는다** (순수 로직).
|
|
126
|
+
- domain 은 **타입 import 만** 허용.
|
|
127
|
+
|
|
128
|
+
`gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
|
|
129
|
+
`gaonjs/vue` 의 `api`/`pageProps` import 를 잡는다.
|
|
130
|
+
|
|
131
|
+
### 5. 레이아웃 관례 (errata E-5 §2.3)
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
apps/web/layouts/Default.vue # 관례상 모든 페이지에 자동 적용
|
|
135
|
+
apps/web/layouts/Empty.vue # 예: 로그인 페이지용
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- `layouts/Default.vue` 가 존재하면 그 앱의 모든 페이지에 **자동
|
|
139
|
+
적용**된다 (파일 존재 = 등록 원칙). 다른 레이아웃이 필요한 페이지만
|
|
140
|
+
페이지 파일에서 명시적으로 바꾼다.
|
|
141
|
+
- **레이아웃은 shared 에 두지 않는다** — 앱마다 레이아웃이 다른 것이
|
|
142
|
+
정상 (웹 vs 관리자). 레이아웃 안에서 쓰는 공용 조각(로고·푸터 등)만
|
|
143
|
+
`shared/components/` 로 뽑는다.
|
|
144
|
+
- Inertia 의 persistent layout 패턴 — 페이지 전환 시 레이아웃 상태
|
|
145
|
+
유지 — 을 따른다 (E-5 §2.3).
|
|
146
|
+
|
|
147
|
+
### 6. 자동 import 금지 (errata E-5 §2.4)
|
|
148
|
+
|
|
149
|
+
Nuxt 식 자동 import 는 넣지 않는다. 모든 컴포넌트·컴포저블은 명시적으로
|
|
150
|
+
import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
|
|
151
|
+
설정을 잡는다.
|
|
152
|
+
|
|
153
|
+
## 정본 예시
|
|
154
|
+
|
|
155
|
+
```vue
|
|
156
|
+
<!-- apps/web/pages/Posts/Index.vue — pageProps + api + string id key -->
|
|
157
|
+
<script setup lang="ts">
|
|
158
|
+
import { ref } from 'vue'
|
|
159
|
+
import { pageProps, api } from 'gaonjs/vue'
|
|
160
|
+
import PostCard from '../../components/PostCard.vue'
|
|
161
|
+
|
|
162
|
+
const props = pageProps<'posts#index'>()
|
|
163
|
+
const results = ref<Awaited<ReturnType<typeof runSearch>>>([])
|
|
164
|
+
|
|
165
|
+
async function runSearch(q: string) {
|
|
166
|
+
const res = await api('posts#search', { q })
|
|
167
|
+
return res.results
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<template>
|
|
172
|
+
<div>
|
|
173
|
+
<!-- 컨트롤러가 String(p.id) 정규화 → :key 에 그대로 (결정 37) -->
|
|
174
|
+
<PostCard v-for="post in props.posts" :key="post.id" :title="post.title" />
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## 알려진 함정
|
|
180
|
+
|
|
181
|
+
- **`usePage()`·`defineProps<T>()` 로 pageProps 대체 금지** — Serialized
|
|
182
|
+
경계 우회로 타입 안전 붕괴.
|
|
183
|
+
- **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
|
|
184
|
+
결정한다.
|
|
185
|
+
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
|
|
186
|
+
shared-composable-purity 위반. 데이터는 props/인자로.
|
|
187
|
+
- **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
|
|
188
|
+
`Inertia.post()` (`agents/web.md` §4).
|
|
189
|
+
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
|
|
190
|
+
- **템플릿 `:key="String(p.id)"` 방어 금지** — 정규화는 컨트롤러 한 곳
|
|
191
|
+
(결정 37).
|
|
192
|
+
- **`v-html` 은 XSS 탈출구** — 사용자 입력을 넣지 않는다
|
|
193
|
+
(`agents/security.md`).
|
|
194
|
+
|
|
195
|
+
## 관련 결정 번호
|
|
196
|
+
|
|
197
|
+
| 결정 | 내용 |
|
|
198
|
+
|---|---|
|
|
199
|
+
| 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
|
|
200
|
+
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
|
|
201
|
+
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# agents/realtime.md — 실시간 (채널 · 프레즌스 · 허브)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
+
> 대상 패키지: `@gaonjs/async` (파사드 import 는 `gaonjs/async`). 백본 = NATS JetStream.
|
|
6
|
+
|
|
7
|
+
## 정본 규칙
|
|
8
|
+
|
|
9
|
+
### 1. 아키텍처 (errata E-2 · 2026-07-23)
|
|
10
|
+
|
|
11
|
+
- **웹서버 ↔ 허브 통신 = TCP 지속 연결** — 소켓 `close` 이벤트로 서버
|
|
12
|
+
다운을 **즉시** 감지한다 (실측 SIGKILL 4ms · SIGTERM 3ms).
|
|
13
|
+
- **NATS = broadcast 전용** — 허브 → 다른 웹서버들로 프레즌스 델타·채널
|
|
14
|
+
메시지를 pub/sub 팬아웃한다.
|
|
15
|
+
- 허브 상태는 NATS **KV 로 영속**되고, **리스 기반 리더 선출**로
|
|
16
|
+
HA(active-standby) 다.
|
|
17
|
+
- 운영 프로세스는 serve·work·hub 3종 — 허브는 `gaon hub`.
|
|
18
|
+
|
|
19
|
+
### 2. 채널
|
|
20
|
+
|
|
21
|
+
채널은 `apps/<앱>/channels/<이름>.ts` 파일 하나로 정의한다 — 파일 존재 =
|
|
22
|
+
등록 (다른 배터리와 같은 관례). 파일명이 채널 이름이 되고, default
|
|
23
|
+
export 를 런타임이 집는다. 파일명은 camelCase (루트 §네이밍).
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// apps/web/channels/room.ts
|
|
27
|
+
import { channel } from 'gaonjs/async'
|
|
28
|
+
|
|
29
|
+
export default channel({
|
|
30
|
+
// 연결 인가 — false 면 거부(4401 close). 생략 시 공개 채널.
|
|
31
|
+
authorize(ctx) {
|
|
32
|
+
return ctx.user != null // 로그인 사용자만
|
|
33
|
+
},
|
|
34
|
+
// 접속자 목록에 노출할 공개 메타 (민감 정보 제외)
|
|
35
|
+
presenceInfo(ctx) {
|
|
36
|
+
return { name: (ctx.user as { name: string } | null)?.name ?? '익명' }
|
|
37
|
+
},
|
|
38
|
+
// 참여 (연결 수립·프레즌스 등록 후)
|
|
39
|
+
onJoin(ctx) {
|
|
40
|
+
ctx.broadcast({ type: 'joined', member: ctx.member })
|
|
41
|
+
},
|
|
42
|
+
// 클라이언트 메시지 수신
|
|
43
|
+
async onMessage(ctx, data) {
|
|
44
|
+
ctx.broadcast(data) // 전 서버의 전 연결로 팬아웃
|
|
45
|
+
},
|
|
46
|
+
// 이탈 (연결 종료·프레즌스 해제 후)
|
|
47
|
+
onLeave(ctx) {
|
|
48
|
+
ctx.broadcast({ type: 'left', member: ctx.member })
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**채널 훅** (전부 선택):
|
|
54
|
+
|
|
55
|
+
| 훅 | 시점 | 반환 |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `authorize(ctx)` | 소켓 attach 전 | `boolean` — false 면 연결 거부 |
|
|
58
|
+
| `presenceInfo(ctx)` | attach 전 | 접속자 목록에 실을 공개 메타 |
|
|
59
|
+
| `onJoin(ctx)` | 연결·프레즌스 등록 후 | — |
|
|
60
|
+
| `onMessage(ctx, data)` | 클라이언트 메시지 | — |
|
|
61
|
+
| `onLeave(ctx)` | 연결 종료 후 | — |
|
|
62
|
+
|
|
63
|
+
**채널 컨텍스트** (`onJoin`/`onMessage`/`onLeave` 의 `ctx`):
|
|
64
|
+
|
|
65
|
+
| 멤버 | 설명 |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `ctx.channel` | 채널 이름 |
|
|
68
|
+
| `ctx.user` | 세션 인증 사용자(M5) 또는 `null` |
|
|
69
|
+
| `ctx.member` | 이 연결의 멤버 식별자 |
|
|
70
|
+
| `ctx.query` | 연결 쿼리 파라미터 (예: `?room=42`) |
|
|
71
|
+
| `ctx.send(data)` | 이 연결에만 전송 |
|
|
72
|
+
| `ctx.broadcast(data)` | 채널 전체(모든 서버의 모든 연결)로 브로드캐스트 |
|
|
73
|
+
| `ctx.presence()` | 현재 접속자 목록(허브 권위 · 전 서버 동기화) `Promise<PresenceMember[]>` |
|
|
74
|
+
|
|
75
|
+
멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
|
|
76
|
+
|
|
77
|
+
### 3. 프레즌스
|
|
78
|
+
|
|
79
|
+
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
80
|
+
허브(KV) 이므로, 웹서버가 여러 대여도 같은 목록을 본다.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const members = await ctx.presence()
|
|
84
|
+
// [{ id: 'user:1', info: { name: '가온' } }, …]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **write**(join/leave)는 허브를 경유한다 (단일 writer · 순서 보장).
|
|
88
|
+
- **read**(`presence()`)는 KV(권위)를 직접 조회한다.
|
|
89
|
+
- leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
|
|
90
|
+
**즉시** 정리한다 (TCP `close`).
|
|
91
|
+
|
|
92
|
+
### 4. 클라이언트 (WebSocket)
|
|
93
|
+
|
|
94
|
+
브라우저는 표준 WebSocket 으로 채널에 접속한다. 세션 앱은 세션 쿠키로,
|
|
95
|
+
JWT 앱은 쿼리(`access_token`)로 인증한다.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// 브라우저측 (요지) — 채널 구독 래핑은 컴포저블에 (agents/frontend.md)
|
|
99
|
+
const ws = new WebSocket(`wss://example.com/channels/room?room=42`)
|
|
100
|
+
ws.onmessage = (ev) => {
|
|
101
|
+
const msg = JSON.parse(ev.data)
|
|
102
|
+
// 서버가 broadcast/send 한 데이터 · 프레즌스 델타
|
|
103
|
+
}
|
|
104
|
+
ws.send(JSON.stringify({ text: '안녕하세요' })) // onMessage 로 전달
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 5. 허브 프로세스 (`gaon hub`)
|
|
108
|
+
|
|
109
|
+
허브는 접속자 목록의 단일 권위이자 서버 간 중계다.
|
|
110
|
+
|
|
111
|
+
- 리더로 선출된 인스턴스만 TCP 포트를 bind 하고 도달 주소를 KV 에
|
|
112
|
+
공지한다.
|
|
113
|
+
- 웹서버는 이 TCP 로 지속 연결한다. 웹서버가 죽으면 소켓 `close` 로 그
|
|
114
|
+
서버의 멤버 전원을 **즉시** KV 에서 지우고 leave 델타를 브로드캐스트
|
|
115
|
+
한다.
|
|
116
|
+
- 허브가 죽었다 재시작하면 같은 포트로 재bind 하고 KV 에서 상태를
|
|
117
|
+
복원한다.
|
|
118
|
+
- ping 무활동 타임아웃은 네트워크 파티션 백스톱이다.
|
|
119
|
+
|
|
120
|
+
## 정본 예시
|
|
121
|
+
|
|
122
|
+
서버가 먼저 밀어주는 데이터(알림·채팅·접속자)는 채널/프레즌스가 정답
|
|
123
|
+
경로다 (루트 데이터 경로 판단표 2행). 폴링 `api()` 루프로 흉내내지
|
|
124
|
+
않는다.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// apps/web/channels/chatMessages.ts — 파일명 camelCase
|
|
128
|
+
import { channel } from 'gaonjs/async'
|
|
129
|
+
|
|
130
|
+
export default channel({
|
|
131
|
+
authorize(ctx) { return ctx.user != null },
|
|
132
|
+
presenceInfo(ctx) { return { name: (ctx.user as { name: string } | null)?.name ?? '익명' } },
|
|
133
|
+
async onMessage(ctx, data) { ctx.broadcast(data) },
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## 알려진 함정
|
|
138
|
+
|
|
139
|
+
- **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (채널은
|
|
140
|
+
앱 소속 · 라우트처럼 앱 경계 안).
|
|
141
|
+
- **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
|
|
142
|
+
공개된다. 공개 메타만.
|
|
143
|
+
- **웹서버 ↔ 허브를 NATS 로 잇지 않는다** — TCP 지속 연결이 정본
|
|
144
|
+
(E-2). NATS 는 broadcast 팬아웃 전용.
|
|
145
|
+
- **서버 푸시 데이터를 `api()` 폴링으로 대체 금지** — 데이터 경로
|
|
146
|
+
판단표 위반.
|
|
147
|
+
- **자기 자신의 join 델타** — 클라이언트는 자기 `presence:join` 델타도
|
|
148
|
+
스냅샷과 별개로 받는다(멱등이라 무해). 필요하면 자기 `id` 로 필터.
|
|
149
|
+
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
150
|
+
(`agents/testing.md`).
|
|
151
|
+
|
|
152
|
+
## 관련 결정 번호
|
|
153
|
+
|
|
154
|
+
| 결정 | 내용 |
|
|
155
|
+
|---|---|
|
|
156
|
+
| E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
|
|
157
|
+
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# agents/security.md — 보안 (기본값 · 세션/CSRF · 탈출구 주의)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 보안 관련 코드를 만지기 전에 이 파일을 읽는다.
|
|
5
|
+
|
|
6
|
+
## 정본 규칙
|
|
7
|
+
|
|
8
|
+
### 1. 보안 기본값 = fail-closed (v0.15 §2.5.1)
|
|
9
|
+
|
|
10
|
+
**CORS · rate limit · CSRF 는 코어에서 기본 켬.** 끄는 것은 명시적
|
|
11
|
+
설정으로만 (v0.15 §2.5.1 원문 근거).
|
|
12
|
+
|
|
13
|
+
보안 결함 = 보안이 선택 설치면 설치 안 한 앱의 기본값이 무방비가
|
|
14
|
+
된다. Rails/Laravel 이 증명한 원칙이다 (§2.5.1).
|
|
15
|
+
|
|
16
|
+
### 2. 세션·CSRF·JWT
|
|
17
|
+
|
|
18
|
+
- 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
|
|
19
|
+
이름(`<app>_sid`) · 서명 secret · Redis 키 prefix · 쿠키 path 가
|
|
20
|
+
앱 단위로 갇힌다.
|
|
21
|
+
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
|
|
22
|
+
강제. `api()` 클라이언트는 `<meta name="csrf-token">` 을 자동으로
|
|
23
|
+
읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
|
|
24
|
+
- JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
|
|
25
|
+
|
|
26
|
+
### 3. 시크릿
|
|
27
|
+
|
|
28
|
+
- 시크릿은 env 로만 주입 — 코드·설정 파일에 하드코딩 금지 (doctor
|
|
29
|
+
검사).
|
|
30
|
+
- 비밀번호는 `hashPassword`/`verifyPassword`(`gaonjs/web` ·
|
|
31
|
+
`agents/web.md` §5)로만 다루고, 다이제스트는 hidden 컬럼
|
|
32
|
+
(`passwordDigest: t.string().hidden()`)에 저장한다 — 응답 경계에서
|
|
33
|
+
타입·런타임 양쪽으로 페이지 노출이 막힌다.
|
|
34
|
+
|
|
35
|
+
### 4. 입력 안전 — `this.params` 고정 우선순위 (errata E-3 §5)
|
|
36
|
+
|
|
37
|
+
라우트 파라미터 > body > query 병합 순서는 **설정 불가 고정**이다 —
|
|
38
|
+
`/posts/:id` 의 `id` 를 body 로 바꿔치기하는 파라미터 오염 공격을 원천
|
|
39
|
+
차단한다 (상세는 `agents/web.md` §3).
|
|
40
|
+
|
|
41
|
+
### 5. 탈출구 사용 시 주의 — 안전장치가 꺼지는 지점
|
|
42
|
+
|
|
43
|
+
프레임웍 기본 경로는 안전장치가 내장돼 있지만, 탈출구는 그 장치를
|
|
44
|
+
우회한다. 탈출구를 쓸 때는 아래를 직접 책임진다.
|
|
45
|
+
|
|
46
|
+
- **`v-html` (Vue)** — HTML 이스케이프가 꺼진다 = XSS 표면. 사용자
|
|
47
|
+
입력·DB 저장 문자열을 넣지 않는다. 꼭 써야 하면 서버에서 정화된
|
|
48
|
+
값만 (기본 경로는 `{{ }}` 보간).
|
|
49
|
+
- **`Post.query()` (Kysely 원본 · `agents/data.md` §5)** — Kysely 빌더
|
|
50
|
+
는 바인딩을 유지하지만, `sql` 태그로 raw SQL 을 조립할 때 문자열
|
|
51
|
+
연결로 사용자 입력을 끼우면 SQL 주입이다. 값은 항상 바인딩
|
|
52
|
+
(`sql`\`... ${value}\`` 의 파라미터 위치)으로.
|
|
53
|
+
- **`this.body()` / `this.query()`** — `this.params` 의 고정 우선순위
|
|
54
|
+
안전장치를 벗어난 출처 명시 접근. 웹훅 서명 검증 같은 드문 경우만
|
|
55
|
+
(E-3 §5.3).
|
|
56
|
+
- **`.hidden()` 없는 민감 컬럼** — 직렬화 경계 보호는 hidden 이 있어야
|
|
57
|
+
작동한다. 토큰·다이제스트·개인정보 컬럼은 선언 시점에 hidden.
|
|
58
|
+
- **`presenceInfo`** — 접속자 목록은 채널 전원에게 공개된다. 공개 메타만
|
|
59
|
+
(`agents/realtime.md`).
|
|
60
|
+
|
|
61
|
+
## 정본 예시
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// 시크릿 — env 로만 (gaonjs/env)
|
|
65
|
+
import { getEnvVar } from 'gaonjs/env'
|
|
66
|
+
const apiKey = getEnvVar('PAYMENT_API_KEY') // .env / 배포 환경변수
|
|
67
|
+
|
|
68
|
+
// raw SQL 값은 바인딩 위치에만
|
|
69
|
+
import { sql } from 'kysely'
|
|
70
|
+
const rows = await Post.query()
|
|
71
|
+
.where(sql`lower(title)`, 'like', `%${q.toLowerCase()}%`) // 값은 빌더 바인딩
|
|
72
|
+
.execute()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 알려진 함정
|
|
76
|
+
|
|
77
|
+
- **보안 미들웨어를 끄는 설정을 만들지 않는다** — 요구가 있어도 먼저
|
|
78
|
+
근거 섹션(§2.5.1)을 들어 논의.
|
|
79
|
+
- **`v-html` + 사용자 입력 = XSS** — 벤치·doctor 이전에 리뷰에서 잡을
|
|
80
|
+
1순위.
|
|
81
|
+
- **raw SQL 문자열 연결 = SQL 주입** — `Post.query()` 아래로 내려가도
|
|
82
|
+
값은 바인딩.
|
|
83
|
+
- **시크릿 하드코딩 · `.env` 커밋 금지** — env 주입만.
|
|
84
|
+
- **JWT 를 세션 앱에 섞지 않는다** — JWT 는 API 앱 전용.
|
|
85
|
+
|
|
86
|
+
## 관련 결정 번호
|
|
87
|
+
|
|
88
|
+
| 결정 | 내용 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| §2.5.1 (v0.15) | 보안 기본값 fail-closed (CORS·rate limit·CSRF 기본 켬) |
|
|
91
|
+
| 결정 24 (E-3 §5) | `this.params` 고정 우선순위 — 파라미터 오염 차단 |
|
|
92
|
+
| §7 (v0.15) | 세션 앱별 분리 · JWT 는 API 앱 전용 |
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# agents/testing.md — 테스트 (실 인프라 · 통합 테스트 관례 · 헬퍼)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 테스트 작성 전에 이 파일을 읽는다.
|
|
5
|
+
|
|
6
|
+
## 정본 규칙
|
|
7
|
+
|
|
8
|
+
### 1. 실 인프라 필수 (v0.15 §9 · 절대 규칙)
|
|
9
|
+
|
|
10
|
+
**테스트는 Docker 실인프라 필수 — DB · NATS 모두 목업·인메모리
|
|
11
|
+
대체 절대 금지.** SQLite 인메모리 · NATS 목업으로 테스트를 돌리는
|
|
12
|
+
코드를 만들지 말 것.
|
|
13
|
+
|
|
14
|
+
근거 (v0.14 결정): 개발 DB = 운영 DB 원칙 (v0.6, §7.4) 을 정하고
|
|
15
|
+
테스트만 다른 DB 로 돌리는 것은 자기모순이며, 방언 차이 버그가
|
|
16
|
+
테스트를 통과해 운영에서 터지는 구멍이었다.
|
|
17
|
+
|
|
18
|
+
- 테스트 전에 compose 로 DB · NATS 를 띄우고, **테스트 전용
|
|
19
|
+
데이터베이스**(트랜잭션 롤백 격리) + **테스트 전용 스트림 프리픽스**
|
|
20
|
+
를 쓴다.
|
|
21
|
+
- `gaon test` 는 compose 의 테스트 전용 데이터베이스를 자동 준비한다.
|
|
22
|
+
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
23
|
+
아니다.
|
|
24
|
+
|
|
25
|
+
### 2. 파일 위치·러너 관례
|
|
26
|
+
|
|
27
|
+
- 통합 테스트는 `test/integration/<이름>.integration.test.ts` — 러너는
|
|
28
|
+
vitest (`gaon test --scope integration`).
|
|
29
|
+
- 단위 테스트는 소스 옆 `<이름>.test.ts` (`--scope unit`).
|
|
30
|
+
- `vi.mock('gaonjs/async')` · `vi.mock('nats')` · `vi.mock('@nats-io/…')`
|
|
31
|
+
같은 프레임웍·전송 목업은 금지다 (§9) — 실 접속으로 검증한다.
|
|
32
|
+
|
|
33
|
+
### 3. 실 NATS 접속 관례
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { connectNats } from 'gaonjs/async'
|
|
37
|
+
|
|
38
|
+
const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')
|
|
39
|
+
// ... 검증 ...
|
|
40
|
+
await nats.close()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
raw NATS 클라이언트(`nats` 또는 `@nats-io/transport-node` ·
|
|
44
|
+
`@nats-io/jetstream`)로 직접 접속해 스트림을 검증하는 것도 정합이다 —
|
|
45
|
+
실 접속이기만 하면 된다.
|
|
46
|
+
|
|
47
|
+
### 4. 비동기 테스트 헬퍼 — `expectJobProcessed` (결정 42)
|
|
48
|
+
|
|
49
|
+
잡 "발행 → 실 처리" 검증은 `gaonjs/testing` 의 `expectJobProcessed`
|
|
50
|
+
한 호출로 한다 — 임시 워커를 띄워 해당 잡이 실 JetStream 을 거쳐
|
|
51
|
+
**핸들러까지 실행**되는 것을 확증하고 워커를 정리한다.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// test/integration/welcomeMail.integration.test.ts
|
|
55
|
+
import { describe, it } from 'vitest'
|
|
56
|
+
import { connectNats, expectJobProcessed } from 'gaonjs/testing'
|
|
57
|
+
import { SendWelcomeMail } from '../../domain/jobs/sendWelcomeMail.js'
|
|
58
|
+
|
|
59
|
+
describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
60
|
+
it('발행한 잡이 워커에서 처리된다', async () => {
|
|
61
|
+
const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')
|
|
62
|
+
try {
|
|
63
|
+
await expectJobProcessed(SendWelcomeMail, () => SendWelcomeMail.later(1n), { nats })
|
|
64
|
+
} finally {
|
|
65
|
+
await nats.close()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **시그니처** — `expectJobProcessed(job, publish, { nats, timeoutMs? })`.
|
|
72
|
+
`publish` 는 발행을 일으키는 임의 함수 — 잡 직접 호출도, 서비스
|
|
73
|
+
(`RegisterUser.call(...)`) 경유도 된다 (결정 32 · 발행 위치 자유).
|
|
74
|
+
- 잡이 DLQ 로 가거나 `timeoutMs`(기본 10초) 안에 처리되지 않으면 수리
|
|
75
|
+
안내(§7.5.3)와 함께 실패한다.
|
|
76
|
+
- `configureJobs` 는 헬퍼가 대신 해 준다 — 테스트가 부팅 코드를 흉내낼
|
|
77
|
+
필요가 없다.
|
|
78
|
+
|
|
79
|
+
## 정본 예시
|
|
80
|
+
|
|
81
|
+
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본 예시다.
|
|
82
|
+
발행 도착만 확인하고 싶으면 raw JetStream 구독(§3)도 정합이지만, 기본
|
|
83
|
+
경로는 `expectJobProcessed` 하나다 (The One Way).
|
|
84
|
+
|
|
85
|
+
## 알려진 함정
|
|
86
|
+
|
|
87
|
+
- **`expect(true).toBe(true)` 류 무의미 단언 금지** — 실 인프라에
|
|
88
|
+
접속하지 않는 통합 테스트는 §9 위반으로 판정된다.
|
|
89
|
+
- **NATS·DB 목업 금지** — `vi.mock` 으로 전송을 막으면 검증이 무효.
|
|
90
|
+
- **테스트 격리** — 공유 큐 이름을 쓰면 병렬 테스트가 서로의 잡을
|
|
91
|
+
소비한다. 큐·스트림 프리픽스를 테스트 전용으로.
|
|
92
|
+
- **워커 정리** — 직접 `runWorker` 를 쓰면 테스트 종료 전 `stop()` 을
|
|
93
|
+
보장하라 (`expectJobProcessed` 는 자동 정리).
|
|
94
|
+
|
|
95
|
+
## 관련 결정 번호
|
|
96
|
+
|
|
97
|
+
| 결정 | 내용 |
|
|
98
|
+
|---|---|
|
|
99
|
+
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
|
|
100
|
+
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
101
|
+
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|