@gaonjs/cli 0.41.5 → 0.41.6

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.
@@ -12,9 +12,14 @@ export default controller({
12
12
  const { name, email, password } = this.params({
13
13
  _row: {} as { name: string; email: string; password: string },
14
14
  })
15
+ // 이메일 중복은 스키마 .unique() 가 강제하지만(무결성 backstop), 사용자에겐
16
+ // 친절한 에러를 준다 — 조용한 중복 성공 대신 Signup 을 다시 렌더한다.
17
+ if (await User.where('email', '=', email).first()) {
18
+ return this.render('Auth/Signup', { error: '이미 사용 중인 이메일입니다.' })
19
+ }
15
20
  const passwordDigest = await hashPassword(password)
16
21
  const user = await User.create({ name, email, passwordDigest })
17
- this.auth.login(user)
22
+ await this.auth.login(user)
18
23
  return this.redirect('{{URL_PREFIX}}/dashboard')
19
24
  },
20
25
  })
@@ -13,7 +13,7 @@ export default controller({
13
13
  const user = await User.where('email', '=', email).first()
14
14
  // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
15
15
  if (user && (await verifyPassword(password, user.passwordDigest))) {
16
- this.auth.login(user)
16
+ await this.auth.login(user)
17
17
  return this.redirect('{{URL_PREFIX}}/dashboard')
18
18
  }
19
19
  return this.render('Auth/Login', {
@@ -22,7 +22,7 @@ export default controller({
22
22
  },
23
23
  // DELETE {{URL_PREFIX}}/session — 로그아웃
24
24
  async destroy() {
25
- this.auth.logout()
25
+ await this.auth.logout()
26
26
  return this.redirect('{{URL_PREFIX}}/session/new')
27
27
  },
28
28
  })
@@ -35,12 +35,12 @@ export default controller({
35
35
  error: '이 앱에 접근할 권한이 없습니다.' as string | null,
36
36
  })
37
37
  }
38
- this.auth.login(user)
38
+ await this.auth.login(user)
39
39
  return this.redirect('{{URL_PREFIX}}/dashboard')
40
40
  },
41
41
  // DELETE {{URL_PREFIX}}/session — 로그아웃
42
42
  async destroy() {
43
- this.auth.logout()
43
+ await this.auth.logout()
44
44
  return this.redirect('{{URL_PREFIX}}/session/new')
45
45
  },
46
46
  })
@@ -5,7 +5,7 @@ import { table, t } from 'gaonjs/data'
5
5
  export const users = table('users', {
6
6
  id: t.id(),
7
7
  name: t.string().max(100),
8
- email: t.string().max(255),
8
+ email: t.string().max(255).unique(), // 유니크 — 중복 가입 방지(로그인 .first() 결정성 보장)
9
9
  passwordDigest: t.string().hidden(), // hidden — 페이지로 새지 않는다(§4.2)
10
10
  ...t.timestamps(),
11
11
  })
@@ -142,7 +142,10 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
142
142
  **관계(`include`/지연) 로 로드한 대상 행에서도** 대상 테이블의 hidden 이
143
143
  제외된다(결정 122). 즉 `Post.include('author')` 로 실은 `author`(User)를
144
144
  render props 로 통째로 넘겨도 `passwordDigest` 는 나가지 않는다. hidden 값은
145
- 서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2).
145
+ 서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2). hidden 마커는 **열거
146
+ 가능한 심볼**이라 `{ ...user }` spread·`Object.assign` 을 넘어 보존된다(결정 253)
147
+ — 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
148
+ 컬럼을 직접 넣지 않는다.
146
149
  - `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
147
150
  - `.index()` — 컬럼 레벨 인덱스 (E-4).
148
151
  - `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
@@ -51,6 +51,15 @@
51
51
  자동으로 붙이고, 세션 secret 을 **앱별 env** `<APP>_SESSION_SECRET`(예:
52
52
  `ADMIN_SESSION_SECRET`)로 분리 배선한다(결정 141 · 앱별 세션 완전 분리).
53
53
  운영 배포 시 그 env 를 web 과 **다르게** 설정할 것.
54
+ - **운영 세션 secret 은 반드시 주입한다 (결정 255)**: app.config 는 dev 편의를 위해
55
+ `process.env.SESSION_SECRET ?? 'dev-only-session-secret-…'` 폴백을 각인한다. 운영
56
+ (`NODE_ENV=production`)에서 이 폴백/플레이스홀더가 세션 서명 secret 으로 쓰이면
57
+ **모든 배포가 같은 공개 secret 으로 세션을 서명**(위조 가능)하므로 부팅이 확정
58
+ 종료된다(DB·PORT fail-loud 와 대칭). `compose.prod.yaml` 은 `SESSION_SECRET:?` 로
59
+ 주입을 강제한다 — `openssl rand -base64 32` 로 만든 값을 env 로 넣는다.
60
+ - **로그인은 세션 ID 를 재생성하고 로그아웃은 세션을 파기한다 (결정 254)**:
61
+ `this.auth.login`/`logout` 이 자동 처리한다(session fixation 방어). 스캐폴드는
62
+ `await this.auth.login(user)` 형태다 — 손으로 세션을 조작하지 않는다.
54
63
  - **비-web 앱은 시큐어 기본이다** (결정 155): `gaon g auth --app admin` 은
