@gaonjs/cli 0.55.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.
@@ -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
- const key = `avatars/${this.auth.user!.id}.png`
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` 헤더 전용**이다(결정 133 · 구조적).
104
- `useForm(...).post(url, { headers: { 'x-csrf-token': shared.csrf } })` 보낸다 —
105
- 바디 `_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).
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
- const key = `avatars/${user.id}.png`
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
- - **멀티파트 업로드를 일반 폼처럼 `_csrf` 바디 필드로 보내면 403**헤더로
158
- 옮긴다(결정 133).
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).
@@ -6,6 +6,58 @@
6
6
 
7
7
  ## 정본 규칙
8
8
 
9
+ ### 0. 라우트 DSL — `apps/<앱>/routes.ts` (`packages/web/src/routes.ts`)
10
+
11
+ 라우트는 앱마다 `routes.ts` 하나에 선언한다(`export default routes((r) => {...})`).
12
+ 빌더 `r` 의 표면은 **아래 7개가 전부**다 — 표에 없는 메서드(`r.namespace`·
13
+ `r.scope`·`r.match`·`r.root` 등)는 존재하지 않는다(AGENTS §0 "표에 없는 API 추측 금지").
14
+
15
+ | 호출 | 등록되는 것 |
16
+ |---|---|
17
+ | `r.get(path, '<컨트롤러>#<액션>')` | GET 한 건 |
18
+ | `r.post(path, '<컨트롤러>#<액션>')` | POST 한 건 |
19
+ | `r.put(path, '<컨트롤러>#<액션>')` | PUT 한 건 |
20
+ | `r.patch(path, '<컨트롤러>#<액션>')` | PATCH 한 건 |
21
+ | `r.delete(path, '<컨트롤러>#<액션>')` | DELETE 한 건 |
22
+ | `r.resources('posts')` | **복수** 리소스 7액션 |
23
+ | `r.resource('session')` | **단수** 리소스 5액션(`index`·`:id` 없음) |
24
+
25
+ ```ts
26
+ // apps/web/routes.ts
27
+ import { routes } from 'gaonjs/web'
28
+
29
+ export default routes((r) => {
30
+ r.resources('posts') // 표준 7액션 전개
31
+ r.resource('session') // 단수 리소스(로그인 세션)
32
+ r.get('/dashboard', 'dashboard#show') // 단건 라우트
33
+ r.post('/posts/:id/tagAdd', 'posts#tagAdd') // 액션명 = camelCase 메서드명(§1)
34
+ })
35
+ ```
36
+
37
+ `r.resources('posts')` 가 펴는 7 엔트리(Rails 관례):
38
+
39
+ | 메서드 | 경로 | 액션 |
40
+ |---|---|---|
41
+ | GET | `/posts` | `index` |
42
+ | GET | `/posts/new` | `new` |
43
+ | POST | `/posts` | `create` |
44
+ | GET | `/posts/:id` | `show` |
45
+ | GET | `/posts/:id/edit` | `edit` |
46
+ | PATCH | `/posts/:id` | `update` |
47
+ | DELETE | `/posts/:id` | `destroy` |
48
+
49
+ `r.resource('session')`(단수) 5 엔트리: GET `/session/new`→`new` ·
50
+ POST `/session`→`create` · GET `/session`→`show` · PATCH `/session`→`update` ·
51
+ DELETE `/session`→`destroy`.
52
+
53
+ - **`update` 는 PATCH 다** — `resources`/`resource` 는 PUT 을 등록하지 않는다.
54
+ PUT 이 필요하면 `r.put(...)` 로 직접 건다.
55
+ - **라우트 표는 후보다** — 컨트롤러에 그 액션이 구현돼 있지 않으면 조용히
56
+ 스킵된다(정상 경로). 반면 타깃 형식 불량(`r.get('/x', 'posts')` 처럼 `#`
57
+ 누락)은 **부팅 throw** 다(결정 293 · §알려진 함정).
58
+ - **경로는 앱 프리픽스 이전의 상대 경로**다 — admin 앱의 `r.get('/posts', …)`
59
+ 는 최종 `/admin/posts` 로 뜬다(§7 멀티앱 URL 관례).
60
+
9
61
  ### 1. JSON 액션 — 반환값이 곧 응답 (errata E-3 §3.1)
10
62
 
11
63
  컨트롤러 액션이 `this.render(...)` 나 `this.redirect(...)` 대신
@@ -415,8 +467,14 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
415
467
  빌드 없이 어느 환경에서나 설치가 확실하다.
