@gaonjs/cli 0.52.0 → 0.56.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 (51) hide show
  1. package/dist/commands/check.d.ts +2 -0
  2. package/dist/commands/check.js +43 -1
  3. package/dist/commands/db.js +9 -0
  4. package/dist/commands/gen.d.ts +2 -0
  5. package/dist/commands/gen.js +3 -1
  6. package/dist/commands/new.js +13 -0
  7. package/dist/commands/test.js +28 -4
  8. package/dist/db/journal.d.ts +4 -1
  9. package/dist/db/journal.js +37 -4
  10. package/dist/db/migrate.js +11 -11
  11. package/dist/db/replay.js +1 -1
  12. package/dist/db/resolve.d.ts +11 -1
  13. package/dist/db/resolve.js +24 -2
  14. package/dist/db/status.js +9 -6
  15. package/dist/db.js +26 -5
  16. package/dist/dev.js +2 -2
  17. package/dist/doctor/auth-wiring.js +5 -2
  18. package/dist/doctor/channel-collision.d.ts +9 -0
  19. package/dist/doctor/channel-collision.js +119 -0
  20. package/dist/doctor/fixers/index.d.ts +1 -1
  21. package/dist/doctor/fixers/index.js +6 -1
  22. package/dist/doctor/locale-parity.js +2 -2
  23. package/dist/doctor/types.d.ts +1 -1
  24. package/dist/doctor.d.ts +10 -2
  25. package/dist/doctor.js +45 -3
  26. package/dist/generate.d.ts +5 -0
  27. package/dist/generate.js +11 -3
  28. package/dist/i18n-config.d.ts +20 -0
  29. package/dist/i18n-config.js +87 -12
  30. package/dist/index.js +111 -26
  31. package/dist/mcp/tools.js +4 -0
  32. package/dist/messages-gen.js +11 -3
  33. package/dist/scaffold/controller.js +3 -1
  34. package/dist/scaffold/page.js +6 -4
  35. package/dist/templates/project/AGENTS.md.tpl +9 -5
  36. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  37. package/dist/templates/project/Dockerfile.tpl +11 -1
  38. package/dist/templates/project/agents/async.md.tpl +56 -14
  39. package/dist/templates/project/agents/data.md.tpl +146 -35
  40. package/dist/templates/project/agents/frontend.md.tpl +24 -10
  41. package/dist/templates/project/agents/i18n.md.tpl +32 -4
  42. package/dist/templates/project/agents/mail.md.tpl +6 -0
  43. package/dist/templates/project/agents/realtime.md.tpl +115 -16
  44. package/dist/templates/project/agents/seal.md.tpl +13 -4
  45. package/dist/templates/project/agents/security.md.tpl +29 -4
  46. package/dist/templates/project/agents/storage.md.tpl +57 -13
  47. package/dist/templates/project/agents/testing.md.tpl +58 -0
  48. package/dist/templates/project/agents/web.md.tpl +171 -15
  49. package/dist/work.d.ts +3 -0
  50. package/dist/work.js +4 -0
  51. package/package.json +7 -7
@@ -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
- 토큰이 재연결에 반영된다(고정 객체는 최초 값 고정 — 토큰 만료 후 드롭 4401
189
- 영구 종료). room=42 같은 불변 값은 고정 객체로 넘겨도 된다.
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 §3.2)
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
- onPresence: (members) => { /* 접속자 명단이 바뀔 때마다 전체 명단으로 호출 */ },
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
- 불요). 그 외 종류는 `onFrame`(저수준 탈출구).
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). **미인가(4401 close · authorize 거부·세션 만료)면 재연결하지
233
- 않고 `'closed'`** 서버 다운(재시도)과 인가 거부(포기) close code 로 가른다. 끄려면
234
- `reconnect: false`, 튜닝은 `reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·
235
- 수동 `close()` = 의도적 종료라 재연결하지 않는다.
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,16 +336,38 @@ 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` 로 준다(`docs/guides/operations.md`).
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 · 개행 없는 스트림의
276
- 메모리 증식 차단).
359
+ 메모리 증식 차단). 개행이 **있는** 초과 라인도 동일하게 fail-closed 다
360
+ (결정 400 — 종전엔 그 명령만 조용히 버려져 로스터 불일치로만 관측됐다).
277
361
  - **공유 토큰 인증(선택 · 결정 350).** `GAON_HUB_TOKEN` 을 허브·웹서버 양쪽에
