@gaonjs/cli 0.40.0 → 0.40.4

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/db/diff.js CHANGED
@@ -2,21 +2,9 @@
2
2
  //
3
3
  // 순수 diff — 아무것도 실행하지 않는다(SQL 만 출력). computeMigration 이
4
4
  // 방언별 up/down 을 이미 만든다(@gaonjs/data). 이 함수는 배선·표현 담당.
5
- import { computeMigration } from '@gaonjs/data';
5
+ import { computeMigration, summarizeOp } from '@gaonjs/data';
6
6
  import { resolveDbTarget } from './resolve.js';
7
7
  import { listMigrationFiles } from './replay.js';
8
- function opSummary(op) {
9
- switch (op.kind) {
10
- case 'createTable':
11
- return { kind: op.kind, table: op.table.name };
12
- case 'dropTable':
13
- return { kind: op.kind, table: op.name };
14
- case 'addColumn':
15
- case 'dropColumn':
16
- case 'alterColumn':
17
- return { kind: op.kind, table: op.table, column: op.column };
18
- }
19
- }
20
8
  /**
21
9
  * `gaon db diff` — 커넥션 대비 스키마 diff 를 계산해 텍스트·JSON 을 낳는다.
22
10
  * 아무것도 적용하지 않는다. exitCode 는 항상 0(변경 없음도 성공 · CI 에서
@@ -30,7 +18,7 @@ export async function runDbDiff(opts) {
30
18
  });
31
19
  try {
32
20
  const plan = await computeMigration(target.db, target.tables, target.dialect);
33
- const ops = plan.ops.map(opSummary);
21
+ const ops = plan.ops.map(summarizeOp);
34
22
  // diff 는 스키마↔DB 차이만 미리 보여준다(적용 X). db/migrations/*.ts 는
35
23
  // migrate 의 replay 단계가 실제로 실행한다(§4.8) — 여기선 목록만 참고로 싣는다.
36
24
  const migrationFiles = listMigrationFiles(opts.cwd).map((f) => `db/migrations/${f}`);
@@ -13,20 +13,23 @@
13
13
  // 트랜잭션: postgres 는 트랜잭셔널 DDL 이라 각 단계가 원자적. mysql/mariadb 는
14
14
  // DDL 이 autocommit 이라 순차 실행하고 이력만 남긴다(§4.5 방언 차이).
15
15
  import { sql } from 'kysely';
16
- import { computeMigration, renderUp, renderDown } from '@gaonjs/data';
16
+ import { computeMigration, renderUp, renderDown, summarizeOp, DEFERRED_DROP_KINDS } from '@gaonjs/data';
17
17
  import { resolveDbTarget } from './resolve.js';
18
18
  import { ensureJournal, journalExists, recordEntry } from './journal.js';
19
19
  import { listMigrationFiles, replayPending, rollbackLast } from './replay.js';
20
- function opSummary(op) {
20
+ /** drop 계열 부속 op 을 사람이 읽는 한 줄로 — 크게 알리는 note 용. */
21
+ function describeDeferred(op) {
21
22
  switch (op.kind) {
22
- case 'createTable':
23
- return { kind: op.kind, table: op.table.name };
24
- case 'dropTable':
25
- return { kind: op.kind, table: op.name };
26
- case 'addColumn':
27
- case 'dropColumn':
28
- case 'alterColumn':
29
- return { kind: op.kind, table: op.table, column: op.column };
23
+ case 'dropUnique':
24
+ return `unique(${op.name}: ${op.columns.join(', ')})`;
25
+ case 'dropIndex':
26
+ return `index(${op.name})`;
27
+ case 'dropCheck':
28
+ return `check(${op.name})`;
29
+ case 'dropDefault':
30
+ return `default(${op.table}.${op.column})`;
31
+ default:
32
+ return op.kind;
30
33
  }
31
34
  }
32
35
  /** 배치 식별자 — 초 단위 epoch + 적용 문수. 사람도 읽고 정렬도 된다. */
@@ -55,24 +58,31 @@ export async function runDbMigrate(opts) {
55
58
  dryRun: opts.dryRun,
56
59
  });
