@gaonjs/cli 0.52.0 → 0.55.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.
@@ -188,7 +188,11 @@ export const logs = table('logs', {
188
188
  (예: 컬럼 `.index({where})` + 테이블 레벨 `[['col']]`)를 선언하면 이름이 충돌해 **정의 시점에
189
189
  throw** 한다(결정 329). 테이블 레벨 객체의 `name` 으로 구분한다.
190
190
  - 프레임웍이 만든 인덱스는 **`idx_` 접두**만 관리한다 — raw(수동 튜닝) 인덱스는 diff 가 건드리지
191
- 않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다.
191
+ 않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다. 같은 이유로
192
+ **테이블 레벨 객체의 명시 `name` 은 `idx_` 접두 필수**다 — 접두 밖 이름은 introspection 에
193
+ 안 잡혀 매 diff 마다 재생성 대상이 되므로 **정의 시점에 throw** 한다(결정 373). 인덱스
194
+ 이름은 **커넥션(스키마) 전역 유일**이라 서로 다른 테이블의 같은 명시 name 도 diff 진입에서
195
+ throw 한다(결정 382).
192
196
  - **MySQL/MariaDB(legacy §4.5) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
193
197
  `gaon db migrate` 가 **명확히 실패**한다(조용히 btree 로 떨구지 않음). 이런 인덱스는 main(postgres)에.
194
198
 
@@ -222,12 +226,13 @@ export const logs = table('logs', {
222
226
 
223
227
  **`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
224
228
  갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
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()` 병행).
229
+ **벌크·upsert 도 자동 갱신한다**(결정 323 · 286 유보 해소) — `updateAll()` 과 `upsert()` 의
230
+ 충돌-갱신 경로가 `updatedAt` 을 현재 시각으로 함께 갱신한다. "update 하면 updatedAt 이
231
+ 바뀐다" 가 단건/벌크 구분 없이 하나다(The One Way). patch/update 에 `updatedAt` 을 명시하면
232
+ 그 값을 존중한다. **원자 프리미티브는 단건/벌크 모두 대상 밖**(결정 375 — 323 이 벌크 증감만
233
+ 포함하던 비대칭 해소): `rec.increment`/`decrement`/`toggle` 물론 `incrementAll`/
234
+ `decrementAll` 도 카운터·플래그 노이즈가 updatedAt 의 "콘텐츠 갱신" 신호를 덮지 않게
235
+ 갱신하지 않는다(필요하면 `touch()` 병행).
231
236
 
232
237
  ### 4. 체이닝 전체 (`packages/data/src/model.ts`)
233
238
 
@@ -567,13 +572,19 @@ export default defineConfig({
567
572
  mongoose `autoIndex` 기본값이 첫 사용 시 인덱스를 만든다. **운영(NODE_ENV=production)
568
573
  은 autoIndex 기본 false**(결정 333 · §8-3) — 부팅·첫 사용 시점의 암묵 인덱스 빌드가
569
574
  없다. 운영 인덱스는 배포 절차에서 `syncMongoIndexes()`(gaonjs/data 재수출)로 명시
570
- 동기화한다(커넥션 키 지정 가능 · 스키마에 없는 인덱스는 드랍). 커넥션 설정
575
+ 동기화한다(커넥션 키 지정 가능 · 스키마에 없는 인덱스는 드랍). `collection()`
576
+ 정의는 자동 등록되므로 **아직 한 번도 안 쓴 컬렉션도 sync 가 강제 컴파일해
577
+ 포함**한다(결정 365 — 이전엔 이미 쓴 모델만 순회해 부팅 직후 sync 가 무신호
578
+ no-op 였다 · 컴파일된 컬렉션이 0개면 경고). 커넥션 설정
571
579
  `autoIndex: true/false` 명시가 항상 우선. 몽고는 마이그레이션이 없다 —
572
580
  `gaon db diff/migrate`(키 생략 = 전 커넥션 순회)는 mongodb 커넥션을 **건너뛰고
573
581
  skipped 로 보고**하며(결정 292), `--db <mongo키>` 명시 지정만 fail-loud 다.
574
582
  - **문서형 커넥션은 DB 명이 필수** — `url` 에 경로(`mongodb://…/myapp`)를 넣거나
575
583
  `database` 를 지정한다. 둘 다 없으면 부팅이 수리 안내로 throw 한다(결정 335 —
576
- 드라이버 기본 DB `test` 로 조용히 붙던 무신호 오배선 봉합).
584
+ 드라이버 기본 DB `test` 로 조용히 붙던 무신호 오배선 봉합). **경로 없는 url**
585
+ (`mongodb://host:27017`·`mongodb+srv://cluster`·`…/?authSource=admin`)도 단독으로는
586
+ 같은 이유로 throw 하며, `database` 를 병기하면 그 값이 `dbName` 으로 우선한다
587
+ (결정 364 — url 경로와 `database` 병기 시에도 `database` 가 이긴다).
577
588
 
578
589
  **알려진 함정**:
579
590
  - `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
@@ -584,7 +595,10 @@ export default defineConfig({
584
595
  통과하고, **그 밖의 모든 static 호출은 fail-closed** 로 막힌다. Query 체이닝 쓰기
585
596
  (`find(f).updateMany()`·`where(f).deleteMany()`)와 `aggregate` 의 `$out`/`$merge` 도
586
597
  실행 지점 pre 훅이 막는다. **읽기 전용이어도 커스텀 static 은 tx 안에서 막힌다**
587
- (쓰기 여부 판정 불가 — 내장 읽기를 직접 쓰거나 호출을 tx 밖으로). 유일한 예외는
598
+ (쓰기 여부 판정 불가 — 내장 읽기를 직접 쓰거나 호출을 tx 밖으로). **네이티브
599
+ 탈출구 프로퍼티(`Model.collection`·`Model.db`·`Model.base`)도 tx 안 접근 자체가
600
+ 막힌다**(결정 372 — 함수 가드를 우회해 `collection.insertOne` 무가드 쓰기가
601
+ 가능하던 구멍 봉합). 유일한 예외는
588
602
  인스턴스 `doc.save()`(정본 예외 · 미가드). tx 안에서 쓸 일이면 `afterCommit`.
589
603
  - **같은 커넥션·같은 컬렉션명에 다른 스키마를 선언하면 첫 접근에서 throw**(결정 334)
590
604
  — 조용히 첫 스키마를 재사용하지 않는다. 같은 컬렉션이면 mongoSchema 산출을 한
@@ -600,9 +614,15 @@ export default defineConfig({
600
614
  깨져 있다(바닐라 동일 실측 · 지원 안 함).
601
615
  - **중첩 hidden 은 v1.22(adapter-mongo 0.2.0) 이하에서 fail-loud throw 였다**(결정
602
616
  290) — 0.3.0(결정 336)부터 dot-path 로 실지원된다. 지원 밖 위치(옵션 객체의 형제 키
603
- 아래 등)의 hidden 은 여전히 fail-loud(조용한 유출 금지). 서브스키마 인스턴스
604
- (`mongoSchema` 산출을 `type:` 에 넣은 경우)의 내부 hidden 부모가 수집하지 않는다
605
- hidden 필드는 mongoSchema defs 트리 안에서 선언하라.
617
+ 아래 등)의 hidden 은 여전히 fail-loud(조용한 유출 금지). **서브스키마 인스턴스
618
+ (`mongoSchema` 산출을 필드/`type:`/배열에 임베드)의 내부 hidden 부모가 경로
619
+ 접두로 승계한다**(결정 387 이전엔 미수집이라 `.lean()` 경로 무신호 유출이었다).
620
+ 순정 `new mongoose.Schema` 임베드는 마커가 없어 승계 대상이 아니다 — hidden 이
621
+ 필요한 서브스키마는 mongoSchema 로 만들라.
622
+ - **`schema.set('toObject', {...})` 는 hidden 마커 transform 을 통째로 대체한다**
623
+ (mongoose set 의 wholesale 교체) — 응답 경계가 문서 인스턴스 마커를 상속 스레딩해
624
+ hidden 유출은 막지만(결정 371 방어), virtuals 등 옵션이 필요하면 mongoSchema 의
625
+ `toObject` **옵션 인자로** 주는 쪽이 안전하다(transform 이 체이닝된다 · 결정 289).
606
626
 
607
627
  ### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
608
628
 
@@ -778,7 +798,9 @@ const top = await Post.published().latest().limit(5).withCache(30).all()
778
798
  - **적용 지점은 `Chain` 의 `all()`/`first()` 뿐이다** — `.withCache()` 뒤에
779
799
  `include()`/`select()`/`distinct(col)`/`groupBy()`/`withCount()`/`join()`/`paginate()`
780
800
  가 오면 캐시가 적용될 수 없어 **즉시 throw** 한다(수리 안내 포함 · 결정 330 — 이전엔
781
- 조용히 미적용). 캐시가 필요하면 include/select 없는 형태로 `all()`/`first()` 마감하거나,
801
+ 조용히 미적용). **종단 집계(`count()`/`exists()`/`sum()`/`avg()`/`min()`/`max()`/
802
+ `pluck()`)와 벌크 쓰기(`updateAll()` 류)도 같은 이유로 throw** 한다(결정 370 · 330
803
+ 완결). 캐시가 필요하면 include/select 없는 형태로 `all()`/`first()` 마감하거나,
782
804
  결과 조립 전체를 `cache.remember` 로 감싼다.
783
805
  - **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
784
806
  (`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
@@ -908,7 +930,10 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
908
930
  migrate 가 자동 적용**한다(예: `email` 에 `.unique()` 추가 → `ADD CONSTRAINT
909
931
  … UNIQUE` 생성). **제거(제약·인덱스·기본값을 스키마에서 뗌)는 자동 적용하지
910
932
  않고** dropTable 처럼 크게 알린다 — 제거는 `gaon db diff` 의 down SQL 을 보고
911
- 손작성 마이그로 적용한다(수동/외부 제약 보호 · 결정 39 정합). **한계**: 임의
933
+ 손작성 마이그로 적용한다(수동/외부 제약 보호 · 결정 39 정합). **재생성
934
+ 쌍**(인덱스 method 변경·enum 값 변경이 내는 같은 이름의 drop→add)은 제거가
935
+ 아니라 교체이므로 migrate 가 **한 단위로 자동 적용**한다(결정 366 — 이전엔
936
+ drop 반쪽만 걸러져 add 가 "already exists" 로 매번 실패했다). **한계**: 임의
912
937
  `.check(expr)` 의 **표현식만** 바꾸는 변경(같은 컬럼·같은 제약, 식만 수정)은
913
938
  Postgres 가 표현식을 정규화해 신뢰 비교가 불가능하므로 **감지하지 못한다** —
914
939
  이 경우 컬럼을 갈거나 손작성 마이그로 CHECK 를 drop→add 한다. (enum 값 변경과
@@ -919,7 +944,8 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
919
944
  미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
920
945
  COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
921
946
  값 변경은 정상 감지된다.) **FK(belongsTo 의 REFERENCES)는 diff 축이 아니다** — FK 는
922
- createTable/addColumn 시점에만 생성되므로(mysql 도 addColumn 이 FK 를 동반 · 결정 327),
947
+ createTable/addColumn 시점에만 생성되므로(mysql 도 addColumn 이 FK 를 결정적 이름
948
+ `fk_<table>_<col>` 로 동반하고, `migrate down` 롤백이 그 FK 를 선-drop 한다 · 결정 327·369),
923
949
  **기존 컬럼**을 `t.belongsTo()` 로 바꾸거나
924
950
  belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
925
951
  기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
@@ -938,7 +964,9 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
938
964
 
939
965
  - **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
940
966
  에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
941
- 바인딩하므로(§4.5·§7) 시드는 커넥션을 몰라도 된다.
967
+ 바인딩하므로(§4.5·§7) 시드는 커넥션을 몰라도 된다. `gaon db seed` 는 선언된
968
+ **전 SQL 커넥션을 등록하고 시드를 1회 실행**한다(결정 367 — 여러 커넥션의
969
+ 모델을 한 시드에서 섞어 써도 된다 · 문서형(mongodb) 커넥션은 열지 않는다).
942
970
  - **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
943
971
  한다(예: `upsert`/존재 확인 후 생성).
944
972
  - `gaon db seed` 는 모델 레이어를 거치므로 **프로젝트 로컬로 실행**하는 것이
@@ -1084,13 +1112,18 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
1084
1112
  안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
1085
1113
  다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
1086
1114
  그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
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 없는 형태로 마감하거나
1115
+ - **원자 프리미티브(단건·벌크)는 `updatedAt` 을 갱신하지 않는다** (결정 276·323·375) —
1116
+ `update`/`updateAll`/`upsert` 충돌-갱신은 자동 갱신하지만, `rec.increment`/`decrement`/
1117
+ `toggle` 과 `incrementAll`/`decrementAll` 은 카운터 축이라 대상 밖이다. 갱신 시각까지
1118
+ 남기려면 `touch()` 를 병행하거나 patch 로 갱신한다.
1119
+ - **hidden 컬럼을 `pluck()`/`groupBy()` 뽑은 값은 render props 에 싣지 말 것** (결정 379) —
1120
+ 스칼라 배열·그룹 행은 런타임 마커(HIDDEN_COLUMNS) 실을 없어 serializeProps 가 못
1121
+ 떨군다(결정 283 단위). 타입 축은 `Serialized` 가 그 값을 **never** 로 접어 신호한다 —
1122
+ 서버측 내부 검증·비교 용도로만 쓴다.
1123
+ - **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148·330·370 · §8.2) —
1124
+ 뒤에 `include()`/`select()`/`distinct(col)`/`paginate()` 류 전이는 물론 **종단 집계
1125
+ (`count()`/`pluck()` 등)·벌크 쓰기(`updateAll()` 류)도 즉시 throw** 한다(조용한
1126
+ 미적용 대신 수리 안내). include/select 없는 형태로 마감하거나
1094
1127
  `cache.remember` 로 감싼다.
1095
1128
  - **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
1096
1129
  는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
@@ -1130,12 +1163,36 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
1130
1163
  | 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동 갱신 유보 → **결정 323 으로 해소** |
1131
1164
  | 결정 321 | 기존 테이블 `partitionBy` 변경은 diff/migrate 즉시 throw(조용한 no-op 제거 · 손작성 이관 안내 · §3) |
1132
1165
  | 결정 322 | PK 컬럼 키 `id` 강제 — `model()` 정의 시점 수리 안내 throw(§1) |
1133
- | 결정 323 | `updateAll`·`incrementAll`/`decrementAll`·`upsert` 충돌-갱신도 `updatedAt` 자동 갱신(단건 원자 프리미티브 제외 · §3) |
1166
+ | 결정 323 | `updateAll`·`upsert` 충돌-갱신도 `updatedAt` 자동 갱신 벌크 증감 포함은 **결정 375 로 제외 재결정**(§3) |
1134
1167
  | 결정 325 | `batchInsert` 파라미터 상한(65,535) 초과 자동 분할 — 전체 한 트랜잭션(원자성 유지 · §4) |
1135
1168
  | 결정 327 | mysql `addColumn` 이 belongsTo FK 를 같은 ALTER 문으로 동반(§10) |
1136
1169
  | 결정 328 | `distinct(col).paginate` 의 total = 선택 컬럼 distinct 축(§4) |
1137
1170
  | 결정 329 | 인덱스 이름 충돌(같은 컬럼 method/partial 중복) 정의 시점 throw — `name` 으로 구분(§3) |
1138
1171
  | 결정 330 | `.withCache()` 무효 전이(include/select/paginate 류) 즉시 throw(§8.2·함정) |
1172
+ | 결정 364 | 문서형 url 에 DB 경로 없으면 throw · url+`database` 병기 시 `database`(dbName) 우선(335 완결 · §7.1) |
1173
+ | 결정 365 | `collection()` 정의 자동 등록 — `syncMongoIndexes()` 가 미사용 컬렉션도 강제 컴파일 후 동기화(§7.1) |
1174
+ | 결정 366 | 재생성 쌍(인덱스 method·enum 값 변경의 같은 이름 drop→add)은 defer 예외 — migrate 가 한 단위 자동 적용(§10) |
1175
+ | 결정 367 | `gaon db seed` = 전 SQL 커넥션 등록 + 1회 실행 — 멀티커넥션 시드의 '미등록' 실패 봉합(§10.1) |
1176
+ | 결정 368 | 캐시 코덱 bytea(Uint8Array·Buffer) 태그 왕복 — `.withCache` 히트의 조용한 값 손상 봉합(§8.2) |
1177
+ | 결정 369 | mysql `addColumn` FK 결정적 이름(`fk_<table>_<col>`) + `migrate down` 이 FK 선-drop(327 의 down 대칭 · §10) |
1178
+ | 결정 370 | `.withCache()` 뒤 종단 집계(count/pluck 류)·벌크 쓰기도 throw(330 완결 · §8.2·함정) |
1179
+ | 결정 371 | 응답 경계가 문서 인스턴스 마커를 상속 스레딩 — 사용자 `schema.set('toObject')` transform 대체에도 hidden 유출 없음(§7.1 함정) |
1180
+ | 결정 372 | 네이티브 탈출구 프로퍼티(`Model.collection`/`db`/`base`)도 tx 안 접근 fail-closed(331 완결 · §7.1) |
1181
+ | 결정 373 | 테이블 레벨 명시 인덱스 `name` 은 `idx_` 접두 필수 — 정의 시점 throw(329 완결 · §3) |
1182
+ | 결정 374 | `gaon db migrate` 가 applyStatements 경유 — 비호환 타입 변경 실패에 USING 수리 안내(F5 정본 경로 배선 · §10) |
1183
+ | 결정 375 | 원자 증감은 단건/벌크 모두 `updatedAt` 대상 밖(323 비대칭 해소 — 카운터 축 · §3) |
1184
+ | 결정 376 | 몽고 allowlist 읽기 4종(applyVirtuals·applyTimestamps·createSearchIndexes·clientEncryption) 편입 · 죽은 count 제거(§7.1) |
1185
+ | 결정 377 | mysql MODIFY 가 대상 기본값을 재선언 — 기존 DEFAULT 조용한 소거 봉합(§10) |
1186
+ | 결정 378 | eager hasOne 빈 값 = undefined 통일(타입 계약 정합 · §6) |
1187
+ | 결정 379 | 객체 키 밖 hidden 브랜드 값(pluck/groupBy hiddenCol)은 `Serialized` 가 never 로 신호(§4 함정) |
1188
+ | 결정 380 | journal applied_at 프로세스 내 단조 증가 + mysql 원장 timestamp(6)·mediumtext 승격 — 롤백 대상 오선정·64KB 절단 봉합(§10) |
1189
+ | 결정 381 | 파티션 헬퍼 스키마 한정(to_regclass·자식 스키마 반환·한정 drop) + 월 bound UTC 명시(§3) |
1190
+ | 결정 382 | 인덱스 이름 교차 테이블 전역 유일 검사 — diff 진입 fail-loud(329·373 완결 · §3) |
1191
+ | 결정 383 | DDL 식별자 인용 `"` 이스케이프 위생(§10) |
1192
+ | 결정 384 | partitionBy 불일치·mongo 키 안내문 방언/실동작 정정(321·333 문구 · §3·§7.1) |
1193
+ | 결정 385 | 단일 값 enum CHECK(`= 'a'::text` 정규화형)도 값 diff — 1개→N개 확장 감지(§10) |
1194
+ | 결정 386 | collection() Proxy 함수 identity 캐시 — `Coll.on === Coll.on`(off 해제 패턴 성립 · §7.1) |
1195
+ | 결정 387 | 서브스키마 임베드 내부 hidden 을 부모가 경로 접두로 승계 — `.lean()` 무신호 유출 봉합(§7.1) |
1139
1196
  | 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
1140
1197
  | 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
1141
1198
  | 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
@@ -143,7 +143,10 @@ async function search(q: string) {
143
143
  출처 · `packages/vue/src/csrf.ts`) — 로그인(세션 재생성 · 결정 254) 후에도 항상
144
144
  최신이다. 최초 문서 data-page 와 `<meta name="csrf-token">` 은 부팅 전 폴백.
145
145
  `useForm`/`router` 제출도 같은 자동 부착을 받는다(결정 342 — 페이지가 `_csrf`
146
- 바디·수동 헤더를 싣지 않는다 · `agents/security.md`).
146
+ 바디·수동 헤더를 싣지 않는다 · `agents/security.md`). **명시 헤더는 존중된다
147
+ (탈출구 · 결정 388)**: `api(key, params, { headers: { 'X-CSRF-Token': ... } })` 나
148
+ `useForm().post(url, { headers: ... })` 로 토큰을 직접 실으면(대소문자 무관)
149
+ 자동 부착이 그 값을 덮지 않는다.
147
150
  - **파라미터 배열(결정 343)** — `api('web:posts#index', { tags: ['a', 'b'] })` 처럼
148
151
  배열 값을 넘길 수 있다. GET 은 중복 키(`tags=a&tags=b` · 서버 `this.params` 의
149
152
  "중복 키 = 배열" 규칙과 대칭), 본문은 문자열화된 JSON 배열로 실린다. `:param`
@@ -534,6 +537,7 @@ async function runSearch(q: string) {
534
537
  | 결정 343 | `ApiParams` 배열 지원 — GET 중복 키·본문 JSON 배열(서버 중복 키=배열 규칙 대칭) · `:param` 은 스칼라만(§2) |
535
538
  | 결정 344 | `useChannel` 함수형 `params` — 접속·재접속 시점마다 평가(JWT access_token 회전 반영 · `agents/realtime.md` §4) |
536
539
  | 결정 345 | `PageMap` 항목 타입 의도 문서화(`PageMapEntry` — 지연 로더·eager 모듈 · vite glob unknown 제약 명기) |
540
+ | 결정 388 | `api()` 명시 `X-CSRF-Token` 헤더 존중(대소문자 무관 · 인터셉터와 대칭 — 덮어쓰기·콤마 병합 403 봉합) · `:param` 누락 에러 예시를 라우트 키 형태로 정정(§2) |
537
541
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
538
542
 
539
543
  ## `@gaonjs/seal` 켠 앱의 프론트
@@ -237,6 +237,18 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
237
237
 
238
238
  ## 알려진 함정
239
239
 
240
+ - **`i18n.dir`·`fallbackLng` 는 문자열 리터럴로 쓴다**(결정 412) — 변수 참조·env 표현식은
241
+ 타입 축(`.gaon/messages.d.ts`)과 doctor 가 정적으로 못 읽어 기본값(`locales` · 정렬 첫
242
+ 로케일)으로 떨어진다. 못 읽으면 `gaon gen`·`dev`·`check` 가 한 줄 경고를 낸다(무소음 아님).
243
+ 치환 없는 템플릿 리터럴(`` `locales` ``)·`satisfies`/`as` 래핑은 읽는다.
244
+ - **절대 경로 `dir` 도 지원된다**(결정 412) — 런타임과 CLI 축이 같은 규약으로 해석한다.
245
+ - **`gaon check` 가 부팅 조건을 먼저 본다**(결정 413) — 카탈로그가 비었거나
246
+ `fallbackLng`/`supportedLngs` 가 지목한 파일이 없으면 check 가 실패한다(종전엔 check 는
247
+ 통과하고 `gaon serve` 만 죽었다 · 결정 353).
248
+ - **`supportedLngs: []`(빈 배열)은 "미지정" 과 같다**(결정 414) — 종전엔 협상이 조용히
249
+ 꺼져 항상 fallback 이 나갔다. 실제로 제한하려면 언어를 채우고, 제한이 없으면 키를 뺀다.
250
+
251
+
240
252
  - **`i18n` 설정이 없으면 `t()` 는 항상 fallback** — 요청별 로케일 협상은
241
253
  `gaon.config.ts` 에 `i18n` 이 있어야 배선된다(결정 159). 설정만 하면 자동.
242
254
  - **키를 손으로 `string` 으로 넓히지 말 것** — `.gaon/messages.d.ts`(결정 158)가
@@ -267,3 +279,6 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
267
279
  | 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) — 로케일 간 키 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
268
280
  | 결정 352 | messages.d.ts 기준 로케일 = `fallbackLng`(정적 분석 전달) · `i18n.dir` 을 타입 축(gen/dev/check)·locale-parity 가 존중(하드코딩 'locales' 제거) |
269
281
  | 결정 353 | 부팅 fail-loud — i18n 설정 + 빈 카탈로그(전 화면 raw 키 방지) · `fallbackLng`/`supportedLngs` 가 지목한 로케일 파일 통째 부재(조용한 fallback 언어 대체 방지) 는 부팅 에러 + 수리 안내 |
282
+ | 결정 412 | i18n config 정적 분석 범위 확대(템플릿 리터럴·satisfies/as·shorthand) + **미해석 경고**(무소음 제거) · 절대 경로 `dir` 을 런타임과 같은 규약으로 해석 |
283
+ | 결정 413 | 결정 353(부팅 fail-loud) 조건을 `gaon check` 가 정적으로 미리 검사(check green → serve 크래시 사각 제거) |
284
+ | 결정 414 | messages 축을 gen/check 보고에 포함 · 생성 키 이스케이프 · stale `messages.d.ts` 정리 · `supportedLngs: []` 를 미지정과 일원화 · 협상 언어 순서 결정론화 |
@@ -116,6 +116,10 @@ export const SendWelcome = job(async (userId: bigint) => {
116
116
  으로 뺀다(`agents/async.md` 판단표 · doctor `async-offload`).
117
117
  - **메일 로케일 ≠ 요청 로케일** — 메일은 요청과 다른 컨텍스트(잡)에서 나갈 수 있다.
118
118
  `deliver(data, { locale: recipient.locale })` 로 **명시**한다(결정 160).
119
+ - **`secure: true`(465)면 `requireTls` 는 무시된다** — implicit TLS 라 STARTTLS 협상이
120
+ 없다(부팅 경고 · 결정 404). STARTTLS 강제가 목적이면 `secure` 를 끄고 587 + `requireTls`.
121
+ - **발송 로그의 수신자는 마스킹된다**(`h***@example.com` · 결정 404) — 원문 주소가 필요하면
122
+ 앱이 자기 감사 로그에 남긴다(프레임웍 로그는 PII 저장소가 아니다).
119
123
  - **from 이 없으면 발송 에러** — 메시지에 `from` 을 주거나 `configureMailer({ defaultFrom })`
120
124
  (스캐폴드 config 의 `MAIL_FROM`)를 설정한다.
121
125
  - **레이아웃 상속은 v1 에 없다** — 공통 레이아웃은 v1.1 백로그(결정 160). v1 은 각
@@ -127,3 +131,5 @@ export const SendWelcome = job(async (userId: bigint) => {
127
131
  |---|---|
128
132
  | §7 (v0.15) | 메일 배터리 · `domain/mails/` · MailPit dev sink |
129
133
  | 결정 160 (13차 W3) | `deliver(data, { locale, to })` 로케일 인지 · 스캐폴드 mail 블록 · 레이아웃 v1.1 백로그 |
134
+ | 결정 357 | SMTP user/pass 반쪽 설정 부팅 fail-loud · `requireTls`(STARTTLS 강제) 표면 |
135
+ | 결정 404 | `secure: true` + `requireTls` 무의미 조합 부팅 경고 · 발송 로그 수신자 마스킹 |
@@ -273,12 +273,18 @@ export function useRoom(roomId: number) {
273
273
  - **허브 TCP 포트는 내부망 전용이다(결정 311).** 포트(기본 4001)는 방화벽/
274
274
  보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반 백스톱으로 라인 길이 상한
275
275
  (1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed · 개행 없는 스트림의
276
- 메모리 증식 차단).
276
+ 메모리 증식 차단). 개행이 **있는** 초과 라인도 동일하게 fail-closed 다
277
+ (결정 400 — 종전엔 그 명령만 조용히 버려져 로스터 불일치로만 관측됐다).
277
278
  - **공유 토큰 인증(선택 · 결정 350).** `GAON_HUB_TOKEN` 을 허브·웹서버 양쪽에
278
279
  설정하면 웹서버 소켓의 첫 명령이 올바른 `auth` 여야 하고, 미인증 명령·오토큰은
279
280
  즉시 종료된다(fail-closed · 도달 가능한 임의 피어의 로스터 위조 방어). 미설정
280
281
  이면 종전(무인증 · 내부망 가정). 토큰 없는 허브는 auth 를 무시하므로 웹서버에
281
282
  먼저 설정해 둬도 무해하다(무중단 롤아웃: 웹서버 → 허브 순).
283
+ - **연결 실패는 웹서버 쪽에서도 보인다(결정 399).** 허브 접속이 반복 실패하면
284
+ (토큰 불일치로 허브가 즉시 끊음 · 허브 미기동 · `GAON_HUB_ADVERTISE` 오설정)
285
+ 프레즌스 클라이언트가 스트릭당 1회 `log.warn` 으로 원인 후보와 함께 신호한다 —
286
+ 종전엔 무한 재접속 루프가 완전 무로그라 허브 프로세스 로그에만 흔적이 남았다.
287
+ 건강한 연결이 서면 리셋돼 재발 시 다시 경고한다.
282
288
 
283
289
  ## 정본 예시
284
290
 
@@ -374,6 +380,9 @@ export default channel({
374
380
  | 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
375
381
  | 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
376
382
  | 결정 350 | 허브 공유 토큰 인증(§5 · 선택) — `GAON_HUB_TOKEN` 설정 시 첫 명령 = `auth` 강제(타이밍 세이프 비교) · 미인증/오토큰 즉시 종료 · 토큰 없는 허브는 auth 무시(혼재 롤아웃 호환) |
383
+ | 결정 395 | 리스 사임 CAS 삭제 — `stop()` 이 자기 revision 에서만 리더 키 삭제(`previousSeq`) · stale 리더 종료가 활성 리더 키를 지우던 재선출 순단 봉합(endpoint 결정 259 동형 · 허브·스케줄러 공통) |
384
+ | 결정 399 | 프레즌스 클라 연결 실패 warn(§5) — 단명 연결·리더 미발견 연속 시 스트릭당 1회 log.warn(토큰 불일치·허브 부재 안내) · 건강한 연결에 리셋 · 종전 무로그 재접속 루프 봉합 |
385
+ | 결정 400 | P3 청소(실시간 축) — 허브 KV 복원이 오염 키에 throw 해 전 인스턴스 crash-loop 하던 것을 try/continue 방어(presenceStats 와 대칭) · 라인 디코더 완결 초과 라인도 onOverflow(fail-closed 통일) |
377
386
 
378
387
  ## `@gaonjs/seal` 켠 앱의 채널
379
388
 
@@ -210,7 +210,7 @@ export default controller({
210
210
  6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
211
211
  7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
212
212
  8. **리버스 프록시의 Host 재작성 금지** — 서버 키 시드는 `Host` 헤더, 브라우저는 `location.hostname` 을 쓴다. 프록시가 Host 를 upstream 이름으로 바꾸면 키가 갈려 data-page 개봉 실패(blank)·전 요청 403 이 된다. 프록시는 원 Host 를 보존해야 한다(`proxy_set_header Host $host` 류 · `x-forwarded-host` 는 참조하지 않는다).
213
- 9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 안 탄 인바운드(직접 URL 등)의 쿼리는 평문일 수 있다 — "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`) — 직접 URL 쿼리를 싣는 경로는 except 로 빼야 한다.
213
+ 9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`). **strictQuery 를 켰을 때 실제로 깨지는 정당 경로는 쿼리 실린 redirect 다**(결정 416) POST→303→GET 브라우저 XHR 투명 추종할 때 Location 의 쿼리는 클라 인터셉터를 못 타 평문으로 도착한다. 그런 대상 경로는 `except`빼거나 redirect 에 쿼리를 싣지 말 것. (주소창 직접 접근은 `Accept: text/html` + X-Inertia 부재라 애초에 seal-target 이 아니다 — 옛 서술 정정.)
214
214
  10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
215
215
  11. **클라 인터셉터는 except 를 모른다** — `gaonjs/vue` 인터셉터는 모든 same-origin 요청의 쿼리를 `?q=` 로 봉인하는데, 서버는 excluded 경로에서 개봉을 건너뛴다. seal 앱 **자신의 브라우저 코드가 except 경로를 쿼리와 함께 호출**하면 핸들러가 `q=<암호문>` 을 받고 실 파라미터는 소실된다(무에러 오동작). except 경로는 외부 호출자 전용으로 두고 앱 자신은 호출하지 말 것(클라 except 전파는 백로그 DEFER).
216
216
 
@@ -224,4 +224,6 @@ export default controller({
224
224
  - **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
225
225
  - **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
226
226
  - **결정 354** — seal 백로그 2건: ① **prefix 앱 except 무력** 수정 — except 글롭·기본 헬스 제외를 **앱 상대 경로**로도 매칭(전체 경로 매칭 병행 · 하위 호환). 배선부(web)가 앱 prefix 를 normalizeSealConfig 로 전달. ② **strictQuery 옵션** 신설 — 봉인 강제 요청의 평문 쿼리를 403 `plaintext_query` 로 거부(기본 off — 직접 URL 인바운드가 흔해 기본 강제는 정당한 요청을 깬다). 클라 인터셉터 except 전파는 DEFER(함정 11).
227
+ - **결정 416** — 함정 9 정정: strictQuery 의 파손 클래스는 "직접 URL" 이 아니라 **쿼리 실린 redirect**(클라 인터셉터가 못 타는 홉)다. docstring·문서를 실제 경로로 교체.
228
+ - **결정 417** — seal 위생 3건: `isExcluded` prefix **경계 검사**(`/apiv2` 가 `/api` 앱 제외로 새던 과확장 차단 · standalone 표면) · 봉인 응답 `Cache-Control: no-store` + `Vary: User-Agent`(공유 캐시가 per-request 키 응답을 재사용하면 개봉 실패 = 가용성 사고).
227
229
  - **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).
@@ -47,9 +47,14 @@
47
47
  })
48
48
  ```
49
49
  생략한 필드는 전역 상속 · `false` 는 그 앱에서만 끔(명시적으로만 · 규칙 8).
50
- rate limit 카운터는 **앱 단위 버킷**이다(override 여부와 무관 클라이언트가
51
- web·api 함께 써도 상한은 앱별로 센다). 보안 응답 헤더는 전역 전용(앱
52
- override 없음).
50
+ **override 객체는 전역과 필드 단위로 병합된다(결정 389)**`rateLimit: {
51
+ timeWindow: '10 minutes' }` 처럼 일부 필드만 줘도 `max` 전역(없으면 코어
52
+ 기본 100)을 상속한다(객체 전체 치환이면 전역 max 600 이 조용히 100 으로
53
+ 하향되는 무신호 파단이었다). CORS 는 앱이 `origin` 을 명시하지 않으면 전역/
54
+ 코어 기본(`origin:false`)이 유지된다 — **부분 override 로 CORS 가 열리지
55
+ 않는다**(fail-closed). rate limit 카운터는 **앱 단위 버킷**이다(override
56
+ 여부와 무관 — 한 클라이언트가 web·api 를 함께 써도 상한은 앱별로 센다).
57
+ 보안 응답 헤더는 전역 전용(앱 override 없음).
53
58
  - **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
54
59
  기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
55
60
  될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
@@ -134,6 +139,10 @@
134
139
  --app <api>`(결정 338) — `.env` 의 `<APP>_JWT_SECRET` 으로 주입한다.
135
140
  - **폐기 한계**: 토큰은 stateless — 서버측 폐기(로그아웃·강제 무효화)가 없다.
136
141
  유출 리프레시 토큰은 만료까지 유효 · 민감 앱은 `refreshTtl` 단축(v1 범위 밖).
142
+ - **결정 391 보강**: `this.jwt.refresh` 는 재발급 전 `loadUser(sub)` 실존 확인 —
143
+ 삭제/정지된 계정은 리프레시 토큰이 살아 있어도 재발급이 거부된다(토큰 폐기가
144
+ 아니라 부재 계정 차단 — stateless 한계는 그대로). Bearer 스킴은 RFC 7235 대로
145
+ 대소문자 무관 매칭이다.
137
146
  - **인증·인가는 3층이다** (결정 145 · 149):
138
147
  - **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
139
148
  로그인 페이지 리다이렉트).
@@ -285,3 +294,7 @@ const rows = await Post.query()
285
294
  | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }` · 전역 상속 · 앱 단위 rate limit 버킷 · 보안 헤더는 전역 전용(§1) |
286
295
  | 결정 341 | CSRF 토큰 출처 = **라이브 Inertia 페이지 props**(최초 문서 data-page·meta 는 부팅 전 폴백) — 로그인 세션 재생성(결정 254) 후 stale 403 봉합(`packages/vue/src/csrf.ts` · §2) |
287
296
  | 결정 342 | CSRF 부착 The One Way — `useForm`/`router`/`api()` 상태 변경에 `X-CSRF-Token` 자동 부착 · 수동 `_csrf` 바디/헤더 제거(스캐폴드 동기) · 우회 전송은 `readCsrfToken()` 탈출구(§2) |
297
+ | 결정 388 | `api()` 도 명시 `X-CSRF-Token` 헤더를 대소문자 무관으로 존중(무조건 덮어쓰기·소문자 공존 콤마 병합 403 봉합 — 인터셉터와 대칭) |
298
+ | 결정 389 | 앱 스코프 보안 override = 전역과 **필드 병합**(부분 override 의 조용한 코어 기본 리셋 봉합) · CORS origin 미명시 = fail-closed(§1) |
299
+ | 결정 391 | JWT 보강 — `refresh` 사용자 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§2) |
300
+ | 결정 393 | 멀티파트 CSRF 403 안내 = 자동 부착 실태 + `readCsrfToken()` 탈출구로 갱신(종전 useForm 수동 헤더 예시는 결정 342 와 모순) |
@@ -27,8 +27,17 @@
27
27
  `publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **프레임웍이 이 경로를 직접 서빙한다**
28
28
  (결정 355 · `gaon serve`/`gaon dev` 의 루트에 자동 등록 — 상대 publicUrl 만 · 폴더 이탈 차단).
29
29
  업로드→`url()` 렌더→표시가 zero-config 로 흐른다. publicUrl 이 절대 URL(별도 서버/CDN)이면
30
- 서빙하지 않는다(그 서버 몫). 공개 서빙이라 **비공개 파일은 로컬 디스크 공개 경로에 두지 말
31
- 것**(접근 제어가 필요하면 s3 presigned 또는 컨트롤러 라우트).
30
+ 서빙하지 않는다(그 서버 몫).
31
+ - **공개 범위는 `publicPrefix` 아래로 한정된다**(결정 401 · 기본 `'public/'`). 서빙되는 키는
32
+ `public/...` 뿐이고 그 밖의 키는 파일이 있어도 **404**(수리 안내 포함)다 — 로컬 디스크는
33
+ 한 폴더에 공개·비공개가 섞이므로 접두사가 유일한 경계다. 따라서 **표시할 파일은
34
+ `public/` 아래에 저장한다**: `Storage.put('public/avatars/1.png', body)` →
35
+ `url('public/avatars/1.png')` = `/storage/public/avatars/1.png`.
36
+ - 접근 제어가 필요한 파일은 `public/` 밖에 두고(예 `private/...`) 컨트롤러 라우트로
37
+ 권한을 검사해 `Storage.get()` 으로 내보낸다. 디스크 전체를 공개하려면
38
+ `publicPrefix: ''` 로 **명시 옵트인**한다(비공개 파일이 없을 때만).
39
+ - 업로드된 `text/html`·`image/svg+xml` 은 `Content-Disposition: attachment` 로 내려간다
40
+ (결정 402) — 사용자가 올린 문서가 앱과 같은 오리진에서 렌더되지 않게 한다.
32
41
  - **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
33
42
  없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
34
43
  존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
@@ -63,8 +72,9 @@ storage: process.env.STORAGE_ENDPOINT
63
72
  - dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
64
73
  (결정 132 · zero-config). `.env` 는 `gaon new` 가 자동 생성하므로(결정 198)
65
74
  `gaon dev` 만으로 우회 0.
66
- - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` — `url(key)`
67
- = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동 서빙 — 결정 355).
75
+ - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage', publicPrefix?: 'public/' }` —
76
+ `url(key)` = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동
77
+ 서빙 — 결정 355). `publicPrefix`(기본 `'public/'`)는 **서빙되는 키 범위**다(결정 401).
68
78
  config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
69
79
  쓰면 컴파일 에러).
70
80
  - 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
@@ -93,6 +103,19 @@ export default controller({
93
103
  - **멀티파트 폼의 CSRF 는 `x-csrf-token` 헤더 전용**이다(결정 133 · 구조적).
94
104
  `useForm(...).post(url, { headers: { 'x-csrf-token': shared.csrf } })` 로 보낸다 —
95
105
  바디 `_csrf` 는 멀티파트에서 안 걸린다(상세는 `agents/web.md` §3).
106
+ - **업로드 한도는 `web.uploads`** 다(결정 356 · 기본 파일당 10MB · 최대 10개):
107
+
108
+ ```ts
109
+ // gaon.config.ts
110
+ export default defineConfig({
111
+ web: { uploads: { maxFileSize: 50 * 1024 * 1024, maxFiles: 5 } },
112
+ // web: { uploads: false }, // 멀티파트 자체를 끔
113
+ })
114
+ ```
115
+
116
+ 한도를 넘으면 **413** 이 나가고 응답에 어느 설정을 올리라는 안내가 담긴다. Inertia
117
+ 요청(`useForm().post()`)도 같은 통로로 마감된다(결정 403 · CSRF 409·415 와 동형).
118
+ 파일은 메모리에 버퍼링되므로 `maxFileSize × maxFiles` 가 요청당 메모리 상한이다.
96
119
 
97
120
  ### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
98
121
 
@@ -133,6 +156,10 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
133
156
  인프라가 버킷을 만든다. 프레임웍은 런타임에 버킷을 만들지 않는다(결정 132).
134
157
  - **멀티파트 업로드를 일반 폼처럼 `_csrf` 바디 필드로 보내면 403** — 헤더로
135
158
  옮긴다(결정 133).
159
+ - **`public/` 밖 키는 `url()` 이 만들어도 404** — 서빙 범위는 `publicPrefix`(기본
160
+ `'public/'`)로 한정된다(결정 401). 표시할 파일은 `public/` 아래에 저장한다.
161
+ - **업로드 파일명 확장자를 신뢰하지 말 것** — 사용자가 올린 `.html`·`.svg` 는 첨부로
162
+ 내려가지만(결정 402), 그 밖의 처리(썸네일·파싱)는 앱이 직접 검증해야 한다.
136
163
 
137
164
  ## 관련 결정 번호
138
165
 
@@ -141,3 +168,8 @@ const tempLink = await Storage.url(key, { expiresIn: 600 })
141
168
  - 결정 133 — 멀티파트 CSRF = `x-csrf-token` 헤더 전용(구조적).
142
169
  - 결정 129 — `gaon work` 도 `wireDomain` 으로 스토리지·메일 배선(운영 워커).
143
170
  - 결정 136 — `gaon test` 하네스가 스토리지·메일을 테스트 격리 값으로 배선.
171
+ - 결정 355 — 로컬 디스크 공개 경로를 프레임웍이 직접 서빙(조용한 404 제거).
172
+ - 결정 401 — 로컬 자동 서빙 범위를 `publicPrefix`(기본 `'public/'`)로 한정.
173
+ - 결정 402 — 업로드된 렌더 가능 타입(html·svg)은 `Content-Disposition: attachment`.
174
+ - 결정 356·403 — `web.uploads` 한도 표면 · 413 수리 안내(Inertia 동형 마감).
175
+ - 결정 405 — 서빙 root 계산을 디스크 배선과 공유(좌표 표류 방지) · 심링크 이탈 차단.
@@ -492,6 +492,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
492
492
  통하지 않는다. **알려진 한계**: 토큰은 stateless 라 서버측 폐기(로그아웃·강제
493
493
  무효화) 수단이 없다 — 유출된 리프레시 토큰은 만료(기본 7d)까지 유효하므로
494
494
  민감한 앱은 `refreshTtl` 을 짧게 잡는다(서버측 폐기 목록은 v1 범위 밖).
495
+ **결정 391 보강**: ① `this.jwt.refresh` 는 재발급 전에 `loadUser(sub)` 로 사용자
496
+ 실존을 확인한다 — 삭제/정지된 계정(loadUser 가 falsy)은 리프레시 토큰이 만료
497
+ 전이어도 재발급이 거부된다(토큰 폐기가 아니라 부재 계정 차단 — stateless 한계는
498
+ 그대로). ② `Authorization` 의 Bearer 스킴은 대소문자 무관이다(RFC 7235 —
499
+ `bearer`/`BEARER` 클라이언트도 인증된다).
495
500
 
496
501
  - **JWT 스캐폴드 = `gaon g auth --jwt --app <api>`** (결정 338 · `--app` 필수 ·
497
502
  web 불가 — web 은 세션이 정본). 페이지·회원가입 없이 스키마/모델(공유) +
@@ -595,6 +600,10 @@ export default controller({
595
600
  - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
596
601
  - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
597
602
  (결정 37 · 상세는 `agents/frontend.md`).
603
+ - **render/JSON props 에 `Map`/`Set` 을 넘기지 말 것 (결정 392)** — JSON 직렬화
604
+ 대응이 하나가 아니라 프레임웍이 자동 변환하지 않고 **수리 안내 에러**로 막는다
605
+ (이전엔 조용히 `{}` 가 됐다 — 무신호 파손). `Object.fromEntries(map)`·
606
+ `[...map.entries()]`·`[...set]` 으로 변환해 넘긴다.
598
607
  - **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
599
608
  (결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
600
609
  라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
@@ -638,6 +647,11 @@ export default controller({
638
647
  | 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
639
648
  | 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
640
649
  | 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
650
+ | 결정 389 | 앱 스코프 보안 override 는 전역과 **필드 병합** — 부분 override 가 나머지 필드를 코어 기본으로 리셋하지 않음 · CORS 는 origin 미명시 시 fail-closed(`agents/security.md` §1) |
651
+ | 결정 390 | Inertia 렌더의 `Vary: X-Inertia` 는 기존 Vary(CORS `Origin` 등)에 **병합**(치환 아님) |
652
+ | 결정 391 | JWT 보강 — `refresh` 가 `loadUser(sub)` 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§6) |
653
+ | 결정 392 | render/JSON props 의 `Map`/`Set` = 수리 안내 에러(조용한 `{}` 봉합 · 함정 §알려진 함정) |
654
+ | 결정 393 | 멀티파트 CSRF 403 안내문 = 결정 342 실태(자동 부착 · 커스텀 전송은 `readCsrfToken()`) 로 갱신 |
641
655
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
642
656
 
643
657
  ## `@gaonjs/seal` 켠 앱
package/dist/work.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface WorkCommandOptions {
19
19
  readonly outboxPurgeIntervalMs?: number;
20
20
  /** 아웃박스 릴레이 폴링 주기(ms). 생략 시 GAON_OUTBOX_RELAY_POLL_MS(결정 312). */
21
21
  readonly relayPollMs?: number;
22
+ /** 아웃박스 claim 리스(ms · dedupe 창보다 작아야 함 — 결정 396). 생략 시 GAON_OUTBOX_CLAIM_TIMEOUT_MS. */
23
+ readonly outboxClaimTimeoutMs?: number;
22
24
  /** 시그널 등록·해제(테스트 주입). 기본 process. */
23
25
  readonly signals?: {
24
26
  on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
@@ -35,6 +37,7 @@ export declare function outboxTuningFromEnv(env?: Record<string, string | undefi
35
37
  outboxRetentionMs?: number;
36
38
  outboxPurgeIntervalMs?: number;
37
39
  relayPollMs?: number;
40
+ outboxClaimTimeoutMs?: number;
38
41
  };
39
42
  /** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
40
43
  * 오류·리스너 폐기·워커 인프라 오류)가 기본(human) 모드에서 0 신호이던 갭을 닫는다. */
package/dist/work.js CHANGED
@@ -47,6 +47,9 @@ export function outboxTuningFromEnv(env = process.env) {
47
47
  outboxRetentionMs: readInt('GAON_OUTBOX_RETENTION_MS'),
48
48
  outboxPurgeIntervalMs: readInt('GAON_OUTBOX_PURGE_INTERVAL_MS'),
49
49
  relayPollMs: readInt('GAON_OUTBOX_RELAY_POLL_MS'),
50
+ // 결정 396: claim 리스 운영 튜닝 표면 — dedupe 창(기본 120s)보다 작아야 하며,
51
+ // 위반은 runWork 부팅이 fail-loud 로 잡는다.
52
+ outboxClaimTimeoutMs: readInt('GAON_OUTBOX_CLAIM_TIMEOUT_MS'),
50
53
  };
51
54
  }
52
55
  /** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
@@ -164,6 +167,7 @@ export async function runWorkCommand(opts = {}) {
164
167
  outboxRetentionMs: opts.outboxRetentionMs ?? outboxEnv.outboxRetentionMs,
165
168
  outboxPurgeIntervalMs: opts.outboxPurgeIntervalMs ?? outboxEnv.outboxPurgeIntervalMs,
166
169
  relayPollMs: opts.relayPollMs ?? outboxEnv.relayPollMs,
170
+ outboxClaimTimeoutMs: opts.outboxClaimTimeoutMs ?? outboxEnv.outboxClaimTimeoutMs,
167
171
  onEvent: emit,
168
172
  });
169
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.52.0",
3
+ "version": "0.55.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
- "@gaonjs/async": "0.17.0",
36
- "@gaonjs/config": "0.22.0",
35
+ "@gaonjs/config": "0.23.0",
36
+ "@gaonjs/async": "0.18.0",
37
37
  "@gaonjs/core": "0.2.4",
38
- "@gaonjs/data": "0.24.0",
39
- "@gaonjs/i18n": "0.2.4",
40
- "@gaonjs/mail": "0.4.0",
41
- "@gaonjs/web": "0.27.0"
38
+ "@gaonjs/i18n": "0.3.0",
39
+ "@gaonjs/data": "0.25.0",
40
+ "@gaonjs/mail": "0.5.0",
41
+ "@gaonjs/web": "0.29.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""