278
362
  설정하면 웹서버 소켓의 첫 명령이 올바른 `auth` 여야 하고, 미인증 명령·오토큰은
279
363
  즉시 종료된다(fail-closed · 도달 가능한 임의 피어의 로스터 위조 방어). 미설정
280
364
  이면 종전(무인증 · 내부망 가정). 토큰 없는 허브는 auth 를 무시하므로 웹서버에
281
365
  먼저 설정해 둬도 무해하다(무중단 롤아웃: 웹서버 → 허브 순).
366
+ - **연결 실패는 웹서버 쪽에서도 보인다(결정 399).** 허브 접속이 반복 실패하면
367
+ (토큰 불일치로 허브가 즉시 끊음 · 허브 미기동 · `GAON_HUB_ADVERTISE` 오설정)
368
+ 프레즌스 클라이언트가 스트릭당 1회 `log.warn` 으로 원인 후보와 함께 신호한다 —
369
+ 종전엔 무한 재접속 루프가 완전 무로그라 허브 프로세스 로그에만 흔적이 남았다.
370
+ 건강한 연결이 서면 리셋돼 재발 시 다시 경고한다.
282
371
 
283
372
  ## 정본 예시
284
373
 
@@ -324,8 +413,13 @@ export default channel({
324
413
 
325
414
  ## 알려진 함정
326
415
 
327
- - **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (채널은
328
- 앱 소속 · 라우트처럼 앱 경계 안).
416
+ - **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (정의·인가·WS 경로가
417
+ 라우트처럼 앱 경계 안).
418
+ - **채널 *이름* 은 전역이다 — 앱 소속이 아니다(§2).** 파일이 앱 아래 있다고 이름까지
419
+ 앱별로 갈리는 게 아니다: 발화 subject(`gaon.chan.<이름>`)와 프레즌스 키
420
+ (`presence.<이름>.<멤버>`)는 이름만 쓴다. 두 앱에 같은 이름의 채널 파일을 두면
421
+ 구독자·로스터가 섞이고, 한쪽 `authorize` 가 다른 앱 경로의 구독자를 막지 못한다.
422
+ 이름을 전역 고유로 짓는다(`adminRoom`·`webRoom`).
329
423
  - **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
330
424
  공개된다. 공개 메타만.
331
425
  - **raw data 에코는 반정본** — `onMessage(ctx, data) { ctx.broadcast(data) }` 처럼
@@ -358,6 +452,7 @@ export default channel({
358
452
  | 결정 | 내용 |
359
453
  |---|---|
360
454
  | E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
455
+ | §7 채널 네임스페이스 | **채널 이름 = 전역 네임스페이스**(§2) — WS 접속 경로·훅·`authorize` 만 앱 스코프, 발화 subject(`gaon.chan.<이름>`)·프레즌스 키(`presence.<이름>.<멤버>`)는 이름 단위 · 앱간 동명 채널은 구독자·로스터가 섞이므로 이름을 전역 고유로 |
361
456
  | §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
362
457
  | 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
363
458
  | 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
@@ -369,11 +464,15 @@ export default channel({
369
464
  | 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
370
465
  | 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
371
466
  | 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
372
- | 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 고정이라 만료 후 재연결이 4401 영구 종료되던 갭 봉합 |
467
+ | 결정 222·318 | seal 개봉 실패 = WS `4500` 종단(§4) — 서버 wsTerminator 대칭 · transient 아니라 재연결하지 않음(`agents/seal.md`) |
468
+ | 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 값 고정이라 만료 토큰으로 재접속(→ 익명 강등 → `authorize` 거부 시 4401 종단)하던 갭 봉합 |
373
469
  | 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
374
470
  | 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
375
471
  | 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
376
472
  | 결정 350 | 허브 공유 토큰 인증(§5 · 선택) — `GAON_HUB_TOKEN` 설정 시 첫 명령 = `auth` 강제(타이밍 세이프 비교) · 미인증/오토큰 즉시 종료 · 토큰 없는 허브는 auth 무시(혼재 롤아웃 호환) |
473
+ | 결정 395 | 리스 사임 CAS 삭제 — `stop()` 이 자기 revision 에서만 리더 키 삭제(`previousSeq`) · stale 리더 종료가 활성 리더 키를 지우던 재선출 순단 봉합(endpoint 결정 259 동형 · 허브·스케줄러 공통) |
474
+ | 결정 399 | 프레즌스 클라 연결 실패 warn(§5) — 단명 연결·리더 미발견 연속 시 스트릭당 1회 log.warn(토큰 불일치·허브 부재 안내) · 건강한 연결에 리셋 · 종전 무로그 재접속 루프 봉합 |
475
+ | 결정 400 | P3 청소(실시간 축) — 허브 KV 복원이 오염 키에 throw 해 전 인스턴스 crash-loop 하던 것을 try/continue 방어(presenceStats 와 대칭) · 라인 디코더 완결 초과 라인도 onOverflow(fail-closed 통일) |
377
476
 
378
477
  ## `@gaonjs/seal` 켠 앱의 채널
379
478
 
@@ -65,17 +65,24 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
65
65
 
66
66
  ### 1. 켜는 법 — The One Way (결정 121·124)
67
67
 
68
+ 1) 설치 — 선택 플러그인이라 기본 스캐폴드에 없다(프로젝트의 패키지 매니저를 쓴다):
69
+
68
70
  ```bash
69
- 1) npm i @gaonjs/seal # 선택 플러그인 · 기본 스캐폴드 미포함
71
+ npm i @gaonjs/seal # pnpm add @gaonjs/seal · yarn add @gaonjs/seal 동형
70
72
  ```
73
+
74
+ 2) 앱 설정에서 켠다:
75
+
71
76
  ```ts
72
- // 2) apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
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
- // 3) apps/<앱>/main.ts — seal 클라이언트를 정적 import 해 createGaonApp 에 넘긴다.
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 })
@@ -210,7 +217,7 @@ export default controller({
210
217
  6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
211
218
  7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
212
219
  8. **리버스 프록시의 Host 재작성 금지** — 서버 키 시드는 `Host` 헤더, 브라우저는 `location.hostname` 을 쓴다. 프록시가 Host 를 upstream 이름으로 바꾸면 키가 갈려 data-page 개봉 실패(blank)·전 요청 403 이 된다. 프록시는 원 Host 를 보존해야 한다(`proxy_set_header Host $host` 류 · `x-forwarded-host` 는 참조하지 않는다).
213
- 9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 안 탄 인바운드(직접 URL 등)의 쿼리는 평문일 수 있다 — "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`) — 직접 URL 쿼리를 싣는 경로는 except 로 빼야 한다.
220
+ 9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`). **strictQuery 를 켰을 때 실제로 깨지는 정당 경로는 쿼리 실린 redirect 다**(결정 416) POST→303→GET 브라우저 XHR 투명 추종할 때 Location 의 쿼리는 클라 인터셉터를 못 타 평문으로 도착한다. 그런 대상 경로는 `except`빼거나 redirect 에 쿼리를 싣지 말 것. (주소창 직접 접근은 `Accept: text/html` + X-Inertia 부재라 애초에 seal-target 이 아니다 — 옛 서술 정정.)
214
221
  10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
215
222
  11. **클라 인터셉터는 except 를 모른다** — `gaonjs/vue` 인터셉터는 모든 same-origin 요청의 쿼리를 `?q=` 로 봉인하는데, 서버는 excluded 경로에서 개봉을 건너뛴다. seal 앱 **자신의 브라우저 코드가 except 경로를 쿼리와 함께 호출**하면 핸들러가 `q=<암호문>` 을 받고 실 파라미터는 소실된다(무에러 오동작). except 경로는 외부 호출자 전용으로 두고 앱 자신은 호출하지 말 것(클라 except 전파는 백로그 DEFER).
216
223
 
@@ -224,4 +231,6 @@ export default controller({
224
231
  - **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
225
232
  - **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
226
233
  - **결정 354** — seal 백로그 2건: ① **prefix 앱 except 무력** 수정 — except 글롭·기본 헬스 제외를 **앱 상대 경로**로도 매칭(전체 경로 매칭 병행 · 하위 호환). 배선부(web)가 앱 prefix 를 normalizeSealConfig 로 전달. ② **strictQuery 옵션** 신설 — 봉인 강제 요청의 평문 쿼리를 403 `plaintext_query` 로 거부(기본 off — 직접 URL 인바운드가 흔해 기본 강제는 정당한 요청을 깬다). 클라 인터셉터 except 전파는 DEFER(함정 11).
234
+ - **결정 416** — 함정 9 정정: strictQuery 의 파손 클래스는 "직접 URL" 이 아니라 **쿼리 실린 redirect**(클라 인터셉터가 못 타는 홉)다. docstring·문서를 실제 경로로 교체.
235
+ - **결정 417** — seal 위생 3건: `isExcluded` prefix **경계 검사**(`/apiv2` 가 `/api` 앱 제외로 새던 과확장 차단 · standalone 표면) · 봉인 응답 `Cache-Control: no-store` + `Vary: User-Agent`(공유 캐시가 per-request 키 응답을 재사용하면 개봉 실패 = 가용성 사고).
227
236
  - **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).
@@ -47,9 +47,14 @@
47
47
  })
48
48
  ```
49
49
  생략한 필드는 전역 상속 · `false` 는 그 앱에서만 끔(명시적으로만 · 규칙 8).
50
- rate limit 카운터는 **앱 단위 버킷**이다(override 여부와 무관 클라이언트가
51
- web·api 함께 써도 상한은 앱별로 센다). 보안 응답 헤더는 전역 전용(앱
52
- override 없음).
50
+ **override 객체는 전역과 필드 단위로 병합된다(결정 389)**`rateLimit: {
51
+ timeWindow: '10 minutes' }` 처럼 일부 필드만 줘도 `max` 전역(없으면 코어
52
+ 기본 100)을 상속한다(객체 전체 치환이면 전역 max 600 이 조용히 100 으로
53
+ 하향되는 무신호 파단이었다). CORS 는 앱이 `origin` 을 명시하지 않으면 전역/
54
+ 코어 기본(`origin:false`)이 유지된다 — **부분 override 로 CORS 가 열리지
55
+ 않는다**(fail-closed). rate limit 카운터는 **앱 단위 버킷**이다(override
56
+ 여부와 무관 — 한 클라이언트가 web·api 를 함께 써도 상한은 앱별로 센다).
57
+ 보안 응답 헤더는 전역 전용(앱 override 없음).
53
58
  - **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
54
59
  기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
55
60
  될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
@@ -93,7 +98,10 @@
93
98
  **공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
94
99
  로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
95
100
  라우트에 **역할 게이트**(`this.requireAuth()` + `this.authorize(user.role === 'admin')`
96
- · 결정 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).
97
105
  관리자는 직접 만들거나 승격한다(공개 가입 라우트 없음). web 앱은 현행대로 공개 가입 O.
98
106
  공개 비-web 앱이 필요하면 `--public` 로 공개 가입을 opt-in 한다.
99
107
  - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
@@ -134,6 +142,10 @@
134
142
  --app <api>`(결정 338) — `.env` 의 `<APP>_JWT_SECRET` 으로 주입한다.
135
143
  - **폐기 한계**: 토큰은 stateless — 서버측 폐기(로그아웃·강제 무효화)가 없다.
136
144
  유출 리프레시 토큰은 만료까지 유효 · 민감 앱은 `refreshTtl` 단축(v1 범위 밖).
145
+ - **결정 391 보강**: `this.jwt.refresh` 는 재발급 전 `loadUser(sub)` 실존 확인 —
146
+ 삭제/정지된 계정은 리프레시 토큰이 살아 있어도 재발급이 거부된다(토큰 폐기가
147
+ 아니라 부재 계정 차단 — stateless 한계는 그대로). Bearer 스킴은 RFC 7235 대로
148
+ 대소문자 무관 매칭이다.
137
149
  - **인증·인가는 3층이다** (결정 145 · 149):
138
150
  - **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
139
151
  로그인 페이지 리다이렉트).
@@ -141,6 +153,14 @@
141
153
  **403**. 존재 자체를 숨겨야 하면 `this.authorize(condition, { notFound: true })` → 404.
142
154
  조건은 호출자가 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`).
143
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).
144
164
  - **③ 정책 객체 `policy()` + `this.can`** (결정 149) — **재사용할 인가 규칙**을 리소스별
145
165
  "액션 → 조건 함수"로 묶는다. 가드는 저수준 authorize 로 수렴한다:
146
166
 
@@ -271,6 +291,7 @@ const rows = await Post.query()
271
291
  | 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
272
292
  | 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
273
293
  | 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
294
+ | 결정 58 | 역할 게이트 전제 — `currentUser` 타입은 `apps/<app>/auth.ts` 의 `GaonCurrentUser` 증강(손 선언) · schema 에 `role` 추가 시 증강도 갱신(미갱신 = TS2339 · §2 · `agents/web.md` §6) |
274
295
  | 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
275
296
  | 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
276
297
  | 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
@@ -285,3 +306,7 @@ const rows = await Post.query()
285
306
  | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }` · 전역 상속 · 앱 단위 rate limit 버킷 · 보안 헤더는 전역 전용(§1) |
286
307
  | 결정 341 | CSRF 토큰 출처 = **라이브 Inertia 페이지 props**(최초 문서 data-page·meta 는 부팅 전 폴백) — 로그인 세션 재생성(결정 254) 후 stale 403 봉합(`packages/vue/src/csrf.ts` · §2) |
287
308
  | 결정 342 | CSRF 부착 The One Way — `useForm`/`router`/`api()` 상태 변경에 `X-CSRF-Token` 자동 부착 · 수동 `_csrf` 바디/헤더 제거(스캐폴드 동기) · 우회 전송은 `readCsrfToken()` 탈출구(§2) |
309
+ | 결정 388 | `api()` 도 명시 `X-CSRF-Token` 헤더를 대소문자 무관으로 존중(무조건 덮어쓰기·소문자 공존 콤마 병합 403 봉합 — 인터셉터와 대칭) |
310
+ | 결정 389 | 앱 스코프 보안 override = 전역과 **필드 병합**(부분 override 의 조용한 코어 기본 리셋 봉합) · CORS origin 미명시 = fail-closed(§1) |
311
+ | 결정 391 | JWT 보강 — `refresh` 사용자 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§2) |
312
+ | 결정 393 | 멀티파트 CSRF 403 안내 = 자동 부착 실태 + `readCsrfToken()` 탈출구로 갱신(종전 useForm 수동 헤더 예시는 결정 342 와 모순) |
@@ -27,8 +27,17 @@
27
27
  `publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **프레임웍이 이 경로를 직접 서빙한다**