57
60
  // 2) schema-diff — replay 이후 상태를 다시 읽어 나머지를 계산.
58
- // 합성형(결정 39): migrate 는 스키마에 없는 테이블을 **자동 DROP 하지 않는다**
59
- // — replay(파일 마이그)만든 테이블·외부 테이블을 보호한다. 테이블 제거는
60
- // 손작성 마이그(down 포함)로 한다. dropTable 은 'gaon db diff' 가 계속 보여준다.
61
+ // 합성형(결정 39·220): migrate 는 **drop 계열(테이블·unique·index·check·default 제거)을
62
+ // 자동 적용하지 않는다** — replay 가 만든/외부 객체·수동 추가한 제약을 보호한다. 제거는
63
+ // 손작성 마이그로 하고, 'gaon db diff' 가 계속 보여준다(조용히 넘어가지 않는다). ADD·SET
64
+ // (unique·index·check·default 추가)은 자동 적용해 "조용한 no-op"(결정 220 배경)을 없앤다.
61
65
  const full = await computeMigration(target.db, target.tables, target.dialect);
62
66
  const droppedTables = full.ops
63
67
  .filter((o) => o.kind === 'dropTable')
64
68
  .map((o) => o.name);
65
- const schemaOps = full.ops.filter((o) => o.kind !== 'dropTable');
69
+ const deferredDrops = full.ops.filter((o) => o.kind !== 'dropTable' && DEFERRED_DROP_KINDS.has(o.kind));
70
+ const schemaOps = full.ops.filter((o) => !DEFERRED_DROP_KINDS.has(o.kind));
66
71
  const plan = {
67
72
  ops: schemaOps,
68
73
  up: renderUp(schemaOps, target.dialect),
69
74
  down: renderDown(schemaOps, target.dialect),
70
75
  };
71
- const ops = plan.ops.map(opSummary);
72
- const dropNote = droppedTables.length > 0
76
+ const ops = plan.ops.map(summarizeOp);
77
+ const tableDropNote = droppedTables.length > 0
73
78
  ? ` ℹ 스키마에 없는 테이블 ${droppedTables.length}개는 자동 DROP 하지 않았습니다: ${droppedTables.join(', ')}\n` +
74
79
  ` → 제거하려면 손작성 마이그(down 포함)를 쓰거나, 'gaon db diff' 로 계획을 확인하세요.`
75
80
  : '';
81
+ const auxDropNote = deferredDrops.length > 0
82
+ ? ` ℹ 스키마에서 사라진 제약·인덱스·기본값 ${deferredDrops.length}개는 자동 제거하지 않았습니다: ${deferredDrops.map(describeDeferred).join(', ')}\n` +
83
+ ` → 제거하려면 'gaon db diff' 로 down SQL 을 확인해 손작성 마이그로 적용하세요.`
84
+ : '';
85
+ const dropNote = [tableDropNote, auxDropNote].filter(Boolean).join('\n');
76
86
  const replayLine = replay.applied.length > 0
77
87
  ? ` [${opts.dbKey}] 마이그레이션 파일 ${replay.applied.length}개 실행: ${replay.applied.join(', ')}`
78
88
  : replay.pending.length > 0 && opts.dryRun
@@ -85,6 +95,7 @@ export async function runDbMigrate(opts) {
85
95
  pendingMigrations: replay.pending,
86
96
  migrationFiles: replay.all,
87
97
  skippedDropTables: droppedTables,
98
+ skippedDrops: deferredDrops.map(summarizeOp),
88
99
  };
89
100
  const prefixLines = (body) => (replayLine ? replayLine + '\n' : '') + (dropNote ? dropNote + '\n' : '') + body;
90
101
  // schema 변경이 없다 — replay 만 있었을 수 있다. 성공으로 취급.
@@ -76,8 +76,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
76
76
  `router.delete(...)` 등). REST + `fetch()` 는 **API 앱(JWT) 전용**.
77
77
  7. **한 액션은 한 종류 응답만** (render 또는 JSON 또는 redirect —
78
78
  혼용 금지 · doctor response-mixing).
