@gaonjs/cli 0.43.0 → 0.47.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/README.md +1 -1
- package/dist/commands/db.js +29 -7
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +8 -3
- package/dist/db/resolve.d.ts +15 -0
- package/dist/db/resolve.js +24 -2
- package/dist/doctor/pageprops-destructure.d.ts +2 -2
- package/dist/doctor/pageprops-destructure.js +29 -23
- package/dist/doctor.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -2
- package/dist/scaffold/job.js +3 -1
- package/dist/templates/project/.dockerignore.tpl +3 -0
- package/dist/templates/project/.env.example.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +2 -1
- package/dist/templates/project/CLAUDE.md.tpl +4 -3
- package/dist/templates/project/Dockerfile.tpl +6 -1
- package/dist/templates/project/agents/async.md.tpl +44 -6
- package/dist/templates/project/agents/data.md.tpl +86 -13
- package/dist/templates/project/agents/frontend.md.tpl +36 -8
- package/dist/templates/project/agents/mail.md.tpl +2 -1
- package/dist/templates/project/agents/realtime.md.tpl +26 -2
- package/dist/templates/project/agents/seal.md.tpl +9 -1
- package/dist/templates/project/agents/security.md.tpl +18 -0
- package/dist/templates/project/agents/storage.md.tpl +15 -8
- package/dist/templates/project/agents/web.md.tpl +38 -2
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
- package/dist/templates/project/apps/web/main.ts.tpl +1 -1
- package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
- package/dist/templates/project/docker-compose.yaml.tpl +1 -1
- package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +1 -1
- package/dist/work.d.ts +21 -0
- package/dist/work.js +45 -1
- package/package.json +12 -7
- package/dist/templates/index.ts +0 -109
|
@@ -55,7 +55,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
55
55
|
|
|
56
56
|
| 선언 | 시그니처 | 레코드 접근 | 반환 |
|
|
57
57
|
|---|---|---|---|
|
|
58
|
-
| `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건) |
|
|
58
|
+
| `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건 · FK 가 `.nullable()` 이면 `Row \| null` — 결정 285) |
|
|
59
59
|
| `hasMany(target, opts?)` | `(target, { foreignKey? })` | `await post.comments()` | `Row[]` |
|
|
60
60
|
| `hasOne(target, opts?)` | `(target, { foreignKey? })` | `await post.cover()` | `Row \| undefined` |
|
|
61
61
|
| `belongsToMany(target, opts?)` | `(target, { through?, foreignKey?, otherKey? })` | `await post.tags()` | `Row[]` |
|
|
@@ -145,7 +145,8 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
145
145
|
서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2). hidden 마커는 **열거
|
|
146
146
|
가능한 심볼**이라 `{ ...user }` spread·`Object.assign` 을 넘어 보존된다(결정 253)
|
|
147
147
|
— 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
|
|
148
|
-
컬럼을 직접 넣지 않는다.
|
|
148
|
+
컬럼을 직접 넣지 않는다. `select()`/`distinct(col)` 로 hidden 컬럼을 **명시 선택**한
|
|
149
|
+
좁은 행에도 마커가 실려 직렬화 경계에서 동일하게 제외된다(결정 283).
|
|
149
150
|
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
150
151
|
- `.index()` — 컬럼 레벨 인덱스 (E-4). 옵션 객체로 method·partial 지정 (결정 273):
|
|
151
152
|
- `.index()` — 기본 인덱스. **jsonb 컬럼은 자동 gin**(유연 검색 `@>`·`?` 용 · The One Way), 그 외는 btree.
|
|
@@ -211,6 +212,9 @@ export const logs = table('logs', {
|
|
|
211
212
|
|
|
212
213
|
**`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
|
|
213
214
|
갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
|
|
215
|
+
**자동 갱신은 `rec.update()` 단건 전용이다** — `updateAll()`/`incrementAll` 류 벌크와 `upsert()` 의
|
|
216
|
+
충돌-갱신 경로는 `updatedAt` 을 건드리지 않는다(명시한 컬럼만 쓴다). 벌크로도 갱신 시각을 남기려면
|
|
217
|
+
patch 에 `updatedAt: new Date()` 를 직접 넣는다(결정 286 유보 — 벌크 자동 갱신은 동작 결정 대기).
|
|
214
218
|
|
|
215
219
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
216
220
|
|
|
@@ -283,6 +287,11 @@ export const logs = table('logs', {
|
|
|
283
287
|
| `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
|
|
284
288
|
| `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
|
|
285
289
|
|
|
290
|
+
> **`upsert` 의 `onConflict` 는 MySQL/MariaDB(legacy) 방언에서 문법상 반영되지 않는다** —
|
|
291
|
+
> `ON DUPLICATE KEY UPDATE` 는 충돌 대상을 지정할 수 없어 **어떤 UNIQUE/PK 충돌이든** 갱신을
|
|
292
|
+
> 트리거한다(PG 는 지정한 컬럼 충돌만). unique 제약이 여럿인 테이블에서 방언 간 의미가 갈리니,
|
|
293
|
+
> legacy 커넥션의 upsert 대상 테이블은 충돌 축(unique)을 하나로 유지한다.
|
|
294
|
+
>
|
|
286
295
|
> 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`)를 돌려준다(결정 90 ·
|
|
287
296
|
> 원안의 "비 RETURNING 방언 throw" 폐기). `count` 는 **처리된 입력 행 수**로 방언
|
|
288
297
|
> 무관 같은 의미다 — MySQL upsert 의 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
|
|
@@ -459,9 +468,16 @@ npm i @gaonjs/adapter-mongo mongoose
|
|
|
459
468
|
|
|
460
469
|
```ts
|
|
461
470
|
// domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
|
|
471
|
+
// 정본 패턴: 문서 인터페이스를 선언하고 mongoSchema<Doc>() 에 명시한다(결정 288).
|
|
462
472
|
import { collection, mongoSchema } from 'gaonjs/data'
|
|
463
473
|
|
|
464
|
-
|
|
474
|
+
interface AuditDoc {
|
|
475
|
+
actorId: string
|
|
476
|
+
action: string
|
|
477
|
+
ip?: string // hidden 이어도 서버 코드는 투명하게 읽는다
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const auditLogSchema = mongoSchema<AuditDoc>({
|
|
465
481
|
actorId: { type: String, required: true, index: true },
|
|
466
482
|
action: { type: String, required: true },
|
|
467
483
|
ip: { type: String, hidden: true }, // 직렬화 경계 제외 (SQL .hidden() 과 동일 의미)
|
|
@@ -476,6 +492,10 @@ auditLogSchema.statics.recent = function (limit: number) {
|
|
|
476
492
|
export const AuditLog = collection('audit_logs', auditLogSchema, { db: 'logs' })
|
|
477
493
|
```
|
|
478
494
|
|
|
495
|
+
제네릭을 생략해도 정의 지점 추론이 성립하지만(`new mongoose.Schema` 와 동일 ·
|
|
496
|
+
결정 288 이 시그니처를 Mongoose 생성자 복제로 고정), **정본은 명시 인터페이스**다 —
|
|
497
|
+
문서 타입이 코드에 보이고, statics/methods 캐스트 지점에서 재사용한다.
|
|
498
|
+
|
|
479
499
|
```ts
|
|
480
500
|
// gaon.config.ts — 문서형 커넥션은 adapter: 'mongodb' 로 SQL 과 나란히 선언한다.
|
|
481
501
|
export default defineConfig({
|
|
@@ -488,8 +508,12 @@ export default defineConfig({
|
|
|
488
508
|
|
|
489
509
|
**정본 규칙**:
|
|
490
510
|
- **`hidden` 은 SQL 과 같은 의미** — "서버는 읽고 직렬화만 제외". `select:false` 를
|
|
491
|
-
쓰지 않는다. render props 로 흘릴 땐 **`.lean()`** 을 쓴다(정본 경로) —
|
|
492
|
-
응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형
|
|
511
|
+
쓰지 않는다. render props 로 흘릴 땐 **`.lean()`** 을 쓴다(정본 경로 · 경량) —
|
|
512
|
+
`_id`(ObjectId)는 응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형
|
|
513
|
+
대응). `create()` 반환 문서·`doc.toObject()` 를 그대로 넘겨도 직렬화 경계가
|
|
514
|
+
방어한다(결정 289 — save/insertMany 훅 + toObject/toJSON transform 마커 + 응답
|
|
515
|
+
경계의 문서 → POJO 정규화). 단 `hidden` 은 **top-level 경로 전용**이다 — 중첩
|
|
516
|
+
경로에 달면 mongoSchema 가 fail-loud 로 던진다(결정 290 · 조용한 유출 방지).
|
|
493
517
|
- **몽고 쓰기를 SQL `service()` tx 안에서 하지 말 것** — 몽고는 v1 에서 트랜잭션이
|
|
494
518
|
없어, tx 안 몽고 쓰기는 `MongoCrossConnectionWriteError` 로 **막힌다**(조용한 부분
|
|
495
519
|
커밋 방지 · 결정 281). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
|
|
@@ -502,15 +526,35 @@ export default defineConfig({
|
|
|
502
526
|
```
|
|
503
527
|
- **SQL ↔ 문서형 관계 금지** — `belongsTo`/`hasMany` 는 SQL 전용이다. 몽고 컬렉션끼리의
|
|
504
528
|
참조는 Mongoose `ref`/`populate` 로 사용자가 직접 한다(Gaon doctor 가 강제하지 않음).
|
|
505
|
-
- **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) —
|
|
506
|
-
`
|
|
529
|
+
- **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) — mongoose
|
|
530
|
+
`autoIndex` **기본값**이 모델 첫 사용 시 인덱스를 만든다(dev·prod 공통 · Gaon 이
|
|
531
|
+
별도 배선을 얹지 않는다). prod 에서 끄려면 스키마 옵션 `autoIndex: false` + 배포
|
|
532
|
+
절차에서 `Model.syncIndexes()` 를 직접 부른다. 몽고는 마이그레이션이 없다 —
|
|
533
|
+
`gaon db diff/migrate`(키 생략 = 전 커넥션 순회)는 mongodb 커넥션을 **건너뛰고
|
|
534
|
+
skipped 로 보고**하며(결정 292), `--db <mongo키>` 명시 지정만 fail-loud 다.
|
|
507
535
|
|
|
508
536
|
**알려진 함정**:
|
|
509
537
|
- `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
|
|
510
538
|
SQL `Post.query()`(Kysely 원본) raw 탈출구가 hidden 을 우회하는 것과 동형. 문서를
|
|
511
539
|
안전하게 렌더에 흘리려면 `find()/findOne().lean()` 을 쓴다.
|
|
512
|
-
-
|
|
513
|
-
`
|
|
540
|
+
- **크로스커넥션 쓰기 가드(결정 281·291)는 static 메서드 경로만 본다** — 인스턴스
|
|
541
|
+
`document.save()`, **Query 체이닝 쓰기**(`find(f).updateMany(u)`·`where(f).deleteMany()`),
|
|
542
|
+
`aggregate` 의 `$out`/`$merge` 스테이지는 가드 밖이다(Proxy 가 반환된 Query/문서를
|
|
543
|
+
감싸지 않는다). 정본 쓰기는 static `create/insertOne/insertMany/updateOne/…`(가드
|
|
544
|
+
대상 · 결정 291 이 insertOne·bulkSave 편입). tx 안에서 쓸 일이면 `afterCommit`.
|
|
545
|
+
- **`hidden` 은 top-level 경로 전용** — 중첩 def(`profile: { ssn: { hidden: true } }`)에
|
|
546
|
+
달면 mongoSchema 가 던진다(결정 290). top-level 로 옮기거나(`profileSsn`) render
|
|
547
|
+
props 에서 빼라. v1.21(adapter-mongo 0.1.0) 이하에서는 조용히 무시돼 유출됐다.
|
|
548
|
+
- **`database`(또는 url 경로) 생략 시 몽고 드라이버 기본 DB `test` 로 조용히 붙는다** —
|
|
549
|
+
`adapter: 'mongodb'` 커넥션에는 `url`(`mongodb://…/mydb`) 또는 `database` 를 항상
|
|
550
|
+
명시하라. fail-loud 화는 유보(동작 변경) — 현재는 관례로 방어한다.
|
|
551
|
+
- **`service()` 는 SQL 전용** — `service(fn, { db: '<mongo키>' })` 는 SQL 레지스트리
|
|
552
|
+
조회라 "커넥션 미등록" 에러가 난다(안내 문구도 SQL 기준). 몽고 쓰기에는 서비스
|
|
553
|
+
트랜잭션 개념이 없다(v1 몽고 tx 없음) — 그냥 static 으로 쓰거나 SQL 서비스의
|
|
554
|
+
`afterCommit` 에서 쓴다.
|
|
555
|
+
- **mongoSchema 제네릭**: v1.21(adapter-mongo 0.1.0) 이하에서는 제네릭을 생략하면
|
|
556
|
+
문서 타입이 `{ actorId: StringConstructor }` 로 **조용히 붕괴**했다 — 0.2.0(결정
|
|
557
|
+
288)부터 정의 지점 추론이 성립하지만, 정본은 위처럼 명시 인터페이스다.
|
|
514
558
|
|
|
515
559
|
### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
|
|
516
560
|
|
|
@@ -683,6 +727,10 @@ const top = await Post.published().latest().limit(5).withCache(30).all()
|
|
|
683
727
|
**지우지 않는다**. 정합이 복잡하고 틀리면 조용한 stale 을 낳기 때문이다.
|
|
684
728
|
신선도가 중요하면 **짧은 TTL** 을 쓰거나 `cache.forget(key)` 로 명시
|
|
685
729
|
무효화한다. `.withCache` 결과는 TTL 만료로만 갱신된다(자동 퍼지 없음).
|
|
730
|
+
- **적용 지점은 `Chain` 의 `all()`/`first()` 뿐이다** — `.withCache()` 뒤에
|
|
731
|
+
`include()`/`select()`/`distinct(col)` 로 체인이 갈라지거나 `paginate()` 로 끝나면
|
|
732
|
+
캐시가 **조용히 적용되지 않는다**(타입은 통과). 캐시가 필요하면 include/select 없는
|
|
733
|
+
형태로 `all()`/`first()` 마감하거나, 결과 조립 전체를 `cache.remember` 로 감싼다.
|
|
686
734
|
- **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
|
|
687
735
|
(`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
|
|
688
736
|
- 백엔드는 Redis 가 기본(설정에 `redis` 있으면 자동), 메모리는 dev/테스트/폴백.
|
|
@@ -705,8 +753,9 @@ export const PublishPost = service(async (postId: bigint) => {
|
|
|
705
753
|
return published
|
|
706
754
|
})
|
|
707
755
|
|
|
708
|
-
//
|
|
709
|
-
// const
|
|
756
|
+
// 컨트롤러에서 (결정 294: this.params('id') 같은 단일 키 접근 표면은 없다):
|
|
757
|
+
// const { id } = this.params({ _row: {} as { id: string } })
|
|
758
|
+
// const post = await PublishPost.call(BigInt(id))
|
|
710
759
|
```
|
|
711
760
|
|
|
712
761
|
- **시그니처** — `service(handler, options?)` → `{ call(...args) }`.
|
|
@@ -820,8 +869,16 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
820
869
|
비교가 안 되므로 **존재만 비교**한다(값이 바뀌어도 no-op). 허위 diff(멱등 붕괴)보다
|
|
821
870
|
미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
|
|
822
871
|
COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
|
|
823
|
-
값 변경은 정상 감지된다.)
|
|
824
|
-
|
|
872
|
+
값 변경은 정상 감지된다.) **FK(belongsTo 의 REFERENCES)는 diff 축이 아니다** — FK 는
|
|
873
|
+
createTable/addColumn 시점에만 생성되므로, **기존 컬럼**을 `t.belongsTo()` 로 바꾸거나
|
|
874
|
+
belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
|
|
875
|
+
기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
|
|
876
|
+
이 제약·기본값 diff 는 postgres 커넥션 기준이다
|
|
877
|
+
(mysql=legacy §4.5 는 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
|
|
878
|
+
introspection 은 `char(n)`→uuid·`longtext`→jsonb **휴리스틱 매핑**을 쓴다(MariaDB 가
|
|
879
|
+
uuid/json 을 그 물리 타입으로 저장하는 왕복 정합 · 결정 273 Bug B) — Gaon 스키마가 만든
|
|
880
|
+
DB 에선 정확하지만, **기존(외부) DB 의 진짜 char/longtext 컬럼**은 uuid/jsonb 로 오인돼
|
|
881
|
+
허위 alterColumn diff 가 뜰 수 있다. 그런 컬럼은 스키마에 싣지 않거나 타입을 맞춘다.
|
|
825
882
|
- 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
|
|
826
883
|
|
|
827
884
|
### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
|
|
@@ -977,6 +1034,14 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
977
1034
|
안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
|
|
978
1035
|
다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
|
|
979
1036
|
그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
|
|
1037
|
+
- **벌크·upsert 는 `updatedAt` 을 자동 갱신하지 않는다** (결정 276 · 286 유보) —
|
|
1038
|
+
`updateAll()`/`incrementAll` 류와 `upsert()` 충돌-갱신은 명시한 컬럼만 쓴다. 자동
|
|
1039
|
+
갱신은 `rec.update()` 단건 전용. 벌크로 갱신 시각을 남기려면 patch 에
|
|
1040
|
+
`updatedAt: new Date()` 를 직접 넣는다.
|
|
1041
|
+
- **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148 · §8.2) —
|
|
1042
|
+
뒤에 `include()`/`select()`/`distinct(col)`/`paginate()` 가 오면 캐시가 **조용히
|
|
1043
|
+
적용되지 않는다**(타입은 통과). include/select 없는 형태로 마감하거나
|
|
1044
|
+
`cache.remember` 로 감싼다.
|
|
980
1045
|
- **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
|
|
981
1046
|
는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
|
|
982
1047
|
만료 전까지 stale 을 준다. 신선도가 중요하면 짧은 TTL 이나 `cache.forget(key)`. 자동
|
|
@@ -1010,7 +1075,15 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
1010
1075
|
| 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
|
|
1011
1076
|
| 결정 265 | 조인 시 자기 테이블 컬럼 자동 한정 — `where`/`whereAny`/`orderBy` 가 자기 테이블로 한정돼 ambiguous column 방지(§4 join) |
|
|
1012
1077
|
| 결정 278 | 문서형 동사 `collection()`(Mongoose) — SQL `model()` 과 분리 · `mongoSchema()` 얇은 래퍼(판별 태그+hidden 전개) · `@gaonjs/adapter-mongo`(mongoose optional peer)(§7.1) |
|
|
1078
|
+
| 결정 283 | `select()`/`distinct(col)` 좁은 행에도 hidden 마커 부착 — 명시 select 한 hidden 컬럼의 조용한 직렬화 유출 봉합(§3 `.hidden()`) |
|
|
1079
|
+
| 결정 285 | nullable FK 의 belongsTo 관계는 `Row \| null` — 지연·include 모두 null 정규화(타입 green NPE 봉합 · §1.1) |
|
|
1080
|
+
| 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동 갱신은 유보(미갱신 명기 · §3·함정) |
|
|
1013
1081
|
| 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
|
|
1014
1082
|
| 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
|
|
1015
1083
|
| 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
|
|
1084
|
+
| 결정 288 | `mongoSchema()` 제네릭 = Mongoose Schema 생성자 복제 — 자작 시그니처의 문서 타입 조용한 붕괴(StringConstructor) 봉합 · 정본 = 명시 인터페이스(§7.1) |
|
|
1085
|
+
| 결정 289 | 문서 인스턴스 경로 hidden 마커 전파 — save/insertMany 훅 + toObject/toJSON transform + 응답 경계 문서→POJO 정규화(§7.1) |
|
|
1086
|
+
| 결정 290 | 중첩 경로 `hidden: true` 는 fail-loud throw — top-level 전용(조용한 유출 방지)(§7.1) |
|
|
1087
|
+
| 결정 291 | 쓰기 가드에 `insertOne`(mongoose 8.16+)·`bulkSave` 편입 — static 가드 갭 봉합(§7.1 · 결정 281 확장) |
|
|
1088
|
+
| 결정 292 | `gaon db` 전 커넥션 순회는 mongodb 를 skip + skipped 보고(exit 0) — `--db <mongo키>` 명시만 fail-loud(§7.1) |
|
|
1016
1089
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -59,7 +59,8 @@ const props = pageProps<'web:posts#index'>()
|
|
|
59
59
|
는 매 접근마다 `usePage().props` 를 다시 읽는 **Proxy** 를 돌려준다. `const { csrf } =
|
|
60
60
|
useShared()` 처럼 구조분해하면 그 순간 값을 한 번 스냅샷해 **반응성이 끊긴다**(리다이렉트·
|
|
61
61
|
partial reload 후 flash·currentUser 갱신이 안 보인다). 항상 `const shared = useShared()`
|
|
62
|
-
로 받아 `shared.csrf`·`shared.flash` 로 접근한다.
|
|
62
|
+
로 받아 `shared.csrf`·`shared.flash` 로 접근한다. useShared 구조분해도 doctor
|
|
63
|
+
**pageprops-destructure** 가 잡는다(`.vue` + 앱 `.ts` 공통 · 결정 302).
|
|
63
64
|
- **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
|
|
64
65
|
로 import 하지 않는다.
|
|
65
66
|
- **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
|
|
@@ -120,8 +121,22 @@ async function search(q: string) {
|
|
|
120
121
|
(서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
|
|
121
122
|
- **반환 타입** = 액션 반환의 `Serialized<>`. JSON 액션이 객체를
|
|
122
123
|
반환하면 그 타입 그대로, render 액션이면 render props 타입이 온다.
|
|
123
|
-
|
|
124
|
-
`
|
|
124
|
+
`this.json(data)` 액션은 **data 의 타입**이 온다(`{ json, status }` 래퍼가
|
|
125
|
+
아님 — 서버가 data 만 실어 보낸다 · `status` 는 HTTP 상태로만 · 결정 299).
|
|
126
|
+
액션이 조건부 `this.redirect(...)` 를 섞어도 render props 타입은 살아남는다
|
|
127
|
+
(유니온 멤버별 분배 · 결정 299).
|
|
128
|
+
- **실패** — 4xx/5xx 는 `ApiError` 로 던진다(결정 304). 캐스트 대신
|
|
129
|
+
`isApiError(e)` 로 좁혀 `e.status`·`e.body` 를 읽는다. 422(`ValidationError`)는
|
|
130
|
+
`e.body` 가 `ApiValidationBody`(`{ error, message, issues }`) 모양이다:
|
|
131
|
+
```ts
|
|
132
|
+
import { api, isApiError, type ApiValidationBody } from 'gaonjs/vue'
|
|
133
|
+
try { await api('web:posts#create', { title }) }
|
|
134
|
+
catch (e) {
|
|
135
|
+
if (isApiError(e) && e.status === 422) {
|
|
136
|
+
const { issues } = e.body as ApiValidationBody // field·source·이유
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
125
140
|
- **CSRF** — 세션 앱은 상태 변경 요청(POST/PUT/PATCH/DELETE)에 CSRF 토큰을
|
|
126
141
|
자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166). 정본
|
|
127
142
|
출처는 data-page 의 `props.csrf` 공유 prop(결정 116 · `useForm({ _csrf: shared.csrf })`
|
|
@@ -417,10 +432,12 @@ async function runSearch(q: string) {
|
|
|
417
432
|
|
|
418
433
|
- **`usePage()`·`defineProps<T>()` 로 pageProps 대체 금지** — Serialized
|
|
419
434
|
경계 우회로 타입 안전 붕괴.
|
|
420
|
-
- **`pageProps()`·`useShared()` 는 setup
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
(
|
|
435
|
+
- **`pageProps()`·`useShared()` 는 `<script setup>` 최상위에서 한 번 잡는다** —
|
|
436
|
+
결정 300: usePage(@inertiajs/vue3 3.6)는 모듈 싱글턴이라 호출 자체는 어디서든
|
|
437
|
+
성립하지만(inject 아님), **앱 부팅(createGaonApp 마운트) 전에 속성에 접근**하면
|
|
438
|
+
페이지가 아직 없어 수리 안내 에러가 난다(모듈 최상위에서 잡는 실수의 실제 실패
|
|
439
|
+
모드). 관례는 setup 최상위에서 한 번 잡아(반응형 프록시라 그대로) 쓰는 것 —
|
|
440
|
+
이벤트 핸들러마다 다시 부를 필요도 없다.
|
|
424
441
|
- **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
|
|
425
442
|
결정한다.
|
|
426
443
|
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
|
|
@@ -446,7 +463,11 @@ async function runSearch(q: string) {
|
|
|
446
463
|
재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
|
|
447
464
|
useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
|
|
448
465
|
켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은
|
|
449
|
-
재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것.
|
|
466
|
+
재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
|
|
467
|
+
OPEN 이 아니면 보내지 않고 `false` 를 반환한다(큐잉 없음 — 유실 불가 송신은 반환값
|
|
468
|
+
확인). 컴포넌트 **밖**에서 부르면 즉시 접속되지만 자동 정리가 없어 호출자가
|
|
469
|
+
`close()` 를 책임진다(기본 배치는 setup 안). 오래 사는 채널은 `maxMessages` 로
|
|
470
|
+
`messages` 상한을 잡는다(`agents/realtime.md` §4).
|
|
450
471
|
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
|
|
451
472
|
중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
|
|
452
473
|
- **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
|
|
@@ -495,6 +516,13 @@ async function runSearch(q: string) {
|
|
|
495
516
|
| 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
|
|
496
517
|
| 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §7) |
|
|
497
518
|
| 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
|
|
519
|
+
| 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
|
|
520
|
+
| 결정 300 | pageProps/useShared 부팅 전 접근 가드(수리 안내) + setup-only 근거 정정(usePage=모듈 싱글턴 · inject 아님 · 실측) (§1·알려진 함정) |
|
|
521
|
+
| 결정 301 | `useShared()` Proxy 열거 트랩 정합 — 스프레드/Object.keys/JSON.stringify 빈 객체 봉합 · 코어 3종 `in` 정합(pageProps 4-트랩과 동일 계약) (§1) |
|
|
522
|
+
| 결정 302 | doctor pageprops-destructure 확장 — `useShared` 구조분해 + 앱 `.ts`(컴포저블) 순회(문서-검사 갭 봉합) (§1) |
|
|
523
|
+
| 결정 303 | `useChannel` 계약 3정비 — `send()` OPEN 아니면 `false` · 컴포넌트 밖 = 즉시 접속(정리는 호출자) · `maxMessages` 상한 (`agents/realtime.md` §4) |
|
|
524
|
+
| 결정 304 | `api()` 실패 타입 공개 — `ApiError`·`isApiError`·`ApiValidationBody`(422 issues 타입드) (§2) |
|
|
525
|
+
| 결정 305 | `createGaonApp` 마운트 대상(`#app`) 부재 = 수리 안내 throw(조용한 빈 화면 봉합) |
|
|
498
526
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
499
527
|
|
|
500
528
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -56,7 +56,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
|
56
56
|
|
|
57
57
|
`gaon dev` 의 compose 가 MailPit 을 띄운다(SMTP 캡처 + 웹 UI `:8025`) — 개발 중
|
|
58
58
|
보낸 메일은 실제로 나가지 않고 MailPit 수신함에서 확인한다. 스캐폴드 `gaon.config.ts`
|
|
59
|
-
·`.env.example` 에 mail 블록이 이미
|
|
59
|
+
·`.env.example` 에 mail 블록이 이미 있고 `.env` 는 `gaon new` 가 자동 생성하므로
|
|
60
|
+
(결정 160·198) 바로 돈다.
|
|
60
61
|
운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
|
|
61
62
|
|
|
62
63
|
- **미리보기 정본 = MailPit** 이다 — 조용히 메모리로 삼키는 `captureTransport`(in-memory 싱크)는 **프레임웍 내부 테스트 전용**이라 파사드(`gaonjs/mail`)에 노출하지 않는다(결정 238 · 프로덕션 오배선 시 무신호 유실 방지).
|
|
@@ -86,7 +86,10 @@ export default channel({
|
|
|
86
86
|
- `onMessage` throw → 그 연결에 **에러 프레임(`{ t:'error' }`)을 보내고 `1011` 로 종료**한다
|
|
87
87
|
(요청 실패지만 서버는 생존 = HTTP 500 대칭).
|
|
88
88
|
- `onLeave` throw → 연결이 **이미 닫히는 중**이라 통지할 곳이 없다. **로그만 남기고 정리(프레즌스
|
|
89
|
-
|
|
89
|
+
해제·로컬 연결 회수)를 계속**한다(추가 종료·에러 프레임 없음 · 결정 309 — 정리는 finally 라
|
|
90
|
+
예외에도 누수가 없다).
|
|
91
|
+
- `onJoin`(또는 접속 직후 스냅샷 조회) throw → 연결은 join 실패(`1011`)로 닫히고, **이미 등록된
|
|
92
|
+
프레즌스는 자동 보상 해제**된다(결정 307) — 접속하지 못한 멤버가 로스터에 유령으로 남지 않는다.
|
|
90
93
|
|
|
91
94
|
재시도가 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
|
|
92
95
|
|
|
@@ -208,7 +211,11 @@ export function useRoom(roomId: number) {
|
|
|
208
211
|
```
|
|
209
212
|
|
|
210
213
|
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
211
|
-
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
|
|
214
|
+
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다. **소켓이 OPEN 이 아니면
|
|
215
|
+
(접속 전·재연결 중) 보내지 않고 `false` 를 반환한다**(결정 303 · 큐잉 없음) — 유실이
|
|
216
|
+
안 되는 송신은 반환값을 확인해 재시도/안내한다.
|
|
217
|
+
- **메시지 상한** — `messages` 는 기본 무제한 축적이다. 채팅·대시보드처럼 오래 사는
|
|
218
|
+
채널은 `maxMessages: 200` 처럼 상한을 잡는다(초과분은 오래된 것부터 버림 · 결정 303).
|
|
212
219
|
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
|
|
213
220
|
스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
|
|
214
221
|
`onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타 병합
|
|
@@ -256,6 +263,14 @@ export function useRoom(roomId: number) {
|
|
|
256
263
|
스케줄러(고정 5s)는 서로 다른 KV 버킷(`gaon_lease_<역할>`)을 써 각자 TTL 을
|
|
257
264
|
가진다 — 한 버킷을 공유하던 시절의 TTL 플래핑(부팅 순서 의존)이 없다.
|
|
258
265
|
허브 TTL 을 튜닝해도 스케줄러 failover 타이밍에 영향을 주지 않는다.
|
|
266
|
+
- **멀티호스트는 `GAON_HUB_ADVERTISE` 필수.** KV 에 공지하는 도달 주소의 기본은
|
|
267
|
+
`127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
|
|
268
|
+
자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
|
|
269
|
+
(예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(`docs/guides/operations.md`).
|
|
270
|
+
- **허브 TCP 포트는 내부망 전용이다(결정 311).** 허브 명령 채널에는 인증이 없다 —
|
|
271
|
+
포트(기본 4001)는 방화벽/보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반
|
|
272
|
+
백스톱으로 라인 길이 상한(1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed ·
|
|
273
|
+
개행 없는 스트림의 메모리 증식 차단).
|
|
259
274
|
|
|
260
275
|
## 정본 예시
|
|
261
276
|
|
|
@@ -322,6 +337,11 @@ export default channel({
|
|
|
322
337
|
스냅샷과 별개로 받는다. `useChannel` 은 `members` 를 `id` 로 키잉해 이 중복을
|
|
323
338
|
**멱등**으로 흡수하므로 명단이 불어나지 않는다(결정 272). 직접 `new WebSocket`
|
|
324
339
|
을 쓰는 탈출구에서는 스스로 `id` 로 dedup 한다.
|
|
340
|
+
- **`useChannel` 을 컴포넌트 밖에서 부르면 자동 정리가 없다** — 컴포넌트 setup
|
|
341
|
+
안이면 마운트 접속·언마운트 정리가 자동이다. 밖(모듈 스코프 store 등)이면 즉시
|
|
342
|
+
접속은 되지만(결정 303 — 과거엔 라이프사이클 훅이 발화하지 않아 **영원히 closed**
|
|
343
|
+
인 무신호 미접속이었다) 언마운트 정리가 없으므로 **호출자가 `close()` 를 책임**진다.
|
|
344
|
+
기본 배치는 컴포저블 → 페이지/컴포넌트 setup 에서 호출.
|
|
325
345
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
326
346
|
(`agents/testing.md`).
|
|
327
347
|
|
|
@@ -340,6 +360,10 @@ export default channel({
|
|
|
340
360
|
| 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
|
|
341
361
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
342
362
|
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
363
|
+
| 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
|
|
364
|
+
| 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
|
|
365
|
+
| 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
|
|
366
|
+
| 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 인증 없음 = 방화벽으로 내부망 한정 |
|
|
343
367
|
|
|
344
368
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
345
369
|
|
|
@@ -119,7 +119,10 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
119
119
|
- **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
120
120
|
**403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
|
|
121
121
|
(`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
|
|
122
|
-
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단
|
|
122
|
+
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단 —
|
|
123
|
+
서버발 4500 도 close code 로 동일 판정 · 결정 318). 서버의 WS **에러 통지 프레임도 봉인**해 송신한다
|
|
124
|
+
(결정 318 — 평문 통지는 에러 문자열 wire 노출 + 클라 오도 "개봉 실패" 였다). 핸들러 실패(1011)는
|
|
125
|
+
transient 라 재연결한다 — 4500(봉인 계약 위반)만 종단.
|
|
123
126
|
|
|
124
127
|
### 3. CSP (결정 124)
|
|
125
128
|
|
|
@@ -204,6 +207,10 @@ export default controller({
|
|
|
204
207
|
4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
|
|
205
208
|
5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
|
|
206
209
|
6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
|
|
210
|
+
7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
|
|
211
|
+
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
|
+
10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
|
|
207
214
|
|
|
208
215
|
## 관련 결정 번호
|
|
209
216
|
|
|
@@ -214,3 +221,4 @@ export default controller({
|
|
|
214
221
|
- **결정 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 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
|
|
215
222
|
- **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
|
|
216
223
|
- **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
|
|
224
|
+
- **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
realtime 허용). 끄거나 조정은 `gaon.config.ts` 의 `web.security.securityHeaders` 로
|
|
33
33
|
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
34
34
|
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
35
|
+
- **rate limit 은 앱 라우트뿐 아니라 정적 에셋(`/assets/*`)·static 폴백까지 전
|
|
36
|
+
라우트에 적용된다**(기본 100 req/분/IP · 전역 등록). 에셋이 많은 페이지를 여러
|
|
37
|
+
사용자가 한 IP(NAT·사내망) 뒤에서 열면 기본치에 닿을 수 있다 — 그 경우
|
|
38
|
+
`web.security.rateLimit: { max: ... }` 로 상향한다(끄지 말고 조정).
|
|
39
|
+
- **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
|
|
40
|
+
기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
|
|
41
|
+
될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
|
|
42
|
+
contentSecurityPolicy` 로 자기 호스트(`wss://app.example.com`)만 명시한다.
|
|
35
43
|
- **스토리지 오리진 CSP 자동 배선 (결정 131)** — `gaon.config.ts` 의 storage(S3/R2/MinIO)
|
|
36
44
|
설정이 있으면 코어가 그 오리진을 CSP 의 **img-src**(스토리지 이미지 `<img>` 표시)·
|
|
37
45
|
**connect-src**(브라우저 직접 presigned 업로드/다운로드)에 자동으로 더한다(seal→
|
|
@@ -88,6 +96,13 @@
|
|
|
88
96
|
단일 출처)를 읽어 `X-CSRF-Token` 에 실어 준다(`packages/vue/src/api.ts`). data-page
|
|
89
97
|
가 없거나(비-Inertia) 봉인(seal)이면 레거시 `<meta name="csrf-token">` 로 폴백한다.
|
|
90
98
|
JWT/API 앱은 토큰 인증이라 CSRF 대상이 아니다.
|
|
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 우선 읽기)은 후속 결정.
|
|
91
106
|
- **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
|
|
92
107
|
세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
|
|
93
108
|
`app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
|
|
@@ -249,3 +264,6 @@ const rows = await Post.query()
|
|
|
249
264
|
| 결정 165 | 세션/CSRF 실패·415 를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 무 · 비-Inertia 는 JSON 유지(§2.3) |
|
|
250
265
|
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§2.3 · `this.auth`) |
|
|
251
266
|
| 결정 271 | CSRF 를 끄는 유일 스위치 = `session: { csrf: false }` — 생략은 켬(보안 기본값) · wire 가 `SessionOptions.csrf` 로 전달(§2.3) |
|
|
267
|
+
| 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 으로 조정(wire 전달) · `sameSite:'none'`+secure 미충족 = 부팅 에러(브라우저 조용한 쿠키 거부 방지) |
|
|
268
|
+
| 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw — 조용한 no-op(부팅 green·로그인 영구 실패) 금지 |
|
|
269
|
+
| 결정 297 | Inertia HTML/JSON 응답에 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — 공유 캐시가 사용자별 페이지(csrf·currentUser)를 저장·교차 서빙하지 못하게 |
|
|
@@ -23,8 +23,11 @@
|
|
|
23
23
|
| 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
|
|
24
24
|
|
|
25
25
|
- **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
|
|
26
|
-
- **로컬 디스크**: 항상 `${
|
|
27
|
-
`
|
|
26
|
+
- **로컬 디스크**: 항상 `${publicUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시 ·
|
|
27
|
+
`publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **주의: 프레임웍이 이 경로를 자동 서빙하지
|
|
28
|
+
않는다** — 브라우저에 보여 주려면 앱이 그 경로를 직접 노출해야 한다(예: 컨트롤러 라우트에서
|
|
29
|
+
`Storage.get(key)` 로 읽어 응답). 자동 서빙 없이 `url()` 만 렌더하면 조용한 404 다. 개발·표시가
|
|
30
|
+
목적이면 **s3 디스크(dev = compose MinIO · zero-config)가 정본 경로**다.
|
|
28
31
|
- **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
|
|
29
32
|
없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
|
|
30
33
|
존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
|
|
@@ -57,9 +60,12 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
57
60
|
```
|
|
58
61
|
|
|
59
62
|
- dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
|
|
60
|
-
(결정 132 · zero-config). `
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
(결정 132 · zero-config). `.env` 는 `gaon new` 가 자동 생성하므로(결정 198)
|
|
64
|
+
`gaon dev` 만으로 우회 0.
|
|
65
|
+
- 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` — `url(key)`
|
|
66
|
+
= `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 서빙은 앱 몫 — §2 주의).
|
|
67
|
+
config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
|
|
68
|
+
쓰면 컴파일 에러).
|
|
63
69
|
- 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
|
|
64
70
|
만 env 로 바꾼다.
|
|
65
71
|
|
|
@@ -90,9 +96,10 @@ export default controller({
|
|
|
90
96
|
### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
|
|
91
97
|
|
|
92
98
|
`storage` 에 s3 디스크가 있으면 프레임웍이 그 오리진을 **CSP 에 자동 배선**한다 —
|
|
93
|
-
`endpoint` 는 `connect-src`(
|
|
94
|
-
`
|
|
95
|
-
|
|
99
|
+
`endpoint` 는 `connect-src`(브라우저의 presigned **GET** 조회 — presign 표면은 GET 전용이고
|
|
100
|
+
브라우저 직접 PUT 업로드 API 는 없다 · 업로드는 멀티파트 `this.file` 경로가 정본)와 `img-src` 에,
|
|
101
|
+
`publicUrl` 은 `img-src` 에 붙는다. 이미지 표시가 CSP 로 막히지 않으므로
|
|
102
|
+
**CSP 를 손으로 넓히지 말 것**. 오리진은 scheme+host+port 만 잡는다.
|
|
96
103
|
|
|
97
104
|
### 5. 스토리지를 쓰는 잡·테스트
|
|
98
105
|
|
|
@@ -103,6 +103,11 @@ export default controller({
|
|
|
103
103
|
- `?tag=a&tag=b` 처럼 같은 출처에서 키가 중복되면: 스키마의 해당
|
|
104
104
|
필드가 배열 타입이면 배열로 수집, 아니면 **마지막 값**을 쓴다
|
|
105
105
|
(E-3 §5.2 원문).
|
|
106
|
+
- **애드혹 폼(`{ _row }`)은 last-wins 가 적용되지 않는다(결정 294)** —
|
|
107
|
+
스키마(defs)가 없어 배열/스칼라 의도를 구분할 수 없으므로 중복 쿼리 키는
|
|
108
|
+
**배열 그대로** 통과한다(`?q=a&q=b` → `['a','b']`). 애드혹 폼 타입을
|
|
109
|
+
`string` 으로만 선언하면 배열이 들어와 런타임이 어긋난다 — 배열 가능성이
|
|
110
|
+
있는 키는 `string | string[]` 로 선언하거나 스키마 파생 폼(①)으로 간다.
|
|
106
111
|
|
|
107
112
|
**출처 명시 탈출구 — `this.body()` / `this.query()`:**
|
|
108
113
|
|
|
@@ -365,8 +370,10 @@ shared.locale // 로그인/로그아웃·플래시로
|
|
|
365
370
|
// ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
|
|
366
371
|
// ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
|
|
367
372
|
// ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
|
|
368
|
-
//
|
|
369
|
-
//
|
|
373
|
+
// (결정 294: this.query('page') 같은 단일 키 접근 표면은 없다 — 애드혹 폼으로 받는다)
|
|
374
|
+
// const { page } = this.query({ _row: {} as { page?: string } })
|
|
375
|
+
// const result = await Post.searchPublished(term).latest().paginate(Number(page ?? 1), 20)
|
|
376
|
+
// return this.render('Posts/Index', { page: result }) // 통째로 안전(rows Serialized · 나머지 number)
|
|
370
377
|
```
|
|
371
378
|
|
|
372
379
|
### 4.4 클라이언트 IP · 헤더는 `this.request` (FastifyRequest 탈출구 · 결정 120)
|
|
@@ -422,6 +429,12 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
422
429
|
서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
|
|
423
430
|
프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
|
|
424
431
|
결정 142. 분리는 위 세 축으로 완성된다.)
|
|
432
|
+
- **쿠키 secure·sameSite 는 app.config 의 session 에서 조정한다(결정 295).**
|
|
433
|
+
`secure` 생략 시 운영(NODE_ENV=production)이면 켬 — 운영인데 비-TLS(사내
|
|
434
|
+
내부망 http)로 서빙하면 Secure 쿠키가 안 실려 로그인이 조용히 실패하므로 그
|
|
435
|
+
경우에만 `secure: false` 를 명시한다. `sameSite` 는 기본 `'lax'` —
|
|
436
|
+
`'none'` 은 스펙상 Secure 필수라 `secure: true` 없이 쓰면 부팅 에러다
|
|
437
|
+
(브라우저의 조용한 쿠키 거부를 fail-loud 로 전환).
|
|
425
438
|
- 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
|
|
426
439
|
- **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
|
|
427
440
|
예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
|
|
@@ -447,6 +460,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
447
460
|
| `await this.auth.login(user)` | 세션 ID 를 **재생성**하고 사용자 id 를 심어 로그인 상태로 만든다(fixation 방어 · 결정 254) |
|
|
448
461
|
| `await this.auth.logout()` | 세션을 **파기**한다(잔존 세션 재사용 차단 · 결정 254) |
|
|
449
462
|
|
|
463
|
+
세션이 없는 앱(세션 미구성·JWT/API)에서 `login`/`logout` 을 부르면 **즉시
|
|
464
|
+
throw(500 + 수리 안내)** 한다(결정 296) — 이전엔 조용한 no-op 이라 부팅
|
|
465
|
+
green·로그인만 영구 실패였다. 세션 앱이면 app.config 에 session 을 배선하고,
|
|
466
|
+
JWT 앱이면 `this.jwt.issue`/`this.jwt.refresh` 를 쓴다.
|
|
467
|
+
|
|
450
468
|
`login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
|
|
451
469
|
디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
|
|
452
470
|
|
|
@@ -542,6 +560,18 @@ export default controller({
|
|
|
542
560
|
- **render/JSON/redirect 를 한 액션에서 조건 혼용하면 doctor
|
|
543
561
|
response-mixing 위반** — 액션을 나눈다. 예외는 폼 액션의
|
|
544
562
|
"실패 render + 성공 redirect" 조합 하나뿐(결정 57 보완).
|
|
563
|
+
- **JSON 액션 반환 객체의 예약 키(결정 294)** — 디스패처는 반환값을 모양으로
|
|
564
|
+
분기하므로 `redirect` 키를 가진 객체는 리다이렉트로, `json` 키는 this.json
|
|
565
|
+
결과로, `page`+`props` 조합은 렌더로 **오인**된다. JSON 응답 데이터의 최상위
|
|
566
|
+
키로 `redirect`·`json` 을 쓰거나 `page`·`props` 를 동시에 쓰지 말 것 —
|
|
567
|
+
필요하면 한 겹 감싼다(`return { data: { redirect: url } }`).
|
|
568
|
+
- **액션이 아무것도 반환하지 않으면 204 No Content 다** — `this.render(...)`
|
|
569
|
+
를 호출만 하고 `return` 을 빼먹으면 컴파일은 통과하고 페이지가 조용히
|
|
570
|
+
빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
|
|
571
|
+
- **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
|
|
572
|
+
처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
|
|
573
|
+
이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
|
|
574
|
+
라우트 표에 있는데 **구현 안 된 액션**은 종전대로 조용히 스킵된다(정상 경로).
|
|
545
575
|
- **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `gaonjs/vue` 의
|
|
546
576
|
`useForm(...).post(...)`(결정 64). REST + fetch 는 API 앱(JWT) 전용.
|
|
547
577
|
- **컨트롤러에 비즈니스 로직 인라인 금지** (§5.3 One Way) — 여러 모델·
|
|
@@ -584,6 +614,12 @@ export default controller({
|
|
|
584
614
|
| 결정 183 | 검증 사유 로케일화 — 안정 코드 + 예약 namespace `validation.<code>` 로 요청 로케일 번역(미제공 시 내장 fallback · §4.1) |
|
|
585
615
|
| 결정 253 | hidden 마커 = 열거 가능한 심볼 → `render(page, { ...user })` spread 우회로도 hidden 값이 안 샌다(§4.2) |
|
|
586
616
|
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§4.4 auth) |
|
|
617
|
+
| 결정 293 | 라우트 타깃 형식 불량(`#` 누락·빈 컨트롤러/액션) = 부팅 throw — 조용한 라우트 증발 금지(미구현 액션 스킵은 정상 경로 유지) |
|
|
618
|
+
| 결정 294 | 단일 문자열 키 접근(`this.params('id')`) 표면 없음 — 애드혹 폼으로 받는다 · 애드혹 폼 중복 키는 배열 통과(last-wins 는 스키마 폼만) · JSON 액션 예약 키(redirect/json/page+props) 문서화(§3·§4.3·함정) |
|
|
619
|
+
| 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 에서 조정 · `sameSite:'none'`+secure 미충족 = 부팅 에러(§6) |
|
|
620
|
+
| 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw(조용한 no-op 금지 · §6) |
|
|
621
|
+
| 결정 297 | 초기 HTML 문서에도 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — CDN/공유 캐시가 사용자별 data-page(csrf·currentUser)를 캐시하지 못하게 |
|
|
622
|
+
| 결정 298 | render props 순환 참조 = 스택 오버플로 대신 수리 안내 에러(같은 객체의 형제 중복(DAG)은 정상) |
|
|
587
623
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
588
624
|
|
|
589
625
|
## `@gaonjs/seal` 켠 앱
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
// 앱 전용 컴포저블은 api()/pageProps() 를 자유롭게 쓸 수 있다.
|
|
4
4
|
// shared/composables 는 반대 — 인자로만 받는 순수 로직(E-5 §2.2).
|
|
5
5
|
import { ref } from 'vue'
|
|
6
|
+
import { api } from 'gaonjs/vue'
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
+
/** JSON 액션 home#health 를 api() 로 두드려 서버가 살아 있는지 확인하는 예시 컴포저블. */
|
|
8
9
|
export function useApiPing() {
|
|
9
10
|
const ok = ref<boolean | null>(null)
|
|
10
11
|
const error = ref<string | null>(null)
|
|
11
12
|
|
|
12
13
|
async function ping(): Promise<void> {
|
|
13
14
|
try {
|
|
14
|
-
|
|
15
|
-
const body =
|
|
15
|
+
// 타입드 api() 클라이언트(errata E-3) — raw fetch() 는 API 앱(JWT) 전용이다.
|
|
16
|
+
const body = await api('web:home#health')
|
|
16
17
|
ok.value = body.ok === true
|
|
17
18
|
error.value = null
|
|
18
19
|
} catch (err) {
|
|
@@ -12,7 +12,7 @@ export default controller({
|
|
|
12
12
|
},
|
|
13
13
|
|
|
14
14
|
// GET /health — JSON 액션(errata E-3). 반환값이 곧 응답.
|
|
15
|
-
// 배포 후 헬스체크·60초 실측(v0.
|
|
15
|
+
// 배포 후 헬스체크·60초 실측(v0.17 §13.5 M9 완료 기준)에 쓰인다.
|
|
16
16
|
async health() {
|
|
17
17
|
return { ok: true, service: '{{PROJECT_NAME}}' }
|
|
18
18
|
},
|