28
28
  (결정 355 · `gaon serve`/`gaon dev` 의 루트에 자동 등록 — 상대 publicUrl 만 · 폴더 이탈 차단).
29
29
  업로드→`url()` 렌더→표시가 zero-config 로 흐른다. publicUrl 이 절대 URL(별도 서버/CDN)이면
30
- 서빙하지 않는다(그 서버 몫). 공개 서빙이라 **비공개 파일은 로컬 디스크 공개 경로에 두지 말
31
- 것**(접근 제어가 필요하면 s3 presigned 또는 컨트롤러 라우트).
30
+ 서빙하지 않는다(그 서버 몫).
31
+ - **공개 범위는 `publicPrefix` 아래로 한정된다**(결정 401 · 기본 `'public/'`). 서빙되는 키는
32
+ `public/...` 뿐이고 그 밖의 키는 파일이 있어도 **404**(수리 안내 포함)다 — 로컬 디스크는
33
+ 한 폴더에 공개·비공개가 섞이므로 접두사가 유일한 경계다. 따라서 **표시할 파일은
34
+ `public/` 아래에 저장한다**: `Storage.put('public/avatars/1.png', body)` →
35
+ `url('public/avatars/1.png')` = `/storage/public/avatars/1.png`.
36
+ - 접근 제어가 필요한 파일은 `public/` 밖에 두고(예 `private/...`) 컨트롤러 라우트로
37
+ 권한을 검사해 `Storage.get()` 으로 내보낸다. 디스크 전체를 공개하려면
38
+ `publicPrefix: ''` 로 **명시 옵트인**한다(비공개 파일이 없을 때만).
39
+ - 업로드된 `text/html`·`image/svg+xml` 은 `Content-Disposition: attachment` 로 내려간다
40
+ (결정 402) — 사용자가 올린 문서가 앱과 같은 오리진에서 렌더되지 않게 한다.
32
41
  - **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