79
- 8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`tables.d.ts`
80
- 는 `gaon check`/`gaon dev` 가 재생성한다.
79
+ 8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`tables.d.ts
80
+ `messages.d.ts`(3축 · `locales/` 있을 때 · 결정 158) 는 `gaon check`/
81
+ `gaon dev` 가 재생성한다.
81
82
 
82
83
  ### 2.1 파일 네이밍 표 (2026-07-24 승인 · 벤치마크 R1 실측 고정)
83
84
 
@@ -348,7 +348,14 @@ const rows = await Post.query()
348
348
  대상을 **에러**로 잡는다(배포 후 raw postgres 에러 대신 `gaon doctor` 에서).
349
349
  커넥션 키 등록 정합은 **connections** 검사가 본다.
350
350
  - **`service()` 트랜잭션은 단일 커넥션에서만 원자적** — Gaon 은 분산
351
- 트랜잭션을 흉내 내지 않는다(§9 정본).
351
+ 트랜잭션을 흉내 내지 않는다(§9 정본). 열린 트랜잭션 안에서 다른
352
+ 커넥션에 **쓰기**(create·update·delete)를 하면 **런타임에 throw**된다
353
+ (조용한 부분 커밋 방지 · fail-closed · 결정 221). **읽기는 예외**(다른
354
+ 커넥션 조회는 자유). 커넥션 간 쓰기는 아래처럼 커밋 뒤 `afterCommit`
355
+ 으로 잇는다. 중첩 `service({ db })`·`transaction:false`·`afterCommit`
356
+ 은 자기 경계라 정상이다. (한계: model·`Post.query()`·`getConnection()`
357
+ 쓰기는 가드가 잡지만, `kysely` 에서 직접 import 한 `sql``` raw SQL 쓰기는
358
+ 못 잡는다 — 크로스커넥션 raw 쓰기는 직접 피한다.)
352
359
  - **마이그레이션은 전 커넥션에 걸린다** — `gaon db migrate`(인자 없음)는 등록된
353
360
  **모든** 커넥션을 순회 적용한다(결정 139 · One Way — 커넥션 하나를 잊어 빈 채
354
361
  배포하는 사고 방지). 한 커넥션만 좁히려면 `gaon db migrate --db legacy`.
@@ -662,6 +669,24 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
662
669
  테이블 보호 · 자동 apply 의 데이터 손실 배제). 테이블 제거는 손작성 마이그의
663
670
  `down`(또는 명시적 마이그)으로 한다. `gaon db diff` 는 dropTable 을 계속
664
671
  미리 보여주고, migrate 는 건너뛴 테이블을 크게 알린다(조용한 무시 방지).
672
+ - **스키마 diff 는 컬럼 타입·널뿐 아니라 제약·인덱스·기본값 변경도 잡는다**
673
+ (결정 220). 기존 테이블 컬럼에 `.unique()`·`.index()`·`.default()`·`.check()`
674
+ 추가나 `t.enum([...])` 값 목록 변경을 하면 diff 가 감지해 **추가(ADD·SET)는
675
+ migrate 가 자동 적용**한다(예: `email` 에 `.unique()` 추가 → `ADD CONSTRAINT
676
+ … UNIQUE` 생성). **제거(제약·인덱스·기본값을 스키마에서 뗌)는 자동 적용하지
677
+ 않고** dropTable 처럼 크게 알린다 — 제거는 `gaon db diff` 의 down SQL 을 보고
678
+ 손작성 마이그로 적용한다(수동/외부 제약 보호 · 결정 39 정합). **한계**: 임의
679
+ `.check(expr)` 의 **표현식만** 바꾸는 변경(같은 컬럼·같은 제약, 식만 수정)은
680
+ Postgres 가 표현식을 정규화해 신뢰 비교가 불가능하므로 **감지하지 못한다** —
681
+ 이 경우 컬럼을 갈거나 손작성 마이그로 CHECK 를 drop→add 한다. (enum 값 변경과
682
+ 제약 **추가/제거** 는 정상 감지된다.) 마찬가지로 **기본값의 값 변경** 중 일부는
683
+ 미검출된다 — **시간 타입**(`t.datetime()`·`t.date()`·`t.time()`)의 기본값 값 변경과
684
+ 선행 0 문자열(`'007'` 류)의 값 변경은, Postgres 가 리터럴을 재포맷·정규화해 신뢰
685
+ 비교가 안 되므로 **존재만 비교**한다(값이 바뀌어도 no-op). 허위 diff(멱등 붕괴)보다
686
+ 미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
687
+ COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
688
+ 값 변경은 정상 감지된다.) 이 제약·기본값 diff 는 postgres 커넥션 기준이다
689
+ (mysql=legacy §4.5 는 aux introspect 미지원 → 이 diff 생략).
665
690
  - 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
666
691
 
667
692
  ### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
@@ -103,8 +103,11 @@ async function search(q: string) {
103
103
  반환하면 그 타입 그대로, render 액션이면 render props 타입이 온다.
104
104
  - **실패** — 4xx/5xx 는 예외로 던진다. 422(`ValidationError`)는
105
105
  `err.body` 에 검증 이슈가 실려 있다.
106
- - **CSRF** — 세션 앱은 `<meta name="csrf-token">` 값을 자동으로
107
- `X-CSRF-Token` 헤더에 붙인다 (`agents/security.md`).
106
+ - **CSRF** — 세션 앱은 상태 변경 요청(POST/PUT/PATCH/DELETE)에 CSRF 토큰을
107
+ 자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166). 정본
108
+ 출처는 data-page 의 `props.csrf` 공유 prop(결정 116 · `useForm({ _csrf: shared.csrf })`
109
+ 와 **같은 단일 출처**)이고, `<meta name="csrf-token">` 은 레거시 폴백이다(표준 셸은
110
+ 안 내지만 앱이 손수 넣었으면 존중 · `packages/vue/src/api.ts` · `agents/security.md`).
108
111
 
109
112
  ### 3. bigint PK 식별자 — 컨트롤러에서 `String()` 정규화 (결정 37)
110
113
 
@@ -458,6 +461,7 @@ async function runSearch(q: string) {
458
461
  | 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
459
462
  | 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
460
463
  | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
464
+ | 결정 166 | `api()` CSRF 자동 부착 = data-page `props.csrf`(결정 116 과 같은 단일 출처) · `<meta name="csrf-token">` 은 레거시 폴백(§2 · `packages/vue/src/api.ts`) |
461
465
  | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
462
466
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
463
467
  | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
@@ -99,13 +99,22 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
99
99
  네이티브 브라우저 form·정적 자산·HTML 직접 로드는 셋 다 없어 자동 면제된다.
100
100
  - **최초 문서 data-page**: 서버가 `<script data-page="app" data-gaon-sealed="1">` 로 봉인 + `<meta gaon-seal-ts>`.
101
101
  클라 `createGaonApp` 이 Inertia 마운트 **전**에 wasm 으로 개봉 → 소스 보기·개발자도구에 평문 props 미노출.
102
+ **봉인 대상은 sentinel 로 특정 (결정 224)**: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel
103
+ 로 표시되고 봉인 정규식이 그것만 잡는다 — 스캐폴드 주석·유저 index.html 의 예시 data-page 리터럴(decoy)을 봉인하고
104
+ 멈추던 first-match P0(진짜 data-page·props·CSRF 평문 유출)를 근본 차단한다. 봉인 후에도 평문 data-page 가 남으면
105
+ **렌더가 throw**(fail-closed 가드 · 조용한 유출 금지). 커스텀 rootTemplate 을 쓰더라도 data-page script 를 직접
106
+ 만들지 말고 서버가 주입하게 둔다.
102
107
  - **WS 프레임 (결정 124 · §3.4)**: `useChannel` 이 `setWsFrameCodec`(seal 클라 `wsEncode`/`wsDecode`)로
103
108
  채널 송수신을 봉인한다. 송신 `E:<ts>:<base64>` · seal namespace 는 평문 `P:` 프레임 **거부**(requireDecrypt · 결정 121).
109
+ **수신은 서버·클라가 대칭 (결정 222)**: 서버 `SealWsTerminator` 도, 클라 `wsDecode` 도 `E:` 만 개봉하고 `P:`
110
+ 평문·무prefix 프레임을 거부한다 — 클라 수신이 평문을 통과시키면(fail-open) 봉인 앱 클라가 주입된 평문을 그대로
111
+ 소비해 봉인이 깨진다. 클라 개봉 실패는 조용히 무시하지 않고 소켓을 **4500 종료**(`useChannel` · 아래 fail-closed).
104
112
  - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
105
113
  직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
106
114
  모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
107
115
  - **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
108
- **403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 소켓 4500 종료(silent fallback 없음).
116
+ **403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
117
+ fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단).
109
118
 
110
119
  ## 3. CSP (결정 124)
111
120
 
@@ -135,7 +144,11 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
135
144
  - **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
136
145
  `SHA256(hex(HMAC-SHA256(masterSecret, "domain:path:uaSlice:timestamp")))` · per-frame keying(userId 미포함).
137
146
  - **replay 방어**: AES-GCM 12-byte nonce 를 `setIfNotExists`(Redis SETNX 대응) 캐시 + timestamp drift(±60s)로
138
- 차단. WS 기본은 drift 윈도우(고빈도라 프레임마다 SETNX 비용 과다 · 엄격 nonce 옵션 주입).
147
+ 차단. **HTTP 경로는 nonce 검사가 항상 배선된다 (결정 223)** 앱이 세션 Redis 가지면 그걸 재사용(멀티
148
+ 인스턴스 안전), 없으면 **in-memory 폴백(단일 인스턴스 전용)**을 쓰고 부팅 시 경고한다(nonce 검사가 조용히
149
+ 사라지지 않는다). **in-memory 는 프로세스별 격리라 멀티 워커(`--workers`·`WEB_CONCURRENCY>1`)·멀티 서버에서
150
+ replay 를 완전히 막지 못하므로, 프로덕션 멀티 인스턴스는 세션 Redis 를 구성한다.** WS 기본은 drift 윈도우
151
+ (고빈도라 프레임마다 SETNX 는 비용 과다 · 엄격 nonce 는 옵션 주입).
139
152
  - **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
140
153
  UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
141
154
 
@@ -145,6 +158,8 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
145
158
  `test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