416
468
  - **저장 위치** — 결과는 스키마의 hidden 컬럼(`passwordDigest: t.string().hidden()`)
417
469
  에 담는다 — 응답 경계에서 타입·런타임 양쪽으로 페이지 노출이 막힌다.
418
- - 회원 생성 로직은 컨트롤러가 아니라 **서비스**(`agents/data.md` §9 · registerUser)에
419
- 둔다 `const user = await RegisterUser.call({ name, email, password })`.
470
+ - **가입 컨트롤러는 이메일 중복을 pre-check 한다 (결정 256).** `gaon g auth` 스키마의
471
+ `email` `.unique()` DDL UNIQUE 중복을 막지만, 그것만 믿으면 중복 가입이 raw DB
472
+ 에러(500)로 터진다. 컨트롤러가 먼저 조회해 친절한 폼 에러로 마감하고, 유니크 제약은
473
+ TOCTOU 레이스의 backstop 으로 남긴다(아래 §정본 예시 = 스캐폴드와 같은 형태).
474
+ - 모델 여럿·트랜잭션·외부 API 가 얽히면 회원 생성 로직을 **서비스**(`domain/services/` ·
475
+ `service()` 시그니처·트랜잭션 계약은 `agents/data.md` §9)로 뺀다 —
476
+ `const user = await RegisterUser.call({ name, email, password })`. 스캐폴드 기본형은
477
+ 모델 하나뿐이라 컨트롤러에 둔다(§5.3 판단표 · 아래 §정본 예시).
420
478
 
421
479
  ### 6. 인증·세션 (v0.15 §7 · M5)
422
480
 
@@ -425,6 +483,10 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
425
483
  서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
426
484
  프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
427
485
  결정 142. 분리는 위 세 축으로 완성된다.)
486
+ - **세션 secret env 이름 = web 은 `SESSION_SECRET`, 그 외 앱은 `<APP>_SESSION_SECRET`**
487
+ (앱 이름 대문자화 · 예: admin → `ADMIN_SESSION_SECRET` · 결정 141). `gaon g auth --app <앱>`
488
+ 이 `.env`·`.env.example` 에 그 키를 시드하고 app.config 가 읽는다 — 운영에서는 앱마다
489
+ **다른** 값을 주입한다(운영 fail-loud 는 `agents/security.md` §2 · 결정 255).
428
490
  - **쿠키 secure·sameSite 는 app.config 의 session 에서 조정한다(결정 295).**
429
491
  `secure` 생략 시 운영(NODE_ENV=production)이면 켬 — 운영인데 비-TLS(사내
430
492
  내부망 http)로 서빙하면 Secure 쿠키가 안 실려 로그인이 조용히 실패하므로 그
@@ -459,7 +521,7 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
459
521
  세션이 없는 앱(세션 미구성·JWT/API)에서 `login`/`logout` 을 부르면 **즉시
460
522
  throw(500 + 수리 안내)** 한다(결정 296) — 이전엔 조용한 no-op 이라 부팅
461
523
  green·로그인만 영구 실패였다. 세션 앱이면 app.config 에 session 을 배선하고,
462
- JWT 앱이면 `this.jwt.issue`/`this.jwt.refresh` 를 쓴다.
524
+ JWT 앱이면 `this.jwt!.issue`/`this.jwt!.refresh` 를 쓴다.
463
525
 
464
526
  `login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
465
527
  디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
@@ -483,7 +545,10 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
483
545
  }
484
546
  ```
485
547
 
486
- API 앱(JWT)은 세션 대신 `this.jwt.issue(user)` / `this.jwt.refresh(token)` 를 쓴다.
548
+ API 앱(JWT)은 세션 대신 `this.jwt!.issue(user)` / `this.jwt!.refresh(token)` 를 쓴다 —
549
+ `this.jwt` 는 **optional 프로퍼티**(`readonly jwt?: JwtApi`)라 JWT 앱에서도 non-null
550
+ 단언 `!` 이 필요하다(세션 앱엔 없기 때문 · 스캐폴드 `jwt.session.controller` 동형).
551
+ `refresh` 는 토큰이 무효·만료면 `null` 을 돌려주므로 401 로 마감한다.
487
552
 
488
553
  - **JWT 하드닝 (결정 337 · 세션 결정 255 와 대칭).** JWT secret 은 **32자 이상**이
489
554
  부팅 요건이고(미만 = 부팅 에러), 운영(NODE_ENV=production)에서 dev 폴백/