33
42
  없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
34
43
  존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
@@ -63,8 +72,9 @@ storage: process.env.STORAGE_ENDPOINT
63
72
  - dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
64
73
  (결정 132 · zero-config). `.env` 는 `gaon new` 가 자동 생성하므로(결정 198)
65
74
  `gaon dev` 만으로 우회 0.
66
- - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` — `url(key)`
67
- = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동 서빙 — 결정 355).
75
+ - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage', publicPrefix?: 'public/' }` —
76
+ `url(key)` = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동
77
+ 서빙 — 결정 355). `publicPrefix`(기본 `'public/'`)는 **서빙되는 키 범위**다(결정 401).
68
78
  config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
69
79
  쓰면 컴파일 에러).
70
80
  - 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
@@ -83,16 +93,32 @@ export default controller({
83
93
  this.flash('error', '파일이 필요합니다.') // useShared().flash.error 로 표시(결정 116)
84
94
  return this.redirect('/profile')
85
95
  }
86
- const key = `avatars/${this.auth.user!.id}.png`
96
+ // public/ 아래에 저장해야 로컬 디스크에서 url() 이 실제로 서빙된다(결정 401 · §1).
97
+ const key = `public/avatars/${this.auth.user!.id}.png`
87
98
  await Storage.put(key, f.buffer, { contentType: f.mimetype })
88
99
  return this.redirect('/profile')
89
100
  },
