@gaonjs/cli 0.55.0 → 0.57.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/dev.js +22 -3
- package/dist/commands/test.js +28 -4
- package/dist/doctor/auth-wiring.js +5 -2
- package/dist/doctor/channel-collision.d.ts +9 -0
- package/dist/doctor/channel-collision.js +119 -0
- package/dist/doctor/dotenv-node-env.d.ts +5 -0
- package/dist/doctor/dotenv-node-env.js +68 -0
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +11 -1
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +10 -2
- package/dist/doctor.js +50 -3
- package/dist/generate.d.ts +5 -0
- package/dist/generate.js +11 -3
- package/dist/index.js +7 -5
- package/dist/nodeEnv.d.ts +23 -0
- package/dist/nodeEnv.js +26 -0
- package/dist/scaffold/controller.js +3 -1
- package/dist/scaffold/page.js +6 -4
- package/dist/serve.js +13 -1
- package/dist/templates/project/AGENTS.md.tpl +34 -5
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +20 -7
- package/dist/templates/project/agents/data.md.tpl +65 -11
- package/dist/templates/project/agents/frontend.md.tpl +19 -9
- package/dist/templates/project/agents/i18n.md.tpl +17 -4
- package/dist/templates/project/agents/realtime.md.tpl +105 -15
- package/dist/templates/project/agents/seal.md.tpl +19 -7
- package/dist/templates/project/agents/security.md.tpl +13 -1
- package/dist/templates/project/agents/storage.md.tpl +21 -9
- package/dist/templates/project/agents/testing.md.tpl +58 -0
- package/dist/templates/project/agents/web.md.tpl +157 -15
- package/package.json +6 -6
|
@@ -22,6 +22,28 @@
|
|
|
22
22
|
등록 (다른 배터리와 같은 관례). 파일명이 채널 이름이 되고, default
|
|
23
23
|
export 를 런타임이 집는다. 파일명은 camelCase (루트 §네이밍).
|
|
24
24
|
|
|
25
|
+
**채널 이름은 전역 네임스페이스다 — 앱 소속이 아니다.** 파일이 앱 폴더 아래
|
|
26
|
+
있는 것은 **정의(훅·인가)와 WS 접속 경로**가 앱에 매인다는 뜻이고, **이름으로
|
|
27
|
+
식별되는 것들은 전부 앱을 가로지른다**:
|
|
28
|
+
|
|
29
|
+
| 축 | 스코프 | 실체 |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| WS 접속 경로 | **앱** | `<앱 프리픽스>/gaon/ws/<이름>`(web 의 `room`→`/gaon/ws/room` · admin 의 `adminRoom`→`/admin/gaon/ws/adminRoom`) |
|
|
32
|
+
| `authorize`·`presenceInfo`·훅 | **앱** | 그 앱 폴더의 채널 파일이 실행된다 |
|
|
33
|
+
| 발화(`broadcast`·`sendToUsers`) | **전역** | NATS subject `gaon.chan.<이름>` — 이름만 씀 |
|
|
34
|
+
| 프레즌스 로스터 | **전역** | 허브 KV 키 `presence.<이름>.<멤버>` — 이름만 씀 |
|
|
35
|
+
|
|
36
|
+
- **앱간 동명 채널은 충돌한다.** `apps/web/channels/room.ts` 와
|
|
37
|
+
`apps/admin/channels/room.ts` 를 함께 두면 **접속 경로는 갈리지만 subject·프레즌스
|
|
38
|
+
키는 하나**다 — admin 구독자가 web 발화를 받고, 두 앱 접속자가 한 로스터에 섞인다.
|
|
39
|
+
각 앱의 `authorize` 는 **자기 경로로 들어온 연결만** 거르므로, 한쪽 앱의 엄격한
|
|
40
|
+
인가가 다른 앱 경로로 들어온 구독자를 막아 주지 못한다.
|
|
41
|
+
- **그래서 이름을 전역에서 고유하게 짓는다** — 앱별로 갈라야 하면 이름에 접두를
|
|
42
|
+
넣는다(`adminRoom.ts`·`webRoom.ts`). 서버 발화 `broadcast('room', …)` 도 이름 하나로
|
|
43
|
+
전 서버·전 앱 구독자에게 가므로(§2.5), 이름이 곧 격리 경계다.
|
|
44
|
+
- 발화 대상을 좁혀야 하면 **채널을 분리**한다(§2.5 · `authorize` 로 입장을 제한하고 그
|
|
45
|
+
채널로 broadcast) — broadcast 자체에는 대상 필터가 없다. 특정 유저 지정은 `sendToUsers`(§2.6).
|
|
46
|
+
|
|
25
47
|
```ts
|
|
26
48
|
// apps/web/channels/room.ts
|
|
27
49
|
import { channel } from 'gaonjs/async'
|
|
@@ -185,8 +207,16 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
185
207
|
을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
|
|
186
208
|
자동 인증, JWT 앱은 `params: () => ({ access_token: token.value })` — **함수형으로
|
|
187
209
|
넘겨라**(결정 344). params 는 접속·재접속 시점마다 평가되므로 함수형이면 회전한
|
|
188
|
-
토큰이 재연결에 반영된다(고정 객체는 최초 값
|
|
189
|
-
|
|
210
|
+
토큰이 재연결에 반영된다(고정 객체는 최초 값 고정이라 만료 후 재연결이 계속 옛
|
|
211
|
+
토큰으로 붙는다). room=42 같은 불변 값은 고정 객체로 넘겨도 된다.
|
|
212
|
+
|
|
213
|
+
- **무효·만료 토큰 자체는 연결 거부가 아니라 익명 강등이다.** JWT 앱의 멤버 해석은
|
|
214
|
+
`access_token` 이 유효하면 `user:<sub>`, **아니면 조용히 `conn:<uuid>`·`user=null`**
|
|
215
|
+
로 떨어진다(`packages/web/src/app.ts` memberResolverFor). 그래서 만료 토큰으로 붙어도
|
|
216
|
+
공개 채널(`authorize` 없음)이면 **접속은 성공**하고, `authorize(ctx) { return ctx.user != null }`
|
|
217
|
+
같은 게이트가 있을 때 비로소 **4401** 로 끊긴다 — 4401 은 언제나 `authorize` 거부의
|
|
218
|
+
결과지 토큰 검증 실패 코드가 아니다(§2 `authorize`). 인증이 필요한 채널은 반드시
|
|
219
|
+
`authorize` 를 둔다(토큰만 믿고 생략하면 만료 사용자가 익명으로 입장한다).
|
|
190
220
|
|
|
191
221
|
**앱 프리픽스는 자동이다(결정 154).** 서버는 채널 WS 를 `<앱 프리픽스>/gaon/ws/:channel`
|
|
192
222
|
에 등록하고, `useChannel` 은 그 앱 번들의 `import.meta.env.BASE_URL`(= vite base = 앱
|
|
@@ -196,23 +226,38 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
196
226
|
로만 남는다(명시하면 그대로 쓴다). 프리픽스를 손으로 넣던 옛 관례는 폐기됐다.
|
|
197
227
|
|
|
198
228
|
```ts
|
|
199
|
-
// apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §
|
|
229
|
+
// apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §4)
|
|
200
230
|
import { useChannel } from 'gaonjs/vue'
|
|
201
231
|
|
|
202
232
|
export function useRoom(roomId: number) {
|
|
203
233
|
// messages·members(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
204
234
|
const { messages, members, status, send } = useChannel('room', {
|
|
205
235
|
params: { room: roomId },
|
|
206
|
-
onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
|
|
236
|
+
onMessage: (data, frame) => { /* 서버가 broadcast/send 한 데이터 · frame = 원 봉투 */ },
|
|
207
237
|
// members = 현재 접속자 **전체 명단**. 스냅샷·들어옴·나감을 하나로 반영하므로
|
|
208
238
|
// 델타를 손으로 병합하지 않는다. 반응형이라 template 에서 그대로 렌더해도 된다.
|
|
209
|
-
|
|
239
|
+
// 둘째 인자 frame 으로 무엇이 트리거했는지 안다(frame.t = presence/join/leave).
|
|
240
|
+
onPresence: (members, frame) => { /* 접속자 명단이 바뀔 때마다 전체 명단으로 호출 */ },
|
|
210
241
|
onReconnect: () => { /* 재연결됨 — 놓친 데이터를 Inertia partial reload 로 따라잡기 */ },
|
|
211
242
|
})
|
|
212
243
|
return { messages, members, status, send }
|
|
213
244
|
}
|
|
214
245
|
```
|
|
215
246
|
|
|
247
|
+
**옵션·콜백 표** (`ChannelOptions` · `packages/vue/src/useChannel.ts`):
|
|
248
|
+
|
|
249
|
+
| 옵션 | 기본 | 뜻 |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| `params` | — | 쿼리 파라미터. **함수형이면 접속·재접속마다 재평가**(회전 토큰 · 결정 344) |
|
|
252
|
+
| `immediate` | `true` | 마운트 시 자동 접속. `false` 면 접속하지 않고 **`connect()` 를 직접 부른다** |
|
|
253
|
+
| `maxMessages` | 무제한 | `messages` 보관 상한(초과분은 오래된 것부터 버림 · 결정 303) |
|
|
254
|
+
| `reconnect` | `true` | 자동 재연결. `false` 로 끄거나 `{ curveMs, maxDelayMs, maxAttempts }` 로 튜닝 |
|
|
255
|
+
| `path` | 자동 | WS 경로 override(탈출구 · 기본은 앱 프리픽스 자동 · 결정 154) |
|
|
256
|
+
| `onMessage(data, frame)` | — | `msg` 프레임 — `messages` 축적과 함께 호출 |
|
|
257
|
+
| `onPresence(members, frame)` | — | 접속자 명단 변화 — 첫 인자는 **갱신된 전체 명단**, 둘째는 트리거한 프레임 |
|
|
258
|
+
| `onFrame(frame)` | — | **모든 프레임**에 호출(아래) |
|
|
259
|
+
| `onReconnect()` | — | 재연결 **성공** 시(최초 접속엔 호출 안 됨) |
|
|
260
|
+
|
|
216
261
|
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
217
262
|
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다. **소켓이 OPEN 이 아니면
|
|
218
263
|
(접속 전·재연결 중) 보내지 않고 `false` 를 반환한다**(결정 303 · 큐잉 없음) — 유실이
|
|
@@ -221,18 +266,40 @@ export function useRoom(roomId: number) {
|
|
|
221
266
|
채널은 `maxMessages: 200` 처럼 상한을 잡는다(초과분은 오래된 것부터 버림 · 결정 303).
|
|
222
267
|
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
|
|
223
268
|
스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
|
|
224
|
-
`onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타
|
|
225
|
-
불요).
|
|
269
|
+
`onPresence(members, frame)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타
|
|
270
|
+
병합 불요).
|
|
271
|
+
- **`onFrame` 은 "그 외"가 아니라 *모든* 프레임에 호출된다** — `msg`·`presence`·
|
|
272
|
+
`presence:join`·`presence:leave` 를 각각 처리한 **뒤에도** 매번 호출되는 저수준 훅이다.
|
|
273
|
+
그래서 `onMessage` 와 `onFrame` 을 함께 쓰면 같은 `msg` 프레임을 **두 번** 보게 된다 —
|
|
274
|
+
`onFrame` 안에서 처리하려면 `frame.t` 로 직접 갈라라(중복 집계 주의).
|
|
275
|
+
- **`members` 는 드롭·종료에 비워지지 않는다** — 로스터는 서버가 보내는 `presence`
|
|
276
|
+
스냅샷으로만 교체된다. 소켓이 끊겨 `status` 가 `'reconnecting'` 이 돼도, `close()`
|
|
277
|
+
로 끊어 `'closed'` 가 돼도 `members` 에는 **마지막 명단이 그대로 남는다**(스테일 렌더).
|
|
278
|
+
접속자 목록 UI 는 `status` 를 함께 봐서 끊긴 동안 흐리거나 숨긴다 — 재연결이 성공하면
|
|
279
|
+
서버가 새 스냅샷을 밀어 자동으로 정확해진다.
|
|
280
|
+
```vue
|
|
281
|
+
<ul v-if="status === 'open'"><li v-for="m in members" :key="m.id">{{ m.info?.name }}</li></ul>
|
|
282
|
+
<p v-else>접속자 목록 동기화 중…</p>
|
|
283
|
+
```
|
|
226
284
|
- **자동 재연결(결정 128 · 기본 켬)** — 소켓이 끊기면(서버 재시작·네트워크 blip)
|
|
227
285
|
useChannel 이 **지수 백오프**(1s·2s·5s·10s · 이후 10s 반복 · 지터)로 자동 재접속한다.
|
|
228
286
|
새 소켓은 서버가 다시 인가하고 프레즌스 스냅샷을 다시 밀어주므로 접속자 목록이
|
|
229
287
|
재동기된다(`messages` 는 유지 · 놓친 이벤트 리플레이는 범위 밖). `status` 는
|
|
230
288
|
`'connecting' | 'open' | 'reconnecting' | 'closed'` 로, 드롭 후 `'reconnecting'`,
|
|
231
289
|
성공하면 `'open'`. **재연결 성공 시 `onReconnect` 콜백**으로 놓친 데이터를 따라잡는다
|
|
232
|
-
(Inertia partial reload).
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
290
|
+
(Inertia partial reload). 끄려면 `reconnect: false`, 튜닝은
|
|
291
|
+
`reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·수동 `close()` =
|
|
292
|
+
의도적 종료라 재연결하지 않는다.
|
|
293
|
+
- **재연결하지 않는 종단 close code 2종** — 서버 다운(재시도)과 계약 위반(포기)을
|
|
294
|
+
코드로 가른다. 둘 다 `status` 가 `'closed'` 로 고정되므로, 앱은 이 상태를 사용자에게
|
|
295
|
+
알리고 재시도 수단(로그인·새로고침)을 준다.
|
|
296
|
+
|
|
297
|
+
| code | 언제 | 뒤처리 |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 포함) | 재연결 안 함 — 로그인으로 유도 |
|
|
300
|
+
| `4500` | **seal 개봉 실패**(봉인 계약 위반 · 결정 222·318) | 재연결 안 함 — transient 가 아니라 주입/변조/키 불일치. 콘솔에 원인이 찍힌다(`agents/seal.md`) |
|
|
301
|
+
|
|
302
|
+
그 외 코드(서버 재시작·네트워크 blip·핸들러 실패 `1011`)는 전부 자동 재연결 대상이다.
|
|
236
303
|
- **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
|
|
237
304
|
을 직접 쓸 수 있다(봉투·라이프사이클·재연결을 스스로 책임진다). 기본 경로는 `useChannel`.
|
|
238
305
|
|
|
@@ -269,7 +336,23 @@ export function useRoom(roomId: number) {
|
|
|
269
336
|
- **멀티호스트는 `GAON_HUB_ADVERTISE` 필수.** KV 에 공지하는 도달 주소의 기본은
|
|
270
337
|
`127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
|
|
271
338
|
자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
|
|
272
|
-
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(
|
|
339
|
+
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(운영 배치 가이드는
|
|
340
|
+
https://gaonjs.dev 의 operations 문서).
|
|
341
|
+
- **허브 env 전체(`gaon hub` 가 읽는 값 · `packages/cli/src/hub.ts`):**
|
|
342
|
+
|
|
343
|
+
| env | 기본값 | 무엇 |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| `GAON_HUB_PORT` | `4001` | TCP 리슨 포트(웹서버 접속 지점 · 내부망 한정) |
|
|
346
|
+
| `GAON_HUB_HOST` | `0.0.0.0` | 리슨 바인드 주소 — 특정 NIC 로 좁힐 때 |
|
|
347
|
+
| `GAON_HUB_ADVERTISE` | `127.0.0.1:<port>` | KV 에 공지할 **도달 주소**(멀티호스트 필수) |
|
|
348
|
+
| `GAON_HUB_TTL_MS` | `5000` | 리더 리스 TTL(역할별 독립 · 결정 260) |
|
|
349
|
+
| `GAON_HUB_PING_TIMEOUT_MS` | `10000` | 웹서버 소켓 무활동 임계 — 넘으면 끊어 파티션을 회수 |
|
|
350
|
+
| `GAON_HUB_SWEEP_MS` | `2000` | 무활동·회수 스윕 주기 |
|
|
351
|
+
| `GAON_HUB_RECLAIM_GRACE_MS` | `15000` | 서버 끊긴 뒤 멤버를 회수하기까지의 유예(재시작 창) |
|
|
352
|
+
| `GAON_HUB_TOKEN` | (없음) | 공유 토큰 인증(선택 · 아래) — 허브·웹서버 양쪽에 같은 값 |
|
|
353
|
+
|
|
354
|
+
`GAON_HUB_ADVERTISE` 의 포트는 `GAON_HUB_PORT` 와 맞춘다(주소만 바꾸고 포트를
|
|
355
|
+
잊으면 웹서버가 엉뚱한 포트로 붙는다).
|
|
273
356
|
- **허브 TCP 포트는 내부망 전용이다(결정 311).** 포트(기본 4001)는 방화벽/
|
|
274
357
|
보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반 백스톱으로 라인 길이 상한
|
|
275
358
|
(1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed · 개행 없는 스트림의
|
|
@@ -330,8 +413,13 @@ export default channel({
|
|
|
330
413
|
|
|
331
414
|
## 알려진 함정
|
|
332
415
|
|
|
333
|
-
- **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (
|
|
334
|
-
|
|
416
|
+
- **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (정의·인가·WS 경로가
|
|
417
|
+
라우트처럼 앱 경계 안).
|
|
418
|
+
- **채널 *이름* 은 전역이다 — 앱 소속이 아니다(§2).** 파일이 앱 아래 있다고 이름까지
|
|
419
|
+
앱별로 갈리는 게 아니다: 발화 subject(`gaon.chan.<이름>`)와 프레즌스 키
|
|
420
|
+
(`presence.<이름>.<멤버>`)는 이름만 쓴다. 두 앱에 같은 이름의 채널 파일을 두면
|
|
421
|
+
구독자·로스터가 섞이고, 한쪽 `authorize` 가 다른 앱 경로의 구독자를 막지 못한다.
|
|
422
|
+
이름을 전역 고유로 짓는다(`adminRoom`·`webRoom`).
|
|
335
423
|
- **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
|
|
336
424
|
공개된다. 공개 메타만.
|
|
337
425
|
- **raw data 에코는 반정본** — `onMessage(ctx, data) { ctx.broadcast(data) }` 처럼
|
|
@@ -364,6 +452,7 @@ export default channel({
|
|
|
364
452
|
| 결정 | 내용 |
|
|
365
453
|
|---|---|
|
|
366
454
|
| E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
|
|
455
|
+
| §7 채널 네임스페이스 | **채널 이름 = 전역 네임스페이스**(§2) — WS 접속 경로·훅·`authorize` 만 앱 스코프, 발화 subject(`gaon.chan.<이름>`)·프레즌스 키(`presence.<이름>.<멤버>`)는 이름 단위 · 앱간 동명 채널은 구독자·로스터가 섞이므로 이름을 전역 고유로 |
|
|
367
456
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
368
457
|
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
369
458
|
| 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
|
|
@@ -375,7 +464,8 @@ export default channel({
|
|
|
375
464
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
376
465
|
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
377
466
|
| 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
|
|
378
|
-
| 결정
|
|
467
|
+
| 결정 222·318 | seal 개봉 실패 = WS `4500` 종단(§4) — 서버 wsTerminator 와 대칭 · transient 가 아니라 재연결하지 않음(`agents/seal.md`) |
|
|
468
|
+
| 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 값 고정이라 만료 토큰으로 재접속(→ 익명 강등 → `authorize` 거부 시 4401 종단)하던 갭 봉합 |
|
|
379
469
|
| 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
|
|
380
470
|
| 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
|
|
381
471
|
| 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
|
|
@@ -65,17 +65,24 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
|
|
|
65
65
|
|
|
66
66
|
### 1. 켜는 법 — The One Way (결정 121·124)
|
|
67
67
|
|
|
68
|
+
1) 설치 — 선택 플러그인이라 기본 스캐폴드에 없다(프로젝트의 패키지 매니저를 쓴다):
|
|
69
|
+
|
|
68
70
|
```bash
|
|
69
|
-
|
|
71
|
+
npm i @gaonjs/seal # pnpm add @gaonjs/seal · yarn add @gaonjs/seal 동형
|
|
70
72
|
```
|
|
73
|
+
|
|
74
|
+
2) 앱 설정에서 켠다:
|
|
75
|
+
|
|
71
76
|
```ts
|
|
72
|
-
//
|
|
77
|
+
// apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
|
|
73
78
|
export default defineAppConfig({
|
|
74
79
|
seal: true, // 또는 { except: ['/webhooks/*'], strictQuery: true } — except = 외부가 seal 을 모르는 경로만 평문 통과 · strictQuery = 평문 쿼리도 거부(결정 354)
|
|
75
80
|
})
|
|
76
81
|
```
|
|
82
|
+
3) 클라이언트를 배선한다:
|
|
83
|
+
|
|
77
84
|
```ts
|
|
78
|
-
//
|
|
85
|
+
// apps/<앱>/main.ts — seal 클라이언트를 정적 import 해 createGaonApp 에 넘긴다.
|
|
79
86
|
import { createGaonApp } from 'gaonjs/vue'
|
|
80
87
|
import * as sealClient from '@gaonjs/seal/client' // 정적 import (사용자 vite 가 wasm 포함 번들)
|
|
81
88
|
void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
@@ -153,10 +160,14 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
153
160
|
- **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
|
|
154
161
|
`SHA256(hex(HMAC-SHA256(masterSecret, "domain:path:uaSlice:timestamp")))` · per-frame keying(userId 미포함).
|
|
155
162
|
- **replay 방어**: AES-GCM 12-byte nonce 를 `setIfNotExists`(Redis SETNX 대응) 캐시 + timestamp drift(±60s)로
|
|
156
|
-
차단. **HTTP 경로는 nonce 검사가 항상 배선된다 (결정 223)** —
|
|
157
|
-
인스턴스 안전), 없으면 **in-memory 폴백(단일 인스턴스 전용)**을 쓰고 부팅 시 경고한다(nonce 검사가
|
|
158
|
-
사라지지 않는다). **
|
|
159
|
-
|
|
163
|
+
차단. **HTTP 경로는 nonce 검사가 항상 배선된다 (결정 223)** — Redis 핸들이 있으면 공유 store 를 재사용하고
|
|
164
|
+
(멀티 인스턴스 안전), 없으면 **in-memory 폴백(단일 인스턴스 전용)**을 쓰고 부팅 시 경고한다(nonce 검사가
|
|
165
|
+
조용히 사라지지 않는다). **Redis 핸들은 두 통로에서 온다 (결정 431)**: ① 앱의 세션 Redis(`app.config.ts`
|
|
166
|
+
의 `session`) ② 없으면 **프로세스 공용 Redis** — `gaon.config.ts` 의 `redis: { url: env('REDIS_URL') }`.
|
|
167
|
+
즉 **세션을 쓰지 않는 seal 앱(JWT API 앱)도 `REDIS_URL` 만 구성하면 공유 nonce store 를 탄다** — 세션을
|
|
168
|
+
억지로 붙일 필요가 없다. **in-memory 는 프로세스별 격리라 멀티 워커(`--workers`·`WEB_CONCURRENCY>1`)·멀티
|
|
169
|
+
서버에서 replay 를 완전히 막지 못하므로, 프로덕션 멀티 인스턴스는 `gaon.config.ts` 에 `redis` 를 구성한다**
|
|
170
|
+
(그러면 부팅 경고도 사라진다 — 경고가 남아 있다는 건 두 통로가 모두 비었다는 뜻이다). WS 기본은 drift 윈도우
|
|
160
171
|
(고빈도라 프레임마다 SETNX 는 비용 과다 · 엄격 nonce 는 옵션 주입).
|
|
161
172
|
- **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
|
|
162
173
|
UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
|
|
@@ -221,6 +232,7 @@ export default controller({
|
|
|
221
232
|
- **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
|
|
222
233
|
- **결정 222** — **클라 WS 수신 fail-open P1** 수정: 클라 `wsDecode`(client.ts)가 `P:` 평문·무prefix 프레임을 throw 없이 원문 통과시켜, 서버는 requireDecrypt 로 거부하는데 클라만 주입된 평문을 소비하던 봉인 파괴. `wsDecode` 를 서버 `SealWsTerminator` 와 대칭으로 만들어 `E:` 만 개봉·`P:`/무prefix 거부. `useChannel` 은 개봉 실패를 조용히 드롭하지 않고 소켓을 **4500 종료**(서버 대칭) + 콘솔 명시 + 재연결 안 함.
|
|
223
234
|
- **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
|
|
235
|
+
- **결정 431** — **세션 없는 seal 앱의 nonce store 폴백**: seal 이 `spec.session?.redis` 만 봐서, 세션을 쓰지 않는 seal 앱(JWT API 앱이 정확히 이 모양)은 `REDIS_URL` 을 구성해 두고도 in-memory nonce 로 떨어졌다 — 멀티 워커·멀티 인스턴스에서 replay 방어가 프로세스별로 갈리는 보안 갭(부팅 green·무신호). `createApp` 에 프로세스 공용 `redis` 옵션을 두고(부트스트랩이 `gaon.config` 의 redis 를 전달) `세션 redis ?? 공용 redis` 로 폴백한다. 폴백에도 없을 때만 남는 경고 문구도 실제 수리 경로로 정정 — 옛 문구 "세션 Redis 를 구성하세요" 는 세션이 필요 없는 API 앱에 틀린 처방이었다. **기각: 세션 강제(앱마다 session 배선 요구)** — API 앱에 쓰지도 않는 세션 저장소를 켜게 하는 역행이고 앱별 세션 분리 원칙과 충돌. **기각: seal 이 자기 Redis 커넥션을 새로 연다** — 프로세스당 커넥션 1개 규약(결정 202)을 깨고 종료 소유권이 갈린다.
|
|
224
236
|
- **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
|
|
225
237
|
- **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
|
|
226
238
|
- **결정 354** — seal 백로그 2건: ① **prefix 앱 except 무력** 수정 — except 글롭·기본 헬스 제외를 **앱 상대 경로**로도 매칭(전체 경로 매칭 병행 · 하위 호환). 배선부(web)가 앱 prefix 를 normalizeSealConfig 로 전달. ② **strictQuery 옵션** 신설 — 봉인 강제 요청의 평문 쿼리를 403 `plaintext_query` 로 거부(기본 off — 직접 URL 인바운드가 흔해 기본 강제는 정당한 요청을 깬다). 클라 인터셉터 except 전파는 DEFER(함정 11).
|
|
@@ -98,7 +98,10 @@
|
|
|
98
98
|
**공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
|
|
99
99
|
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
100
100
|
라우트에 **역할 게이트**(`this.requireAuth()` + `this.authorize(user.role === 'admin')`
|
|
101
|
-
· 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role`
|
|
101
|
+
· 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role` 컬럼(`t.string().default('user')`)
|
|
102
|
+
을 두라고 안내한다. **컬럼과 함께 `apps/<app>/auth.ts` 의 `GaonCurrentUser` 증강에도
|
|
103
|
+
`role: string` 을 더한다** — 증강은 손 선언이라 스키마와 자동 동기되지 않아, 빠뜨리면
|
|
104
|
+
역할 게이트가 TS2339 로 컴파일에서 막힌다(아래 ② · `agents/web.md` §6 · 결정 58).
|
|
102
105
|
관리자는 직접 만들거나 승격한다(공개 가입 라우트 없음). web 앱은 현행대로 공개 가입 O.
|
|
103
106
|
공개 비-web 앱이 필요하면 `--public` 로 공개 가입을 opt-in 한다.
|
|
104
107
|
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
|
|
@@ -150,6 +153,14 @@
|
|
|
150
153
|
**403**. 존재 자체를 숨겨야 하면 `this.authorize(condition, { notFound: true })` → 404.
|
|
151
154
|
조건은 호출자가 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`).
|
|
152
155
|
일회성 규칙·탈출구다.
|
|
156
|
+
- **역할 게이트를 쓰려면 `role` 을 스키마와 타입 증강 양쪽에 더한다.** `currentUser`
|
|
157
|
+
의 타입은 `apps/<app>/auth.ts` 의 `GaonCurrentUser` **선언 병합 증강**이고, 스캐폴드
|
|
158
|
+
기본값은 `{ id, name, email }` 뿐이라 스키마에 `role` 컬럼만 추가하면 위 예시가
|
|
159
|
+
**TS2339**(`role` 없음)로 컴파일에서 막힌다. 두 곳을 함께 고친다 —
|
|
160
|
+
① `domain/schema/users.ts` 에 `role: t.string().default('user')` 등 컬럼 추가 +
|
|
161
|
+
마이그레이션, ② `apps/<app>/auth.ts` 의 증강에 `role: string` 추가
|
|
162
|
+
(증강은 자동 생성이 아니라 손 선언이라 스키마와 자동 동기되지 않는다 ·
|
|
163
|
+
`agents/web.md` §6 · 결정 58).
|
|
153
164
|
- **③ 정책 객체 `policy()` + `this.can`** (결정 149) — **재사용할 인가 규칙**을 리소스별
|
|
154
165
|
"액션 → 조건 함수"로 묶는다. 가드는 저수준 authorize 로 수렴한다:
|
|
155
166
|
|
|
@@ -280,6 +291,7 @@ const rows = await Post.query()
|
|
|
280
291
|
| 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
|
|
281
292
|
| 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
|
|
282
293
|
| 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
|
|
294
|
+
| 결정 58 | 역할 게이트 전제 — `currentUser` 타입은 `apps/<app>/auth.ts` 의 `GaonCurrentUser` 증강(손 선언) · schema 에 `role` 추가 시 증강도 갱신(미갱신 = TS2339 · §2 · `agents/web.md` §6) |
|
|
283
295
|
| 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
|
|
284
296
|
| 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
|
|
285
297
|
| 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
|
|
@@ -93,16 +93,19 @@ export default controller({
|
|
|
93
93
|
this.flash('error', '파일이 필요합니다.') // useShared().flash.error 로 표시(결정 116)
|
|
94
94
|
return this.redirect('/profile')
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// public/ 아래에 저장해야 로컬 디스크에서 url() 이 실제로 서빙된다(결정 401 · §1).
|
|
97
|
+
const key = `public/avatars/${this.auth.user!.id}.png`
|
|
97
98
|
await Storage.put(key, f.buffer, { contentType: f.mimetype })
|
|
98
99
|
return this.redirect('/profile')
|
|
99
100
|
},
|
|
100
101
|
})
|
|
101
102
|
```
|
|
102
103
|
|
|
103
|
-
- **멀티파트 폼의 CSRF 는 `x-csrf-token`
|
|
104
|
-
`
|
|
105
|
-
|
|
104
|
+
- **멀티파트 폼의 CSRF 는 서버가 `x-csrf-token` 헤더만 본다**(결정 133 · 구조적 —
|
|
105
|
+
바디 `_csrf` 는 멀티파트에서 검사 시점에 파싱돼 있지 않다). **부착은 프레임웍이
|
|
106
|
+
자동으로 한다**(결정 342) — `useForm(...).post('/uploads')` 그대로 두고 헤더를 손으로
|
|
107
|
+
싣지 않는다. `useForm`/`router` 를 우회하는 커스텀 업로더만 `readCsrfToken()`
|
|
108
|
+
(`gaonjs/vue`)으로 토큰을 읽어 직접 실는다(상세는 `agents/web.md` §3).
|
|
106
109
|
- **업로드 한도는 `web.uploads`** 다(결정 356 · 기본 파일당 10MB · 최대 10개):
|
|
107
110
|
|
|
108
111
|
```ts
|
|
@@ -138,13 +141,20 @@ export default controller({
|
|
|
138
141
|
|
|
139
142
|
```ts
|
|
140
143
|
// 저장 → 공개/서명 URL 얻기(드라이버 무관 · 같은 코드). url() 은 async.
|
|
141
|
-
|
|
144
|
+
// 화면에 표시할 파일이면 키를 public/ 아래에 둔다 — 로컬 디스크의 서빙 범위가
|
|
145
|
+
// publicPrefix(기본 'public/')로 한정되기 때문이다(결정 401 · 밖이면 404).
|
|
146
|
+
const key = `public/avatars/${user.id}.png`
|
|
142
147
|
await Storage.put(key, buffer, { contentType: 'image/png' })
|
|
143
|
-
const src = await Storage.url(key) // 로컬=`/storage/avatars/<id>.png` · s3=공개 URL 또는 presigned
|
|
148
|
+
const src = await Storage.url(key) // 로컬=`/storage/public/avatars/<id>.png` · s3=공개 URL 또는 presigned
|
|
144
149
|
// 만료 있는 서명 URL(s3 · 로컬은 expiresIn 무시):
|
|
145
150
|
const tempLink = await Storage.url(key, { expiresIn: 600 })
|
|
146
151
|
```
|
|
147
152
|
|
|
153
|
+
- **로컬 디스크가 실제로 서빙하는 조건은 셋** — ① 키가 `publicPrefix`(기본 `'public/'`)
|
|
154
|
+
아래일 것 ② `publicUrl` 이 상대 경로일 것(기본 `/storage` · 절대 URL 은 그 서버 몫) ③
|
|
155
|
+
요청을 받는 프로세스가 `gaon serve`/`gaon dev` 일 것(라우트가 부팅 때 등록된다 · 결정 355).
|
|
156
|
+
s3 디스크에는 이 접두사 규칙이 없다(버킷 정책·CDN 이 공개 범위를 정한다).
|
|
157
|
+
|
|
148
158
|
업로드(멀티파트) 수신·저장의 정본은 §3(`this.file('avatar')` → `Storage.put`).
|
|
149
159
|
|
|
150
160
|
## 알려진 함정
|
|
@@ -154,8 +164,9 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
|
|
|
154
164
|
- **`Storage.url()` 은 async** 다 — `await` 를 빠뜨리면 `[object Promise]` 가 렌더된다.
|
|
155
165
|
- **버킷 미준비 = `NoSuchBucket`** — dev 는 compose `createbuckets` 가, 운영은
|
|
156
166
|
인프라가 버킷을 만든다. 프레임웍은 런타임에 버킷을 만들지 않는다(결정 132).
|
|
157
|
-
- **멀티파트
|
|
158
|
-
|
|
167
|
+
- **멀티파트 업로드에 `_csrf` 바디 필드를 넣어도 무의미하다**(결정 133 — 검사 시점에
|
|
168
|
+
파싱돼 있지 않다). `useForm`/`router` 는 헤더를 자동으로 붙이므로(결정 342) 그대로
|
|
169
|
+
두고, 이 둘을 우회한 커스텀 업로더만 `readCsrfToken()` 으로 헤더를 실는다(안 실으면 403).
|
|
159
170
|
- **`public/` 밖 키는 `url()` 이 만들어도 404** — 서빙 범위는 `publicPrefix`(기본
|
|
160
171
|
`'public/'`)로 한정된다(결정 401). 표시할 파일은 `public/` 아래에 저장한다.
|
|
161
172
|
- **업로드 파일명 확장자를 신뢰하지 말 것** — 사용자가 올린 `.html`·`.svg` 는 첨부로
|
|
@@ -165,7 +176,8 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
|
|
|
165
176
|
|
|
166
177
|
- 결정 131 — 스토리지 오리진 CSP 자동 배선(img-src·connect-src).
|
|
167
178
|
- 결정 132 — dev 버킷 zero-config(compose `createbuckets` · 런타임 버킷 생성 안 함).
|
|
168
|
-
- 결정 133 — 멀티파트 CSRF = `x-csrf-token` 헤더 전용(구조적).
|
|
179
|
+
- 결정 133 — 멀티파트 CSRF 검사 = `x-csrf-token` 헤더 전용(구조적).
|
|
180
|
+
- 결정 342 — CSRF 헤더 **자동 부착**(`useForm`/`router` 상태 변경 제출 · 수동 헤더 제거).
|
|
169
181
|
- 결정 129 — `gaon work` 도 `wireDomain` 으로 스토리지·메일 배선(운영 워커).
|
|
170
182
|
- 결정 136 — `gaon test` 하네스가 스토리지·메일을 테스트 격리 값으로 배선.
|
|
171
183
|
- 결정 355 — 로컬 디스크 공개 경로를 프레임웍이 직접 서빙(조용한 404 제거).
|
|
@@ -229,6 +229,64 @@ BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감
|
|
|
229
229
|
그래서 격리는 service 가 실제로 커밋하는 운영 경로를 그대로 두고 매 테스트
|
|
230
230
|
뒤 truncate 로 비운다 — service 든 아니든 항상 안전하다.
|
|
231
231
|
|
|
232
|
+
### 5.1 문서형(몽고) 컬렉션 테스트 — SQL 격리와 규칙이 다르다
|
|
233
|
+
|
|
234
|
+
`collection()`(문서형 · `agents/data.md` §7.1)을 쓰는 프로젝트도 표준 하네스가
|
|
235
|
+
**커넥션은 SQL 과 똑같이 배선해 준다** — 다만 **격리 규약은 다르다.** 몽고에는
|
|
236
|
+
마이그레이션도 트랜잭션도 없어서, `gaon test` 의 SQL 프로비저닝(`CREATE DATABASE` +
|
|
237
|
+
마이그레이션)과 `truncateAllConnections()`(Kysely 커넥션 순회)는 **문서형 커넥션을
|
|
238
|
+
타지 않는다.** 실 mongod 로 돌리는 것(§1 목업 금지)은 SQL 과 같다.
|
|
239
|
+
|
|
240
|
+
- **테스트 DB 는 `<db>_test` 로 자동 분리된다** — SQL 과 대칭이다. `connectTestDatabase()`
|
|
241
|
+
가 `gaon.config.ts` 의 몽고 커넥션을 `<db>_test` 로 파생해 등록한다(url 경로의 DB 명이든
|
|
242
|
+
`database` 값이든 어느 쪽이든 잡고, 이미 `_test` 로 끝나면 그대로 — 멱등). 그래서
|
|
243
|
+
테스트 안에서 `collection()` 모델을 그냥 쓰면 되고, 개발 DB 는 건드리지 않는다.
|
|
244
|
+
**개발 DB 와 달리 `<db>_test` 를 미리 만들어 둘 필요는 없다** — 몽고는 첫 쓰기에 DB 가
|
|
245
|
+
생긴다(SQL 의 `CREATE DATABASE` 단계가 없는 이유). url·`database` 가 **둘 다 없으면**
|
|
246
|
+
하네스는 손대지 않고 부팅이 수리 안내로 실패한다 — DB 명은 문서형 커넥션의 필수
|
|
247
|
+
항목이다(결정 335).
|
|
248
|
+
- **격리는 `deleteMany({})`** — SQL 의 truncate 자리다. 테스트가 건드리는 컬렉션을
|
|
249
|
+
`afterEach` 에서 비운다(스캐폴드 `test/setup.ts` 의 `truncateAll()` 은 SQL 커넥션만
|
|
250
|
+
비우므로 문서형은 테스트 파일이 직접 정리한다). 커넥션 자체는 `afterAll` 의
|
|
251
|
+
`close()` 가 SQL 과 함께 닫는다(안 닫으면 열린 소켓이 vitest 프로세스를 붙잡는다).
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
// test/integration/auditLog.integration.test.ts
|
|
255
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
256
|
+
import { AuditLog } from '../../domain/schema/auditLog.js'
|
|
257
|
+
|
|
258
|
+
afterEach(async () => {
|
|
259
|
+
await AuditLog.deleteMany({}) // 문서형 격리 = 컬렉션 비우기(SQL truncate 대응)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('AuditLog(실 mongod)', () => {
|
|
263
|
+
it('감사 로그가 남는다', async () => {
|
|
264
|
+
await AuditLog.create({ actorId: 'u1', action: 'login' })
|
|
265
|
+
expect(await AuditLog.countDocuments()).toBe(1)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- **SQL 서비스가 남기는 몽고 쓰기는 `afterCommit` 경계에서 검증한다** — `service()`
|
|
271
|
+
트랜잭션 **안**의 몽고 쓰기는 `MongoCrossConnectionWriteError` 로 막히므로(결정 281·331),
|
|
272
|
+
도메인은 커밋 뒤 `afterCommit` 으로 잇는다(`agents/data.md` §7.1). `afterCommit` 콜백은
|
|
273
|
+
서비스가 반환하기 **전에** 실행되므로, 테스트는 서비스 호출을 `await` 한 직후 몽고
|
|
274
|
+
문서를 단언하면 된다(별도 대기·폴링 불필요).
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// SQL 커밋 → 몽고 감사 로그 순서를 한 흐름으로 확증한다.
|
|
278
|
+
const user = await SignUp.call({ email: 'a@b.c', password: 'x' }) // main(SQL) 커밋 + afterCommit
|
|
279
|
+
expect(await AuditLog.countDocuments({ actorId: String(user.id) })).toBe(1)
|
|
280
|
+
|
|
281
|
+
// 반대 방향도 함께 본다 — 롤백이면 afterCommit 자체가 안 돌아 몽고에도 아무것도 안 남는다.
|
|
282
|
+
await expect(SignUp.call({ email: 'dup@b.c', password: 'x' })).rejects.toThrow()
|
|
283
|
+
expect(await AuditLog.countDocuments({ actorId: 'dup' })).toBe(0)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- **아웃박스 헬퍼(§4.1)는 문서형 대상이 아니다** — `_gaon_outbox` 는 SQL 트랜잭션에
|
|
287
|
+
스테이징되는 이벤트 경로다. 몽고 쓰기는 `afterCommit` 이 정본이며, 실패 시 재시도가
|
|
288
|
+
필요하면 잡으로 옮겨 `expectJobProcessed`(§4)로 확증한다.
|
|
289
|
+
|
|
232
290
|
### 6. 직렬화 경계 테스트 — 관계 경유 hidden 을 반드시 포함한다 (결정 122)
|
|
233
291
|
|
|
234
292
|
`.hidden()` 컬럼(예: `passwordDigest`)은 페이지 props 로 나가면 안 된다(§4.2).
|