@@ -498,12 +563,31 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
498
563
  그대로). ② `Authorization` 의 Bearer 스킴은 대소문자 무관이다(RFC 7235 —
499
564
  `bearer`/`BEARER` 클라이언트도 인증된다).
500
565
 
501
- - **JWT 스캐폴드 = `gaon g auth --jwt --app <api>`** (결정 338 · `--app` 필수 ·
502
- web 불가 — web 은 세션이 정본). 페이지·회원가입 없이 스키마/모델(공유) +
503
- 토큰 컨트롤러(JSON 전용: `POST /session` 발급 · `POST /session/refresh` 재발급 ·
504
- `GET /session` 현재 사용자[Bearer]) + `auth: { strategy:'jwt', secret, loadUser }`
505
- 배선 + `.env` 에 `<APP>_JWT_SECRET` 시드를 깐다. 계정은 web 앱 가입 또는
506
- seed 만든다(API 앱에 공개 가입 없음).
566
+ - **API(JWT) 생성 The One Way = `gaon g auth --jwt --app <이름>` 단독** (결정 338 ·
567
+ `--app` 필수 · web 불가 — web 은 세션이 정본). **`gaon g app <이름>` 을 먼저 돌리지
568
+ 않는다** 명령 하나가 폴더째 낳는다:
569
+
570
+ ```bash
571
+ gaon g auth --jwt --app api # apps/api/ 가 이 한 줄로 생긴다(g app 선행 불필요)
572
+ ```
573
+
574
+ | 생성물 | 내용 |
575
+ |---|---|
576
+ | `domain/schema/users.ts` · `domain/models/User.ts` | 스키마·모델(세션 앱과 **공유** · 이미 있으면 skip) |
577
+ | `apps/<app>/routes.ts` | 토큰 라우트 3종(없으면 생성 · 있으면 패치) |
578
+ | `apps/<app>/controllers/session.ts` | 토큰 컨트롤러(JSON 전용) |
579
+ | `apps/<app>/auth.ts` | `loadUser`(JWT sub → 사용자) + `GaonCurrentUser` 증강 |
580
+ | `apps/<app>/app.config.ts` | `auth: { strategy:'jwt', secret, loadUser }` 배선 |
581
+ | `.env`·`.env.example` | `<APP>_JWT_SECRET` 시드(예: `API_JWT_SECRET`) |
582
+
583
+ 라우트 3종은 `POST /session` 발급 · `POST /session/refresh` 재발급 ·
584
+ `GET /session` 현재 사용자(Bearer)다. 계정은 web 앱 가입 또는 seed 로 만든다
585
+ (API 앱에 공개 가입 없음 — `--public` 은 JWT 변형에서 거부된다).
586
+
587
+ - **`gaon g app` 을 먼저 돌리면 오히려 어긋난다** — `g app` 은 **프론트 앱**을 깔기
588
+ 때문에 `index.html`·`main.ts`·`pages/`·Tailwind 배선까지 함께 생긴다. API 앱엔 이
589
+ 파일들이 불필요하고, 남아 있으면 `gaon build`·`gaon check` 가 이 앱을 프론트 앱으로
590
+ 보고 빌드·검사한다. 이미 `g app` 으로 만들어 버렸다면 그 프론트 파일들을 지운다.
507
591
 
508
592
  - **API 앱은 프론트엔드가 없다(JSON 전용).** `apps/<app>/app.config.ts`(`auth: { strategy:'jwt', … }`)
509
593
  + `routes.ts` + `controllers/` 만 두면 된다 — `index.html`·`main.ts`·`pages/` 는 만들지 않는다.
@@ -522,6 +606,20 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
522
606
  }
523
607
  ```
524
608
 
609
+ **스키마에 컬럼을 더하면 이 증강도 함께 갱신한다.** 증강은 자동 생성이 아니라
610
+ 스캐폴드가 한 번 심는 손 선언이라 스키마와 자동 동기되지 않는다 — 예컨대 역할
611
+ 게이트(`this.authorize(this.currentUser?.role === 'admin')` · `agents/security.md` §2)
612
+ 를 쓰려고 `domain/schema/users.ts` 에 `role` 컬럼을 추가했다면, `apps/<app>/auth.ts`
613
+ 의 `GaonCurrentUser` 에도 `role: string` 을 더해야 한다. 안 그러면 그 접근이
614
+ **TS2339**(`role` 없음)로 컴파일에서 막힌다.
615
+
616
+ ```ts
617
+ // apps/<app>/auth.ts — role 컬럼을 추가했다면 증강도 함께
618
+ declare module 'gaonjs/web' {
619
+ interface GaonCurrentUser { id: bigint; name: string; email: string; role: string }
620
+ }
621
+ ```
622
+
525
623
  - **인증 배선 = `apps/<app>/app.config.ts` — 로그인 기능의 필수 구성 요소다**
526
624
  (결정 59). 컨트롤러·페이지만 만들면 컴파일은 통과하지만, 이 배선이 없으면
527
625
  세션에 로그인해도 요청마다 사용자를 로드할 길이 없어 `this.currentUser`/
@@ -548,23 +646,62 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
548
646
  await User.where('id', '=', BigInt(String(id))).first()
549
647
  ```
