@gaonjs/cli 0.39.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 만 있었을 수 있다. 성공으로 취급.
@@ -55,9 +55,9 @@ export const FIXER_CAPABILITIES = [
55
55
  note: '수동 · `gaon db diff` → 리뷰 → `gaon db migrate` (SQL 은 사람이 확인).',
56
56
  },
57
57
  {
58
- rule: 'shared-composable-purity',
58
+ rule: 'shared-purity',
59
59
  hasFixer: false,
60
- note: '수동 · 컴포저블에 든 앱 종속 로직을 앱 쪽으로 옮겨야 합니다(E-5 §2.2).',
60
+ note: '수동 · shared(.ts·.vue)에 든 앱 종속 로직(api/pageProps·domain 값)을 앱 쪽으로 옮기거나 props/인자로 받으세요(E-5 §2.2 · 결정 217).',
61
61
  },
62
62
  {
63
63
  rule: 'no-auto-import',
@@ -0,0 +1,9 @@
1
+ import type { DoctorCheck, RuleReport } from './types.js';
2
+ /**
3
+ * 단일 shared 소스(.ts 원문 또는 .vue script 블록)를 검사해 순수성 위반
4
+ * 목록을 낸다(단위 테스트 진입점). file 확장자가 .vue 면 <script> 만 뽑아
5
+ * 파싱한다 — line 번호는 script 블록 기준(안내 정확도보다 위반 유무가 우선).
6
+ */
7
+ export declare function inspectSharedSource(file: string, source: string, cwd: string): DoctorCheck[];
8
+ /** shared/ 전체(.ts · .vue)를 훑어 순수성 위반을 모두 낸다. */
9
+ export declare function checkSharedPurity(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,188 @@
1
+ // @gaonjs/cli · doctor · shared 순수성 검사 (M9-E 확장 · 결정 217 · errata E-5 §2.2)
2
+ //
3
+ // shared/ 는 앱을 몰라야 한다(§5 · CLAUDE.md rule 5). 라우트를 몰라야 하고
4
+ // (api·pageProps 값 import 금지) · domain 은 타입으로만 참조해야 한다. 필요한
5
+ // 데이터·호출 함수는 props/인자로 받는다. 이 규칙은 shared 컴포저블뿐 아니라
6
+ // shared 컴포넌트(.vue)에도 똑같이 적용된다 — 관례가 컴포저블·컴포넌트에 대칭
7
+ // 이므로(결정 25) 검사도 한 규칙으로 통일한다(결정 217).
8
+ //
9
+ // 이력: M9-E 때는 `shared-composable-purity` 라는 이름으로 shared/composables/
10
+ // 의 .ts 만 훑었다. 그러나 shared/components/*.vue 안에서 domain 모델을 값으로
11
+ // import 해도 27 검사가 전부 통과하는 공백이 12차 실사용(support-desk)에서 드러났다
12
+ // (§4.5 잔재). 결정 217: 규칙명을 `shared-purity` 로 개정하고 검사 범위를
13
+ // shared/ 전체(.ts · .vue)로 넓혀 The One Way 로 통일한다.
14
+ //
15
+ // 검사 대상(파일 종류 무관):
16
+ // 1) 프레임웍 모듈(gaonjs · gaonjs/vue · @gaonjs/vue · @gaonjs/web) 에서
17
+ // 'api' · 'pageProps' 를 value import 하면 error.
18
+ // - type-only 는 무해(구조 참조뿐 · 라우트 지식 필요 없음).
19
+ // 2) domain 을 value import 하면 error.
20
+ // - type-only 는 허용(모델 Row 타입 등 · 순수 타입 참조).
21
+ //
22
+ // 방법: shared/ 아래 .ts 는 소스 그대로, .vue 는 <script> 블록만 뽑아 TS AST 로
23
+ // 파싱해 import 선언만 훑는다. 상대 import 는 실 파일까지 해석하지 않고 경로
24
+ // 접두사(domain/) 로 판정 — 이 검사는 순수성 게이트라 정확도보다 재현성이 우선
25
+ // (false positive 는 오히려 안전).
26
+ import { readdir, readFile } from 'node:fs/promises';
27
+ import { join, relative, resolve, dirname } from 'node:path';
28
+ import ts from 'typescript';
29
+ /** 라우트 지식을 담은 심볼 — shared 는 참조할 수 없다. */
30
+ const FORBIDDEN_FRAMEWORK_NAMES = new Set(['api', 'pageProps']);
31
+ /**
32
+ * 프레임웍 모듈 패턴. 문서 표기(gaon/vue)는 실 패키지 이름의 짧은
33
+ * 별칭이며, 실 배포본은 gaonjs 파사드 subpath 와 @gaonjs 스코프를 쓴다.
34
+ * 하나만 잡으면 우회가 쉬우므로 알려진 표기 3종을 모두 매치한다.
35
+ */
36
+ const FRAMEWORK_MODULE_PATTERNS = [
37
+ /^gaonjs$/,
38
+ /^gaonjs\/vue$/,
39
+ /^@gaonjs\/vue$/,
40
+ /^@gaonjs\/web$/,
41
+ ];
42
+ /** import 선언에서 이름과 type-only 플래그를 추출한다. */
43
+ function collectImportedNames(node) {
44
+ const out = [];
45
+ const clause = node.importClause;
46
+ if (!clause)
47
+ return out;
48
+ const clauseTypeOnly = clause.isTypeOnly;
49
+ if (clause.name)
50
+ out.push({ name: clause.name.text, typeOnly: clauseTypeOnly });
51
+ const bindings = clause.namedBindings;
52
+ if (bindings) {
53
+ if (ts.isNamespaceImport(bindings)) {
54
+ out.push({ name: bindings.name.text, typeOnly: clauseTypeOnly });
55
+ }
56
+ else if (ts.isNamedImports(bindings)) {
57
+ for (const el of bindings.elements) {
58
+ out.push({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly });
59
+ }
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function isRelativeSpecifier(s) {
65
+ return s.startsWith('./') || s.startsWith('../');
66
+ }
67
+ function matchesFrameworkModule(spec) {
68
+ return FRAMEWORK_MODULE_PATTERNS.some((re) => re.test(spec));
69
+ }
70
+ /**
71
+ * .vue SFC 에서 <script>·<script setup> 블록 안 텍스트만 뽑아 하나로 잇는다.
72
+ * doctor 는 import 선언만 보므로 script 밖(<template>·<style>)은 무시한다.
73
+ * 두 블록(일반+setup)이 다 있을 수 있어 전부 이어 붙인다.
74
+ */
75
+ function extractVueScript(source) {
76
+ const blocks = [];
77
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
78
+ let m;
79
+ while ((m = re.exec(source)) !== null)
80
+ blocks.push(m[1] ?? '');
81
+ return blocks.join('\n');
82
+ }
83
+ /**
84
+ * 단일 shared 소스(.ts 원문 또는 .vue script 블록)를 검사해 순수성 위반
85
+ * 목록을 낸다(단위 테스트 진입점). file 확장자가 .vue 면 <script> 만 뽑아
86
+ * 파싱한다 — line 번호는 script 블록 기준(안내 정확도보다 위반 유무가 우선).
87
+ */
88
+ export function inspectSharedSource(file, source, cwd) {
89
+ const issues = [];
90
+ const rel = relative(cwd, file);
91
+ const code = file.endsWith('.vue') ? extractVueScript(source) : source;
92
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.ES2022, true);
93
+ const baseDir = dirname(file);
94
+ const visit = (node) => {
95
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
96
+ const spec = node.moduleSpecifier.text;
97
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
98
+ const names = collectImportedNames(node);
99
+ // 1) 프레임웍의 api/pageProps 를 value import → error
100
+ if (matchesFrameworkModule(spec)) {
101
+ for (const n of names) {
102
+ if (n.typeOnly)
103
+ continue; // type-only 는 라우트 실행 지식이 아님
104
+ if (!FORBIDDEN_FRAMEWORK_NAMES.has(n.name))
105
+ continue;
106
+ issues.push({
107
+ rule: 'shared-purity',
108
+ level: 'error',
109
+ file: rel,
110
+ line: line + 1,
111
+ message: `${rel} (line ${line + 1})\n` +
112
+ ` '${spec}' 의 '${n.name}' import 발견 · shared 는 라우트를 몰라야 함\n` +
113
+ `→ 옵션: apps/<앱>/ 로 옮기거나 · ${n.name} 결과를 props/인자로 받도록 바꾸라`,
114
+ detail: {
115
+ kind: 'framework-api',
116
+ module: spec,
117
+ name: n.name,
118
+ },
119
+ });
120
+ }
121
+ }
122
+ // 2) domain value import → error (type-only 는 허용)
123
+ if (isRelativeSpecifier(spec)) {
124
+ const abs = resolve(baseDir, spec);
125
+ const parts = relative(cwd, abs).split(/[\\/]/).filter(Boolean);
126
+ if (parts[0] === 'domain') {
127
+ const valueNames = names.filter((n) => !n.typeOnly);
128
+ if (valueNames.length > 0) {
129
+ issues.push({
130
+ rule: 'shared-purity',
131
+ level: 'error',
132
+ file: rel,
133
+ line: line + 1,
134
+ message: `${rel} (line ${line + 1})\n` +
135
+ ` domain 값 import 발견 · shared 는 domain 을 타입으로만 참조해야 함\n` +
136
+ `→ 'import type { ... } from ...' 형태로 바꾸거나 · 필요한 값은 props/인자로 받도록 바꾸라`,
137
+ detail: {
138
+ kind: 'domain-value',
139
+ module: spec,
140
+ names: valueNames.map((n) => n.name),
141
+ },
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ts.forEachChild(node, visit);
148
+ };
149
+ visit(sf);
150
+ return issues;
151
+ }
152
+ /** shared/ 전체(.ts · .vue)를 훑어 순수성 위반을 모두 낸다. */
153
+ export async function checkSharedPurity(cwd) {
154
+ const sharedDir = join(cwd, 'shared');
155
+ const files = [];
156
+ await collectSharedFiles(sharedDir, files);
157
+ const issues = [];
158
+ for (const file of files) {
159
+ const src = await readFile(file, 'utf8');
160
+ issues.push(...inspectSharedSource(file, src, cwd));
161
+ }
162
+ return { rule: 'shared-purity', issues };
163
+ }
164
+ async function collectSharedFiles(root, out) {
165
+ let entries;
166
+ try {
167
+ entries = (await readdir(root, { withFileTypes: true }));
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ for (const e of entries) {
173
+ const name = e.name;
174
+ if (name === 'node_modules' || name === 'dist' || name === '.gaon')
175
+ continue;
176
+ const full = join(root, name);
177
+ if (e.isDirectory())
178
+ await collectSharedFiles(full, out);
179
+ else if (e.isFile() && isScannable(name))
180
+ out.push(full);
181
+ }
182
+ }
183
+ /** shared 순수성 검사 대상 파일인가 — .ts(선언·테스트 제외) 또는 .vue. */
184
+ function isScannable(name) {
185
+ if (name.endsWith('.vue'))
186
+ return true;
187
+ return name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts');
188
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -7,7 +7,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
7
7
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
8
8
  export { checkSchemaRelations } from './doctor/schema-relations.js';
9
9
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
10
- export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
10
+ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
11
11
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
12
12
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
13
13
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
package/dist/doctor.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
8
8
  * 4) connections (v0.15 §4.5)
9
9
  * 5) migration-diff (최소 감지 · 상세는 M9-D)
10
- * 6) shared-composable-purity (errata E-5 §2.2 · 결정 25)
10
+ * 6) shared-purity (errata E-5 §2.2 · 결정 25·217 · shared/ 전체 .ts·.vue)
11
11
  * 7) no-auto-import (errata E-5 §2.4 · v0.15 §1.2)
12
12
  * 8) schema-filename (§1.1 · 결정 38 · 스키마 파일명 camelCase)
13
13
  * 9) agents-doc-index (§0 색인 ↔ agents/ 실 파일 · 결정 40)
@@ -47,7 +47,7 @@ import { checkDependencyDirection } from './doctor/dependency-direction.js';
47
47
  import { checkConnections } from './doctor/connections.js';
48
48
  import { checkSchemaRelations } from './doctor/schema-relations.js';
49
49
  import { checkMigrationDiff } from './doctor/migration-diff.js';
50
- import { checkSharedComposablePurity } from './doctor/shared-composable-purity.js';
50
+ import { checkSharedPurity } from './doctor/shared-purity.js';
51
51
  import { checkNoAutoImport } from './doctor/no-auto-import.js';
52
52
  import { checkSchemaFilename } from './doctor/schema-filename.js';
53
53
  import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -78,7 +78,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
78
78
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
79
79
  export { checkSchemaRelations } from './doctor/schema-relations.js';
80
80
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
81
- export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
81
+ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
82
82
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
83
83
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
84
84
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -110,7 +110,7 @@ export const ALL_RULES = [
110
110
  'dependency-direction',
111
111
  'connections',
112
112
  'migration-diff',
113
- 'shared-composable-purity',
113
+ 'shared-purity',
114
114
  'no-auto-import',
115
115
  'schema-filename',
116
116
  'agents-doc-index',
@@ -139,7 +139,7 @@ const CHECKERS = {
139
139
  'dependency-direction': checkDependencyDirection,
140
140
  connections: checkConnections,
141
141
  'migration-diff': checkMigrationDiff,
142
- 'shared-composable-purity': checkSharedComposablePurity,
142
+ 'shared-purity': checkSharedPurity,
143
143
  'no-auto-import': checkNoAutoImport,
144
144
  'schema-filename': checkSchemaFilename,
145
145
  'agents-doc-index': checkAgentsDocIndex,
package/dist/generate.js CHANGED
@@ -33,6 +33,20 @@ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates',
33
33
  function urlPrefixFor(app) {
34
34
  return app === 'web' ? '' : `/${app}`;
35
35
  }
36
+ /**
37
+ * 결정 219: `~ 패치` 라벨을 파일별 실제 편집 내용으로 정확히 붙인다. patchEnvFiles·
38
+ * patchAppConfigAddAuth·patchRoutes 가 서로 다른 편집을 하는데 예전엔 전부 "라우트 추가"
39
+ * 로 라벨해 .env·app.config 편집을 오도했다(scaffold 로그 정확성).
40
+ */
41
+ function patchLabel(rel) {
42
+ if (rel === '.env' || rel === '.env.example')
43
+ return '세션 secret 추가';
44
+ if (rel.endsWith('/app.config.ts') || rel === 'app.config.ts')
45
+ return '인증 배선 추가';
46
+ if (rel.endsWith('/routes.ts') || rel === 'routes.ts')
47
+ return '라우트 추가';
48
+ return '수정';
49
+ }
36
50
  /**
37
51
  * 앱의 세션 secret 환경변수명 — web 은 'SESSION_SECRET'(기존), 그 외는
38
52
  * '<APP>_SESSION_SECRET'(결정 141 · 앱별 세션 완전 분리 · session.ts 는 앱마다
@@ -99,6 +113,9 @@ function renderTemplate(name, app, includePublic) {
99
113
  .replaceAll('{{APP_NAME}}', app)
100
114
  .replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
101
115
  .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
116
+ // 결정 218: 시큐어 세션 컨트롤러의 로그인 역할 게이트 기준. 결정 155·145 의 대시보드
117
+ // 역할 게이트(role === 'admin')와 같은 값으로 고정해 두 층이 어긋나지 않게 한다.
118
+ .replaceAll('{{REQUIRED_ROLE}}', 'admin')
102
119
  .replaceAll('{{SIGNUP_LINK}}', includePublic ? signupLinkMarkup(app) : '');
103
120
  }
104
121
  /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
@@ -110,7 +127,13 @@ export function authScaffoldFiles(opts = {}) {
110
127
  { tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
111
128
  { tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
112
129
  { tpl: 'auth.wiring.ts.tpl', out: `apps/${app}/auth.ts` },
113
- { tpl: 'session.controller.ts.tpl', out: `apps/${app}/controllers/session.ts` },
130
+ // 결정 218: 시큐어 앱은 로그인 시점에 역할을 검증하는 세션 컨트롤러를 깐다 — 고객이
131
+ // 인증만 통과해 세션을 얻은 뒤 대시보드에서 403 을 만나는 rough UX 를 진입점에서 막는다.
132
+ // 공개 앱(web 등)은 역할 개념이 없어 표준 세션 컨트롤러(로그인 = 세션 생성)를 쓴다.
133
+ {
134
+ tpl: includePublic ? 'session.controller.ts.tpl' : 'session.secure.controller.ts.tpl',
135
+ out: `apps/${app}/controllers/session.ts`,
136
+ },
114
137
  // 결정 155: 시큐어(비-web · 비-public) 앱은 역할 게이트 대시보드, 공개 앱은 requireAuth 대시보드.
115
138
  {
116
139
  tpl: includePublic ? 'dashboard.controller.ts.tpl' : 'dashboard.secure.controller.ts.tpl',
@@ -266,14 +289,16 @@ export function writeAuthScaffold(cwd, opts = {}) {
266
289
  // 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
267
290
  // 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
268
291
  patched.push(...patchEnvFiles(root, app));
269
- // 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
270
- // 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
292
+ // 결정 155·218: 시큐어 앱(공개 가입 미생성)은 로그인 시점 역할 게이트 세션 컨트롤러 +
293
+ // 역할 게이트 대시보드를 깔았다 role 컬럼이 있어야 동작하므로 추가를 안내한다
294
+ // (§7.5.3 · authorize·로그인 게이트 예시가 실제 역할 규칙이 되도록).
271
295
  if (!includePublic) {
272
- warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155).\n` +
296
+ warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155·218).\n` +
273
297
  `→ 관리자는 직접 만들거나 승격하세요(공개 가입 라우트 없음). 공개 가입이 필요하면 --public 로 다시 생성.\n` +
274
298
  `→ 역할 인가를 완성하려면 domain/schema/users.ts 에 role 컬럼을 추가하세요:\n` +
275
299
  ` role: t.string().default('user'),\n` +
276
- ` 그러면 apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트가 실제 역할로 동작합니다.`);
300
+ ` 그러면 로그인(controllers/session.ts)이 역할 부족 세션을 만들지 않고,\n` +
301
+ ` apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트도 실제 역할로 동작합니다.`);
277
302
  }
278
303
  return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
279
304
  }
@@ -292,8 +317,10 @@ export function runGenerateAuthCommand(opts = {}) {
292
317
  lines.push('');
293
318
  for (const f of result.created)
294
319
  lines.push(` + ${f}`);
320
+ // 결정 219: 패치 대상은 파일마다 편집 내용이 다르다 — routes.ts(라우트) · app.config.ts(인증
321
+ // 배선) · .env(세션 secret). 전부 "라우트 추가" 로 라벨하면 .env·app.config 편집을 오도한다.
295
322
  for (const f of result.patched)
296
- lines.push(` ~ ${f} (라우트 추가)`);
323
+ lines.push(` ~ ${f} (${patchLabel(f)})`);
297
324
  for (const f of result.skipped)
298
325
  lines.push(` · ${f} (이미 있음 — 건너뜀)`);
299
326
  for (const w of result.warnings)
@@ -0,0 +1,46 @@
1
+ // 세션 컨트롤러(로그인/로그아웃 · 관리 앱) — gaon g auth --app {{APP_NAME}}.
2
+ // 시큐어 앱은 로그인 **시점**에 역할을 검증한다(결정 218). 인증(비밀번호)만 통과한
3
+ // 고객이 세션을 얻은 뒤 대시보드에서야 403 을 만나는 rough UX 를 진입점에서 막는다:
4
+ // · 자격 증명이 틀리면 → 인증 실패(401 성격) · 세션 안 만듦 · 재렌더
5
+ // · 자격은 맞지만 역할 부족 → 인가 실패(403 성격) · 세션 안 만듦 · 재렌더
6
+ // 401 vs 403 을 메시지로 구분한다 — 어느 쪽도 세션을 만들지 않는다.
7
+ import { controller, verifyPassword } from 'gaonjs/web'
8
+ import { User } from '../../../domain/models/User.js'
9
+
10
+ // 이 앱에 로그인할 수 있는 역할. domain/schema/users.ts 에 role 컬럼을 두고
11
+ // (예: role: t.string().default('user')) 관리자에게 '{{REQUIRED_ROLE}}' 을 부여하세요.
12
+ const REQUIRED_ROLE = '{{REQUIRED_ROLE}}'
13
+
14
+ export default controller({
15
+ // GET {{URL_PREFIX}}/session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
16
+ async new() {
17
+ return this.render('Auth/Login', { error: null as string | null })
18
+ },
19
+ // POST {{URL_PREFIX}}/session — 로그인 + 역할 게이트
20
+ async create() {
21
+ const { email, password } = this.params({ _row: {} as { email: string; password: string } })
22
+ const user = await User.where('email', '=', email).first()
23
+ // 인증(401 성격): 자격 증명이 틀리면 세션을 만들지 않고 같은 폼을 재렌더한다.
24
+ // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
25
+ if (!user || !(await verifyPassword(password, user.passwordDigest))) {
26
+ return this.render('Auth/Login', {
27
+ error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
28
+ })
29
+ }
30
+ // 인가(403 성격): 자격은 맞지만 역할이 부족하면 세션을 만들지 않고 재렌더한다.
31
+ // 고객이 "로그인은 됐는데 403" 을 겪지 않도록 진입점에서 막는다(결정 218 · 결정 155·145 의
32
+ // 대시보드 역할 게이트와 정합 — 여기서 세션 자체를 안 만들어 관리 앱을 깨끗이 격리).
33
+ if ((user as { role?: string }).role !== REQUIRED_ROLE) {
34
+ return this.render('Auth/Login', {
35
+ error: '이 앱에 접근할 권한이 없습니다.' as string | null,
36
+ })
37
+ }
38
+ this.auth.login(user)
39
+ return this.redirect('{{URL_PREFIX}}/dashboard')
40
+ },
41
+ // DELETE {{URL_PREFIX}}/session — 로그아웃
42
+ async destroy() {
43
+ this.auth.logout()
44
+ return this.redirect('{{URL_PREFIX}}/session/new')
45
+ },
46
+ })
@@ -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
 
@@ -115,7 +116,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
115
116
  3. `dependency-direction` — 의존 방향 4규칙 위반
116
117
  4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
117
118
  5. `migration-diff` — 스키마 vs DB 상태 불일치
118
- 6. `shared-composable-purity` — shared `api`/`pageProps` import (결정 25)
119
+ 6. `shared-purity` — shared/ 전체(.ts·.vue)가 `api`/`pageProps`·domain 값을 import (컴포저블·컴포넌트 통일 · 결정 25·217)
119
120
  7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
120
121
  8. `schema-filename` — 스키마 파일명 camelCase 관례 (결정 38 · `--fix` 지원)
121
122
  9. `agents-doc-index` — 이 문서 색인(§0) ↔ `agents/` 실 파일 불일치 (결정 40)
@@ -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
 
@@ -153,15 +156,17 @@ shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로
153
156
  | 컴포넌트 | 자유 (pageProps·api 사용 가능) | props 로만 받는 순수 UI |
154
157
  | 컴포저블 | 자유 (api·채널 래핑 가능) | 인자로만 받는 순수 로직 |
155
158
 
156
- **shared 컴포저블 제약:**
159
+ **shared 순수성 제약 (컴포저블·컴포넌트 공통):**
157
160
 
158
161
  - **라우트를 몰라야 한다** — `api()`·`pageProps()` 호출 금지.
159
162
  `.gaon/routes.d.ts` 는 앱별 생성이므로 구조적으로도 불가능하다.
160
- - 필요한 데이터·호출 함수는 **인자로 받는다** (순수 로직).
163
+ - 필요한 데이터·호출 함수는 **props/인자로 받는다** (순수 로직·순수 UI).
161
164
  - domain 은 **타입 import 만** 허용.
162
165
 
163
- `gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
164
- `gaonjs/vue` 의 `api`/`pageProps` import 잡는다.
166
+ `gaon doctor` 의 **shared-purity** 검사가 `shared/` 전체(`.ts` 컴포저블 +
167
+ `.vue` 컴포넌트)에서 `gaonjs/vue` 의 `api`/`pageProps` import domain
168
+ 값 import 를 잡는다 (결정 217 — 컴포저블·컴포넌트 한 규칙으로 통일 · `.vue` 는
169
+ `<script>` 블록을 훑는다).
165
170
 
166
171
  **shared 컴포넌트(UI 킷 §8)의 허용/금지도 같은 기준(결정 25·105):** 허용 = 라우트 키와
167
172
  무관한 범용 API(`useForm`·`Link`·`router`) · 금지 = 앱 라우트 지식(`api()`·`pageProps`).
@@ -395,8 +400,8 @@ async function runSearch(q: string) {
395
400
  경계 우회로 타입 안전 붕괴.
396
401
  - **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
397
402
  결정한다.
398
- - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
399
- shared-composable-purity 위반. 데이터는 props/인자로.
403
+ - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
404
+ doctor **shared-purity** 위반(`.ts`·`.vue` 공통 · 결정 217). 데이터는 props/인자로.
400
405
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
401
406
  `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
402
407
  - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
@@ -456,6 +461,7 @@ async function runSearch(q: string) {
456
461
  | 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
457
462
  | 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
458
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`) |
459
465
  | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
460
466
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
461
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,8 +9,10 @@
9
9
  <!--
10
10
  Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
11
11
  vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
12
- Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 아래와 같은 구조의
13
- <div id="app" data-page="..."> 내려보낸다 개발과 운영이 같은 셸.
12
+ Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 div#app 뒤에 초기 페이지 객체를
13
+ script[type=application/json][data-page=app] 엘리먼트로 자동 주입한다(data-page
14
+ div 속성이 아니라 이 script 태그에 실린다 · 여기에 직접 쓰지 말 것 — 서버가 넣는다).
15
+ 개발과 운영이 같은 셸이다.
14
16
  -->
15
17
  <div id="app"></div>
16
18
  <script type="module" src="/main.ts"></script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.39.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/data": "0.16.1",
31
- "@gaonjs/async": "0.13.0",
30
+ "@gaonjs/config": "0.16.1",
31
+ "@gaonjs/core": "0.2.2",
32
32
  "@gaonjs/i18n": "0.2.1",
33
- "@gaonjs/config": "0.16.0",
34
- "@gaonjs/web": "0.19.0",
35
- "@gaonjs/mail": "0.2.1",
36
- "@gaonjs/core": "0.2.2"
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})\""