@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 +2 -14
- package/dist/db/migrate.js +27 -16
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/agents/data.md.tpl +26 -1
- package/dist/templates/project/agents/frontend.md.tpl +6 -2
- package/dist/templates/project/agents/seal.md.tpl +19 -2
- package/dist/templates/project/agents/storage.md.tpl +4 -1
- package/dist/templates/project/agents/web.md.tpl +3 -2
- package/dist/templates/project/apps/web/index.html.tpl +3 -3
- package/package.json +6 -6
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(
|
|
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}`);
|
package/dist/db/migrate.js
CHANGED
|
@@ -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
|
-
|
|
20
|
+
/** drop 계열 부속 op 을 사람이 읽는 한 줄로 — 크게 알리는 note 용. */
|
|
21
|
+
function describeDeferred(op) {
|
|
21
22
|
switch (op.kind) {
|
|
22
|
-
case '
|
|
23
|
-
return {
|
|
24
|
-
case '
|
|
25
|
-
return {
|
|
26
|
-
case '
|
|
27
|
-
|
|
28
|
-
case '
|
|
29
|
-
return {
|
|
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 는
|
|
59
|
-
// — replay
|
|
60
|
-
// 손작성
|
|
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
|
|
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(
|
|
72
|
-
const
|
|
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
|
|
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** — 세션 앱은
|
|
107
|
-
`X-CSRF-Token` 헤더에 붙인다
|
|
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
|
|
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
|
-
차단.
|
|
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)
|
|
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
|
|
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
|
-
|
|
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 응답 셸에서 빈
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
-
"@gaonjs/
|
|
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})\""
|