146
159
  평문 `P:` 거부·hidden 누출 0·**비-seal 앱 번들 무-wasm 단언**) + `seal-fullstack-e2e`(실 `gaon serve`+`gaon hub`+NATS
147
160
  경유 봉인 broadcast).
161
+ - **배포 게이트 편입 (결정 224)**: 이 두 e2e 는 `pnpm test:runtime-e2e` 에 들어 있다 — 이전엔 어느 스크립트에도 없어
162
+ **red 인데도 배포를 통과**해 seal 최초 문서 data-page 평문 유출 P0 를 못 잡았다(결정 224 근본). 배포 전 반드시 green.
148
163
  - **목업 e2e 로 대체 금지.** 서버 inject·단위·wasm-parity 는 byte 호환을 잠글 뿐 "실 브라우저에서 마운트되나"를
149
164
  못 본다 — 원 wave 가 실-브라우저 e2e 를 미뤄 P0 3건(번들 불가·CSP 차단·Inertia 인터셉터 파손)을 놓친 교훈(결정 124).
150
165
  - **봉인 검증은 네트워크 날 바디로만 봐야 한다 (검증법 함정).** seal 앱에서 `page.evaluate(fetch(...))` 나 렌더된
@@ -167,3 +182,5 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
167
182
  - **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