550
648
 
649
+ ### 7. 멀티앱 URL 관례 (`packages/web/src/dispatch.ts` · `host-router.ts`)
650
+
651
+ 앱이 둘 이상이면 URL 이 앱 경계를 표현한다. 규칙은 셋이다:
652
+
653
+ 1. **앱 폴더명 = URL 프리픽스 · web 만 `/`** — `prefixFor(name)` = `web` → `/`,
654
+ 그 외 → `/<name>`. `apps/admin/routes.ts` 의 `r.get('/posts', 'posts#index')`
655
+ 는 최종 `/admin/posts` 로 뜬다(라우트 경로는 프리픽스 **이전**의 상대 경로 · §0).
656
+ 2. **개발 모드는 `<앱>.localhost` 매핑도 함께 동작** — `admin.localhost:3000/posts`
657
+ 요청은 URL 이 `/admin/posts` 로 재작성돼 같은 프리픽스 라우팅에 도달한다(새 라우팅
658
+ 층이 아니라 재작성 한 겹). 이미 프리픽스가 붙어 있으면 재작성하지 않고(중복 방지),
659
+ web 앱(프리픽스 `/`)은 재작성 대상이 아니다. 운영 도메인은 `app.config.ts` 의
660
+ `hosts: ['admin.example.com']` 로 같은 경로를 탄다.
661
+ 3. **프리픽스는 그 앱의 예약 경로다** — admin 앱이 있으면 web 앱이 `/admin/...` 을
662
+ 라우팅하지 않는다. 기계적 강제(doctor 검사)는 없는 **관례**라 어기면 이렇게 갈린다:
663
+ 최종 URL 이 **정확히 겹치면 부팅 실패**(`FST_ERR_DUPLICATED_ROUTE` · 2026-08-05 실측),
664
+ 부분만 겹치면 **둘 다 조용히 살아** 앱 경계가 흐려진다. 후자가 더 나쁘므로 관례를 지킨다.
665
+
666
+ **앱 안의 절대 경로는 항상 풀 프리픽스로 쓴다.** 리다이렉트·폼 action·`Link href`
667
+ 가 앱 안 상대 경로가 아니라 **최종 URL** 이라서, admin 앱의 대시보드 리다이렉트는
668
+ `/dashboard` 가 아니라 **`/admin/dashboard`** 다. `gaon g auth --app admin` 스캐폴드가
669
+ 이 형태로 생성한다(`loginRedirect: '/admin/session/new'` · `this.redirect('/admin/dashboard')` ·
670
+ `form.post('/admin/session')`). 프리픽스를 빼면 web 앱 경로로 새 나가 404 나 엉뚱한 앱에
671
+ 도달한다.
672
+
673
+ - `api()` 라우트 키도 앱을 포함한다 — `api('admin:posts#search', …)`(`<app>:<컨트롤러>#<액션>` ·
674
+ `agents/frontend.md` §1·§2). URL 은 매니페스트가 프리픽스까지 채워 주므로 손으로 붙이지 않는다.
675
+ - 채널 WS 접속 경로도 앱 프리픽스를 탄다(`/admin/gaon/ws/<채널>`) — `useChannel` 이 자동
676
+ 주입한다. 단 **채널 이름 자체는 전역 네임스페이스**다(`agents/realtime.md` §2).
677
+ - 앱별 세션 분리(쿠키 이름 `<app>_sid`·secret·Redis prefix)는 §6, 앱 스코프 보안
678
+ override 는 `agents/security.md` §1.
679
+
551
680
  ## 정본 예시
552
681
 
