@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 +2 -14
- package/dist/db/migrate.js +27 -16
- package/dist/doctor/fixers/index.js +2 -2
- package/dist/doctor/shared-purity.d.ts +9 -0
- package/dist/doctor/shared-purity.js +188 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +5 -5
- package/dist/generate.js +33 -6
- package/dist/templates/auth/session.secure.controller.ts.tpl +46 -0
- package/dist/templates/project/AGENTS.md.tpl +4 -3
- package/dist/templates/project/agents/data.md.tpl +26 -1
- package/dist/templates/project/agents/frontend.md.tpl +14 -8
- 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 +4 -2
- package/package.json +7 -7
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 만 있었을 수 있다. 성공으로 취급.
|
|
@@ -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-
|
|
58
|
+
rule: 'shared-purity',
|
|
59
59
|
hasFixer: false,
|
|
60
|
-
note: '수동 ·
|
|
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
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-
|
|
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 {
|
|
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-
|
|
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 {
|
|
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 {
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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: 시큐어 앱(공개 가입 미생성)은 역할 게이트
|
|
270
|
-
//
|
|
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
|
-
` 그러면
|
|
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
|
|
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-
|
|
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** — 세션 앱은
|
|
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
|
|
|
@@ -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-
|
|
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`
|
|
399
|
-
shared-
|
|
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
|
|
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,8 +9,10 @@
|
|
|
9
9
|
<!--
|
|
10
10
|
Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
|
|
11
11
|
vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
|
|
12
|
-
Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서
|
|
13
|
-
|
|
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.
|
|
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/
|
|
31
|
-
"@gaonjs/
|
|
30
|
+
"@gaonjs/config": "0.16.1",
|
|
31
|
+
"@gaonjs/core": "0.2.2",
|
|
32
32
|
"@gaonjs/i18n": "0.2.1",
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
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})\""
|