@gaonjs/cli 0.47.0 → 0.52.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/check.d.ts +1 -1
- package/dist/commands/check.js +1 -1
- package/dist/commands/test.js +16 -3
- package/dist/db/journal.d.ts +4 -3
- package/dist/db/journal.js +21 -10
- package/dist/db/migrate.d.ts +3 -1
- package/dist/db/migrate.js +3 -3
- package/dist/db/replay.js +2 -2
- package/dist/db/status.js +13 -3
- package/dist/dev.d.ts +6 -4
- package/dist/dev.js +9 -4
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +6 -1
- package/dist/doctor/locale-parity.js +4 -1
- package/dist/doctor/render-return.d.ts +11 -0
- package/dist/doctor/render-return.js +143 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -2
- package/dist/doctor.js +16 -5
- package/dist/generate.d.ts +20 -1
- package/dist/generate.js +120 -21
- package/dist/hub.js +2 -0
- package/dist/i18n-config.d.ts +12 -0
- package/dist/i18n-config.js +95 -0
- package/dist/index.js +41 -12
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +9 -6
- package/dist/messages-gen.d.ts +1 -1
- package/dist/messages-gen.js +5 -2
- package/dist/templates/auth/Dashboard.vue.tpl +3 -2
- package/dist/templates/auth/Login.vue.tpl +3 -5
- package/dist/templates/auth/Signup.vue.tpl +3 -5
- package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
- package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
- package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
- package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +35 -8
- package/dist/templates/project/agents/data.md.tpl +113 -49
- package/dist/templates/project/agents/frontend.md.tpl +15 -4
- package/dist/templates/project/agents/i18n.md.tpl +5 -2
- package/dist/templates/project/agents/mail.md.tpl +2 -1
- package/dist/templates/project/agents/realtime.md.tpl +16 -6
- package/dist/templates/project/agents/seal.md.tpl +6 -3
- package/dist/templates/project/agents/security.md.tpl +37 -19
- package/dist/templates/project/agents/storage.md.tpl +6 -5
- package/dist/templates/project/agents/web.md.tpl +40 -22
- package/package.json +6 -6
|
@@ -25,6 +25,10 @@ export const posts = table('posts', {
|
|
|
25
25
|
**컬럼 정의가 곧 DB 타입 + TS 타입 + 검증 규칙의 단일 원천**이다
|
|
26
26
|
(v0.15 §4.2 line 388 원문). 세 곳에 따로 쓰지 않는다.
|
|
27
27
|
|
|
28
|
+
**PK 컬럼 키는 언제나 `id`** 다(The One Way · 결정 322) — `t.id()`/`t.uuidPk()` 를 다른
|
|
29
|
+
키(`userId: t.uuidPk()` 등)에 달면 `model()` 정의 시점에 수리 안내로 throw 한다.
|
|
30
|
+
단건 경로(`find`·`rec.update`/`delete`·원자 프리미티브)가 `id` 컬럼을 사용한다.
|
|
31
|
+
|
|
28
32
|
### 1.1 관계 선언 (M2D · 결정 33)
|
|
29
33
|
|
|
30
34
|
관계는 **두 자리**에 나뉘어 산다 — FK 컬럼을 **가진 쪽**은 컬럼으로,
|
|
@@ -180,6 +184,9 @@ export const logs = table('logs', {
|
|
|
180
184
|
```
|
|
181
185
|
|
|
182
186
|
- 인덱스 원소가 **문자열 배열**이면 기존과 100% 동일(btree). **객체**면 `cols`·`expr`·`using`·`where`·`name`.
|
|
187
|
+
- 이름 규약 `idx_<table>_<cols>` 는 method/partial 을 구분하지 않는다 — **같은 컬럼에 두 인덱스**
|
|
188
|
+
(예: 컬럼 `.index({where})` + 테이블 레벨 `[['col']]`)를 선언하면 이름이 충돌해 **정의 시점에
|
|
189
|
+
throw** 한다(결정 329). 테이블 레벨 객체의 `name` 으로 구분한다.
|
|
183
190
|
- 프레임웍이 만든 인덱스는 **`idx_` 접두**만 관리한다 — raw(수동 튜닝) 인덱스는 diff 가 건드리지
|
|
184
191
|
않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다.
|
|
185
192
|
- **MySQL/MariaDB(legacy §4.5) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
|
|
@@ -209,12 +216,18 @@ export const logs = table('logs', {
|
|
|
209
216
|
`dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
|
|
210
217
|
`keep` 을 명시하고 그 잡을 실행할 때만 지운다(결정 39 자동 DROP 금지 정신).
|
|
211
218
|
- MySQL/MariaDB 커넥션은 선언적 파티셔닝을 지원하지 않는다(migrate 시 fail-loud).
|
|
219
|
+
- **기존 테이블의 `partitionBy` 변경(추가·제거·전략/키 변경)은 diff/migrate 가 즉시 throw**
|
|
220
|
+
한다(결정 321) — PostgreSQL 은 기존 테이블을 ALTER 로 파티션 테이블로 못 바꾼다. 전환은
|
|
221
|
+
손작성 마이그로 "새 파티션 테이블 생성 → `INSERT ... SELECT` 이관 → rename 교체" 순서다.
|
|
212
222
|
|
|
213
223
|
**`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
|
|
214
224
|
갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
**벌크·upsert 도 자동 갱신한다**(결정 323 · 286 유보 해소) — `updateAll()`·`incrementAll`/
|
|
226
|
+
`decrementAll` 과 `upsert()` 의 충돌-갱신 경로가 `updatedAt` 을 현재 시각으로 함께 갱신한다.
|
|
227
|
+
"update 하면 updatedAt 이 바뀐다" 가 단건/벌크 구분 없이 하나다(The One Way). patch/update 에
|
|
228
|
+
`updatedAt` 을 명시하면 그 값을 존중한다. **단건 원자 프리미티브(`rec.increment`/`decrement`/
|
|
229
|
+
`toggle`)는 대상 밖** — 카운터·플래그 노이즈가 updatedAt 의 "콘텐츠 갱신" 신호를 덮지 않게
|
|
230
|
+
유지한다(필요하면 `touch()` 병행).
|
|
218
231
|
|
|
219
232
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
220
233
|
|
|
@@ -281,7 +294,7 @@ patch 에 `updatedAt: new Date()` 를 직접 넣는다(결정 286 유보 — 벌
|
|
|
281
294
|
| 메서드 | 시그니처 | 반환 | 비고 |
|
|
282
295
|
|---|---|---|---|
|
|
283
296
|
| `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
|
|
284
|
-
| `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에
|
|
297
|
+
| `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용. **프리페어드 파라미터 상한(65,535)을 넘는 대량 배치는 자동 분할**되고 전체가 한 트랜잭션(원자성 유지 · 결정 325) |
|
|
285
298
|
| `insertOrIgnore` | `(row \| rows)` | `Promise<BulkResult>` | **멱등 삽입** (M2F) — 충돌 행은 건너뛰고 `count` = **실제로 삽입된 행 수**(PG=`ON CONFLICT DO NOTHING` · MySQL=`INSERT IGNORE`). 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
|
|
286
299
|
| `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<BulkResult>` | **있으면 갱신 없으면 삽입** (M2F) — PG=`ON CONFLICT DO UPDATE` · MySQL=`ON DUPLICATE KEY UPDATE`. `count` = 처리된 입력 행 수(삽입+갱신 · 갱신 대상 없으면 실 삽입 수). `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 덮음 |
|
|
287
300
|
| `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
|
|
@@ -454,7 +467,7 @@ export const PlaceOrder = service(async (input: { userId: string; total: number
|
|
|
454
467
|
- **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
|
|
455
468
|
`afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
|
|
456
469
|
|
|
457
|
-
### 7.1 문서형 컬렉션 — `collection()` (v1.1 · `@gaonjs/adapter-mongo` · 결정 278~282)
|
|
470
|
+
### 7.1 문서형 컬렉션 — `collection()` (v1.1 · `@gaonjs/adapter-mongo` · 결정 278~282 · 288~292 · 331~336)
|
|
458
471
|
|
|
459
472
|
SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose** 위에 둔다.
|
|
460
473
|
로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
|
|
@@ -468,33 +481,54 @@ npm i @gaonjs/adapter-mongo mongoose
|
|
|
468
481
|
|
|
469
482
|
```ts
|
|
470
483
|
// domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
|
|
471
|
-
// 정본
|
|
484
|
+
// 정본 패턴(결정 288·332): Doc + Methods + Model 인터페이스를 선언하고 제네릭 3개를
|
|
485
|
+
// 명시한다(mongoose 공식 타입드 statics 패턴). statics/methods 는 **옵션으로** 선언 —
|
|
486
|
+
// AuditLog.recent(50)·doc.summary() 가 캐스트 없이 컴파일된다.
|
|
472
487
|
import { collection, mongoSchema } from 'gaonjs/data'
|
|
488
|
+
import type { Model } from 'mongoose'
|
|
473
489
|
|
|
474
490
|
interface AuditDoc {
|
|
475
491
|
actorId: string
|
|
476
492
|
action: string
|
|
477
493
|
ip?: string // hidden 이어도 서버 코드는 투명하게 읽는다
|
|
494
|
+
createdAt?: Date // { timestamps: true } 산출
|
|
495
|
+
updatedAt?: Date
|
|
496
|
+
}
|
|
497
|
+
interface AuditMethods {
|
|
498
|
+
summary(): string
|
|
499
|
+
}
|
|
500
|
+
interface AuditModel extends Model<AuditDoc, {}, AuditMethods> {
|
|
501
|
+
recent(limit: number): Promise<AuditDoc[]>
|
|
478
502
|
}
|
|
479
503
|
|
|
480
|
-
const auditLogSchema = mongoSchema<AuditDoc>({
|
|
504
|
+
const auditLogSchema = mongoSchema<AuditDoc, AuditModel, AuditMethods>({
|
|
481
505
|
actorId: { type: String, required: true, index: true },
|
|
482
506
|
action: { type: String, required: true },
|
|
483
507
|
ip: { type: String, hidden: true }, // 직렬화 경계 제외 (SQL .hidden() 과 동일 의미)
|
|
484
|
-
}, {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
508
|
+
}, {
|
|
509
|
+
timestamps: true,
|
|
510
|
+
statics: {
|
|
511
|
+
async recent(limit: number) {
|
|
512
|
+
return (await this.find().sort({ createdAt: -1 }).limit(limit).lean()) as AuditDoc[]
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
methods: {
|
|
516
|
+
summary() { return `${this.actorId}:${this.action}` },
|
|
517
|
+
},
|
|
518
|
+
})
|
|
490
519
|
|
|
491
520
|
// db: 'logs' — 몽고 커넥션 바인딩 (SQL 의 { db: 'legacy' } 와 대칭)
|
|
492
521
|
export const AuditLog = collection('audit_logs', auditLogSchema, { db: 'logs' })
|
|
522
|
+
|
|
523
|
+
// 사용감 — 전부 캐스트 없이 타입이 흐른다(결정 332).
|
|
524
|
+
// const logs = await AuditLog.recent(50)
|
|
525
|
+
// const doc = await AuditLog.create({ actorId: 'u1', action: 'login' }); doc.summary()
|
|
493
526
|
```
|
|
494
527
|
|
|
495
|
-
|
|
496
|
-
결정 288 이
|
|
497
|
-
|
|
528
|
+
statics/methods 가 없으면 제네릭 없이 `mongoSchema({...})` 만으로 정의 지점 추론이
|
|
529
|
+
성립한다(`new mongoose.Schema` 와 동일 · 결정 288 이 생성자 제네릭 복제로 고정).
|
|
530
|
+
statics 가 필요하면 **반드시 위 3-제네릭 패턴** — 제네릭 없이 statics 만 옵션에 넣는
|
|
531
|
+
추론 조합은 mongoose 업스트림 타이핑이 깨져 있어(바닐라도 동일 실측) 지원하지 않는다.
|
|
498
532
|
|
|
499
533
|
```ts
|
|
500
534
|
// gaon.config.ts — 문서형 커넥션은 adapter: 'mongodb' 로 SQL 과 나란히 선언한다.
|
|
@@ -512,11 +546,14 @@ export default defineConfig({
|
|
|
512
546
|
`_id`(ObjectId)는 응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형
|
|
513
547
|
대응). `create()` 반환 문서·`doc.toObject()` 를 그대로 넘겨도 직렬화 경계가
|
|
514
548
|
방어한다(결정 289 — save/insertMany 훅 + toObject/toJSON transform 마커 + 응답
|
|
515
|
-
경계의 문서 → POJO 정규화).
|
|
516
|
-
|
|
549
|
+
경계의 문서 → POJO 정규화). **중첩 경로도 지원**한다(결정 336 · dot-path 마커):
|
|
550
|
+
`profile: { ssn: { type: String, hidden: true } }` → `profile.ssn` 만 제외(형제
|
|
551
|
+
보존), 서브도큐먼트 배열 `items: [{ secret: {...hidden} }]` → 요소별 `secret` 제외,
|
|
552
|
+
배열 요소 def 자체의 hidden(`tags: [{ type: String, hidden: true }]`) → 배열 통째
|
|
553
|
+
제외. 지원 밖 위치(옵션 객체의 형제 키 아래 등)는 조용히 무시하지 않고 fail-loud.
|
|
517
554
|
- **몽고 쓰기를 SQL `service()` tx 안에서 하지 말 것** — 몽고는 v1 에서 트랜잭션이
|
|
518
555
|
없어, tx 안 몽고 쓰기는 `MongoCrossConnectionWriteError` 로 **막힌다**(조용한 부분
|
|
519
|
-
커밋 방지 · 결정 281). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
|
|
556
|
+
커밋 방지 · 결정 281 · 331). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
|
|
520
557
|
```ts
|
|
521
558
|
export const SignUp = service(async (input) => {
|
|
522
559
|
const user = await Users.create(input) // main(SQL) 트랜잭션
|
|
@@ -526,35 +563,46 @@ export default defineConfig({
|
|
|
526
563
|
```
|
|
527
564
|
- **SQL ↔ 문서형 관계 금지** — `belongsTo`/`hasMany` 는 SQL 전용이다. 몽고 컬렉션끼리의
|
|
528
565
|
참조는 Mongoose `ref`/`populate` 로 사용자가 직접 한다(Gaon doctor 가 강제하지 않음).
|
|
529
|
-
- **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) —
|
|
530
|
-
`autoIndex`
|
|
531
|
-
|
|
532
|
-
절차에서 `
|
|
566
|
+
- **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) — dev·test 는
|
|
567
|
+
mongoose `autoIndex` 기본값이 첫 사용 시 인덱스를 만든다. **운영(NODE_ENV=production)
|
|
568
|
+
은 autoIndex 기본 false**(결정 333 · §8-3) — 부팅·첫 사용 시점의 암묵 인덱스 빌드가
|
|
569
|
+
없다. 운영 인덱스는 배포 절차에서 `syncMongoIndexes()`(gaonjs/data 재수출)로 명시
|
|
570
|
+
동기화한다(커넥션 키 지정 가능 · 스키마에 없는 인덱스는 드랍). 커넥션 설정
|
|
571
|
+
`autoIndex: true/false` 명시가 항상 우선. 몽고는 마이그레이션이 없다 —
|
|
533
572
|
`gaon db diff/migrate`(키 생략 = 전 커넥션 순회)는 mongodb 커넥션을 **건너뛰고
|
|
534
573
|
skipped 로 보고**하며(결정 292), `--db <mongo키>` 명시 지정만 fail-loud 다.
|
|
574
|
+
- **문서형 커넥션은 DB 명이 필수** — `url` 에 경로(`mongodb://…/myapp`)를 넣거나
|
|
575
|
+
`database` 를 지정한다. 둘 다 없으면 부팅이 수리 안내로 throw 한다(결정 335 —
|
|
576
|
+
드라이버 기본 DB `test` 로 조용히 붙던 무신호 오배선 봉합).
|
|
535
577
|
|
|
536
578
|
**알려진 함정**:
|
|
537
579
|
- `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
|
|
538
580
|
SQL `Post.query()`(Kysely 원본) raw 탈출구가 hidden 을 우회하는 것과 동형. 문서를
|
|
539
581
|
안전하게 렌더에 흘리려면 `find()/findOne().lean()` 을 쓴다.
|
|
540
|
-
- **크로스커넥션 쓰기
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
명시하라. fail-loud 화는 유보(동작 변경) — 현재는 관례로 방어한다.
|
|
582
|
+
- **크로스커넥션 쓰기 가드는 allowlist 반전이다(결정 331 · 281 확장)** — SQL tx 안에서
|
|
583
|
+
는 읽기 전용으로 확인된 내장(find/findOne/countDocuments/aggregate 읽기 등)만
|
|
584
|
+
통과하고, **그 밖의 모든 static 호출은 fail-closed** 로 막힌다. Query 체이닝 쓰기
|
|
585
|
+
(`find(f).updateMany()`·`where(f).deleteMany()`)와 `aggregate` 의 `$out`/`$merge` 도
|
|
586
|
+
실행 지점 pre 훅이 막는다. **읽기 전용이어도 커스텀 static 은 tx 안에서 막힌다**
|
|
587
|
+
(쓰기 여부 판정 불가 — 내장 읽기를 직접 쓰거나 호출을 tx 밖으로). 유일한 예외는
|
|
588
|
+
인스턴스 `doc.save()`(정본 예외 · 미가드). tx 안에서 쓸 일이면 `afterCommit`.
|
|
589
|
+
- **같은 커넥션·같은 컬렉션명에 다른 스키마를 선언하면 첫 접근에서 throw**(결정 334)
|
|
590
|
+
— 조용히 첫 스키마를 재사용하지 않는다. 같은 컬렉션이면 mongoSchema 산출을 한
|
|
591
|
+
모듈에서 export 해 재사용하라(스키마 객체 하나).
|
|
551
592
|
- **`service()` 는 SQL 전용** — `service(fn, { db: '<mongo키>' })` 는 SQL 레지스트리
|
|
552
593
|
조회라 "커넥션 미등록" 에러가 난다(안내 문구도 SQL 기준). 몽고 쓰기에는 서비스
|
|
553
594
|
트랜잭션 개념이 없다(v1 몽고 tx 없음) — 그냥 static 으로 쓰거나 SQL 서비스의
|
|
554
595
|
`afterCommit` 에서 쓴다.
|
|
555
596
|
- **mongoSchema 제네릭**: v1.21(adapter-mongo 0.1.0) 이하에서는 제네릭을 생략하면
|
|
556
597
|
문서 타입이 `{ actorId: StringConstructor }` 로 **조용히 붕괴**했다 — 0.2.0(결정
|
|
557
|
-
288)부터 정의 지점 추론이
|
|
598
|
+
288)부터 정의 지점 추론이 성립한다. statics/methods 타입이 필요하면 3-제네릭 정본
|
|
599
|
+
패턴(결정 332)만 쓴다 — 제네릭 없는 statics 옵션 추론은 mongoose 업스트림 타이핑이
|
|
600
|
+
깨져 있다(바닐라 동일 실측 · 지원 안 함).
|
|
601
|
+
- **중첩 hidden 은 v1.22(adapter-mongo 0.2.0) 이하에서 fail-loud throw 였다**(결정
|
|
602
|
+
290) — 0.3.0(결정 336)부터 dot-path 로 실지원된다. 지원 밖 위치(옵션 객체의 형제 키
|
|
603
|
+
아래 등)의 hidden 은 여전히 fail-loud(조용한 유출 금지). 서브스키마 인스턴스
|
|
604
|
+
(`mongoSchema` 산출을 `type:` 에 넣은 경우)의 내부 hidden 은 부모가 수집하지 않는다
|
|
605
|
+
— hidden 필드는 한 mongoSchema defs 트리 안에서 선언하라.
|
|
558
606
|
|
|
559
607
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
560
608
|
|
|
@@ -728,9 +776,10 @@ const top = await Post.published().latest().limit(5).withCache(30).all()
|
|
|
728
776
|
신선도가 중요하면 **짧은 TTL** 을 쓰거나 `cache.forget(key)` 로 명시
|
|
729
777
|
무효화한다. `.withCache` 결과는 TTL 만료로만 갱신된다(자동 퍼지 없음).
|
|
730
778
|
- **적용 지점은 `Chain` 의 `all()`/`first()` 뿐이다** — `.withCache()` 뒤에
|
|
731
|
-
`include()`/`select()`/`distinct(col)
|
|
732
|
-
캐시가
|
|
733
|
-
형태로 `all()`/`first()` 마감하거나,
|
|
779
|
+
`include()`/`select()`/`distinct(col)`/`groupBy()`/`withCount()`/`join()`/`paginate()`
|
|
780
|
+
가 오면 캐시가 적용될 수 없어 **즉시 throw** 한다(수리 안내 포함 · 결정 330 — 이전엔
|
|
781
|
+
조용히 미적용). 캐시가 필요하면 include/select 없는 형태로 `all()`/`first()` 마감하거나,
|
|
782
|
+
결과 조립 전체를 `cache.remember` 로 감싼다.
|
|
734
783
|
- **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
|
|
735
784
|
(`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
|
|
736
785
|
- 백엔드는 Redis 가 기본(설정에 `redis` 있으면 자동), 메모리는 dev/테스트/폴백.
|
|
@@ -870,7 +919,8 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
870
919
|
미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
|
|
871
920
|
COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
|
|
872
921
|
값 변경은 정상 감지된다.) **FK(belongsTo 의 REFERENCES)는 diff 축이 아니다** — FK 는
|
|
873
|
-
createTable/addColumn 시점에만
|
|
922
|
+
createTable/addColumn 시점에만 생성되므로(mysql 도 addColumn 이 FK 를 동반 · 결정 327),
|
|
923
|
+
**기존 컬럼**을 `t.belongsTo()` 로 바꾸거나
|
|
874
924
|
belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
|
|
875
925
|
기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
|
|
876
926
|
이 제약·기본값 diff 는 postgres 커넥션 기준이다
|
|
@@ -1034,13 +1084,13 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1034
1084
|
안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
|
|
1035
1085
|
다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
|
|
1036
1086
|
그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
|
|
1037
|
-
-
|
|
1038
|
-
`updateAll
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
- **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148 · §8.2) —
|
|
1042
|
-
뒤에 `include()`/`select()`/`distinct(col)`/`paginate()`
|
|
1043
|
-
|
|
1087
|
+
- **단건 원자 프리미티브는 `updatedAt` 을 갱신하지 않는다** (결정 276·323) — `update`/
|
|
1088
|
+
`updateAll`/`incrementAll`/`upsert` 충돌-갱신은 자동 갱신하지만, `rec.increment`/
|
|
1089
|
+
`decrement`/`toggle` 은 카운터 축이라 대상 밖이다. 갱신 시각까지 남기려면 `touch()` 를
|
|
1090
|
+
병행하거나 patch 로 갱신한다.
|
|
1091
|
+
- **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148·330 · §8.2) —
|
|
1092
|
+
뒤에 `include()`/`select()`/`distinct(col)`/`paginate()` 류가 오면 **즉시 throw**
|
|
1093
|
+
한다(조용한 미적용 대신 수리 안내). include/select 없는 형태로 마감하거나
|
|
1044
1094
|
`cache.remember` 로 감싼다.
|
|
1045
1095
|
- **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
|
|
1046
1096
|
는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
|
|
@@ -1077,13 +1127,27 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1077
1127
|
| 결정 278 | 문서형 동사 `collection()`(Mongoose) — SQL `model()` 과 분리 · `mongoSchema()` 얇은 래퍼(판별 태그+hidden 전개) · `@gaonjs/adapter-mongo`(mongoose optional peer)(§7.1) |
|
|
1078
1128
|
| 결정 283 | `select()`/`distinct(col)` 좁은 행에도 hidden 마커 부착 — 명시 select 한 hidden 컬럼의 조용한 직렬화 유출 봉합(§3 `.hidden()`) |
|
|
1079
1129
|
| 결정 285 | nullable FK 의 belongsTo 관계는 `Row \| null` — 지연·include 모두 null 정규화(타입 green NPE 봉합 · §1.1) |
|
|
1080
|
-
| 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동
|
|
1130
|
+
| 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동 갱신 유보 → **결정 323 으로 해소** |
|
|
1131
|
+
| 결정 321 | 기존 테이블 `partitionBy` 변경은 diff/migrate 즉시 throw(조용한 no-op 제거 · 손작성 이관 안내 · §3) |
|
|
1132
|
+
| 결정 322 | PK 컬럼 키 `id` 강제 — `model()` 정의 시점 수리 안내 throw(§1) |
|
|
1133
|
+
| 결정 323 | `updateAll`·`incrementAll`/`decrementAll`·`upsert` 충돌-갱신도 `updatedAt` 자동 갱신(단건 원자 프리미티브 제외 · §3) |
|
|
1134
|
+
| 결정 325 | `batchInsert` 파라미터 상한(65,535) 초과 자동 분할 — 전체 한 트랜잭션(원자성 유지 · §4) |
|
|
1135
|
+
| 결정 327 | mysql `addColumn` 이 belongsTo FK 를 같은 ALTER 문으로 동반(§10) |
|
|
1136
|
+
| 결정 328 | `distinct(col).paginate` 의 total = 선택 컬럼 distinct 축(§4) |
|
|
1137
|
+
| 결정 329 | 인덱스 이름 충돌(같은 컬럼 method/partial 중복) 정의 시점 throw — `name` 으로 구분(§3) |
|
|
1138
|
+
| 결정 330 | `.withCache()` 무효 전이(include/select/paginate 류) 즉시 throw(§8.2·함정) |
|
|
1081
1139
|
| 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
|
|
1082
1140
|
| 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
|
|
1083
1141
|
| 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
|
|
1084
1142
|
| 결정 288 | `mongoSchema()` 제네릭 = Mongoose Schema 생성자 복제 — 자작 시그니처의 문서 타입 조용한 붕괴(StringConstructor) 봉합 · 정본 = 명시 인터페이스(§7.1) |
|
|
1085
1143
|
| 결정 289 | 문서 인스턴스 경로 hidden 마커 전파 — save/insertMany 훅 + toObject/toJSON transform + 응답 경계 문서→POJO 정규화(§7.1) |
|
|
1086
|
-
| 결정 290 | 중첩 경로 `hidden: true` 는 fail-loud throw — top-level 전용
|
|
1087
|
-
| 결정 291 | 쓰기 가드에 `insertOne`(mongoose 8.16+)·`bulkSave` 편입
|
|
1144
|
+
| 결정 290 | 중첩 경로 `hidden: true` 는 fail-loud throw — top-level 전용 → **결정 336 이 실지원으로 대체**(§7.1) |
|
|
1145
|
+
| 결정 291 | 쓰기 가드에 `insertOne`(mongoose 8.16+)·`bulkSave` 편입 → **결정 331 allowlist 반전으로 흡수**(§7.1 · 결정 281 확장) |
|
|
1088
1146
|
| 결정 292 | `gaon db` 전 커넥션 순회는 mongodb 를 skip + skipped 보고(exit 0) — `--db <mongo키>` 명시만 fail-loud(§7.1) |
|
|
1147
|
+
| 결정 331 | 쓰기 가드 allowlist 반전 — 읽기 전용 내장만 SQL tx 통과 · Query 체이닝/aggregate $out·$merge 는 실행 지점 pre 훅 · 커스텀 static 도 tx 안 fail-closed · 예외 = doc.save()(§7.1) |
|
|
1148
|
+
| 결정 332 | statics/methods 타입 통로 — Doc+Methods+Model 인터페이스 3-제네릭 정본 · collection() 반환이 mongoose.model() 오버로드 복제(캐스트 0)(§7.1) |
|
|
1149
|
+
| 결정 333 | 운영(NODE_ENV=production) autoIndex 기본 false + `syncMongoIndexes()` 명시 동기화 표면(§7.1 · §8-3) |
|
|
1150
|
+
| 결정 334 | 같은 커넥션·같은 이름·다른 스키마 재선언은 첫 접근 throw — 조용한 첫 스키마 재사용 봉합(§7.1) |
|
|
1151
|
+
| 결정 335 | 문서형 커넥션 url·database 둘 다 없으면 부팅 fail-loud — 드라이버 기본 `test` DB 무신호 접속 봉합(§7.1) |
|
|
1152
|
+
| 결정 336 | 중첩 hidden dot-path 실지원(290 대체) — 중첩 객체·type:{} 서브도큐먼트·서브도큐먼트 배열 · 직렬화 경계 상속 스레딩 · 지원 밖 위치는 fail-loud(§7.1) |
|
|
1089
1153
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -138,10 +138,16 @@ async function search(q: string) {
|
|
|
138
138
|
}
|
|
139
139
|
```
|
|
140
140
|
- **CSRF** — 세션 앱은 상태 변경 요청(POST/PUT/PATCH/DELETE)에 CSRF 토큰을
|
|
141
|
-
자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166).
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166·341). 출처는
|
|
142
|
+
**라이브 Inertia 페이지 props 의 csrf**(결정 341 · `useShared().csrf` 와 같은 단일
|
|
143
|
+
출처 · `packages/vue/src/csrf.ts`) — 로그인(세션 재생성 · 결정 254) 후에도 항상
|
|
144
|
+
최신이다. 최초 문서 data-page 와 `<meta name="csrf-token">` 은 부팅 전 폴백.
|
|
145
|
+
`useForm`/`router` 제출도 같은 자동 부착을 받는다(결정 342 — 페이지가 `_csrf`
|
|
146
|
+
바디·수동 헤더를 싣지 않는다 · `agents/security.md`).
|
|
147
|
+
- **파라미터 배열(결정 343)** — `api('web:posts#index', { tags: ['a', 'b'] })` 처럼
|
|
148
|
+
배열 값을 넘길 수 있다. GET 은 중복 키(`tags=a&tags=b` · 서버 `this.params` 의
|
|
149
|
+
"중복 키 = 배열" 규칙과 대칭), 본문은 문자열화된 JSON 배열로 실린다. `:param`
|
|
150
|
+
자리표시자엔 스칼라만(배열이면 수리 안내 throw).
|
|
145
151
|
|
|
146
152
|
### 3. bigint PK 식별자 — 컨트롤러에서 `String()` 정규화 (결정 37)
|
|
147
153
|
|
|
@@ -523,6 +529,11 @@ async function runSearch(q: string) {
|
|
|
523
529
|
| 결정 303 | `useChannel` 계약 3정비 — `send()` OPEN 아니면 `false` · 컴포넌트 밖 = 즉시 접속(정리는 호출자) · `maxMessages` 상한 (`agents/realtime.md` §4) |
|
|
524
530
|
| 결정 304 | `api()` 실패 타입 공개 — `ApiError`·`isApiError`·`ApiValidationBody`(422 issues 타입드) (§2) |
|
|
525
531
|
| 결정 305 | `createGaonApp` 마운트 대상(`#app`) 부재 = 수리 안내 throw(조용한 빈 화면 봉합) |
|
|
532
|
+
| 결정 341 | CSRF 토큰 출처 = 라이브 Inertia 페이지 props(`csrf.ts` · 최초 문서·meta 는 부팅 전 폴백) — 로그인 세션 재생성 후 api() 403 stale 봉합(§2) |
|
|
533
|
+
| 결정 342 | `useForm`/`router` 상태 변경에 `X-CSRF-Token` 자동 부착(라이브 출처 · api() 와 단일화) — `_csrf` 수동 보일러플레이트 제거 · 명시 헤더 존중 · 우회 전송은 `readCsrfToken()` 탈출구 |
|
|
534
|
+
| 결정 343 | `ApiParams` 배열 지원 — GET 중복 키·본문 JSON 배열(서버 중복 키=배열 규칙 대칭) · `:param` 은 스칼라만(§2) |
|
|
535
|
+
| 결정 344 | `useChannel` 함수형 `params` — 접속·재접속 시점마다 평가(JWT access_token 회전 반영 · `agents/realtime.md` §4) |
|
|
536
|
+
| 결정 345 | `PageMap` 항목 타입 의도 문서화(`PageMapEntry` — 지연 로더·eager 모듈 · vite glob unknown 제약 명기) |
|
|
526
537
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
527
538
|
|
|
528
539
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -71,7 +71,7 @@ export default defineConfig({
|
|
|
71
71
|
i18n: {
|
|
72
72
|
fallbackLng: 'ko',
|
|
73
73
|
supportedLngs: ['ko', 'en', 'ja'], // 생략 시 locales/ 폴더 하위 언어들
|
|
74
|
-
// dir: 'locales', // 생략 시 'locales'
|
|
74
|
+
// dir: 'locales', // 생략 시 'locales' — 타입 축·doctor 도 이 값을 따른다(결정 352)
|
|
75
75
|
// detect: { cookieName: 'gaon_locale', priority: ['session', 'cookie', 'header'] }, // 기본값
|
|
76
76
|
},
|
|
77
77
|
})
|
|
@@ -200,7 +200,8 @@ CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적
|
|
|
200
200
|
|
|
201
201
|
### 7. 로케일 커버리지 — `locale-parity` 경고 (결정 216)
|
|
202
202
|
|
|
203
|
-
`messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4
|
|
203
|
+
`messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4 · 기준 = `fallbackLng` — 결정 352 ·
|
|
204
|
+
이전엔 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에 정박했다). 그래서 어떤 키를
|
|
204
205
|
`ko.json`·`en.json` 에는 넣고 `ja.json` 에만 빠뜨리면 **컴파일은 통과**하고, 런타임에
|
|
205
206
|
일본어 사용자만 fallback(대개 다른 언어) 번역을 조용히 본다 — 타입도 화면도 못 잡는
|
|
206
207
|
사각이다. `gaon doctor`/`gaon check` 의 `locale-parity`(§2.2 27번)가 `locales/` 의
|
|
@@ -264,3 +265,5 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
|
|
|
264
265
|
| 결정 213 | Vue 클라 소비 = 서버 주도 render props/sharedProps 만(§5) · `t()`·`useT()` 클라 미노출(서버 ALS 전용) · sharedProps 는 결정 150 동형 |
|
|
265
266
|
| 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 요청 협상 로케일 자동 추종(§6) · 템플릿 정적값은 기본값 · 비-i18n 무회귀 |
|
|
266
267
|
| 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) — 로케일 간 키 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
|
|
268
|
+
| 결정 352 | messages.d.ts 기준 로케일 = `fallbackLng`(정적 분석 전달) · `i18n.dir` 을 타입 축(gen/dev/check)·locale-parity 가 존중(하드코딩 'locales' 제거) |
|
|
269
|
+
| 결정 353 | 부팅 fail-loud — i18n 설정 + 빈 카탈로그(전 화면 raw 키 방지) · `fallbackLng`/`supportedLngs` 가 지목한 로케일 파일 통째 부재(조용한 fallback 언어 대체 방지) 는 부팅 에러 + 수리 안내 |
|
|
@@ -90,7 +90,8 @@ export default defineConfig({
|
|
|
90
90
|
| `host` | `string` | SMTP 호스트(dev = MailPit `127.0.0.1`) |
|
|
91
91
|
| `port` | `number` | SMTP 포트(dev MailPit = 1025) |
|
|
92
92
|
| `secure?` | `boolean` | TLS(운영). 생략 = false |
|
|
93
|
-
| `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능) |
|
|
93
|
+
| `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능 · **둘 다 또는 둘 다 없음** — 한쪽만 있으면 부팅 fail-loud · 결정 357) |
|
|
94
|
+
| `requireTls?` | `boolean` | STARTTLS **강제**(결정 357). 기본 STARTTLS 는 기회적이라 서버가 광고 안 하면 평문 진행 — 자격 있는 운영 전송은 `secure: true`(465) 또는 이걸 켠다 |
|
|
94
95
|
| `defaultFrom` | `string` | 발신자 기본값 — 메시지에 `from` 이 없을 때. `configureMailer({ defaultFrom })` 와 동치 |
|
|
95
96
|
|
|
96
97
|
## 정본 예시
|
|
@@ -183,7 +183,10 @@ const members = await ctx.presence()
|
|
|
183
183
|
URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
|
|
184
184
|
감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
|
|
185
185
|
을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
|
|
186
|
-
자동 인증, JWT 앱은 `params: { access_token }
|
|
186
|
+
자동 인증, JWT 앱은 `params: () => ({ access_token: token.value })` — **함수형으로
|
|
187
|
+
넘겨라**(결정 344). params 는 접속·재접속 시점마다 평가되므로 함수형이면 회전한
|
|
188
|
+
토큰이 재연결에 반영된다(고정 객체는 최초 값 고정 — 토큰 만료 후 드롭 시 4401
|
|
189
|
+
영구 종료). room=42 같은 불변 값은 고정 객체로 넘겨도 된다.
|
|
187
190
|
|
|
188
191
|
**앱 프리픽스는 자동이다(결정 154).** 서버는 채널 WS 를 `<앱 프리픽스>/gaon/ws/:channel`
|
|
189
192
|
에 등록하고, `useChannel` 은 그 앱 번들의 `import.meta.env.BASE_URL`(= vite base = 앱
|
|
@@ -267,10 +270,15 @@ export function useRoom(roomId: number) {
|
|
|
267
270
|
`127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
|
|
268
271
|
자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
|
|
269
272
|
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(`docs/guides/operations.md`).
|
|
270
|
-
- **허브 TCP 포트는 내부망 전용이다(결정 311).**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
273
|
+
- **허브 TCP 포트는 내부망 전용이다(결정 311).** 포트(기본 4001)는 방화벽/
|
|
274
|
+
보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반 백스톱으로 라인 길이 상한
|
|
275
|
+
(1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed · 개행 없는 스트림의
|
|
276
|
+
메모리 증식 차단).
|
|
277
|
+
- **공유 토큰 인증(선택 · 결정 350).** `GAON_HUB_TOKEN` 을 허브·웹서버 양쪽에
|
|
278
|
+
설정하면 웹서버 소켓의 첫 명령이 올바른 `auth` 여야 하고, 미인증 명령·오토큰은
|
|
279
|
+
즉시 종료된다(fail-closed · 도달 가능한 임의 피어의 로스터 위조 방어). 미설정
|
|
280
|
+
이면 종전(무인증 · 내부망 가정). 토큰 없는 허브는 auth 를 무시하므로 웹서버에
|
|
281
|
+
먼저 설정해 둬도 무해하다(무중단 롤아웃: 웹서버 → 허브 순).
|
|
274
282
|
|
|
275
283
|
## 정본 예시
|
|
276
284
|
|
|
@@ -361,9 +369,11 @@ export default channel({
|
|
|
361
369
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
362
370
|
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
363
371
|
| 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
|
|
372
|
+
| 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 값 고정이라 만료 후 재연결이 4401 영구 종료되던 갭 봉합 |
|
|
364
373
|
| 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
|
|
365
374
|
| 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
|
|
366
|
-
| 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는
|
|
375
|
+
| 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
|
|
376
|
+
| 결정 350 | 허브 공유 토큰 인증(§5 · 선택) — `GAON_HUB_TOKEN` 설정 시 첫 명령 = `auth` 강제(타이밍 세이프 비교) · 미인증/오토큰 즉시 종료 · 토큰 없는 허브는 auth 무시(혼재 롤아웃 호환) |
|
|
367
377
|
|
|
368
378
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
369
379
|
|
|
@@ -71,7 +71,7 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
|
|
|
71
71
|
```ts
|
|
72
72
|
// 2) apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
|
|
73
73
|
export default defineAppConfig({
|
|
74
|
-
seal: true, // 또는 { except: ['/webhooks/*'] } — 외부가 seal 을 모르는 경로만 평문 통과
|
|
74
|
+
seal: true, // 또는 { except: ['/webhooks/*'], strictQuery: true } — except = 외부가 seal 을 모르는 경로만 평문 통과 · strictQuery = 평문 쿼리도 거부(결정 354)
|
|
75
75
|
})
|
|
76
76
|
```
|
|
77
77
|
```ts
|
|
@@ -115,7 +115,8 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
115
115
|
소비해 봉인이 깨진다. 클라 개봉 실패는 조용히 무시하지 않고 소켓을 **4500 종료**(`useChannel` · 아래 fail-closed).
|
|
116
116
|
- **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
|
|
117
117
|
직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
|
|
118
|
-
모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
|
|
118
|
+
모르는 경로는 `seal: { except: ['/webhooks/*'] }`. except 글롭·기본 헬스 제외는 **앱 상대 경로**로
|
|
119
|
+
매칭된다(prefix 앱도 정본 예시 그대로 동작 · 전체 경로 매칭도 병행 — 결정 354).
|
|
119
120
|
- **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
120
121
|
**403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
|
|
121
122
|
(`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
|
|
@@ -209,8 +210,9 @@ export default controller({
|
|
|
209
210
|
6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
|
|
210
211
|
7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
|
|
211
212
|
8. **리버스 프록시의 Host 재작성 금지** — 서버 키 시드는 `Host` 헤더, 브라우저는 `location.hostname` 을 쓴다. 프록시가 Host 를 upstream 이름으로 바꾸면 키가 갈려 data-page 개봉 실패(blank)·전 요청 403 이 된다. 프록시는 원 Host 를 보존해야 한다(`proxy_set_header Host $host` 류 · `x-forwarded-host` 는 참조하지 않는다).
|
|
212
|
-
9. **쿼리 봉인은 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 안 탄 인바운드(직접 URL 등)의 쿼리는 평문일 수 있다 — "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것.
|
|
213
|
+
9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 안 탄 인바운드(직접 URL 등)의 쿼리는 평문일 수 있다 — "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`) — 직접 URL 로 쿼리를 싣는 경로는 except 로 빼야 한다.
|
|
213
214
|
10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
|
|
215
|
+
11. **클라 인터셉터는 except 를 모른다** — `gaonjs/vue` 인터셉터는 모든 same-origin 요청의 쿼리를 `?q=` 로 봉인하는데, 서버는 excluded 경로에서 개봉을 건너뛴다. seal 앱 **자신의 브라우저 코드가 except 경로를 쿼리와 함께 호출**하면 핸들러가 `q=<암호문>` 을 받고 실 파라미터는 소실된다(무에러 오동작). except 경로는 외부 호출자 전용으로 두고 앱 자신은 호출하지 말 것(클라 except 전파는 백로그 DEFER).
|
|
214
216
|
|
|
215
217
|
## 관련 결정 번호
|
|
216
218
|
|
|
@@ -221,4 +223,5 @@ export default controller({
|
|
|
221
223
|
- **결정 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 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
|
|
222
224
|
- **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
|
|
223
225
|
- **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
|
|
226
|
+
- **결정 354** — seal 백로그 2건: ① **prefix 앱 except 무력** 수정 — except 글롭·기본 헬스 제외를 **앱 상대 경로**로도 매칭(전체 경로 매칭 병행 · 하위 호환). 배선부(web)가 앱 prefix 를 normalizeSealConfig 로 전달. ② **strictQuery 옵션** 신설 — 봉인 강제 요청의 평문 쿼리를 403 `plaintext_query` 로 거부(기본 off — 직접 URL 인바운드가 흔해 기본 강제는 정당한 요청을 깬다). 클라 인터셉터 except 전파는 DEFER(함정 11).
|
|
224
227
|
- **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).
|
|
@@ -33,9 +33,23 @@
|
|
|
33
33
|
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
34
34
|
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
35
35
|
- **rate limit 은 앱 라우트뿐 아니라 정적 에셋(`/assets/*`)·static 폴백까지 전
|
|
36
|
-
라우트에 적용된다**(기본 100 req/분/IP
|
|
36
|
+
라우트에 적용된다**(기본 100 req/분/IP). 에셋이 많은 페이지를 여러
|
|
37
37
|
사용자가 한 IP(NAT·사내망) 뒤에서 열면 기본치에 닿을 수 있다 — 그 경우
|
|
38
38
|
`web.security.rateLimit: { max: ... }` 로 상향한다(끄지 말고 조정).
|
|
39
|
+
- **CORS·rate limit 은 앱 스코프 단위로 override 할 수 있다 (결정 339).** 전역
|
|
40
|
+
(`gaon.config.ts` 의 `web.security`)이 기본이고, 앱이 `app.config.ts` 의
|
|
41
|
+
`security: { cors, rateLimit }` 로 자기 것만 바꾼다 — **API 앱만 크로스 오리진을
|
|
42
|
+
열어도 web 앱의 same-origin 기본은 그대로다**(앱별 세션 분리와 같은 축):
|
|
43
|
+
```ts
|
|
44
|
+
// apps/api/app.config.ts
|
|
45
|
+
export default defineAppConfig({
|
|
46
|
+
security: { cors: { origin: ['https://app.example.com'] }, rateLimit: { max: 600 } },
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
생략한 필드는 전역 상속 · `false` 는 그 앱에서만 끔(명시적으로만 · 규칙 8).
|
|
50
|
+
rate limit 카운터는 **앱 단위 버킷**이다(override 여부와 무관 — 한 클라이언트가
|
|
51
|
+
web·api 를 함께 써도 상한은 앱별로 센다). 보안 응답 헤더는 전역 전용(앱
|
|
52
|
+
override 없음).
|
|
39
53
|
- **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
|
|
40
54
|
기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
|
|
41
55
|
될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
|
|
@@ -85,24 +99,17 @@
|
|
|
85
99
|
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
|
|
86
100
|
**토큰은 `<meta>` 태그가 아니라 data-page 공유 prop 으로 온다 (결정 116).**
|
|
87
101
|
프레임웍 Inertia 셸은 `<meta name="csrf-token">` 을 **넣지 않는다** — csrf 는
|
|
88
|
-
모든 렌더에 자동 주입되는 공유 prop
|
|
89
|
-
(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
- **알려진 한계**: 로그인(`this.auth.login`)은 세션을 재생성하므로(결정 254)
|
|
100
|
-
풀 리로드 없이 로그인한 직후에는 최초 문서의 data-page 토큰이 stale 이 돼
|
|
101
|
-
`api()` 상태 변경 호출이 403 이 날 수 있다(useForm 은 렌더마다 갱신되는
|
|
102
|
-
`shared.csrf` 를 쓰므로 무관). 로그인 성공 후 `api()` 변이를 이어가야 하면
|
|
103
|
-
풀 리로드(서버 redirect 는 SPA 네비게이션이라 불충분)를 거치거나 `useShared().csrf`
|
|
104
|
-
를 `opts.headers['X-CSRF-Token']` 으로 직접 넘긴다 — 근본 수정(라이브 페이지
|
|
105
|
-
props 우선 읽기)은 후속 결정.
|
|
102
|
+
모든 렌더에 자동 주입되는 공유 prop 이다(`packages/vue/src/shared.ts`).
|
|
103
|
+
**부착은 프레임웍 자동이다(결정 341·342 · The One Way)**: `useForm`/`router` 의
|
|
104
|
+
상태 변경 제출과 `api()` 상태 변경 호출 전부에 **라이브 Inertia 페이지 props 의
|
|
105
|
+
csrf**(`useShared().csrf` 와 같은 단일 출처 · `packages/vue/src/csrf.ts`)가
|
|
106
|
+
`X-CSRF-Token` 헤더로 자동 실린다 — 페이지가 `_csrf` 바디·수동 헤더를 싣지
|
|
107
|
+
않는다(스캐폴드 Login/Signup/Dashboard 동기). 라이브 출처라 로그인(세션 재생성 ·
|
|
108
|
+
결정 254) 직후에도 항상 최신 토큰이다 — 종전의 "최초 문서 스냅샷 stale → 로그인
|
|
109
|
+
후 api() 403" 한계는 결정 341 로 봉합됐다(최초 문서 data-page·레거시 meta 는
|
|
110
|
+
부팅 전 폴백으로만 남는다). `useForm`/`router`/`api()` 를 우회하는 커스텀 전송은
|
|
111
|
+
`readCsrfToken()`(gaonjs/vue)으로 토큰을 읽어 직접 실어라. JWT/API 앱은 토큰
|
|
112
|
+
인증이라 CSRF 대상이 아니다(토큰 없으면 아무것도 안 붙는 무회귀 경로).
|
|
106
113
|
- **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
|
|
107
114
|
세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
|
|
108
115
|
`app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
|
|
@@ -121,6 +128,12 @@
|
|
|
121
128
|
같은 핸들러가 Inertia-네이티브 에러+수리 안내로 마감한다. 비-Inertia(API/JWT)는 종전
|
|
122
129
|
JSON 유지(회귀 없음). 상세는 `agents/web.md` §4.1. 앱은 아무것도 안 한다.
|
|
123
130
|
- JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
|
|
131
|
+
- **JWT 하드닝 (결정 337)**: secret **32자 미만 = 부팅 에러**(항상) · 운영에서
|
|
132
|
+
dev 폴백/플레이스홀더 secret = 부팅 확정 종료(세션 결정 255 와 대칭) · 검증은
|
|
133
|
+
**HS256 alg 고정**(algorithm confusion 방어). 스캐폴드는 `gaon g auth --jwt
|
|
134
|
+
--app <api>`(결정 338) — `.env` 의 `<APP>_JWT_SECRET` 으로 주입한다.
|
|
135
|
+
- **폐기 한계**: 토큰은 stateless — 서버측 폐기(로그아웃·강제 무효화)가 없다.
|
|
136
|
+
유출 리프레시 토큰은 만료까지 유효 · 민감 앱은 `refreshTtl` 단축(v1 범위 밖).
|
|
124
137
|
- **인증·인가는 3층이다** (결정 145 · 149):
|
|
125
138
|
- **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
|
|
126
139
|
로그인 페이지 리다이렉트).
|
|
@@ -267,3 +280,8 @@ const rows = await Post.query()
|
|
|
267
280
|
| 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 으로 조정(wire 전달) · `sameSite:'none'`+secure 미충족 = 부팅 에러(브라우저 조용한 쿠키 거부 방지) |
|
|
268
281
|
| 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw — 조용한 no-op(부팅 green·로그인 영구 실패) 금지 |
|
|
269
282
|
| 결정 297 | Inertia HTML/JSON 응답에 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — 공유 캐시가 사용자별 페이지(csrf·currentUser)를 저장·교차 서빙하지 못하게 |
|
|
283
|
+
| 결정 337 | JWT 하드닝 — secret 32자 하한 · 운영 dev 폴백 부팅 거부 · 검증 alg HS256 고정 · stateless 폐기 한계 명기(§2) |
|
|
284
|
+
| 결정 338 | `gaon g auth --jwt` — API 앱 토큰 스캐폴드(`<APP>_JWT_SECRET` 시드 · 공개 가입 없음 · `agents/web.md` §6) |
|
|
285
|
+
| 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }` · 전역 상속 · 앱 단위 rate limit 버킷 · 보안 헤더는 전역 전용(§1) |
|
|
286
|
+
| 결정 341 | CSRF 토큰 출처 = **라이브 Inertia 페이지 props**(최초 문서 data-page·meta 는 부팅 전 폴백) — 로그인 세션 재생성(결정 254) 후 stale 403 봉합(`packages/vue/src/csrf.ts` · §2) |
|
|
287
|
+
| 결정 342 | CSRF 부착 The One Way — `useForm`/`router`/`api()` 상태 변경에 `X-CSRF-Token` 자동 부착 · 수동 `_csrf` 바디/헤더 제거(스캐폴드 동기) · 우회 전송은 `readCsrfToken()` 탈출구(§2) |
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
|
|
25
25
|
- **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
|
|
26
26
|
- **로컬 디스크**: 항상 `${publicUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시 ·
|
|
27
|
-
`publicUrl` 생략 시 `/storage`) — presigned 개념이 없다.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
`publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **프레임웍이 이 경로를 직접 서빙한다**
|
|
28
|
+
(결정 355 · `gaon serve`/`gaon dev` 의 루트에 자동 등록 — 상대 publicUrl 만 · 폴더 이탈 차단).
|
|
29
|
+
업로드→`url()` 렌더→표시가 zero-config 로 흐른다. publicUrl 이 절대 URL(별도 서버/CDN)이면
|
|
30
|
+
서빙하지 않는다(그 서버 몫). 공개 서빙이라 **비공개 파일은 로컬 디스크 공개 경로에 두지 말
|
|
31
|
+
것**(접근 제어가 필요하면 s3 presigned 또는 컨트롤러 라우트).
|
|
31
32
|
- **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
|
|
32
33
|
없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
|
|
33
34
|
존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
|
|
@@ -63,7 +64,7 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
63
64
|
(결정 132 · zero-config). `.env` 는 `gaon new` 가 자동 생성하므로(결정 198)
|
|
64
65
|
`gaon dev` 만으로 우회 0.
|
|
65
66
|
- 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` — `url(key)`
|
|
66
|
-
= `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` ·
|
|
67
|
+
= `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동 서빙 — 결정 355).
|
|
67
68
|
config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
|
|
68
69
|
쓰면 컴파일 에러).
|
|
69
70
|
- 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
|