553
682
  ```ts
554
- // apps/web/controllers/registration.ts — 회원 가입 (E-3 · Inertia SPA)
555
- import { controller } from 'gaonjs/web'
556
- import { RegisterUser } from '../../../domain/services/registerUser.js'
683
+ // apps/web/controllers/registration.ts — 회원 가입 (E-3 · Inertia SPA · gaon g auth 스캐폴드 형태)
684
+ import { controller, hashPassword } from 'gaonjs/web'
685
+ import { User } from '../../../domain/models/User.js'
557
686
  import { SendWelcomeMail } from '../../../domain/jobs/sendWelcomeMail.js'
558
687
 
559
688
  export default controller({
560
689
  async new() {
561
- return this.render('Auth/Signup', {})
690
+ return this.render('Auth/Signup', { error: null as string | null })
562
691
  },
563
692
  async create() {
564
693
  const { name, email, password } = this.params({
565
694
  _row: {} as { name: string; email: string; password: string },
566
695
  })
567
- const user = await RegisterUser.call({ name, email, password })
696
+ // 결정 256: 이메일 중복은 스키마 .unique() 강제하지만(무결성 backstop),
697
+ // pre-check 로 raw DB 500 대신 폼 에러를 준다. 폼 액션이라 실패=render·성공=redirect
698
+ // 혼용이 허용된다(§2 결정 57 보완).
699
+ if (await User.where('email', '=', email).first()) {
700
+ return this.render('Auth/Signup', { error: '이미 사용 중인 이메일입니다.' })
701
+ }
702
+ const passwordDigest = await hashPassword(password) // §5 · bcrypt 직접 import 금지
703
+ const user = await User.create({ name, email, passwordDigest })
704
+ await this.auth.login(user) // 세션 ID 재생성 + 로그인 확정(결정 254)
568
705
  await SendWelcomeMail.later(user.id) // 잡 발행 위치는 결정 32 — 서비스 afterCommit 도 정합
569
706
  return this.redirect('/dashboard')
570
707
  },
@@ -614,11 +751,16 @@ export default controller({
614
751
 
615
752
  | 결정 | 내용 |
616
753
  |---|---|
754
+ | §5.1 (v0.17) | 라우트 DSL 표면 7종 — `get/post/put/patch/delete` + `resources`(7액션)·`resource`(5액션) · `update`=PATCH · 경로는 프리픽스 이전 상대 경로(§0) |
755
+ | §3.3 (v0.16) | 멀티앱 URL — 앱 폴더명 = 프리픽스(web `/`) · 개발 `<앱>.localhost` 매핑(URL 재작성 한 겹) · 프리픽스 = 그 앱 예약 경로(관례 · 완전 중복은 부팅 실패) (§7) |
617
756
  | 결정 23 (E-3) | 앱 내 JSON 액션 — 반환값 = 응답 |
618
757
  | 결정 24 (E-3 §5) | `this.params` 안전 규칙 (라우트 > body > query · 중복 키 · body/query 탈출구) |
619
758
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 — `agents/async.md`) |
620
759
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
760
+ | 결정 58 | `this.auth.user`/`requireAuth()` 사용자 타입 = 앱의 `GaonCurrentUser` 선언 병합 증강(`apps/<app>/auth.ts`) — 스키마 컬럼(role 등) 추가 시 증강도 함께 갱신(미갱신 = TS2339 · §6) |
621
761
  | 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
762
+ | 결정 141 | 앱별 세션 secret env — web=`SESSION_SECRET` · 그 외=`<APP>_SESSION_SECRET` · 스캐폴드가 앱 프리픽스 URL(`/admin/...`)로 리다이렉트·폼 경로를 생성(§6·§7) |
763
+ | 결정 256 | `gaon g auth` email `.unique()` + 가입 컨트롤러 이메일 pre-check(raw DB 500 대신 폼 에러 · 유니크는 TOCTOU backstop · §5·§정본 예시) |
622
764
  | 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
623
765
  | 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
624
766
  | 결정 108 | 정적 default 컬럼 빈 입력 채움(coerceParams) · 동적 default 는 DB 위임 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
- "@gaonjs/config": "0.23.0",
36
- "@gaonjs/async": "0.18.0",
37
35
  "@gaonjs/core": "0.2.4",
38
- "@gaonjs/i18n": "0.3.0",
39
- "@gaonjs/data": "0.25.0",
36
+ "@gaonjs/config": "0.24.0",
37
+ "@gaonjs/data": "0.25.1",
40
38
  "@gaonjs/mail": "0.5.0",
41
- "@gaonjs/web": "0.29.0"
39
+ "@gaonjs/async": "0.18.0",
40
+ "@gaonjs/web": "0.29.0",
41
+ "@gaonjs/i18n": "0.3.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""