168
183
  - **결정 124** — §3.1 개정: **app-side 정적 주입**(변수 동적 import 폐기) · **wasm 표면 은닉**(불투명 함수 · domain/ua/path 를 wasm 이 확보 · 미끼 시크릿 내장) · **WS 클라 봉인**(`setWsFrameCodec`) · **seal 앱 한정 CSP** · doctor `seal-security` main.ts 배선 검사 + `seal-client-wiring` fixer · 실 브라우저 e2e 게이트 · 부수 정정(`.wasm` MIME · `session.csrf` forwarding).
169
184
  - **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
185
+ - **결정 222** — **클라 WS 수신 fail-open P1** 수정: 클라 `wsDecode`(client.ts)가 `P:` 평문·무prefix 프레임을 throw 없이 원문 통과시켜, 서버는 requireDecrypt 로 거부하는데 클라만 주입된 평문을 소비하던 봉인 파괴. `wsDecode` 를 서버 `SealWsTerminator` 와 대칭으로 만들어 `E:` 만 개봉·`P:`/무prefix 거부. `useChannel` 은 개봉 실패를 조용히 드롭하지 않고 소켓을 **4500 종료**(서버 대칭) + 콘솔 명시 + 재연결 안 함.
186
+ - **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 는 dev/단일/테스트")와 상충. 폴백+경고가 비파괴적·정본 정합.
@@ -63,7 +63,10 @@ storage: process.env.STORAGE_ENDPOINT
63
63
  export default controller({
64
64
  async updateAvatar() {
65
65
  const f = this.file('avatar') // UploadedFile | undefined
66
- if (!f) return this.back().withErrors({ avatar: '파일이 필요합니다.' })
66
+ if (!f) {
67
+ this.flash('error', '파일이 필요합니다.') // useShared().flash.error 로 표시(결정 116)
68
+ return this.redirect('/profile')
69
+ }
67
70
  const key = `avatars/${this.auth.user!.id}.png`
68
71
  await Storage.put(key, f.buffer, { contentType: f.mimetype })
69
72
  return this.redirect('/profile')
@@ -414,7 +414,7 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
414
414
  ```ts
415
415
  async new() {
416
416
  this.requireAuth() // 미인증이면 로그인으로 리다이렉트
417
- return this.render('Posts/New', { csrf: this.csrfToken() })
417
+ return this.render('Posts/New', {}) // csrf 는 자동 주입 공유 prop — 넘기지 않는다(결정 116·117)
418
418
  }
419
419
  async create() {
420
420
  const user = this.requireAuth() // 반환값을 바로 쓴다
@@ -438,7 +438,8 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
438
438
  const { email, password } = this.params({ _row: {} as { email: string; password: string } })
439
439
  const user = await User.where('email', '=', email).first()
440
440
  if (!user || !(await verifyPassword(password, user.passwordDigest))) {
441
- return this.render('Auth/Login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.', csrf: this.csrfToken() })
441
+ // csrf 자동 주입 공유 prop 이므로 컨트롤러가 넘기지 않는다(결정 116·117)
442
+ return this.render('Auth/Login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.' })
442
443
  }
443
444
  this.auth.login(user) // 세션 확정
444
445
  return this.redirect('/dashboard')
@@ -9,9 +9,9 @@
9
9
  <!--
10
10
  Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
11
11
  vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
12
- Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 빈 <div id="app"></div>
13
- 함께 초기 페이지 객체를 <script type="application/json" data-page="app">…</script>
14
- 엘리먼트로 내려보낸다(data-page 는 div 속성이 아니라 이 script 태그에 실린다)
12
+ Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 빈 div#app 뒤에 초기 페이지 객체를
13
+ script[type=application/json][data-page=app] 엘리먼트로 자동 주입한다(data-page 는
14
+ div 속성이 아니라 이 script 태그에 실린다 · 여기에 직접 쓰지 말 것 서버가 넣는다).
15
15
  개발과 운영이 같은 셸이다.
16
16
  -->
17
17
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.40.0",
3
+ "version": "0.40.4",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/config": "0.16.0",
31
- "@gaonjs/async": "0.13.0",
32
- "@gaonjs/mail": "0.2.1",
30
+ "@gaonjs/config": "0.16.1",
33
31
  "@gaonjs/core": "0.2.2",
34
32
  "@gaonjs/i18n": "0.2.1",
35
- "@gaonjs/data": "0.16.1",
36
- "@gaonjs/web": "0.19.0"
33
+ "@gaonjs/data": "0.16.3",
34
+ "@gaonjs/async": "0.13.0",
35
+ "@gaonjs/web": "0.19.1",
36
+ "@gaonjs/mail": "0.2.1"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""