@gaonjs/cli 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/doctor/fixers/index.d.ts +1 -0
- package/dist/doctor/fixers/index.js +8 -0
- package/dist/doctor/fixers/seal-client-wiring.d.ts +10 -0
- package/dist/doctor/fixers/seal-client-wiring.js +61 -0
- package/dist/doctor/seal-security.d.ts +14 -0
- package/dist/doctor/seal-security.js +104 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.js +3 -0
- package/dist/index.js +1 -1
- package/dist/templates/project/AGENTS.md.tpl +20 -14
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/data.md.tpl +5 -0
- package/dist/templates/project/agents/frontend.md.tpl +6 -0
- package/dist/templates/project/agents/realtime.md.tpl +6 -0
- package/dist/templates/project/agents/seal.md.tpl +131 -0
- package/dist/templates/project/agents/security.md.tpl +21 -0
- package/dist/templates/project/agents/testing.md.tpl +39 -0
- package/dist/templates/project/agents/web.md.tpl +6 -0
- package/package.json +5 -5
|
@@ -4,6 +4,7 @@ export type { Fixer, FixerCapability, FixerPlan, RewriteFixerPlan, RenameFixerPl
|
|
|
4
4
|
export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency-direction.js';
|
|
5
5
|
export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
6
6
|
export { fixModelFilename, rewriteModelImport } from './model-filename.js';
|
|
7
|
+
export { fixSealClientWiring, wireSealClient } from './seal-client-wiring.js';
|
|
7
8
|
/**
|
|
8
9
|
* 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
|
|
9
10
|
* 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
import { fixDependencyDirection } from './dependency-direction.js';
|
|
10
10
|
import { fixSchemaFilename } from './schema-filename.js';
|
|
11
11
|
import { fixModelFilename } from './model-filename.js';
|
|
12
|
+
import { fixSealClientWiring } from './seal-client-wiring.js';
|
|
12
13
|
export { fixDependencyDirection, fixDomainToSharedTypeOnly, } from './dependency-direction.js';
|
|
13
14
|
export { fixSchemaFilename, rewriteSchemaImport } from './schema-filename.js';
|
|
14
15
|
export { fixModelFilename, rewriteModelImport } from './model-filename.js';
|
|
16
|
+
export { fixSealClientWiring, wireSealClient } from './seal-client-wiring.js';
|
|
15
17
|
/**
|
|
16
18
|
* 규칙별 fixer 표. 없는 규칙(값 undefined)은 "수동 수정 필요" 로 리포트된다.
|
|
17
19
|
* 새 fixer 를 만들면 이 표에만 추가하면 된다(runDoctorFix 는 표를 순회).
|
|
@@ -20,6 +22,7 @@ export const FIXERS = {
|
|
|
20
22
|
'dependency-direction': fixDependencyDirection,
|
|
21
23
|
'schema-filename': fixSchemaFilename,
|
|
22
24
|
'model-filename': fixModelFilename,
|
|
25
|
+
'seal-security': fixSealClientWiring,
|
|
23
26
|
};
|
|
24
27
|
/**
|
|
25
28
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
@@ -86,4 +89,9 @@ export const FIXER_CAPABILITIES = [
|
|
|
86
89
|
hasFixer: false,
|
|
87
90
|
note: "수동 · 페이지는 this.render('...') 문자열·Inertia glob 로 해석돼 import 참조 갱신만으론 부족합니다(결정 32·46 · PascalCase 로 rename 후 render 키 확인).",
|
|
88
91
|
},
|
|
92
|
+
{
|
|
93
|
+
rule: 'seal-security',
|
|
94
|
+
hasFixer: true,
|
|
95
|
+
note: 'seal:true 앱의 main.ts 에 @gaonjs/seal/client 정적 import + createGaonApp sealClient 전달을 자동 배선(결정 124). 방어 역전 warning 은 설계 결정이라 수동.',
|
|
96
|
+
},
|
|
89
97
|
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DoctorCheck } from '../types.js';
|
|
2
|
+
import type { FixerPlan } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* main.ts 소스에 seal 클라이언트 배선을 더한다(순수 계산 · 멱등).
|
|
5
|
+
* ① @gaonjs/seal/client 정적 import 가 없으면 gaonjs/vue createGaonApp import 다음(없으면 최상단)에 추가.
|
|
6
|
+
* ② createGaonApp({...}) 호출에 sealClient 가 안 넘어가면 인자로 추가.
|
|
7
|
+
*/
|
|
8
|
+
export declare function wireSealClient(source: string): string;
|
|
9
|
+
/** seal-security 의 main.ts 미배선 error 를 받아 main.ts 재작성 계획을 낸다. */
|
|
10
|
+
export declare function fixSealClientWiring(issues: readonly DoctorCheck[], cwd: string): Promise<readonly FixerPlan[]>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · fixer · seal 클라이언트 배선 (결정 124 · §7.5.3)
|
|
2
|
+
//
|
|
3
|
+
// 대상 위반: seal:true 앱의 main.ts 가 @gaonjs/seal/client 를 정적 import 해
|
|
4
|
+
// createGaonApp 에 sealClient 로 넘기지 않은 경우(seal-security 규칙 error).
|
|
5
|
+
// 자동 정정은 안전하다 — 없는 것만 더한다(import 추가 · sealClient 인자 추가).
|
|
6
|
+
// 이미 배선돼 있으면(멱등) 변경 없음. 결정 124: "seal: true" 한 줄 멘탈모델을
|
|
7
|
+
// 유지하려면 `gaon check --fix` 가 배선을 자동 생성한다(pages/layouts glob 처럼).
|
|
8
|
+
//
|
|
9
|
+
// main.ts 가 아예 없으면(이례) 이 fixer 는 건너뛴다 — 상위가 "수동 수정 필요"
|
|
10
|
+
// 로 보고하고, 규칙 error 메시지가 무엇을 넣을지 안내한다(§7.5.3).
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
const CLIENT_IMPORT = "import * as sealClient from '@gaonjs/seal/client'";
|
|
15
|
+
/**
|
|
16
|
+
* main.ts 소스에 seal 클라이언트 배선을 더한다(순수 계산 · 멱등).
|
|
17
|
+
* ① @gaonjs/seal/client 정적 import 가 없으면 gaonjs/vue createGaonApp import 다음(없으면 최상단)에 추가.
|
|
18
|
+
* ② createGaonApp({...}) 호출에 sealClient 가 안 넘어가면 인자로 추가.
|
|
19
|
+
*/
|
|
20
|
+
export function wireSealClient(source) {
|
|
21
|
+
let out = source;
|
|
22
|
+
if (!/@gaonjs\/seal\/client/.test(out)) {
|
|
23
|
+
const vueImport = /(import\s*\{[^}]*\bcreateGaonApp\b[^}]*\}\s*from\s*['"]gaonjs\/vue['"][^\n]*\n)/;
|
|
24
|
+
out = vueImport.test(out)
|
|
25
|
+
? out.replace(vueImport, `$1${CLIENT_IMPORT}\n`)
|
|
26
|
+
: `${CLIENT_IMPORT}\n${out}`;
|
|
27
|
+
}
|
|
28
|
+
if (!/createGaonApp\s*\([\s\S]*\bsealClient\b/.test(out)) {
|
|
29
|
+
out = out.replace(/createGaonApp\s*\(\s*\{/, 'createGaonApp({\n sealClient,');
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** seal-security 의 main.ts 미배선 error 를 받아 main.ts 재작성 계획을 낸다. */
|
|
34
|
+
export async function fixSealClientWiring(issues, cwd) {
|
|
35
|
+
const plans = [];
|
|
36
|
+
// 이 fixer 는 main.ts 배선 error 만 다룬다(방어 역전 warning 은 설계 결정 · 자동수정 X).
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const issue of issues) {
|
|
39
|
+
const rel = typeof issue.detail?.main === 'string' ? issue.detail.main : undefined;
|
|
40
|
+
if (rel === undefined || seen.has(rel))
|
|
41
|
+
continue;
|
|
42
|
+
seen.add(rel);
|
|
43
|
+
const abs = join(cwd, rel);
|
|
44
|
+
if (!existsSync(abs))
|
|
45
|
+
continue; // main.ts 부재는 수동(상위가 보고).
|
|
46
|
+
const before = await readFile(abs, 'utf8').catch(() => undefined);
|
|
47
|
+
if (before === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
const after = wireSealClient(before);
|
|
50
|
+
if (after === before)
|
|
51
|
+
continue;
|
|
52
|
+
plans.push({
|
|
53
|
+
file: rel,
|
|
54
|
+
before,
|
|
55
|
+
after,
|
|
56
|
+
summary: `${rel}: @gaonjs/seal/client 정적 import + createGaonApp 에 sealClient 전달을 추가했습니다 ` +
|
|
57
|
+
`(결정 124 · 봉인 문서 개봉 배선).`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return plans;
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** app.config.ts 소스에 seal 토글(true 또는 { … })이 켜져 있는지 판정한다(단위 테스트 진입점). */
|
|
3
|
+
export declare function hasSealEnabled(appConfigSource: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* 결정 121(§3.1): seal 앱의 main.ts 가 seal 클라이언트를 배선했는지 판정한다(단위 테스트 진입점).
|
|
6
|
+
* 봉인 문서를 브라우저가 열려면 main.ts 가 @gaonjs/seal/client 를 **정적** import 해 createGaonApp 에
|
|
7
|
+
* sealClient 로 넘겨야 한다 — 변수 동적 import 는 vite 가 번들하지 못해 실 브라우저에서 마운트가 깨진다
|
|
8
|
+
* (seal-browser-e2e 로 실측). import + sealClient 전달을 둘 다 확인한다.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hasSealClientWired(mainSource: string): boolean;
|
|
11
|
+
/** gaon.config.ts 소스에서 명시적으로 꺼진 보안 방어층 이름을 뽑는다(rateLimit·securityHeaders·cors). */
|
|
12
|
+
export declare function disabledDefenses(gaonConfigSource: string): string[];
|
|
13
|
+
/** seal 앱이 있는데 진짜 방어층이 명시적으로 꺼져 있으면 경고한다. */
|
|
14
|
+
export declare function checkSealSecurity(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · seal ↔ 보안 기본값 역전 검사 (결정 121 · §7)
|
|
2
|
+
//
|
|
3
|
+
// @gaonjs/seal 은 난독화·replay 방어·구간암호화를 줄 뿐 **서버 검증을 대체하지 않는다**(agents/security.md
|
|
4
|
+
// §0). "seal 켰으니 검증 느슨해도 됨" 이라는 가짜 안심이 이 기능의 유일한 진짜 위험이다. 그래서 seal 을
|
|
5
|
+
// 켠 앱이 있는데 진짜 방어층(rate limit·보안 헤더)을 **명시적으로 껐으면** 경고한다 — 봉인을 켜고 실제
|
|
6
|
+
// 방어를 끄는 역전을 막는다. CSRF(세션 미배선)는 csrf-wiring 검사가 담당하므로 여기선 중복하지 않는다.
|
|
7
|
+
//
|
|
8
|
+
// 오탐 방지: 명시적 `false`(rateLimit: false · securityHeaders: false)만 잡는다. 기본값(생략)은 코어가
|
|
9
|
+
// 전부 켜므로(규칙 8) 경고 대상이 아니다. seal 앱이 없으면 검사 자체가 no-op.
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
/** app.config.ts 소스에 seal 토글(true 또는 { … })이 켜져 있는지 판정한다(단위 테스트 진입점). */
|
|
14
|
+
export function hasSealEnabled(appConfigSource) {
|
|
15
|
+
// seal: true · seal: { except: [...] } — false/생략은 제외.
|
|
16
|
+
return /\bseal\s*:\s*(true|\{)/.test(appConfigSource);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 결정 121(§3.1): seal 앱의 main.ts 가 seal 클라이언트를 배선했는지 판정한다(단위 테스트 진입점).
|
|
20
|
+
* 봉인 문서를 브라우저가 열려면 main.ts 가 @gaonjs/seal/client 를 **정적** import 해 createGaonApp 에
|
|
21
|
+
* sealClient 로 넘겨야 한다 — 변수 동적 import 는 vite 가 번들하지 못해 실 브라우저에서 마운트가 깨진다
|
|
22
|
+
* (seal-browser-e2e 로 실측). import + sealClient 전달을 둘 다 확인한다.
|
|
23
|
+
*/
|
|
24
|
+
export function hasSealClientWired(mainSource) {
|
|
25
|
+
const imported = /import[^\n]*@gaonjs\/seal\/client/.test(mainSource);
|
|
26
|
+
// createGaonApp(...) 호출 안에 sealClient 가 실제로 전달됐는지(멀티라인 객체 허용).
|
|
27
|
+
const passed = /createGaonApp\s*\([\s\S]*\bsealClient\b/.test(mainSource);
|
|
28
|
+
return imported && passed;
|
|
29
|
+
}
|
|
30
|
+
/** gaon.config.ts 소스에서 명시적으로 꺼진 보안 방어층 이름을 뽑는다(rateLimit·securityHeaders·cors). */
|
|
31
|
+
export function disabledDefenses(gaonConfigSource) {
|
|
32
|
+
const off = [];
|
|
33
|
+
if (/\brateLimit\s*:\s*false/.test(gaonConfigSource))
|
|
34
|
+
off.push('rateLimit');
|
|
35
|
+
if (/\bsecurityHeaders\s*:\s*false/.test(gaonConfigSource))
|
|
36
|
+
off.push('securityHeaders');
|
|
37
|
+
if (/\bcors\s*:\s*false/.test(gaonConfigSource))
|
|
38
|
+
off.push('cors');
|
|
39
|
+
return off;
|
|
40
|
+
}
|
|
41
|
+
/** seal 앱이 있는데 진짜 방어층이 명시적으로 꺼져 있으면 경고한다. */
|
|
42
|
+
export async function checkSealSecurity(cwd) {
|
|
43
|
+
const issues = [];
|
|
44
|
+
const appsDir = join(cwd, 'apps');
|
|
45
|
+
const sealApps = [];
|
|
46
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
47
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
48
|
+
const src = existsSync(acPath) ? await readFile(acPath, 'utf8').catch(() => undefined) : undefined;
|
|
49
|
+
if (src != null && hasSealEnabled(src))
|
|
50
|
+
sealApps.push(app);
|
|
51
|
+
}
|
|
52
|
+
if (sealApps.length === 0)
|
|
53
|
+
return { rule: 'seal-security', issues };
|
|
54
|
+
// 결정 121(§3.1): 각 seal 앱의 main.ts 가 seal 클라이언트를 배선했는지 확인한다(error). 미배선이면
|
|
55
|
+
// 봉인 문서를 브라우저가 못 열어 앱이 blank 로 뜬다 — 컴파일·서버 게이트는 못 잡는 부류(브라우저 e2e 로 발견).
|
|
56
|
+
for (const app of sealApps) {
|
|
57
|
+
const mainPath = join(appsDir, app, 'main.ts');
|
|
58
|
+
const mainSrc = existsSync(mainPath) ? await readFile(mainPath, 'utf8').catch(() => undefined) : undefined;
|
|
59
|
+
if (mainSrc == null || !hasSealClientWired(mainSrc)) {
|
|
60
|
+
issues.push({
|
|
61
|
+
rule: 'seal-security',
|
|
62
|
+
level: 'error',
|
|
63
|
+
file: `apps/${app}/main.ts`,
|
|
64
|
+
message: `seal 을 켠 앱 '${app}' 의 main.ts 가 seal 클라이언트를 배선하지 않았습니다 (결정 121 · §3.1).\n` +
|
|
65
|
+
`봉인된 최초 문서를 브라우저가 열려면 main.ts 가 @gaonjs/seal/client 를 **정적** import 해 ` +
|
|
66
|
+
`createGaonApp 에 넘겨야 합니다 — 변수 동적 import 는 vite 가 번들하지 못해 실 브라우저 빌드에서 ` +
|
|
67
|
+
`마운트가 깨집니다(blank).\n` +
|
|
68
|
+
`→ apps/${app}/main.ts 상단에: import * as sealClient from '@gaonjs/seal/client'\n` +
|
|
69
|
+
`→ createGaonApp({ pages, layouts, /* … */ sealClient }) 처럼 sealClient 를 넘기세요.\n` +
|
|
70
|
+
`→ @gaonjs/seal 미설치면: npm i @gaonjs/seal`,
|
|
71
|
+
detail: { app, main: `apps/${app}/main.ts` },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const gaonCfgPath = join(cwd, 'gaon.config.ts');
|
|
76
|
+
const gaonCfg = existsSync(gaonCfgPath) ? await readFile(gaonCfgPath, 'utf8').catch(() => undefined) : undefined;
|
|
77
|
+
if (gaonCfg == null)
|
|
78
|
+
return { rule: 'seal-security', issues };
|
|
79
|
+
const off = disabledDefenses(gaonCfg);
|
|
80
|
+
if (off.length > 0) {
|
|
81
|
+
issues.push({
|
|
82
|
+
rule: 'seal-security',
|
|
83
|
+
level: 'warning',
|
|
84
|
+
file: 'gaon.config.ts',
|
|
85
|
+
message: `seal 을 켠 앱(${sealApps.join(', ')})이 있는데 gaon.config.ts 가 진짜 방어층 [${off.join(', ')}] 을 ` +
|
|
86
|
+
`명시적으로 껐습니다 (결정 121 · §7).\n` +
|
|
87
|
+
`⚠️ seal 은 난독화·replay 방어·구간암호화일 뿐 **서버 검증·rate limit·CORS·보안 헤더를 대체하지 ` +
|
|
88
|
+
`않습니다**(agents/security.md §0). "봉인을 켰으니 방어를 꺼도 된다" 는 이 기능의 유일한 진짜 위험입니다.\n` +
|
|
89
|
+
`→ gaon.config.ts 의 web.security 에서 [${off.join(', ')}] 를 다시 켜세요(기본값=켬 · 규칙 8).\n` +
|
|
90
|
+
`→ 정말 꺼야 한다면 seal 과 무관한 별도 이유를 문서화하세요 — seal 은 이유가 되지 못합니다.`,
|
|
91
|
+
detail: { sealApps, disabled: off },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { rule: 'seal-security', issues };
|
|
95
|
+
}
|
|
96
|
+
async function safeListDirs(dir) {
|
|
97
|
+
try {
|
|
98
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
99
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
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-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';
|
|
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';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
package/dist/doctor.js
CHANGED
|
@@ -58,6 +58,7 @@ import { checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
|
58
58
|
import { checkInternalAnchor } from './doctor/internal-anchor.js';
|
|
59
59
|
import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
|
|
60
60
|
import { checkAsyncOffload } from './doctor/async-offload.js';
|
|
61
|
+
import { checkSealSecurity } from './doctor/seal-security.js';
|
|
61
62
|
import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
|
|
62
63
|
import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
|
|
63
64
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
@@ -113,6 +114,7 @@ const ALL_RULES = [
|
|
|
113
114
|
'async-offload',
|
|
114
115
|
'page-layout-breakpoint',
|
|
115
116
|
'link-button-nesting',
|
|
117
|
+
'seal-security',
|
|
116
118
|
];
|
|
117
119
|
const CHECKERS = {
|
|
118
120
|
'response-mixing': checkResponseMixing,
|
|
@@ -138,6 +140,7 @@ const CHECKERS = {
|
|
|
138
140
|
'async-offload': checkAsyncOffload,
|
|
139
141
|
'page-layout-breakpoint': checkPageLayoutBreakpoint,
|
|
140
142
|
'link-button-nesting': checkLinkButtonNesting,
|
|
143
|
+
'seal-security': checkSealSecurity,
|
|
141
144
|
};
|
|
142
145
|
/**
|
|
143
146
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/index.js
CHANGED
|
@@ -101,7 +101,7 @@ function renderHelp(version = VERSION) {
|
|
|
101
101
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
102
102
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
103
103
|
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
|
|
104
|
-
" gaon doctor 정적 검사 (
|
|
104
|
+
" gaon doctor 정적 검사 (24 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전)",
|
|
105
105
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
106
106
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
107
107
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -28,6 +28,7 @@ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구
|
|
|
28
28
|
| 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
|
|
29
29
|
| 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
|
|
30
30
|
| 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
|
|
31
|
+
| 페이로드 봉인 (`@gaonjs/seal` · wire/문서/WS 암호화 · 선택 플러그인) | `agents/seal.md` |
|
|
31
32
|
|
|
32
33
|
예: 회원가입 세로 조각(스키마+서비스+잡+컨트롤러+테스트)이면
|
|
33
34
|
`data · web · async · testing` 네 파일을 먼저 읽는다.
|
|
@@ -104,7 +105,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
104
105
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
105
106
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
106
107
|
|
|
107
|
-
### 2.2 `gaon doctor` 검사
|
|
108
|
+
### 2.2 `gaon doctor` 검사 24종
|
|
108
109
|
|
|
109
110
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
110
111
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -129,6 +130,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
129
130
|
21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
|
|
130
131
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
131
132
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
133
|
+
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
132
134
|
|
|
133
135
|
## 3. 로직 배치 One Way 판단표
|
|
134
136
|
|
|
@@ -189,7 +191,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
189
191
|
```bash
|
|
190
192
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
191
193
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
192
|
-
gaon doctor # 정적 검사
|
|
194
|
+
gaon doctor # 정적 검사 24종 (§2.2)
|
|
193
195
|
```
|
|
194
196
|
|
|
195
197
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -215,19 +217,23 @@ gaon doctor # 정적 검사 23종 (§2.2)
|
|
|
215
217
|
개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
|
|
216
218
|
비-production 부팅 시 이 안내를 한 줄 출력한다.
|
|
217
219
|
|
|
218
|
-
## 5. npm 배포본
|
|
220
|
+
## 5. npm 배포본 — 패키지 → 역할
|
|
219
221
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
|
224
|
-
|
|
225
|
-
|
|
|
226
|
-
| `@gaonjs/
|
|
227
|
-
| `@gaonjs/
|
|
228
|
-
| `@gaonjs/
|
|
229
|
-
| `@gaonjs/
|
|
230
|
-
| `@gaonjs/
|
|
222
|
+
개발자는 파사드 **`gaonjs`** 하나만 설치한다(CLI 명령 `gaon`). 아래는 내부 패키지의 역할 지도다.
|
|
223
|
+
**실버전은 `npm view <pkg> version` 으로 확인한다** — 버전은 릴리스마다 바뀌므로 문서에 박지 않는다.
|
|
224
|
+
|
|
225
|
+
| 패키지 | 역할 |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `gaonjs` | 파사드(설치 단위) · CLI `gaon` |
|
|
228
|
+
| `@gaonjs/cli` | 제너레이터·스캐폴딩·명령 라우팅 |
|
|
229
|
+
| `@gaonjs/data` | 스키마 DSL · 모델 · 마이그레이션 |
|
|
230
|
+
| `@gaonjs/config` | `gaon.config.ts`·`app.config.ts` |
|
|
231
|
+
| `@gaonjs/web` | Fastify 웹 레이어 · 인증 · JSON 액션 |
|
|
232
|
+
| `@gaonjs/vue` | Vue 어댑터 · pageProps · 타입드 `api()` |
|
|
233
|
+
| `@gaonjs/async` | 채널 · 프레즌스 · 허브 · 잡 · 스케줄 |
|
|
234
|
+
| `@gaonjs/core` | 메타데이터 · 직렬화 프리미티브(Hidden) |
|
|
235
|
+
| `@gaonjs/mail` / `storage` / `i18n` | 메일 · 파일 스토리지 · 다국어 |
|
|
236
|
+
| `@gaonjs/seal` | 페이로드 봉인(선택 플러그인 · `agents/seal.md`) |
|
|
231
237
|
|
|
232
238
|
## 6. 원칙 · 엄수
|
|
233
239
|
|
|
@@ -138,6 +138,11 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
138
138
|
**타입 레벨로** 제외 (모든 타입에 확장). 페이지 props 에서 접근하면
|
|
139
139
|
컴파일 에러 — 유출이 타입 시스템에서 막힌다 (v0.15 §4.2 line
|
|
140
140
|
396–399 원문). 비밀번호 다이제스트가 대표: `passwordDigest: t.string().hidden()`.
|
|
141
|
+
런타임도 `serializeProps` 가 hidden 컬럼을 값에서 떨군다 —
|
|
142
|
+
**관계(`include`/지연) 로 로드한 대상 행에서도** 대상 테이블의 hidden 이
|
|
143
|
+
제외된다(결정 122). 즉 `Post.include('author')` 로 실은 `author`(User)를
|
|
144
|
+
render props 로 통째로 넘겨도 `passwordDigest` 는 나가지 않는다. hidden 값은
|
|
145
|
+
서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2).
|
|
141
146
|
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
142
147
|
- `.index()` — 컬럼 레벨 인덱스 (E-4).
|
|
143
148
|
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
|
|
@@ -402,3 +402,9 @@ async function runSearch(q: string) {
|
|
|
402
402
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
|
|
403
403
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
404
404
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
405
|
+
|
|
406
|
+
## `@gaonjs/seal` 켠 앱의 프론트
|
|
407
|
+
|
|
408
|
+
seal 앱은 페이지·컴포넌트·`useForm`·`api()` 코드를 바꾸지 않는다 — 봉인/개봉은 전송 경계 인터셉터가
|
|
409
|
+
투명하게 한다. **단, 그 앱의 `main.ts` 는 seal 클라이언트를 배선해야 한다**(`import * as sealClient from
|
|
410
|
+
'@gaonjs/seal/client'` → `createGaonApp({ …, sealClient })`). 켜는 법·이유·CSP·함정은 **`agents/seal.md`** 가 정본.
|
|
@@ -170,3 +170,9 @@ export default channel({
|
|
|
170
170
|
|---|---|
|
|
171
171
|
| E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
|
|
172
172
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
173
|
+
|
|
174
|
+
## `@gaonjs/seal` 켠 앱의 채널
|
|
175
|
+
|
|
176
|
+
seal 앱은 HTTP 뿐 아니라 **WS 채널 프레임도 봉인**된다(namespace requireDecrypt · 평문 `P:` 거부 · 송신
|
|
177
|
+
`E:<ts>:<base64>`). 채널 코드는 안 바뀐다(경계 인터셉터). **정본은 `agents/seal.md`**. seal 은 채널
|
|
178
|
+
`authorize`/멤버십 검사를 대체하지 않는다.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# agents/seal.md — 페이로드 봉인 (`@gaonjs/seal` · 선택 플러그인)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 요약만 담는다 — seal 을 켜거나 seal 관련 코드를 만지기 전에 이 파일을 읽는다.
|
|
5
|
+
> `@gaonjs/seal` 은 **선택 플러그인**이다(파사드 `gaonjs` 에 미포함 · `npm i @gaonjs/seal` 로 별도 설치).
|
|
6
|
+
|
|
7
|
+
## 0. 정직한 포지셔닝 — 먼저 읽어라 (결정 121)
|
|
8
|
+
|
|
9
|
+
**`@gaonjs/seal` 은 "완전한 보안" 이 아니다.** 키 유도·봉인 코드가 **클라이언트(wasm)에 실린다** —
|
|
10
|
+
작정한 공격자는 wasm 을 역공학해 봉인 규약을 복원·조작할 수 있다. 그러므로:
|
|
11
|
+
|
|
12
|
+
**seal 은 서버 검증을 대체하지 않는다.** 아래 기존 방어층을 seal 을 켠 뒤에도 **그대로** 유지한다 —
|
|
13
|
+
seal 은 이들 중 어느 것의 이유도 되지 못한다:
|
|
14
|
+
|
|
15
|
+
- 입력 스키마 검증 · 대량 할당(mass assignment) 방어 · `this.params` 오염 방어(결정 24)
|
|
16
|
+
- `requireAuth` + 소유권 확인(`ownedBy` → 404) · 세션/CSRF · rate limit · CORS · 보안 헤더
|
|
17
|
+
- Kysely 파라미터 바인딩(SQL 인젝션 방어) · Vue 템플릿 이스케이프(XSS 방어) · hidden 컬럼 계약
|
|
18
|
+
|
|
19
|
+
**seal 이 주는 것은 딱 3가지다:**
|
|
20
|
+
|
|
21
|
+
1. **난독화 계층** — 소스 보기·개발자도구·자동 스캐너를 막아 **공격 비용을 올린다**(방어 아님).
|
|
22
|
+
2. **replay 방어** — 탈취한 트래픽 재전송을 nonce + timestamp drift 로 차단한다(HTTPS 도 못 하는 부분 · **실보안**).
|
|
23
|
+
3. **구간암호화 컴플라이언스** — 금융권류 wire 암호화 요구를 만족시킨다.
|
|
24
|
+
|
|
25
|
+
> ⚠️ **이 기능의 유일한 진짜 위험은 "seal 켰으니 검증을 느슨하게 해도 된다" 는 가짜 안심이다.**
|
|
26
|
+
> 그런 판단을 하는 순간 seal 은 보안을 **낮춘다**. `gaon doctor` 의 `seal-security` 가
|
|
27
|
+
> seal 앱에서 rate limit·보안 헤더·CORS 를 명시적으로 끈 경우를 경고한다.
|
|
28
|
+
|
|
29
|
+
### 막는 것 / 못 막는 것 경계표
|
|
30
|
+
|
|
31
|
+
| 위협 | seal 이 막나 | 진짜 방어 |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| 소스/개발자도구로 API 규약 훔쳐보기 | ✅ 비용↑(난독화) | — (seal 이 담당) |
|
|
34
|
+
| 탈취 트래픽 재전송(replay) | ✅ nonce+drift | — (seal 이 담당) |
|
|
35
|
+
| wire 평문 노출(구간 암호화) | ✅ AES-256-GCM | HTTPS(보완) |
|
|
36
|
+
| 조작된 입력·과도한 필드(mass assignment) | ❌ | 스키마 검증 · `pick`/대량할당 방어 |
|
|
37
|
+
| 인가 우회(남의 리소스 접근) | ❌ | `requireAuth` + `ownedBy`(404) |
|
|
38
|
+
| SQL 인젝션 · XSS | ❌ | Kysely 바인딩 · Vue 이스케이프 |
|
|
39
|
+
| CSRF · 무차별 요청(DoS) | ❌ | 세션 CSRF · rate limit |
|
|
40
|
+
| 작정한 공격자의 봉인 위조 | ❌(클라에 규약 있음) | 위 서버 검증 전부 |
|
|
41
|
+
|
|
42
|
+
## 1. 켜는 법 — The One Way (결정 121·124)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
1) npm i @gaonjs/seal # 선택 플러그인 · 기본 스캐폴드 미포함
|
|
46
|
+
```
|
|
47
|
+
```ts
|
|
48
|
+
// 2) apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
|
|
49
|
+
export default defineAppConfig({
|
|
50
|
+
seal: true, // 또는 { except: ['/webhooks/*'] } — 외부가 seal 을 모르는 경로만 평문 통과
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
```ts
|
|
54
|
+
// 3) apps/<앱>/main.ts — seal 클라이언트를 정적 import 해 createGaonApp 에 넘긴다.
|
|
55
|
+
import { createGaonApp } from 'gaonjs/vue'
|
|
56
|
+
import * as sealClient from '@gaonjs/seal/client' // 정적 import (사용자 vite 가 wasm 포함 번들)
|
|
57
|
+
void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **왜 main.ts 배선이 필요한가 (결정 124 · §3.1)**: `@gaonjs/vue` 는 선택 플러그인 seal 을 **몰라야** 한다
|
|
61
|
+
(비-seal 앱 번들에 wasm 유입 금지). 그래서 seal 클라이언트·wasm 은 seal 을 설치한 **사용자 프로젝트의
|
|
62
|
+
vite** 가 번들하도록 `main.ts` 에서 **app-side 정적 import** 로 주입한다. (과거 `@gaonjs/vue` 안의
|
|
63
|
+
**변수-specifier 동적 import** 는 vite 가 번들하지 못해 실 브라우저 마운트가 blank 로 깨졌다 — **폐기**.)
|
|
64
|
+
- **doctor 가 지킨다**: `seal-security` 가 `seal: true` 인데 main.ts 배선이 없으면 **에러 + 수리 안내**를 내고,
|
|
65
|
+
`gaon check --fix` 의 `seal-client-wiring` fixer 가 배선을 자동 생성한다. 개발자·AI 멘탈모델은 여전히
|
|
66
|
+
"seal: true 한 줄" 이고, 배선 누락은 doctor 가 즉시 잡는다(§7.5.3 에러=수리 안내서). 런타임도 봉인 문서를
|
|
67
|
+
배선 없이 받으면 정확한 수리 메시지로 throw 한다(blank 대신).
|
|
68
|
+
- 미설치로 `seal: true` 를 켜면 **부팅 에러**(수리 안내). `masterSecret` 설정 표면은 없다 — 미끼 literal
|
|
69
|
+
이라 설정할 이유가 없다(비밀 착시·랜덤화 사고 방지).
|
|
70
|
+
|
|
71
|
+
## 2. 무엇이 봉인되나
|
|
72
|
+
|
|
73
|
+
- **요청/응답 JSON**: 클라 `installClientSeal()` 이 Inertia XHR 인터셉터(`XMLHttpRequest.prototype`) +
|
|
74
|
+
`api()`/`fetch` 봉인을 설치한다. 서버는 Fastify **4-stage 훅**(`plugin.ts` · onRequest 분류/fail-closed →
|
|
75
|
+
preParsing body 개봉+replay → preValidation query `?q=` 개봉+replay → onSend 응답 봉인)으로 대칭 복호.
|
|
76
|
+
**JSON-intent 판별**: `Accept`/`Content-Type: application/json` 요청만 봉인 강제(비-JSON HTML·form 은 자동 면제).
|
|
77
|
+
- **최초 문서 data-page**: 서버가 `<script data-page="app" data-gaon-sealed="1">` 로 봉인 + `<meta gaon-seal-ts>`.
|
|
78
|
+
클라 `createGaonApp` 이 Inertia 마운트 **전**에 wasm 으로 개봉 → 소스 보기·개발자도구에 평문 props 미노출.
|
|
79
|
+
- **WS 프레임 (결정 124 · §3.4)**: `useChannel` 이 `setWsFrameCodec`(seal 클라 `wsEncode`/`wsDecode`)로
|
|
80
|
+
채널 송수신을 봉인한다. 송신 `E:<ts>:<base64>` · seal namespace 는 평문 `P:` 프레임 **거부**(requireDecrypt · 결정 121).
|
|
81
|
+
- **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비-JSON 은 **자동 제외**(사람 판단 없이
|
|
82
|
+
Content-Type 기계 판별). 외부(웹훅 등)가 봉인을 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
|
|
83
|
+
- **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
84
|
+
**403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 소켓 4500 종료(silent fallback 없음).
|
|
85
|
+
|
|
86
|
+
## 3. CSP (결정 124)
|
|
87
|
+
|
|
88
|
+
seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입**한다(wasm 컴파일만 허용 ·
|
|
89
|
+
`'unsafe-eval'` 보다 좁음). **전역 완화 금지** — 비-seal 앱은 strict CSP(`script-src 'self'`) 그대로다.
|
|
90
|
+
요청 단위 판별(seal 스코프가 요청을 표시 · 보안 헤더 훅이 그 응답에만 보정).
|
|
91
|
+
|
|
92
|
+
## 4. 아키텍처 경계 (AI 가 넘지 말 것)
|
|
93
|
+
|
|
94
|
+
- **`@gaonjs/vue` 는 seal 무지 유지 · 비-seal 앱 번들에 wasm 0.** 두 번째 http/ws 클라이언트를 이식하지
|
|
95
|
+
않는다 — 봉인/개봉은 기존 전송 경로(Inertia·`api()`·`useChannel`) **경계 인터셉터**가 한다.
|
|
96
|
+
- **wasm 표면 은닉 (결정 124)**: wasm 은 **불투명 고수준 함수만** 노출한다(`sealHttp`/`openHttp`/`sealWs`/`openWs`).
|
|
97
|
+
키 유도(`deriveKey`)·UA 슬라이스(`getUaSlice`)·`seal`/`open` primitive 은 비공개, **미끼 마스터 시크릿은
|
|
98
|
+
wasm 바이너리 내장**(JS 번들 미노출). **domain·user-agent 는 wasm 이 브라우저(web-sys)에서 직접 읽고**
|
|
99
|
+
path 는 넘겨받은 URL 에서 wasm 이 파싱한다 — JS 소스에 "무엇이 키 유도 입력인가" 힌트를 남기지 않는다.
|
|
100
|
+
(위조는 서버가 실 요청 헤더로 독립 유도해 이미 막힌다 · 이 은닉은 힌트 제거·공격 비용 상승 목적.)
|
|
101
|
+
- **서버는 wasm 이 아니다** — JS mirror(`crypto.ts`)로 봉인/개봉하고, Rust 정본(wasm)과 **known-vector parity**
|
|
102
|
+
(`wasm-parity` 테스트)로 byte 호환을 강제한다.
|
|
103
|
+
- **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
|
|
104
|
+
`SHA256(hex(HMAC-SHA256(masterSecret, "domain:path:uaSlice:timestamp")))` · per-frame keying(userId 미포함).
|
|
105
|
+
- **replay 방어**: AES-GCM 12-byte nonce 를 `setIfNotExists`(Redis SETNX 대응) 캐시 + timestamp drift(±60s)로
|
|
106
|
+
차단. WS 기본은 drift 윈도우(고빈도라 프레임마다 SETNX 는 비용 과다 · 엄격 nonce 는 옵션 주입).
|
|
107
|
+
- **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
|
|
108
|
+
UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
|
|
109
|
+
|
|
110
|
+
## 5. 게이트 — seal 검증은 **실 브라우저가 blocking** (결정 124)
|
|
111
|
+
|
|
112
|
+
- 정본 게이트는 **실 vite 프로덕션 빌드 + 실 chromium + 실 wasm** e2e 다:
|
|
113
|
+
`test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
|
|
114
|
+
평문 `P:` 거부·hidden 누출 0·**비-seal 앱 번들 무-wasm 단언**) + `seal-fullstack-e2e`(실 `gaon serve`+`gaon hub`+NATS
|
|
115
|
+
경유 봉인 broadcast).
|
|
116
|
+
- **목업 e2e 로 대체 금지.** 서버 inject·단위·wasm-parity 는 byte 호환을 잠글 뿐 "실 브라우저에서 마운트되나"를
|
|
117
|
+
못 본다 — 원 wave 가 실-브라우저 e2e 를 미뤄 P0 3건(번들 불가·CSP 차단·Inertia 인터셉터 파손)을 놓친 교훈(결정 124).
|
|
118
|
+
|
|
119
|
+
## 알려진 함정
|
|
120
|
+
|
|
121
|
+
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고 `--fix` 가 배선. 런타임도 수리 안내 throw.
|
|
122
|
+
2. **변수-specifier 동적 import** (`const s='@gaonjs/seal/client'; import(s)`) — vite 가 번들 못 해 프로덕션 blank. **폐기됨** — app-side 정적 주입만.
|
|
123
|
+
3. **전역 CSP 완화 금지** — `'wasm-unsafe-eval'` 은 seal 앱 응답에만. 비-seal 앱 strict CSP 유지.
|
|
124
|
+
4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
|
|
125
|
+
5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
|
|
126
|
+
6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
|
|
127
|
+
|
|
128
|
+
## 관련 결정
|
|
129
|
+
|
|
130
|
+
- **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
|
|
131
|
+
- **결정 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).
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
4
|
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 보안 관련 코드를 만지기 전에 이 파일을 읽는다.
|
|
5
5
|
|
|
6
|
+
## 0. `@gaonjs/seal`(페이로드 봉인) — 정본은 `agents/seal.md`
|
|
7
|
+
|
|
8
|
+
`@gaonjs/seal`(선택 플러그인 · `npm i @gaonjs/seal`)은 요청/응답 JSON·최초 문서·WS 프레임을 봉인한다.
|
|
9
|
+
**켜는 법·막는 것/못 막는 것 경계표·아키텍처는 `agents/seal.md` 가 정본** — seal 을 켜거나 만지기 전에 읽는다.
|
|
10
|
+
보안 관점 핵심만:
|
|
11
|
+
|
|
12
|
+
- **seal 은 서버 검증을 대체하지 않는다.** 방어층(스키마 검증·인가·CSRF·rate limit·CORS·보안 헤더·Kysely
|
|
13
|
+
바인딩·Vue 이스케이프·hidden 계약)은 seal 을 켠 뒤에도 그대로 유지한다 — seal 은 난독화·replay 방어·구간
|
|
14
|
+
암호화일 뿐, 이들 중 어느 것의 이유도 되지 못한다("seal 켰으니 검증 느슨해도 됨" = 가짜 안심).
|
|
15
|
+
- `gaon doctor` 의 `seal-security` 가 seal 앱에서 rate limit·보안 헤더·CORS 를 명시적으로 끈 **방어 역전**을 경고한다.
|
|
16
|
+
|
|
6
17
|
## 정본 규칙
|
|
7
18
|
|
|
8
19
|
### 1. 보안 기본값 = fail-closed (v0.15 §2.5.1)
|
|
@@ -46,6 +57,11 @@
|
|
|
46
57
|
`agents/web.md` §5)로만 다루고, 다이제스트는 hidden 컬럼
|
|
47
58
|
(`passwordDigest: t.string().hidden()`)에 저장한다 — 응답 경계에서
|
|
48
59
|
타입·런타임 양쪽으로 페이지 노출이 막힌다.
|
|
60
|
+
- **hidden 계약은 render props 로 나가는 모든 경로에 적용된다** — 직접
|
|
61
|
+
모델 행뿐 아니라 `include()`/지연 관계로 로드한 **대상 행**에서도 그
|
|
62
|
+
테이블의 hidden 이 제외된다(결정 122). 불변식: *페이지로 나가는 값은
|
|
63
|
+
`serializeProps` 를 통과하고, 그 결과 어디에도 hidden 컬럼명이 없다.*
|
|
64
|
+
민감 컬럼은 반드시 `.hidden()` 로 선언해야 이 보호가 작동한다.
|
|
49
65
|
|
|
50
66
|
### 4. 입력 안전 — `this.params` 고정 우선순위 (errata E-3 §5)
|
|
51
67
|
|
|
@@ -122,6 +138,10 @@ const rows = await Post.query()
|
|
|
122
138
|
`{ proxy }`·`{ header }` 를 선언한다(§6).
|
|
123
139
|
- **IP 로 인가 판단 금지** (결정 120) — "특정 IP = 관리자" 류는 위조·회전에 무너진다.
|
|
124
140
|
IP 는 rate limit·로깅까지만, 인가는 신원(세션/JWT)으로.
|
|
141
|
+
- **관계 행을 render props 로 넘길 때 hidden 을 잊지 않는다** (결정 122) —
|
|
142
|
+
hidden 보호는 `serializeProps` 통과 시 자동 적용되고 관계(`include`/지연)
|
|
143
|
+
행에도 적용된다. 단, 민감 컬럼을 `.hidden()` 로 **선언하지 않으면** 어느
|
|
144
|
+
경로로도 막히지 않는다 — 토큰·다이제스트·개인정보는 선언 시점에 hidden.
|
|
125
145
|
|
|
126
146
|
## 관련 결정 번호
|
|
127
147
|
|
|
@@ -132,3 +152,4 @@ const rows = await Post.query()
|
|
|
132
152
|
| §7 (v0.15) | 세션 앱별 분리 · JWT 는 API 앱 전용 |
|
|
133
153
|
| 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
|
|
134
154
|
| 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
|
|
155
|
+
| 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
|
|
@@ -123,6 +123,41 @@ BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감
|
|
|
123
123
|
그래서 격리는 service 가 실제로 커밋하는 운영 경로를 그대로 두고 매 테스트
|
|
124
124
|
뒤 truncate 로 비운다 — service 든 아니든 항상 안전하다.
|
|
125
125
|
|
|
126
|
+
### 6. 직렬화 경계 테스트 — 관계 경유 hidden 을 반드시 포함한다 (결정 122)
|
|
127
|
+
|
|
128
|
+
`.hidden()` 컬럼(예: `passwordDigest`)은 페이지 props 로 나가면 안 된다(§4.2).
|
|
129
|
+
이걸 검증하는 테스트는 **직접 모델 행만 보면 안 된다** — `include()`/지연 관계
|
|
130
|
+
접근자로 로드한 **관계 행**도 함께 렌더해 hidden 이 떨어지는지 봐야 한다.
|
|
131
|
+
관계 경유가 v1.4.1 에서 실제로 샜던 지점이고(결정 122), 직접 행만 보던
|
|
132
|
+
기존 테스트는 그 유출을 못 잡았다.
|
|
133
|
+
|
|
134
|
+
정본 = "직렬화 결과 어디에도 hidden 컬럼명이 없다" 를 **재귀로** 단언한다
|
|
135
|
+
(배열·중첩 관계 전부 훑는 헬퍼). 관계를 포함한 픽스처에 적용한다:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// 직렬화 결과(JSON-safe)에 hidden 컬럼명이 어느 깊이에도 없음을 확인.
|
|
139
|
+
function assertNoHiddenLeak(value: unknown, hidden: readonly string[]): void {
|
|
140
|
+
const walk = (v: unknown): void => {
|
|
141
|
+
if (v === null || typeof v !== 'object') return
|
|
142
|
+
if (Array.isArray(v)) return void v.forEach(walk)
|
|
143
|
+
for (const [k, child] of Object.entries(v)) {
|
|
144
|
+
if (hidden.includes(k)) throw new Error(`hidden 유출: ${k}`)
|
|
145
|
+
walk(child)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 관계를 포함해 렌더 — belongsTo·hasMany·hasOne·belongsToMany 를 커버.
|
|
152
|
+
const post = await Post.include('author').first() // author = User(passwordDigest hidden)
|
|
153
|
+
const props = serializeProps({ post })
|
|
154
|
+
assertNoHiddenLeak(props, ['passwordDigest']) // 관계 author 까지 재귀 확인
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에서만 제외 · §4.2) —
|
|
158
|
+
`serializeProps` 전에는 `user.passwordDigest` 가 정상 접근됨을 함께 단언해 계약을
|
|
159
|
+
양쪽으로 고정한다.
|
|
160
|
+
|
|
126
161
|
## 정본 예시
|
|
127
162
|
|
|
128
163
|
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본 예시이고,
|
|
@@ -139,12 +174,16 @@ BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감
|
|
|
139
174
|
소비한다. 큐·스트림 프리픽스를 테스트 전용으로.
|
|
140
175
|
- **워커 정리** — 직접 `runWorker` 를 쓰면 테스트 종료 전 `stop()` 을
|
|
141
176
|
보장하라 (`expectJobProcessed` 는 자동 정리).
|
|
177
|
+
- **직렬화 테스트가 직접 행만 봄** — hidden 검증을 `Model.create()` 결과
|
|
178
|
+
한 행에만 하면 관계 경유 유출(결정 122)을 못 잡는다. `include()`/지연
|
|
179
|
+
관계 행까지 렌더해 재귀로 확인한다(§6).
|
|
142
180
|
|
|
143
181
|
## 관련 결정 번호
|
|
144
182
|
|
|
145
183
|
| 결정 | 내용 |
|
|
146
184
|
|---|---|
|
|
147
185
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
|
|
186
|
+
| 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
|
|
148
187
|
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
149
188
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
150
189
|
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|
|
@@ -441,3 +441,9 @@ export default controller({
|
|
|
441
441
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
442
442
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
443
443
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
444
|
+
|
|
445
|
+
## `@gaonjs/seal` 켠 앱
|
|
446
|
+
|
|
447
|
+
`app.config seal: true` 면 그 앱의 wire(요청/응답 JSON + 최초 문서 data-page)가 봉인된다 — 컨트롤러·라우트·
|
|
448
|
+
`this.params`·`api()` 코드는 한 줄도 안 바뀐다. **정본은 `agents/seal.md`**(켜는 법·main.ts 배선·`except`·
|
|
449
|
+
자동 제외·fail-closed 403).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
+
"@gaonjs/data": "0.13.1",
|
|
30
31
|
"@gaonjs/async": "0.6.1",
|
|
31
|
-
"@gaonjs/data": "0.13.0",
|
|
32
32
|
"@gaonjs/core": "0.2.1",
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/config": "0.
|
|
35
|
-
"@gaonjs/
|
|
33
|
+
"@gaonjs/web": "0.11.0",
|
|
34
|
+
"@gaonjs/config": "0.9.0",
|
|
35
|
+
"@gaonjs/mail": "0.1.3"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|