55
64
  **공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
56
65
  로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
@@ -45,6 +45,11 @@ export default controller({
45
45
  반환해도 안전하다(손 직렬화 불필요). 드물게 응답 경계 **밖**(예: 채널 broadcast
46
46
  페이로드, 커스텀 문자열 응답)에서 같은 규칙으로 직렬화하고 싶으면 직접 부른다:
47
47
  `import { serializeProps } from 'gaonjs/web'`.
48
+ - **hidden 은 spread 를 넘어 보존된다(결정 253)** — `render(page, { ...user })` 나
49
+ `{ user: { ...row } }` 처럼 모델 레코드를 펼쳐도 hidden 마커가 함께 복제돼
50
+ `passwordDigest` 는 여전히 제외된다. 단, 레코드를 **손으로 재구성**하거나
51
+ (`{ id: u.id, email: u.email }`) `JSON.parse(JSON.stringify(u))` 로 왕복하면
52
+ 마커가 사라진다 — 그 경우 hidden 컬럼을 직접 넣지 말고 필요한 필드만 골라 넘긴다.
48
53
  - **`this.render` 인자** = `<PageFolder>/<Page>` (PascalCase 폴더 · Vue
49
54
  파일명과 정합) — 예: `'Posts/Index'` → `apps/<app>/pages/Posts/Index.vue`.
50
55
  - **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
@@ -429,8 +434,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
429
434
  | 호출 | 하는 일 |
430
435
  |---|---|
431
436
  | `this.auth.user` | 현재 사용자 (`GaonCurrentUser \| null`) |
432
- | `this.auth.login(user)` | 세션에 사용자 id 를 심어 로그인 상태로 만든다 |
433
- | `this.auth.logout()` | 세션에서 사용자를 지운다 |
437
+ | `await this.auth.login(user)` | 세션 ID 를 **재생성**하고 사용자 id 를 심어 로그인 상태로 만든다(fixation 방어 · 결정 254) |
438
+ | `await this.auth.logout()` | 세션을 **파기**한다(잔존 세션 재사용 차단 · 결정 254) |
439
+
440
+ `login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
441
+ 디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
434
442
 
435
443
  ```ts
436
444
  // 로그인 — 폼 액션의 유일한 render+redirect 혼용 예외(결정 57 보완)
@@ -441,12 +449,12 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
441
449
  // csrf 는 자동 주입 공유 prop 이므로 컨트롤러가 넘기지 않는다(결정 116·117)
442
450
  return this.render('Auth/Login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.' })
443
451
  }
444
- this.auth.login(user) // 세션 확정
452
+ await this.auth.login(user) // 세션 ID 재생성 + 로그인 확정(결정 254)
445
453
  return this.redirect('/dashboard')
446
454
  }
447
455
  // 로그아웃 — DELETE /session (페이지에서 router.delete 로 호출 · 결정 64)
448
456
  async destroy() {
449
- this.auth.logout()
457
+ await this.auth.logout() // 세션 파기(결정 254)
450
458
  return this.redirect('/session/new')
451
459
  }
452
460
  ```
@@ -19,6 +19,10 @@ x-app-env: &app-env
19
19
  REDIS_URL: redis://redis:6379
20
20
  NATS_URL: nats://nats:4222
21
21
  COOKIE_SECRET: ${COOKIE_SECRET:?set COOKIE_SECRET (32+ chars)}
22
+ # 세션 서명 secret — 운영에서 반드시 주입한다(미주입 시 스캐폴드의 dev 폴백
23
+ # secret 으로 세션을 서명하게 되어 위조 가능). app.config 의 session.secret 이
24
+ # 이 값을 읽는다. 앱별 세션(gaon g auth)은 <APP>_SESSION_SECRET 을 추가로 주입.
25
+ SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET (32+ chars)}
22
26
  NODE_ENV: production
23
27
 
24
28
  services:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.41.5",
3
+ "version": "0.41.6",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.15.0",
31
- "@gaonjs/core": "0.2.3",
32
- "@gaonjs/config": "0.17.4",
33
- "@gaonjs/data": "0.17.0",
34
- "@gaonjs/web": "0.19.4",
35
- "@gaonjs/i18n": "0.2.2",
36
- "@gaonjs/mail": "0.3.0"
30
+ "@gaonjs/async": "0.15.1",
31
+ "@gaonjs/config": "0.17.5",
32
+ "@gaonjs/core": "0.2.4",
33
+ "@gaonjs/i18n": "0.2.3",
34
+ "@gaonjs/data": "0.17.1",
35
+ "@gaonjs/mail": "0.3.1",
36
+ "@gaonjs/web": "0.20.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""