90
101
  })
91
102
  ```
92
103
 
93
- - **멀티파트 폼의 CSRF 는 `x-csrf-token` 헤더 전용**이다(결정 133 · 구조적).
94
- `useForm(...).post(url, { headers: { 'x-csrf-token': shared.csrf } })` 보낸다 —
95
- 바디 `_csrf` 멀티파트에서 안 걸린다(상세는 `agents/web.md` §3).
104
+ - **멀티파트 폼의 CSRF 는 서버가 `x-csrf-token` 헤더만 본다**(결정 133 · 구조적
105
+ 바디 `_csrf` 멀티파트에서 검사 시점에 파싱돼 있지 않다). **부착은 프레임웍이
106
+ 자동으로 한다**(결정 342) `useForm(...).post('/uploads')` 그대로 두고 헤더를 손으로
107
+ 싣지 않는다. `useForm`/`router` 를 우회하는 커스텀 업로더만 `readCsrfToken()`
108
+ (`gaonjs/vue`)으로 토큰을 읽어 직접 실는다(상세는 `agents/web.md` §3).
109
+ - **업로드 한도는 `web.uploads`** 다(결정 356 · 기본 파일당 10MB · 최대 10개):
110
+
111
+ ```ts
112
+ // gaon.config.ts
113
+ export default defineConfig({
114
+ web: { uploads: { maxFileSize: 50 * 1024 * 1024, maxFiles: 5 } },
115
+ // web: { uploads: false }, // 멀티파트 자체를 끔
116
+ })
117
+ ```
118
+
119
+ 한도를 넘으면 **413** 이 나가고 응답에 어느 설정을 올리라는 안내가 담긴다. Inertia
120
+ 요청(`useForm().post()`)도 같은 통로로 마감된다(결정 403 · CSRF 409·415 와 동형).
121
+ 파일은 메모리에 버퍼링되므로 `maxFileSize × maxFiles` 가 요청당 메모리 상한이다.
96
122
 
97
123
  ### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
98
124
 
@@ -115,13 +141,20 @@ export default controller({
115
141
 
116
142
  ```ts
