@gaonjs/cli 0.41.4 → 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.
- package/dist/templates/auth/registration.controller.ts.tpl +6 -1
- package/dist/templates/auth/session.controller.ts.tpl +2 -2
- package/dist/templates/auth/session.secure.controller.ts.tpl +2 -2
- package/dist/templates/auth/user.schema.ts.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +2 -2
- package/dist/templates/project/agents/async.md.tpl +12 -7
- package/dist/templates/project/agents/data.md.tpl +19 -16
- package/dist/templates/project/agents/frontend.md.tpl +1 -1
- package/dist/templates/project/agents/seal.md.tpl +3 -1
- package/dist/templates/project/agents/security.md.tpl +12 -3
- package/dist/templates/project/agents/testing.md.tpl +4 -1
- package/dist/templates/project/agents/web.md.tpl +14 -6
- package/dist/templates/project/compose.prod.yaml.tpl +4 -0
- package/package.json +8 -8
|
@@ -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
|
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
|
|
4
4
|
사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
|
|
5
5
|
정본은 설계 문서(`docs/gaondesignv0.17.md` · v1.0 출시 기준 스냅샷 ·
|
|
6
|
-
v0.15+errata→v0.16→v0.17 · 결정 31~
|
|
6
|
+
v0.15+errata→v0.16→v0.17 · 결정 31~91)이며, 관례 문서는 **2층 구조**다 (결정 40):
|
|
7
7
|
|
|
8
8
|
- **이 파일 (코어)** — 절대 규칙 · 로직 배치 판단표 · 검증 루프 ·
|
|
9
9
|
카테고리 색인. 여기엔 요약만 있다.
|
|
@@ -264,7 +264,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
|
|
|
264
264
|
|
|
265
265
|
## 7. 참고 문서
|
|
266
266
|
|
|
267
|
-
- 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~
|
|
267
|
+
- 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~91) ·
|
|
268
268
|
이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
|
|
269
269
|
(E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
|
|
270
270
|
체이닝 · E-5 컴포저블·레이아웃).
|
|
@@ -55,7 +55,7 @@ export default schedule((s) => {
|
|
|
55
55
|
|
|
56
56
|
## 정본 규칙
|
|
57
57
|
|
|
58
|
-
### 1. 잡 (`job()`) (`packages/async/src/jobs.ts
|
|
58
|
+
### 1. 잡 (`job()`) (`packages/async/src/jobs.ts`)
|
|
59
59
|
|
|
60
60
|
잡은 도메인 소속이다 — `domain/jobs/*.ts` 에 파일을 놓으면 등록이고,
|
|
61
61
|
어느 앱에서 큐잉하든 같은 워커(`gaon work`)가 처리한다. 모델·서비스와
|
|
@@ -240,6 +240,8 @@ export default schedule((s) => {
|
|
|
240
240
|
- **소비 = 워커 전체 분산** — 발행된 잡은 NATS JetStream 큐 그룹으로 **모든**
|
|
241
241
|
워커에 로드밸런싱된다. 발행은 1인, 처리는 N인.
|
|
242
242
|
- **exactly-once(발행 기준)** — 한 스케줄 틱은 리더 1인이 한 번만 발행한다.
|
|
243
|
+
리더 교체(페일오버) 순간 구·신 리더가 같은 틱을 겹쳐 발행해도, 결정론적
|
|
244
|
+
dedupe 키 + JetStream 중복 윈도우가 이를 1회로 수렴시킨다(결정 233).
|
|
243
245
|
잡 자체는 재시도(백오프)가 있으니 **핸들러는 멱등**하게 짠다(같은 잡이 두 번
|
|
244
246
|
처리돼도 안전하게).
|
|
245
247
|
- **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
|
|
@@ -248,7 +250,7 @@ export default schedule((s) => {
|
|
|
248
250
|
원인은 `gaon work` 를 안 띄운 것이다(개발은 `gaon dev` 가 work 를 자동 기동
|
|
249
251
|
하므로 해당 없음 · 결정 211 · §6).
|
|
250
252
|
|
|
251
|
-
#### 시간대 (결정 202)
|
|
253
|
+
#### 시간대 (결정 202·230)
|
|
252
254
|
|
|
253
255
|
`s.daily.at('04:00', Job)`·`s.cron('0 9 * * 1', Job)` 는 **서버 로컬 타임존**의
|
|
254
256
|
wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **잡별 타임존
|
|
@@ -320,8 +322,9 @@ drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤
|
|
|
320
322
|
따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
|
|
321
323
|
가 있으면 **`gaon serve` 와 `gaon work` 둘 다** 분산 락을 자동 배선한다(결정
|
|
322
324
|
202 · wireDomain 공통 경로) — 그래서 **잡·서비스 안에서도 `lock()` 을 쓸 수
|
|
323
|
-
있다**(예: 중첩 방지 · §5).
|
|
324
|
-
뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
|
|
325
|
+
있다**(예: 중첩 방지 · §5). **운영(`NODE_ENV=production`)에서 `redis` 미설정
|
|
326
|
+
상태로 `lock()` 을 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
|
|
327
|
+
throw** 한다(개발·테스트는 in-memory 백엔드로 폴백해 단일 프로세스에서 그대로 돈다).
|
|
325
328
|
|
|
326
329
|
```ts
|
|
327
330
|
import { lock } from 'gaonjs/async'
|
|
@@ -392,8 +395,8 @@ async create() {
|
|
|
392
395
|
(`agents/testing.md`).
|
|
393
396
|
- **동시 실행 방지에 로컬 뮤텍스·플래그 금지** (결정 147) — `let running = false`
|
|
394
397
|
같은 프로세스 로컬 가드는 멀티 인스턴스에서 안 먹는다. `lock(key, fn)` 을
|
|
395
|
-
쓴다. `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한
|
|
396
|
-
|
|
398
|
+
쓴다. 운영에서 `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한
|
|
399
|
+
파손이 없다(개발·테스트는 in-memory 폴백).
|
|
397
400
|
|
|
398
401
|
## 관련 결정 번호
|
|
399
402
|
|
|
@@ -406,6 +409,8 @@ async create() {
|
|
|
406
409
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
|
|
407
410
|
| 결정 200 | DLQ 재처리 `retryDlq(nats, id)` (이미 설정된 잡 전송 보존 · DLQ 재적재 후 원본 삭제) |
|
|
408
411
|
| 결정 201 | graceful drain 계약 (serve 인플라이트 요청 완결 · work 진행 잡 완결 · §6) |
|
|
409
|
-
| 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러
|
|
412
|
+
| 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 중첩 방지(§5·§7) |
|
|
413
|
+
| 결정 230 | 스케줄러/앱 시간대 = `gaon.config.ts` 의 `timezone` → `process.env.TZ`(config 가 런치 `TZ` 보다 우선 · 앱 전역 단일 타임존 · §5) |
|
|
414
|
+
| 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
|
|
410
415
|
| 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
|
|
411
416
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -100,7 +100,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
100
100
|
이지 `created_at` 아님). 파일명 casing 은 doctor `schema-filename` 이
|
|
101
101
|
강제한다(단수/복수는 대상 밖 · 결정 38).
|
|
102
102
|
|
|
103
|
-
### 2. 컬럼 타입 전체 (실 구현 · `packages/data/src/schema.ts
|
|
103
|
+
### 2. 컬럼 타입 전체 (실 구현 · `packages/data/src/schema.ts`)
|
|
104
104
|
|
|
105
105
|
| 빌더 | SQL 타입 | TS 타입 | 비고 |
|
|
106
106
|
|---|---|---|---|
|
|
@@ -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).
|
|
@@ -162,14 +165,14 @@ export const posts = table('posts', {
|
|
|
162
165
|
})
|
|
163
166
|
```
|
|
164
167
|
|
|
165
|
-
### 4. 체이닝 전체 (`packages/data/src/model.ts
|
|
168
|
+
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
166
169
|
|
|
167
170
|
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
168
171
|
(`destroy`·`findBy`·`paginate`·`order`·해시 인자
|
|
169
172
|
`where({...})` 같은 다른 ORM 관습)를 추측해서 쓰지 말 것 — 표
|
|
170
173
|
바깥의 쿼리는 §5 `Post.query()` 탈출구로 내려간다.
|
|
171
174
|
|
|
172
|
-
**where op 12종** (`model.ts
|
|
175
|
+
**where op 12종** (`model.ts`):
|
|
173
176
|
|
|
174
177
|
| 분류 | op | val 인자 |
|
|
175
178
|
|---|---|---|
|
|
@@ -177,7 +180,7 @@ export const posts = table('posts', {
|
|
|
177
180
|
| 컬렉션 (`WhereOpIn`) | `in` `not in` | `ReadonlyArray<Row[col]>` |
|
|
178
181
|
| Null (`WhereOpNull`) | `is null` `is not null` | **없음** — 2-인자 호출 `where('deletedAt', 'is null')` |
|
|
179
182
|
|
|
180
|
-
**체이닝 메서드 전체** (`Chain` · `model.ts
|
|
183
|
+
**체이닝 메서드 전체** (`Chain` · `model.ts`):
|
|
181
184
|
|
|
182
185
|
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
183
186
|
|---|---|---|---|
|
|
@@ -193,7 +196,7 @@ export const posts = table('posts', {
|
|
|
193
196
|
| `first` | `()` | `Promise<Rec \| undefined>` | 자동 `limit 1` |
|
|
194
197
|
| `all` | `()` | `Promise<Rec[]>` | |
|
|
195
198
|
| `paginate` | `(page, perPage)` | `Promise<PaginatedResult<Rec>>` | **체인 종단 페이지네이션**(결정 119) — `{ rows, total, page, pageCount, perPage }` 를 한 번에. count·rows 를 내부 계산(호출자 쿼리 한 번). page/perPage **클램프 내장**(0·음수·초과 = 마지막 페이지 · total 0 = pageCount 1). total=**number**. **result 통째로 render props 안전**(rows 는 Serialized 경계). `latest()` 타이브레이커(결정 110)로 페이지 경계 결정적. 집계 그룹(GroupChain)엔 없음 |
|
|
196
|
-
| `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts
|
|
199
|
+
| `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts`) |
|
|
197
200
|
| `exists` | `()` | `Promise<boolean>` | |
|
|
198
201
|
| `sum` · `avg` | `(col)` | `Promise<string \| null>` | numeric 정확성 · 대상 행 없으면 null |
|
|
199
202
|
| `min` · `max` | `(col)` | `Promise<Row[col] \| null>` | 컬럼 타입 그대로 |
|
|
@@ -222,7 +225,7 @@ export const posts = table('posts', {
|
|
|
222
225
|
> 테이블 Rec 으로 좁혀 "정답이 하나" 원칙을 지킨다 — 조인 테이블 컬럼까지
|
|
223
226
|
> 필요한 SELECT 는 여전히 `Post.query()` 탈출구다.
|
|
224
227
|
|
|
225
|
-
**루트 전용** (`ModelApi` · `model.ts
|
|
228
|
+
**루트 전용** (`ModelApi` · `model.ts`) — 체인 중간에서는 못 쓴다:
|
|
226
229
|
|
|
227
230
|
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
228
231
|
|---|---|---|---|
|
|
@@ -256,7 +259,7 @@ export const posts = table('posts', {
|
|
|
256
259
|
> 합계에서 **정밀도를 잃는**(lossy) breaking 이라 "어림 금지" 원칙에 어긋나 기각했다
|
|
257
260
|
> (결정 91). 쓰기 개수(number)와 읽기 집계(string/bigint)는 축이 다르다.
|
|
258
261
|
|
|
259
|
-
**레코드(`Rec`) 내장** (`model.ts
|
|
262
|
+
**레코드(`Rec`) 내장** (`model.ts`):
|
|
260
263
|
|
|
261
264
|
- `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
|
|
262
265
|
- `rec.delete()` — id 기준 단건 삭제, `Promise<void>` (M2C). 벌크는
|
|
@@ -290,7 +293,7 @@ methods: {
|
|
|
290
293
|
}
|
|
291
294
|
```
|
|
292
295
|
|
|
293
|
-
**체인 상태 전이 주의** (`model.ts
|
|
296
|
+
**체인 상태 전이 주의** (`model.ts`):
|
|
294
297
|
|
|
295
298
|
- `select()`·`include()` 이후에도 빌더 메서드(`where`·`orWhere`·
|
|
296
299
|
`whereIn`·`orderBy`·`reorder`·`latest`·`limit`·`offset`)는 전부
|
|
@@ -314,7 +317,7 @@ methods: {
|
|
|
314
317
|
> "…내부 구현은 Kysely 위에 얹으므로, 복잡한 쿼리는 언제든
|
|
315
318
|
> `Post.query()`로 내려가 순수 쿼리 빌더를 쓸 수 있다(탈출구)."
|
|
316
319
|
|
|
317
|
-
실 구현 (E-4 (h) 정정 · `model.ts
|
|
320
|
+
실 구현 (E-4 (h) 정정 · `model.ts`):
|
|
318
321
|
|
|
319
322
|
```ts
|
|
320
323
|
// 조인·집계·CTE 등 복잡 쿼리
|
|
@@ -331,10 +334,10 @@ const rows = await Post.query()
|
|
|
331
334
|
|
|
332
335
|
두 타입은 이름이 비슷하지만 **별개**다 (E-4 §7).
|
|
333
336
|
|
|
334
|
-
- `packages/vue/src/serialize.ts
|
|
337
|
+
- `packages/vue/src/serialize.ts` — **`Serialized<T>`**: 임의 값의
|
|
335
338
|
JSON-safe 매핑 (Date → string, bigint → string, Hidden 브랜드 제외).
|
|
336
339
|
`api()` 반환 타입 · `pageProps<'app:ctrl#action'>()` 결과 타입이 이것.
|
|
337
|
-
- `packages/data/src/schema.ts
|
|
340
|
+
- `packages/data/src/schema.ts` — **`SerializedOf<Defs>`**: 테이블
|
|
338
341
|
스키마 defs 의 SerializedOf — hidden 컬럼 키 제외한 Row.
|
|
339
342
|
|
|
340
343
|
혼동 유발이라 이름 분리를 유지한다 (E-4 (i) 결정).
|
|
@@ -370,11 +373,11 @@ const rows = await Post.query()
|
|
|
370
373
|
안전하고, 재시도·보정은 잡/아웃박스로 다룬다.
|
|
371
374
|
|
|
372
375
|
```ts
|
|
373
|
-
// domain/services/
|
|
376
|
+
// domain/services/placeOrder.ts — main 커밋 성공 뒤에만 analytics 기록 (파일명 camelCase)
|
|
374
377
|
import { service, afterCommit } from 'gaonjs/service'
|
|
375
378
|
import { getConnection } from 'gaonjs/data'
|
|
376
379
|
|
|
377
|
-
export const
|
|
380
|
+
export const PlaceOrder = service(async (input: { userId: string; total: number }) => {
|
|
378
381
|
const order = await Order.create({ userId: input.userId, total: input.total }) // main 트랜잭션
|
|
379
382
|
|
|
380
383
|
// 커넥션을 가로지르는 쓰기는 트랜잭션 밖 — 커밋 성공 뒤에만.
|
|
@@ -394,7 +397,7 @@ export const placeOrder = service(async (input: { userId: string; total: number
|
|
|
394
397
|
- **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
|
|
395
398
|
`afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
|
|
396
399
|
|
|
397
|
-
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts
|
|
400
|
+
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
398
401
|
|
|
399
402
|
`model()` 은 스키마(§1)를 Kysely 위의 실행 가능한 API 로 감싼다 —
|
|
400
403
|
scope·인스턴스 메서드를 함께 선언하고, 나머지 체이닝(§4)·CRUD 는
|
|
@@ -443,7 +446,7 @@ await post.publish() // 인스턴스 메서
|
|
|
443
446
|
- **두 번째 인자** = `{ scopes?, methods?, hooks? }`. `scopes` 값은
|
|
444
447
|
`(q) => q.where(...)` 형태로 쿼리를 좁히는 함수, `methods` 는 인스턴스
|
|
445
448
|
메서드(`this` 로 레코드 필드·관계에 접근), `hooks` 는 `beforeCreate`
|
|
446
|
-
하나 (`model.ts
|
|
449
|
+
하나 (`model.ts`).
|
|
447
450
|
- **스코프는 반드시 `scopes: {}` 객체 안에** 선언한다 (NAMESPACED) —
|
|
448
451
|
model() 밖 별도 함수·프로퍼티로 흉내내지 않는다.
|
|
449
452
|
- **파라미터 스코프** (M2C · 결정 31) — 첫 인자 `q` 는 고정, 그 뒤
|
|
@@ -95,7 +95,7 @@ async function search(q: string) {
|
|
|
95
95
|
|
|
96
96
|
- **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
|
|
97
97
|
붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
|
|
98
|
-
반환 타입을 결정한다 (`packages/vue/src/api.ts
|
|
98
|
+
반환 타입을 결정한다 (`packages/vue/src/api.ts`).
|
|
99
99
|
- **params** — 라우트에 `:id` 같은 자리표시자가 있으면 거기서 채우고,
|
|
100
100
|
남는 값은 GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 실린다
|
|
101
101
|
(서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
|
|
@@ -183,4 +183,6 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
183
183
|
- **결정 124** — §3.1 개정: **app-side 정적 주입**(변수 동적 import 폐기) · **wasm 표면 은닉**(불투명 함수 · domain/ua/path 를 wasm 이 확보 · 미끼 시크릿 내장) · **WS 클라 봉인**(`setWsFrameCodec`) · **seal 앱 한정 CSP** · doctor `seal-security` main.ts 배선 검사 + `seal-client-wiring` fixer · 실 브라우저 e2e 게이트 · 부수 정정(`.wasm` MIME · `session.csrf` forwarding).
|
|
184
184
|
- **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
|
|
185
185
|
- **결정 222** — **클라 WS 수신 fail-open P1** 수정: 클라 `wsDecode`(client.ts)가 `P:` 평문·무prefix 프레임을 throw 없이 원문 통과시켜, 서버는 requireDecrypt 로 거부하는데 클라만 주입된 평문을 소비하던 봉인 파괴. `wsDecode` 를 서버 `SealWsTerminator` 와 대칭으로 만들어 `E:` 만 개봉·`P:`/무prefix 거부. `useChannel` 은 개봉 실패를 조용히 드롭하지 않고 소켓을 **4500 종료**(서버 대칭) + 콘솔 명시 + 재연결 안 함.
|
|
186
|
-
- **결정 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
|
|
186
|
+
- **결정 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 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
|
|
187
|
+
- **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
|
|
188
|
+
- **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
`Referrer-Policy: strict-origin-when-cross-origin`·`Cross-Origin-Opener-Policy`
|
|
30
30
|
를 자동으로 붙인다. 기본 CSP 는 Inertia SPA + vite 스택에서 안 깨지게 튜닝돼
|
|
31
31
|
있다(`script-src 'self'`·인라인 스타일 허용·`connect-src ... ws: wss:` 로
|
|
32
|
-
realtime 허용). 끄거나 조정은 `
|
|
32
|
+
realtime 허용). 끄거나 조정은 `gaon.config.ts` 의 `web.security.securityHeaders` 로
|
|
33
33
|
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
34
34
|
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
35
35
|
- **스토리지 오리진 CSP 자동 배선 (결정 131)** — `gaon.config.ts` 의 storage(S3/R2/MinIO)
|
|
@@ -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
|
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
@@ -182,8 +191,8 @@
|
|
|
182
191
|
|
|
183
192
|
```ts
|
|
184
193
|
// 시크릿 — env 로만 (gaonjs/env)
|
|
185
|
-
import {
|
|
186
|
-
const apiKey =
|
|
194
|
+
import { env } from 'gaonjs/env'
|
|
195
|
+
const apiKey = env('PAYMENT_API_KEY') // .env / 배포 환경변수
|
|
187
196
|
|
|
188
197
|
// raw SQL 값은 바인딩 위치에만
|
|
189
198
|
import { sql } from 'kysely'
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
- `gaon test` 가 테스트 전용 데이터베이스(`<db>_test`)를 **자동 준비**한다
|
|
22
22
|
— 없으면 만들고(CREATE DATABASE) 마이그레이션까지 적용한 뒤 vitest 를
|
|
23
23
|
돌린다. 스캐폴드 `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`)
|
|
24
|
-
매 테스트 뒤 전 테이블을 비운다(`
|
|
24
|
+
매 테스트 뒤 전 커넥션의 테이블을 비운다(`truncateAllConnections`). 아래 §5 참고.
|
|
25
25
|
- `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
|
|
26
26
|
테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
|
|
27
27
|
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
|
|
@@ -282,6 +282,9 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
|
|
|
282
282
|
| 결정 215 (13차 W3) | `expectEventProcessed` 가 trigger 뒤 아웃박스 1회 자동 드레인 — `service()` 경유 emit(결정 144 스테이징)도 같은 헬퍼로 확증(§4.1) · 명시 탈출구 `drainOutbox()` |
|
|
283
283
|
| 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
|
|
284
284
|
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
285
|
+
| 결정 137 | `truncateAllConnections()` — 등록된 **모든** 커넥션 순회 격리(멀티 커넥션 §4.5 · 스캐폴드 `test/setup.ts` 기본) |
|
|
286
|
+
| 결정 136 | `connectTestDatabase` 가 스토리지·메일 하네스도 배선(버킷 `<bucket>-test`·MailPit) — 배터리 테스트가 실 인프라로 격리 |
|
|
287
|
+
| 결정 249 | `connectTestDatabase` 가 락·캐시 백엔드도 in-memory 로 배선 — `lock()`·`cache()` 쓰는 잡·서비스가 `gaon test` 에서도 그대로(이전 `LockBackendUnconfiguredError` 해소 · §5) |
|
|
285
288
|
| 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
|
|
286
289
|
| 결정 203 | 같은 접두를 **NATS KV 버킷**에도 적용(`gaon_lease`·프레즌스·허브 → `test_gaon_lease` 등) — 스트림만 격리하던 결정 130 의 빈틈(KV 미격리)을 메움. 스케줄러 리스 경합·크로스-프리픽스 KV 누출 방지 |
|
|
287
290
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
@@ -24,7 +24,7 @@ export default controller({
|
|
|
24
24
|
|
|
25
25
|
// JSON 액션 — 객체를 반환하면 JSON 응답
|
|
26
26
|
async search() {
|
|
27
|
-
const { q } = this.params({ _row: {} as { q: string } }) // 검증
|
|
27
|
+
const { q } = this.params({ _row: {} as { q: string } }) // 타입만 · 런타임 검증 없음(스키마 파생 폼과 달리)
|
|
28
28
|
return { results: await Post.published().search(q).limit(10).all() }
|
|
29
29
|
},
|
|
30
30
|
})
|
|
@@ -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)` |
|
|
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
|
```
|
|
@@ -506,7 +514,7 @@ import { SendWelcomeMail } from '../../../domain/jobs/sendWelcomeMail.js'
|
|
|
506
514
|
|
|
507
515
|
export default controller({
|
|
508
516
|
async new() {
|
|
509
|
-
return this.render('Auth/
|
|
517
|
+
return this.render('Auth/Signup', {})
|
|
510
518
|
},
|
|
511
519
|
async create() {
|
|
512
520
|
const { name, email, password } = this.params({
|
|
@@ -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.
|
|
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.
|
|
31
|
-
"@gaonjs/config": "0.17.
|
|
32
|
-
"@gaonjs/core": "0.2.
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/data": "0.17.
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/web": "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})\""
|