@gaonjs/cli 0.27.2 → 0.29.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 +26 -16
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/frontend.md.tpl +6 -0
- package/dist/templates/project/agents/realtime.md.tpl +48 -2
- package/dist/templates/project/agents/seal.md.tpl +136 -0
- package/dist/templates/project/agents/security.md.tpl +16 -0
- package/dist/templates/project/agents/web.md.tpl +6 -0
- package/dist/templates/project/docker-compose.yaml.tpl +6 -0
- package/package.json +6 -6
|
@@ -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
|
|
|
@@ -152,7 +154,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
152
154
|
| 상황 | 경로 | 근거 |
|
|
153
155
|
|---|---|---|
|
|
154
156
|
| 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
|
|
155
|
-
| 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) | §7 |
|
|
157
|
+
| 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) — 서버 개시는 `broadcast(name,data)`, 클라 메시지 응답은 `ctx.broadcast` | §7 |
|
|
156
158
|
| 페이지와 무관한 데이터 요청 (자동완성·옵션 조회 등 앱 내부용) | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md`) | E-3 |
|
|
157
159
|
| 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
|
|
158
160
|
|
|
@@ -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` 지원)
|
|
@@ -198,7 +200,7 @@ gaon doctor # 정적 검사 23종 (§2.2)
|
|
|
198
200
|
|---|---|
|
|
199
201
|
| `gaon new <name>` | 프로젝트 스캐폴드 |
|
|
200
202
|
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**코드 변경 감시·재시작**) |
|
|
201
|
-
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** |
|
|
203
|
+
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
|
|
202
204
|
| `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
|
|
203
205
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
204
206
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
@@ -215,19 +217,27 @@ gaon doctor # 정적 검사 23종 (§2.2)
|
|
|
215
217
|
개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
|
|
216
218
|
비-production 부팅 시 이 안내를 한 줄 출력한다.
|
|
217
219
|
|
|
218
|
-
|
|
220
|
+
**스케일링 구분** — `serve --workers N`(한 포트 · node:cluster 수직) vs 웹 인스턴스
|
|
221
|
+
여러 대(각각 다른 `PORT` · 허브 뒤 수평) vs `work`(포트 없음 · 프로세스만)는 서로
|
|
222
|
+
다르다. 로컬 멀티 인스턴스 레시피·구분표는 `docs/guides/operations.md` "스케일링" 절 참고.
|
|
219
223
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
|
226
|
-
|
|
227
|
-
|
|
|
228
|
-
| `@gaonjs/
|
|
229
|
-
| `@gaonjs/
|
|
230
|
-
| `@gaonjs/
|
|
224
|
+
## 5. npm 배포본 — 패키지 → 역할
|
|
225
|
+
|
|
226
|
+
개발자는 파사드 **`gaonjs`** 하나만 설치한다(CLI 명령 `gaon`). 아래는 내부 패키지의 역할 지도다.
|
|
227
|
+
**실버전은 `npm view <pkg> version` 으로 확인한다** — 버전은 릴리스마다 바뀌므로 문서에 박지 않는다.
|
|
228
|
+
|
|
229
|
+
| 패키지 | 역할 |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `gaonjs` | 파사드(설치 단위) · CLI `gaon` |
|
|
232
|
+
| `@gaonjs/cli` | 제너레이터·스캐폴딩·명령 라우팅 |
|
|
233
|
+
| `@gaonjs/data` | 스키마 DSL · 모델 · 마이그레이션 |
|
|
234
|
+
| `@gaonjs/config` | `gaon.config.ts`·`app.config.ts` |
|
|
235
|
+
| `@gaonjs/web` | Fastify 웹 레이어 · 인증 · JSON 액션 |
|
|
236
|
+
| `@gaonjs/vue` | Vue 어댑터 · pageProps · 타입드 `api()` |
|
|
237
|
+
| `@gaonjs/async` | 채널 · 프레즌스 · 허브 · 잡 · 스케줄 |
|
|
238
|
+
| `@gaonjs/core` | 메타데이터 · 직렬화 프리미티브(Hidden) |
|
|
239
|
+
| `@gaonjs/mail` / `storage` / `i18n` | 메일 · 파일 스토리지 · 다국어 |
|
|
240
|
+
| `@gaonjs/seal` | 페이로드 봉인(선택 플러그인 · `agents/seal.md`) |
|
|
231
241
|
|
|
232
242
|
## 6. 원칙 · 엄수
|
|
233
243
|
|
|
@@ -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`** 가 정본.
|
|
@@ -74,6 +74,37 @@ export default channel({
|
|
|
74
74
|
|
|
75
75
|
멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
|
|
76
76
|
|
|
77
|
+
### 2.5 서버 개시 broadcast (결정 126)
|
|
78
|
+
|
|
79
|
+
`ctx.broadcast` 는 클라이언트 연결 훅(`onJoin`/`onMessage`/`onLeave`) **안에서만** 쓸 수 있다 —
|
|
80
|
+
클라 메시지가 있어야 도는 경로다. **컨트롤러·서비스·잡처럼 서버가 클라 메시지 없이 채널을 밀
|
|
81
|
+
때는 `gaonjs/async` 의 `broadcast(name, data)`** 를 쓴다 — 이름으로 채널을 지목해 전 서버·전
|
|
82
|
+
구독자에게 발화한다(여러 인스턴스 자동 팬아웃 · errata E-2).
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// apps/web/controllers/posts.ts — service·job·listener 어디서든 동일하게 호출
|
|
86
|
+
import { controller } from 'gaonjs/web'
|
|
87
|
+
import { broadcast } from 'gaonjs/async'
|
|
88
|
+
|
|
89
|
+
export default controller({
|
|
90
|
+
async create() {
|
|
91
|
+
const post = await Post.create(this.params(Post.form))
|
|
92
|
+
broadcast('feed', { type: 'new-post', id: String(post.id) }) // 클라 메시지 불필요
|
|
93
|
+
return { id: post.id }
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **`authorize` 재실행 없음** — `authorize` 는 **구독(연결 수립) 시점** 게이트다(§2). 서버 발화
|
|
99
|
+
broadcast 는 authorize 를 **다시 실행하지 않는다** — 이미 접속(= 인가 통과)한 구독자만 받고,
|
|
100
|
+
broadcast 는 신뢰된 서버 코드가 그 대상에게 미는 행위다. 특정 사용자에게만 보내야 하면 **채널을
|
|
101
|
+
그렇게 분리**(예: `authorize` 로 소유자만 입장)하고 그 채널로 broadcast 한다.
|
|
102
|
+
- **payload = `unknown`**(자유 JSON) · 클라 `useChannel` 이 `{ t:'msg', data }` 로 받는다.
|
|
103
|
+
- **seal(결정 121)** — 재봉인은 각 수신 서버의 소켓 경계에서 일어난다. broadcast 는 기존 전달
|
|
104
|
+
경로를 그대로 재사용하므로 seal 앱에서도 봉인된다(추가 처리 불필요).
|
|
105
|
+
- **realtime(NATS) 미설정 앱**에서 호출하면 수리 안내와 함께 throw · **구독자 없음**이면 조용히
|
|
106
|
+
아무 데도 안 간다(fire-and-forget · 예외 아님).
|
|
107
|
+
|
|
77
108
|
### 3. 프레즌스
|
|
78
109
|
|
|
79
110
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
@@ -136,10 +167,15 @@ export function useRoom(roomId: number) {
|
|
|
136
167
|
|
|
137
168
|
서버가 먼저 밀어주는 데이터(알림·채팅·접속자)는 채널/프레즌스가 정답
|
|
138
169
|
경로다 (루트 데이터 경로 판단표 2행). 폴링 `api()` 루프로 흉내내지
|
|
139
|
-
않는다.
|
|
170
|
+
않는다. 그 안에서 **누가 발화하느냐**로 표면이 갈린다:
|
|
171
|
+
|
|
172
|
+
| 발화 주체 | 표면 | 쓰는 곳 |
|
|
173
|
+
| --- | --- | --- |
|
|
174
|
+
| 클라 메시지에 응답 | `ctx.broadcast(data)` | 채널 훅 `onMessage`(클라 메시지 필요) |
|
|
175
|
+
| 서버가 단독으로 밀기 | `broadcast(name, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (클라 메시지 없이 · 결정 126) |
|
|
140
176
|
|
|
141
177
|
```ts
|
|
142
|
-
// apps/web/channels/chatMessages.ts — 파일명 camelCase
|
|
178
|
+
// apps/web/channels/chatMessages.ts — 파일명 camelCase · 클라 메시지 응답형
|
|
143
179
|
import { channel } from 'gaonjs/async'
|
|
144
180
|
|
|
145
181
|
export default channel({
|
|
@@ -149,6 +185,9 @@ export default channel({
|
|
|
149
185
|
})
|
|
150
186
|
```
|
|
151
187
|
|
|
188
|
+
서버 개시(HTTP 요청·잡 처리 결과 등)로 미는 경우는 §2.5 `broadcast(name, data)` 를 쓴다 —
|
|
189
|
+
"flash 로 클라에 심고 클라가 다시 채널로 중계" 같은 우회는 **반정본**이다(탭 닫힘에 구멍 · 결정 126).
|
|
190
|
+
|
|
152
191
|
## 알려진 함정
|
|
153
192
|
|
|
154
193
|
- **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (채널은
|
|
@@ -170,3 +209,10 @@ export default channel({
|
|
|
170
209
|
|---|---|
|
|
171
210
|
| E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
|
|
172
211
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
212
|
+
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
213
|
+
|
|
214
|
+
## `@gaonjs/seal` 켠 앱의 채널
|
|
215
|
+
|
|
216
|
+
seal 앱은 HTTP 뿐 아니라 **WS 채널 프레임도 봉인**된다(namespace requireDecrypt · 평문 `P:` 거부 · 송신
|
|
217
|
+
`E:<ts>:<base64>`). 채널 코드는 안 바뀐다(경계 인터셉터). **정본은 `agents/seal.md`**. seal 은 채널
|
|
218
|
+
`authorize`/멤버십 검사를 대체하지 않는다.
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
**봉인 대상 판별 (결정 125)**: `Accept`/`Content-Type: application/json` **또는** `X-Inertia: true`(Inertia
|
|
77
|
+
방문·네비게이션) 요청을 봉인 강제한다. Inertia GET 네비게이션은 `Accept: text/html` + `X-Inertia:true` 로
|
|
78
|
+
와서 application/json 이 없으므로, 이 헤더까지 봐야 네비게이션 props 가 평문으로 새지 않는다(결정 125 P0).
|
|
79
|
+
네이티브 브라우저 form·정적 자산·HTML 직접 로드는 셋 다 없어 자동 면제된다.
|
|
80
|
+
- **최초 문서 data-page**: 서버가 `<script data-page="app" data-gaon-sealed="1">` 로 봉인 + `<meta gaon-seal-ts>`.
|
|
81
|
+
클라 `createGaonApp` 이 Inertia 마운트 **전**에 wasm 으로 개봉 → 소스 보기·개발자도구에 평문 props 미노출.
|
|
82
|
+
- **WS 프레임 (결정 124 · §3.4)**: `useChannel` 이 `setWsFrameCodec`(seal 클라 `wsEncode`/`wsDecode`)로
|
|
83
|
+
채널 송수신을 봉인한다. 송신 `E:<ts>:<base64>` · seal namespace 는 평문 `P:` 프레임 **거부**(requireDecrypt · 결정 121).
|
|
84
|
+
- **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
|
|
85
|
+
직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
|
|
86
|
+
모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
|
|
87
|
+
- **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
88
|
+
**403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 소켓 4500 종료(silent fallback 없음).
|
|
89
|
+
|
|
90
|
+
## 3. CSP (결정 124)
|
|
91
|
+
|
|
92
|
+
seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입**한다(wasm 컴파일만 허용 ·
|
|
93
|
+
`'unsafe-eval'` 보다 좁음). **전역 완화 금지** — 비-seal 앱은 strict CSP(`script-src 'self'`) 그대로다.
|
|
94
|
+
요청 단위 판별(seal 스코프가 요청을 표시 · 보안 헤더 훅이 그 응답에만 보정).
|
|
95
|
+
|
|
96
|
+
## 4. 아키텍처 경계 (AI 가 넘지 말 것)
|
|
97
|
+
|
|
98
|
+
- **`@gaonjs/vue` 는 seal 무지 유지 · 비-seal 앱 번들에 wasm 0.** 두 번째 http/ws 클라이언트를 이식하지
|
|
99
|
+
않는다 — 봉인/개봉은 기존 전송 경로(Inertia·`api()`·`useChannel`) **경계 인터셉터**가 한다.
|
|
100
|
+
- **wasm 표면 은닉 (결정 124)**: wasm 은 **불투명 고수준 함수만** 노출한다(`sealHttp`/`openHttp`/`sealWs`/`openWs`).
|
|
101
|
+
키 유도(`deriveKey`)·UA 슬라이스(`getUaSlice`)·`seal`/`open` primitive 은 비공개, **미끼 마스터 시크릿은
|
|
102
|
+
wasm 바이너리 내장**(JS 번들 미노출). **domain·user-agent 는 wasm 이 브라우저(web-sys)에서 직접 읽고**
|
|
103
|
+
path 는 넘겨받은 URL 에서 wasm 이 파싱한다 — JS 소스에 "무엇이 키 유도 입력인가" 힌트를 남기지 않는다.
|
|
104
|
+
(위조는 서버가 실 요청 헤더로 독립 유도해 이미 막힌다 · 이 은닉은 힌트 제거·공격 비용 상승 목적.)
|
|
105
|
+
- **서버는 wasm 이 아니다** — JS mirror(`crypto.ts`)로 봉인/개봉하고, Rust 정본(wasm)과 **known-vector parity**
|
|
106
|
+
(`wasm-parity` 테스트)로 byte 호환을 강제한다.
|
|
107
|
+
- **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
|
|
108
|
+
`SHA256(hex(HMAC-SHA256(masterSecret, "domain:path:uaSlice:timestamp")))` · per-frame keying(userId 미포함).
|
|
109
|
+
- **replay 방어**: AES-GCM 12-byte nonce 를 `setIfNotExists`(Redis SETNX 대응) 캐시 + timestamp drift(±60s)로
|
|
110
|
+
차단. WS 기본은 drift 윈도우(고빈도라 프레임마다 SETNX 는 비용 과다 · 엄격 nonce 는 옵션 주입).
|
|
111
|
+
- **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
|
|
112
|
+
UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
|
|
113
|
+
|
|
114
|
+
## 5. 게이트 — seal 검증은 **실 브라우저가 blocking** (결정 124)
|
|
115
|
+
|
|
116
|
+
- 정본 게이트는 **실 vite 프로덕션 빌드 + 실 chromium + 실 wasm** e2e 다:
|
|
117
|
+
`test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
|
|
118
|
+
평문 `P:` 거부·hidden 누출 0·**비-seal 앱 번들 무-wasm 단언**) + `seal-fullstack-e2e`(실 `gaon serve`+`gaon hub`+NATS
|
|
119
|
+
경유 봉인 broadcast).
|
|
120
|
+
- **목업 e2e 로 대체 금지.** 서버 inject·단위·wasm-parity 는 byte 호환을 잠글 뿐 "실 브라우저에서 마운트되나"를
|
|
121
|
+
못 본다 — 원 wave 가 실-브라우저 e2e 를 미뤄 P0 3건(번들 불가·CSP 차단·Inertia 인터셉터 파손)을 놓친 교훈(결정 124).
|
|
122
|
+
|
|
123
|
+
## 알려진 함정
|
|
124
|
+
|
|
125
|
+
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고 `--fix` 가 배선. 런타임도 수리 안내 throw.
|
|
126
|
+
2. **변수-specifier 동적 import** (`const s='@gaonjs/seal/client'; import(s)`) — vite 가 번들 못 해 프로덕션 blank. **폐기됨** — app-side 정적 주입만.
|
|
127
|
+
3. **전역 CSP 완화 금지** — `'wasm-unsafe-eval'` 은 seal 앱 응답에만. 비-seal 앱 strict CSP 유지.
|
|
128
|
+
4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
|
|
129
|
+
5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
|
|
130
|
+
6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
|
|
131
|
+
|
|
132
|
+
## 관련 결정
|
|
133
|
+
|
|
134
|
+
- **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
|
|
135
|
+
- **결정 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).
|
|
136
|
+
- **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
|
|
@@ -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)
|
|
@@ -77,6 +88,11 @@
|
|
|
77
88
|
작동한다. 토큰·다이제스트·개인정보 컬럼은 선언 시점에 hidden.
|
|
78
89
|
- **`presenceInfo`** — 접속자 목록은 채널 전원에게 공개된다. 공개 메타만
|
|
79
90
|
(`agents/realtime.md`).
|
|
91
|
+
- **서버 개시 `broadcast(name, data)` 는 `authorize` 를 재실행하지 않는다** (결정 126) —
|
|
92
|
+
채널 `authorize` 는 **구독(연결) 시점** 게이트다. 서버 발화 broadcast 는 이미 접속(= 인가
|
|
93
|
+
통과)한 구독자에게만 도달하고 authorize 를 다시 돌리지 않는다. 수신 대상을 제한하려면
|
|
94
|
+
**채널을 그렇게 분리**(`authorize` 로 소유자·역할만 입장)하고 그 채널로 broadcast 한다 —
|
|
95
|
+
broadcast 자체에 대상 필터는 없다(`agents/realtime.md` §2.5).
|
|
80
96
|
|
|
81
97
|
### 6. 클라이언트 IP · 프록시 신뢰 (결정 120)
|
|
82
98
|
|
|
@@ -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).
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
#
|
|
4
4
|
# 기동: docker compose up -d (gaon dev 가 자동 실행)
|
|
5
5
|
# 정지: docker compose down
|
|
6
|
+
#
|
|
7
|
+
# 한 머신에서 gaon 프로젝트를 **여러 개** 동시에 개발할 때: `name:` 이 프로젝트별로 고유해
|
|
8
|
+
# 컨테이너·볼륨·네트워크는 서로 섞이지 않는다(다른 프로젝트 컨테이너를 recreate 하지 않음).
|
|
9
|
+
# 다만 아래 **호스트 포트**(5432·6379·4222 …)는 머신에 하나뿐이라 두 스택을 동시에 띄우면
|
|
10
|
+
# 충돌한다. 그럴 땐 한쪽에서 왼쪽(호스트) 포트만 바꾸고(예: "5433:5432"), 그 프로젝트의
|
|
11
|
+
# `gaon.config.ts` 접속 URL 포트도 같은 값으로 맞춘다. 오른쪽(컨테이너) 포트는 그대로 둔다.
|
|
6
12
|
name: {{PROJECT_NAME}}
|
|
7
13
|
|
|
8
14
|
services:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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/
|
|
30
|
+
"@gaonjs/async": "0.7.0",
|
|
31
|
+
"@gaonjs/config": "0.9.1",
|
|
31
32
|
"@gaonjs/data": "0.13.1",
|
|
32
|
-
"@gaonjs/
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/mail": "0.1.3"
|
|
33
|
+
"@gaonjs/mail": "0.1.3",
|
|
34
|
+
"@gaonjs/web": "0.12.0",
|
|
35
|
+
"@gaonjs/core": "0.2.1"
|
|
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})\""
|