117
143
  // 저장 → 공개/서명 URL 얻기(드라이버 무관 · 같은 코드). url() 은 async.
118
- const key = `avatars/${user.id}.png`
144
+ // 화면에 표시할 파일이면 키를 public/ 아래에 둔다 — 로컬 디스크의 서빙 범위가
145
+ // publicPrefix(기본 'public/')로 한정되기 때문이다(결정 401 · 밖이면 404).
146
+ const key = `public/avatars/${user.id}.png`
119
147
  await Storage.put(key, buffer, { contentType: 'image/png' })
120
- 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
121
149
  // 만료 있는 서명 URL(s3 · 로컬은 expiresIn 무시):
122
150
  const tempLink = await Storage.url(key, { expiresIn: 600 })
123
151
  ```
124
152
 
153
+ - **로컬 디스크가 실제로 서빙하는 조건은 셋** — ① 키가 `publicPrefix`(기본 `'public/'`)
154
+ 아래일 것 ② `publicUrl` 이 상대 경로일 것(기본 `/storage` · 절대 URL 은 그 서버 몫) ③
155
+ 요청을 받는 프로세스가 `gaon serve`/`gaon dev` 일 것(라우트가 부팅 때 등록된다 · 결정 355).
156
+ s3 디스크에는 이 접두사 규칙이 없다(버킷 정책·CDN 이 공개 범위를 정한다).
157
+
125
158
  업로드(멀티파트) 수신·저장의 정본은 §3(`this.file('avatar')` → `Storage.put`).
126
159
 
127
160
  ## 알려진 함정
@@ -131,13 +164,24 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
131
164
  - **`Storage.url()` 은 async** 다 — `await` 를 빠뜨리면 `[object Promise]` 가 렌더된다.
132
165
  - **버킷 미준비 = `NoSuchBucket`** — dev 는 compose `createbuckets` 가, 운영은
133
166
  인프라가 버킷을 만든다. 프레임웍은 런타임에 버킷을 만들지 않는다(결정 132).
134
- - **멀티파트 업로드를 일반 폼처럼 `_csrf` 바디 필드로 보내면 403**헤더로
135
- 옮긴다(결정 133).
167
+ - **멀티파트 업로드에 `_csrf` 바디 필드를 넣어도 무의미하다**(결정 133 검사 시점에
168
+ 파싱돼 있지 않다). `useForm`/`router` 는 헤더를 자동으로 붙이므로(결정 342) 그대로
169
+ 두고, 이 둘을 우회한 커스텀 업로더만 `readCsrfToken()` 으로 헤더를 실는다(안 실으면 403).
170
+ - **`public/` 밖 키는 `url()` 이 만들어도 404** — 서빙 범위는 `publicPrefix`(기본
171
+ `'public/'`)로 한정된다(결정 401). 표시할 파일은 `public/` 아래에 저장한다.
172
+ - **업로드 파일명 확장자를 신뢰하지 말 것** — 사용자가 올린 `.html`·`.svg` 는 첨부로
173
+ 내려가지만(결정 402), 그 밖의 처리(썸네일·파싱)는 앱이 직접 검증해야 한다.
136
174
 
137
175
  ## 관련 결정 번호
138
176
 
139
177
  - 결정 131 — 스토리지 오리진 CSP 자동 배선(img-src·connect-src).
140
178
  - 결정 132 — dev 버킷 zero-config(compose `createbuckets` · 런타임 버킷 생성 안 함).
141
- - 결정 133 — 멀티파트 CSRF = `x-csrf-token` 헤더 전용(구조적).
179
+ - 결정 133 — 멀티파트 CSRF 검사 = `x-csrf-token` 헤더 전용(구조적).
180
+ - 결정 342 — CSRF 헤더 **자동 부착**(`useForm`/`router` 상태 변경 제출 · 수동 헤더 제거).
142
181
  - 결정 129 — `gaon work` 도 `wireDomain` 으로 스토리지·메일 배선(운영 워커).
143
182
  - 결정 136 — `gaon test` 하네스가 스토리지·메일을 테스트 격리 값으로 배선.
183
+ - 결정 355 — 로컬 디스크 공개 경로를 프레임웍이 직접 서빙(조용한 404 제거).
184
+ - 결정 401 — 로컬 자동 서빙 범위를 `publicPrefix`(기본 `'public/'`)로 한정.
185
+ - 결정 402 — 업로드된 렌더 가능 타입(html·svg)은 `Content-Disposition: attachment`.
186
+ - 결정 356·403 — `web.uploads` 한도 표면 · 413 수리 안내(Inertia 동형 마감).
187
+ - 결정 405 — 서빙 root 계산을 디스크 배선과 공유(좌표 표류 방지) · 심링크 이탈 차단.
@@ -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).