@gaonjs/cli 0.32.0 → 0.33.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/commands/build.d.ts +11 -0
- package/dist/commands/build.js +49 -0
- package/dist/commands/check.js +41 -0
- package/dist/dev/build.d.ts +34 -0
- package/dist/dev/build.js +144 -0
- package/dist/dev/frontend-build.d.ts +3 -3
- package/dist/dev/frontend-build.js +42 -37
- package/dist/doctor/async-offload.d.ts +3 -1
- package/dist/doctor/async-offload.js +5 -2
- package/dist/doctor/auth-wiring.d.ts +4 -2
- package/dist/doctor/auth-wiring.js +13 -6
- package/dist/doctor/csrf-wiring.d.ts +3 -1
- package/dist/doctor/csrf-wiring.js +9 -5
- package/dist/doctor/seal-security.js +12 -6
- package/dist/doctor/source-scan.d.ts +11 -0
- package/dist/doctor/source-scan.js +70 -0
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +55 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/scaffold/app.js +2 -1
- package/dist/templates/auth/Dashboard.vue.tpl +2 -2
- package/dist/templates/auth/Login.vue.tpl +2 -2
- package/dist/templates/auth/Signup.vue.tpl +2 -2
- package/dist/templates/auth/app.config.ts.tpl +3 -3
- package/dist/templates/auth/dashboard.controller.ts.tpl +1 -1
- package/dist/templates/auth/registration.controller.ts.tpl +3 -3
- package/dist/templates/auth/session.controller.ts.tpl +5 -5
- package/dist/templates/project/agents/async.md.tpl +9 -5
- package/dist/templates/project/agents/security.md.tpl +16 -2
- package/dist/templates/project/agents/testing.md.tpl +5 -0
- package/dist/templates/project/package.json.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +6 -5
- package/package.json +6 -6
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface BuildCommandOptions {
|
|
2
|
+
readonly cwd?: string;
|
|
3
|
+
readonly json?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface BuildCommandResult {
|
|
6
|
+
readonly ok: boolean;
|
|
7
|
+
readonly apps: string[];
|
|
8
|
+
readonly error?: string;
|
|
9
|
+
}
|
|
10
|
+
/** `gaon build` 진입점. .gaon 재생성 + 전 앱 vite build. */
|
|
11
|
+
export declare function runBuildCommand(opts?: BuildCommandOptions): Promise<number>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @gaonjs/cli · `gaon build` — 멀티 앱 프론트 프로덕션 빌드 (결정 146 · 12차 W2)
|
|
2
|
+
//
|
|
3
|
+
// `gaon gen`(.gaon 재생성) 뒤 apps/* 를 순회해 앱마다 root=apps/<앱>·outDir=dist/<앱>·
|
|
4
|
+
// base=/<앱>/ 로 vite build 한다. 예전 스캐폴드 build 스크립트(`gaon gen && vite build`)는
|
|
5
|
+
// apps/web 하나만 빌드해 둘째 앱(admin)의 dist 가 안 생기거나 base 없이 404 였다 —
|
|
6
|
+
// gaon g app 으로 늘린 앱이 별도 손배선 없이 빌드 파이프라인에 자동 편입되게 한다.
|
|
7
|
+
import { regenerateProjectGaon } from './gen.js';
|
|
8
|
+
import { buildAllApps, listFrontendApps } from '../dev/build.js';
|
|
9
|
+
/** `gaon build` 진입점. .gaon 재생성 + 전 앱 vite build. */
|
|
10
|
+
export async function runBuildCommand(opts = {}) {
|
|
11
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
12
|
+
const json = opts.json ?? false;
|
|
13
|
+
const log = (line) => {
|
|
14
|
+
if (!json)
|
|
15
|
+
process.stdout.write(line + '\n');
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
// 1) .gaon 재생성 — build 산출이 routes.manifest 값 import 를 타므로 fresh 여야 한다(규칙 3).
|
|
19
|
+
await regenerateProjectGaon(cwd);
|
|
20
|
+
// 2) 프론트 앱이 없으면(API 전용) 조용히 통과.
|
|
21
|
+
const apps = await listFrontendApps(cwd);
|
|
22
|
+
if (apps.length === 0) {
|
|
23
|
+
const result = { ok: true, apps: [] };
|
|
24
|
+
if (json)
|
|
25
|
+
process.stdout.write(JSON.stringify({ command: 'build', ...result }) + '\n');
|
|
26
|
+
else
|
|
27
|
+
log('빌드할 프론트 앱(apps/<앱>/index.html)이 없습니다 — 건너뜁니다.');
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
// 3) 앱별 빌드.
|
|
31
|
+
log(`gaon build — ${apps.length}개 앱: ${apps.join(', ')}`);
|
|
32
|
+
await buildAllApps(cwd, { onLog: json ? undefined : (l) => process.stdout.write(l + '\n') });
|
|
33
|
+
const result = { ok: true, apps };
|
|
34
|
+
if (json)
|
|
35
|
+
process.stdout.write(JSON.stringify({ command: 'build', ...result }) + '\n');
|
|
36
|
+
else
|
|
37
|
+
log(`✓ 빌드 완료 — ${apps.map((a) => `dist/${a}`).join(' · ')}`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
const result = { ok: false, apps: [], error: msg };
|
|
43
|
+
if (json)
|
|
44
|
+
process.stdout.write(JSON.stringify({ command: 'build', ...result }) + '\n');
|
|
45
|
+
else
|
|
46
|
+
process.stderr.write(`✗ gaon build 실패: ${msg}\n`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/commands/check.js
CHANGED
|
@@ -33,6 +33,7 @@ import { generateRoutesDts } from '@gaonjs/web';
|
|
|
33
33
|
import { runDoctorCommand } from '../doctor.js';
|
|
34
34
|
import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
|
|
35
35
|
import { registerTsResolve } from '../tsResolve.js';
|
|
36
|
+
import { listFrontendApps, verifyAppDist } from '../dev/build.js';
|
|
36
37
|
/**
|
|
37
38
|
* 프로젝트 스크립트 존재 여부. pnpm/npm 어느 쪽이든 `scripts.<name>` 을
|
|
38
39
|
* 정의해 두면 우선 사용한다.
|
|
@@ -78,6 +79,17 @@ async function runStep(step, cwd) {
|
|
|
78
79
|
const cmd = 'pnpm';
|
|
79
80
|
const args = ['run', scriptName];
|
|
80
81
|
const { exitCode, output } = await runSubprocess(cwd, cmd, args);
|
|
82
|
+
// 결정 146(12차 W2): build 가 성공했으면 **등록된 앱마다** dist/<앱>/index.html 과
|
|
83
|
+
// 그 문서가 참조하는 에셋이 실제로 존재하는지 검증한다. build 스크립트가 통과해도
|
|
84
|
+
// 둘째 앱(admin)의 dist 가 안 생기거나 에셋이 404 면 여기서 잡는다("build passed 인데
|
|
85
|
+
// /admin 은 빈 화면" 사각 제거 · curl data-page 만으로 못 보던 지점).
|
|
86
|
+
if (step === 'build' && exitCode === 0) {
|
|
87
|
+
const verify = await verifyBuildOutput(cwd);
|
|
88
|
+
if (!verify.ok) {
|
|
89
|
+
return { step, status: 'failed', command: `${cmd} ${args.join(' ')}`, exitCode, output: output + '\n' + verify.message };
|
|
90
|
+
}
|
|
91
|
+
return { step, status: 'passed', command: `${cmd} ${args.join(' ')}`, exitCode, output: output + '\n' + verify.message };
|
|
92
|
+
}
|
|
81
93
|
return {
|
|
82
94
|
step,
|
|
83
95
|
status: exitCode === 0 ? 'passed' : 'failed',
|
|
@@ -159,6 +171,35 @@ async function runStep(step, cwd) {
|
|
|
159
171
|
'→ 프론트 프로덕션 빌드를 검사에 포함하려면 package.json 에 "build": "vite build" 를 정의하세요.',
|
|
160
172
|
};
|
|
161
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* build 산출 검증(결정 146 · 12차 W2). 등록된 프론트 앱마다 dist/<앱>/index.html 과
|
|
176
|
+
* 그 문서가 참조하는 로컬 에셋이 존재하는지 확인한다. 하나라도 빠지면 ok=false.
|
|
177
|
+
*/
|
|
178
|
+
async function verifyBuildOutput(cwd) {
|
|
179
|
+
const apps = await listFrontendApps(cwd);
|
|
180
|
+
if (apps.length === 0)
|
|
181
|
+
return { ok: true, message: '프론트 앱이 없어 dist 검증을 건너뜁니다.' };
|
|
182
|
+
const problems = [];
|
|
183
|
+
const okApps = [];
|
|
184
|
+
for (const app of apps) {
|
|
185
|
+
const r = await verifyAppDist(cwd, app);
|
|
186
|
+
if (!r.indexExists) {
|
|
187
|
+
problems.push(`✗ apps/${app}: dist/${app}/index.html 이 없습니다 — 이 앱이 빌드되지 않았습니다.\n` +
|
|
188
|
+
` → build 가 apps/${app} 를 순회 빌드하는지 확인하세요(gaon build · 결정 146).`);
|
|
189
|
+
}
|
|
190
|
+
else if (r.missing.length > 0) {
|
|
191
|
+
problems.push(`✗ apps/${app}: index.html 이 참조하는 에셋이 dist/${app} 에 없습니다: ${r.missing.join(', ')}\n` +
|
|
192
|
+
` → base(${app === 'web' ? '/' : `/${app}/`}) 주입이 빠졌을 수 있습니다(비-루트 앱은 /<앱>/ base 필요).`);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
okApps.push(app);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (problems.length > 0) {
|
|
199
|
+
return { ok: false, message: `build 산출 검증 실패(결정 146):\n${problems.join('\n')}` };
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, message: `build 산출 검증 통과 — 앱별 dist: ${okApps.map((a) => `dist/${a}`).join(' · ')}` };
|
|
202
|
+
}
|
|
162
203
|
/** doctor 는 이미 있는 명령을 재사용 — cwd 만 넘긴다. json 은 상위에서. */
|
|
163
204
|
async function runDoctorStep(cwd) {
|
|
164
205
|
try {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** 앱의 URL base — web 은 루트('/'), 그 외는 '/<app>/'(결정 146). vite build 시 주입. */
|
|
2
|
+
export declare function appBase(app: string): string;
|
|
3
|
+
/** apps/* 중 프론트 진입(index.html)이 있는 앱 이름들. web 을 먼저(루트 앱 관례). */
|
|
4
|
+
export declare function listFrontendApps(cwd: string): Promise<string[]>;
|
|
5
|
+
/** vite.config 를 프로젝트 루트·앱 루트 순으로 찾는다(configFile 명시용 · vue 플러그인 보장). */
|
|
6
|
+
export declare function findViteConfig(cwd: string, appRoot: string): string | undefined;
|
|
7
|
+
export interface BuildAppOptions {
|
|
8
|
+
/** watch 모드(dev) — RollupWatcher 를 담은 핸들을 돌려준다(close 로 정리). */
|
|
9
|
+
readonly watch?: boolean;
|
|
10
|
+
/** 통합 콘솔 로그 훅(생략 = vite 가 stdout 으로 직접 출력). */
|
|
11
|
+
readonly onLog?: (line: string, level: 'info' | 'warn' | 'error') => void;
|
|
12
|
+
}
|
|
13
|
+
export interface BuildAppHandle {
|
|
14
|
+
/** watch 모드에서 감시를 종료한다. non-watch 는 no-op. */
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** 한 앱을 vite 로 빌드한다(programmatic · configFile 명시 → vue 플러그인·alias 보장). */
|
|
18
|
+
export declare function buildApp(cwd: string, app: string, opts?: BuildAppOptions): Promise<BuildAppHandle>;
|
|
19
|
+
/** 등록된 프론트 앱을 전부 빌드한다(build 커맨드·게이트 공용). 빌드한 앱 이름을 돌려준다. */
|
|
20
|
+
export declare function buildAllApps(cwd: string, opts?: BuildAppOptions): Promise<string[]>;
|
|
21
|
+
/** 한 앱의 dist 검증 결과. missing 가 비어 있지 않으면 게이트 실패(결정 146). */
|
|
22
|
+
export interface DistVerifyResult {
|
|
23
|
+
readonly app: string;
|
|
24
|
+
readonly indexExists: boolean;
|
|
25
|
+
/** index.html 이 참조하는데 실제 파일이 없는 로컬 에셋 경로(base 기준). */
|
|
26
|
+
readonly missing: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 앱의 dist/<앱>/index.html 과 그 문서가 참조하는 로컬 에셋(script src·link href)이
|
|
30
|
+
* 실제로 존재하는지 검증한다(결정 146 · gaon check build 게이트). base(/<앱>/)로 시작하는
|
|
31
|
+
* 참조만 검사한다(외부 URL 제외). curl data-page 만으로는 못 보던 "문서는 뜨는데 스크립트
|
|
32
|
+
* 404" 를 정면으로 막는다.
|
|
33
|
+
*/
|
|
34
|
+
export declare function verifyAppDist(cwd: string, app: string): Promise<DistVerifyResult>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @gaonjs/cli · dev/build — 멀티 앱 프론트 빌드 파이프라인 (결정 146 · 12차 W2)
|
|
2
|
+
//
|
|
3
|
+
// gaon new 프로젝트는 앱이 하나 이상일 수 있다(gaon g app admin). 각 앱은 자기
|
|
4
|
+
// prefix 아래로 서빙되고(web→'/' · admin→'/admin/'), serve 는 앱별 dist/<앱> 를
|
|
5
|
+
// 기대한다(결정 67). 그런데 예전 파이프라인은 apps/web 하나만 빌드해(vite.config
|
|
6
|
+
// root 하드코딩) 둘째 앱의 dist 가 안 생기고, 생겨도 base 가 없어 index.html 이
|
|
7
|
+
// 절대경로 `/assets/*` 를 박아 비-루트 앱에서 404 였다. 이 모듈이 apps/* 를 순회해
|
|
8
|
+
// 앱마다 root=apps/<앱>·outDir=dist/<앱>·base=/<앱>/ 로 빌드한다(결정 146).
|
|
9
|
+
//
|
|
10
|
+
// vite 는 **프로그램적 build()** 로 부른다 — configFile 을 명시로 넘겨야 vue 플러그인·
|
|
11
|
+
// @shared alias 가 로드된다(positional root 만 주면 앱 폴더에 vite.config 가 없어
|
|
12
|
+
// vue 플러그인이 빠지는 함정 · 결정 146). inline root/base/outDir 가 configFile 을
|
|
13
|
+
// 덮어써 앱별 override 가 확실하다.
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
/** 앱의 URL base — web 은 루트('/'), 그 외는 '/<app>/'(결정 146). vite build 시 주입. */
|
|
18
|
+
export function appBase(app) {
|
|
19
|
+
return app === 'web' ? '/' : `/${app}/`;
|
|
20
|
+
}
|
|
21
|
+
/** apps/* 중 프론트 진입(index.html)이 있는 앱 이름들. web 을 먼저(루트 앱 관례). */
|
|
22
|
+
export async function listFrontendApps(cwd) {
|
|
23
|
+
const appsDir = join(resolve(cwd), 'apps');
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = (await readdir(appsDir, { withFileTypes: true }));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const apps = entries
|
|
32
|
+
.filter((e) => e.isDirectory() && existsSync(join(appsDir, e.name, 'index.html')))
|
|
33
|
+
.map((e) => e.name);
|
|
34
|
+
return apps.sort((a, b) => (a === 'web' ? -1 : b === 'web' ? 1 : a.localeCompare(b)));
|
|
35
|
+
}
|
|
36
|
+
/** vite.config 를 프로젝트 루트·앱 루트 순으로 찾는다(configFile 명시용 · vue 플러그인 보장). */
|
|
37
|
+
export function findViteConfig(cwd, appRoot) {
|
|
38
|
+
for (const dir of [appRoot, resolve(cwd)]) {
|
|
39
|
+
for (const name of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
|
|
40
|
+
const p = join(dir, name);
|
|
41
|
+
if (existsSync(p))
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** 한 앱을 vite 로 빌드한다(programmatic · configFile 명시 → vue 플러그인·alias 보장). */
|
|
48
|
+
export async function buildApp(cwd, app, opts = {}) {
|
|
49
|
+
const root = resolve(cwd);
|
|
50
|
+
const appRoot = join(root, 'apps', app);
|
|
51
|
+
const vite = await loadVite();
|
|
52
|
+
const result = await vite.build({
|
|
53
|
+
configFile: findViteConfig(root, appRoot),
|
|
54
|
+
root: appRoot,
|
|
55
|
+
base: appBase(app),
|
|
56
|
+
// onLog 있으면 vite 출력을 그 훅으로 라우팅(customLogger 가 stdout 대신 훅으로).
|
|
57
|
+
// 없으면(예: `gaon build --json`) 완전히 침묵 — vite 진행 로그가 stdout 의 JSON 을
|
|
58
|
+
// 오염시키면 안 된다(클린룸이 마지막 줄을 JSON 으로 파싱 · 결정 146 실측).
|
|
59
|
+
logLevel: opts.onLog ? 'info' : 'silent',
|
|
60
|
+
customLogger: opts.onLog ? makeLogger(opts.onLog) : undefined,
|
|
61
|
+
build: {
|
|
62
|
+
outDir: join(root, 'dist', app),
|
|
63
|
+
emptyOutDir: true,
|
|
64
|
+
watch: opts.watch ? {} : null,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const watcher = result;
|
|
68
|
+
if (opts.watch && watcher && typeof watcher.close === 'function') {
|
|
69
|
+
return { close: async () => void (await watcher.close()) };
|
|
70
|
+
}
|
|
71
|
+
return { close: async () => { } };
|
|
72
|
+
}
|
|
73
|
+
/** 등록된 프론트 앱을 전부 빌드한다(build 커맨드·게이트 공용). 빌드한 앱 이름을 돌려준다. */
|
|
74
|
+
export async function buildAllApps(cwd, opts = {}) {
|
|
75
|
+
const apps = await listFrontendApps(cwd);
|
|
76
|
+
for (const app of apps) {
|
|
77
|
+
opts.onLog?.(`▶ vite build — apps/${app} → dist/${app} (base=${appBase(app)})`, 'info');
|
|
78
|
+
await buildApp(cwd, app, { onLog: opts.onLog });
|
|
79
|
+
}
|
|
80
|
+
return apps;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 앱의 dist/<앱>/index.html 과 그 문서가 참조하는 로컬 에셋(script src·link href)이
|
|
84
|
+
* 실제로 존재하는지 검증한다(결정 146 · gaon check build 게이트). base(/<앱>/)로 시작하는
|
|
85
|
+
* 참조만 검사한다(외부 URL 제외). curl data-page 만으로는 못 보던 "문서는 뜨는데 스크립트
|
|
86
|
+
* 404" 를 정면으로 막는다.
|
|
87
|
+
*/
|
|
88
|
+
export async function verifyAppDist(cwd, app) {
|
|
89
|
+
const distDir = join(resolve(cwd), 'dist', app);
|
|
90
|
+
const indexPath = join(distDir, 'index.html');
|
|
91
|
+
if (!existsSync(indexPath))
|
|
92
|
+
return { app, indexExists: false, missing: [] };
|
|
93
|
+
const html = await readFile(indexPath, 'utf8');
|
|
94
|
+
const base = appBase(app);
|
|
95
|
+
const missing = [];
|
|
96
|
+
const refs = new Set();
|
|
97
|
+
// <script ... src="..."> · <link ... href="..."> 의 값 추출.
|
|
98
|
+
for (const m of html.matchAll(/<(?:script|link)\b[^>]*\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
|
|
99
|
+
refs.add(m[1]);
|
|
100
|
+
}
|
|
101
|
+
for (const ref of refs) {
|
|
102
|
+
// 이 앱 base 로 시작하는 로컬 에셋만 검사(외부·절대 URL·data: 제외).
|
|
103
|
+
if (!ref.startsWith(base))
|
|
104
|
+
continue;
|
|
105
|
+
const rel = ref.slice(base.length); // base 를 벗겨 dist 상대 경로로.
|
|
106
|
+
const file = join(distDir, rel.split('?')[0].split('#')[0]);
|
|
107
|
+
if (!existsSync(file))
|
|
108
|
+
missing.push(ref);
|
|
109
|
+
}
|
|
110
|
+
return { app, indexExists: true, missing };
|
|
111
|
+
}
|
|
112
|
+
async function loadVite() {
|
|
113
|
+
try {
|
|
114
|
+
return await import('vite');
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
throw new Error(`[gaon build · vite] vite 모듈을 찾을 수 없습니다.\n` +
|
|
118
|
+
`→ 프로젝트 루트에서 설치하세요: pnpm add -D vite @vitejs/plugin-vue\n` +
|
|
119
|
+
`원인: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** vite 로거 어댑터 — 라인을 통합 콘솔 훅으로 흘린다(필요 메서드만). */
|
|
123
|
+
function makeLogger(onLog) {
|
|
124
|
+
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
125
|
+
let hasWarned = false;
|
|
126
|
+
const seen = new WeakSet();
|
|
127
|
+
return {
|
|
128
|
+
info: (m) => onLog(strip(m), 'info'),
|
|
129
|
+
warn: (m) => {
|
|
130
|
+
hasWarned = true;
|
|
131
|
+
onLog(strip(m), 'warn');
|
|
132
|
+
},
|
|
133
|
+
warnOnce: (m) => {
|
|
134
|
+
hasWarned = true;
|
|
135
|
+
onLog(strip(m), 'warn');
|
|
136
|
+
},
|
|
137
|
+
error: (m) => onLog(strip(m), 'error'),
|
|
138
|
+
clearScreen: () => { },
|
|
139
|
+
hasErrorLogged: (e) => seen.has(e),
|
|
140
|
+
get hasWarned() {
|
|
141
|
+
return hasWarned;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface FrontendBuildHandle {
|
|
2
|
-
/** watch 빌드
|
|
2
|
+
/** watch 빌드(모든 앱)를 종료한다. */
|
|
3
3
|
close(): void;
|
|
4
4
|
}
|
|
5
5
|
export interface StartFrontendBuildOptions {
|
|
@@ -8,7 +8,7 @@ export interface StartFrontendBuildOptions {
|
|
|
8
8
|
readonly onLog: (line: string, level: 'info' | 'warn' | 'error') => void;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* vite
|
|
12
|
-
* 않고 no-op 핸들을 돌려준다(dev 는 계속 진행).
|
|
11
|
+
* apps/* 를 순회해 앱마다 vite watch 빌드를 띄운다. 프론트가 없거나 vite 가 없으면
|
|
12
|
+
* 아무 것도 하지 않고 no-op 핸들을 돌려준다(dev 는 계속 진행 · fail-open).
|
|
13
13
|
*/
|
|
14
14
|
export declare function startFrontendBuild(opts: StartFrontendBuildOptions): FrontendBuildHandle;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @gaonjs/cli · dev/frontend-build — 프론트 번들 watch 빌드 (결정 67 ·
|
|
2
|
+
* @gaonjs/cli · dev/frontend-build — 멀티 앱 프론트 번들 watch 빌드 (결정 67 · 결정 146)
|
|
3
3
|
*
|
|
4
4
|
* `gaon dev` 는 서버(serve)를 재시작 방식으로 띄운다(§dev.ts). 프론트 번들은
|
|
5
5
|
* serve 가 dist/<앱>/index.html 을 매 요청 읽어 문서 셸에 이어 붙이므로
|
|
@@ -7,50 +7,55 @@
|
|
|
7
7
|
* watch 모드로 함께 돌린다. 진짜 HMR(모듈 교체)은 v1 어댑터 범위 밖이라(§6.4)
|
|
8
8
|
* "저장 → 재빌드 → 새로고침" 루프를 제공한다.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 결정 146(12차 W2): apps/* 를 순회해 **앱마다** watch 빌드한다(root=apps/<앱>·
|
|
11
|
+
* outDir=dist/<앱>·base=/<앱>/). 예전엔 apps/web 하나만 감시해 둘째 앱(admin)의
|
|
12
|
+
* dist 가 dev 에서 갱신되지 않았다. 프로그램적 build(configFile 명시)로 vue 플러그인
|
|
13
|
+
* 누락 함정을 피한다.
|
|
14
|
+
*
|
|
15
|
+
* fail-open: 프론트 진입(index.html)·vite 가 없으면 조용히 건너뛴다(프론트 없는
|
|
16
|
+
* API 전용 프로젝트도 gaon dev 가 동작해야 한다). vite 실패는 통합 콘솔에 알리되
|
|
17
|
+
* dev 전체를 막지 않는다.
|
|
13
18
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { join, resolve } from 'node:path';
|
|
17
|
-
/** 프로젝트에 프론트 진입 HTML(apps/web/index.html)이 있는가. */
|
|
18
|
-
function hasFrontend(cwd) {
|
|
19
|
-
return existsSync(join(cwd, 'apps', 'web', 'index.html'));
|
|
20
|
-
}
|
|
21
|
-
/** cwd 의 로컬 vite bin 을 찾는다(없으면 undefined — 건너뜀). */
|
|
22
|
-
function resolveViteBin(cwd) {
|
|
23
|
-
const bin = join(cwd, 'node_modules', '.bin', 'vite');
|
|
24
|
-
return existsSync(bin) ? bin : undefined;
|
|
25
|
-
}
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { appBase, buildApp, listFrontendApps } from './build.js';
|
|
26
21
|
/**
|
|
27
|
-
* vite
|
|
28
|
-
* 않고 no-op 핸들을 돌려준다(dev 는 계속 진행).
|
|
22
|
+
* apps/* 를 순회해 앱마다 vite watch 빌드를 띄운다. 프론트가 없거나 vite 가 없으면
|
|
23
|
+
* 아무 것도 하지 않고 no-op 핸들을 돌려준다(dev 는 계속 진행 · fail-open).
|
|
29
24
|
*/
|
|
30
25
|
export function startFrontendBuild(opts) {
|
|
31
26
|
const cwd = resolve(opts.cwd);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
const watchers = [];
|
|
28
|
+
let closed = false;
|
|
29
|
+
void (async () => {
|
|
30
|
+
const apps = await listFrontendApps(cwd);
|
|
31
|
+
if (apps.length === 0) {
|
|
32
|
+
opts.onLog('프론트 진입 HTML(apps/<앱>/index.html)이 없어 vite watch 빌드를 건너뜁니다.', 'info');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const app of apps) {
|
|
36
|
+
if (closed)
|
|
37
|
+
break;
|
|
38
|
+
opts.onLog(`▶ vite build --watch — apps/${app} → dist/${app} (base=${appBase(app)})`, 'info');
|
|
39
|
+
try {
|
|
40
|
+
const handle = await buildApp(cwd, app, { watch: true, onLog: opts.onLog });
|
|
41
|
+
if (closed) {
|
|
42
|
+
await handle.close();
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
watchers.push(handle);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
opts.onLog(`vite watch 빌드를 건너뜁니다(apps/${app}): ${err instanceof Error ? err.message : String(err)}`, 'warn');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})().catch((err) => {
|
|
52
|
+
opts.onLog(`프론트 watch 빌드 오류: ${err instanceof Error ? err.message : String(err)}`, 'error');
|
|
45
53
|
});
|
|
46
|
-
child.stdout?.on('data', (b) => opts.onLog(b.toString().trimEnd(), 'info'));
|
|
47
|
-
child.stderr?.on('data', (b) => opts.onLog(b.toString().trimEnd(), 'error'));
|
|
48
|
-
child.on('error', (err) => opts.onLog(`vite 실행 실패: ${err.message}`, 'error'));
|
|
49
|
-
opts.onLog('▶ vite build --watch — 프론트 번들 감시 (dist/web)', 'info');
|
|
50
54
|
return {
|
|
51
55
|
close() {
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
closed = true;
|
|
57
|
+
for (const w of watchers)
|
|
58
|
+
void w.close();
|
|
54
59
|
},
|
|
55
60
|
};
|
|
56
61
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RuleReport } from './types.js';
|
|
2
|
-
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정).
|
|
2
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정).
|
|
3
|
+
* 결정 140(12차 W1): jwt 판정을 usesJwtStrategy 한 곳으로 통일 — 주석의
|
|
4
|
+
* `// strategy: 'jwt'` 오탐이 offload 검사를 잘못 끄지 않도록. */
|
|
3
5
|
export declare function isApiApp(appConfigSource: string): boolean;
|
|
4
6
|
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
5
7
|
export declare function importsMailSdk(source: string): boolean;
|
|
@@ -28,6 +28,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
28
28
|
import { existsSync } from 'node:fs';
|
|
29
29
|
import { join, relative } from 'node:path';
|
|
30
30
|
import ts from 'typescript';
|
|
31
|
+
import { usesJwtStrategy } from './auth-wiring.js';
|
|
31
32
|
// ── 검출 시그니처 ─────────────────────────────────────────────────────
|
|
32
33
|
/** 메일 SDK 모듈 import (컨트롤러 = 잡 발행만 · async.md 함정 · async-flow forbidden 동일 집합). */
|
|
33
34
|
const MAIL_SDK_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:nodemailer|resend|@sendgrid\/mail|mailgun[.\-/][^'"]*|mailgun)['"]/;
|
|
@@ -44,9 +45,11 @@ const EXTERNAL_HTTP = [
|
|
|
44
45
|
// axios('https://...') · axios.get('https://...') · axios({ url: 'https://...' })
|
|
45
46
|
/\baxios\s*(?:\.\s*(?:get|post|put|patch|delete|request|head)\s*)?\(\s*(?:\{[\s\S]*?\burl\s*:\s*)?[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
46
47
|
];
|
|
47
|
-
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정).
|
|
48
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정).
|
|
49
|
+
* 결정 140(12차 W1): jwt 판정을 usesJwtStrategy 한 곳으로 통일 — 주석의
|
|
50
|
+
* `// strategy: 'jwt'` 오탐이 offload 검사를 잘못 끄지 않도록. */
|
|
48
51
|
export function isApiApp(appConfigSource) {
|
|
49
|
-
return
|
|
52
|
+
return usesJwtStrategy(appConfigSource);
|
|
50
53
|
}
|
|
51
54
|
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
52
55
|
export function importsMailSdk(source) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { RuleReport } from './types.js';
|
|
2
2
|
/** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
|
|
3
3
|
export declare function usesAuthSurface(source: string): boolean;
|
|
4
|
-
/** app.config.ts 소스에 auth 배선이 있는지(
|
|
4
|
+
/** app.config.ts 소스에 auth 배선이 있는지(주석/문자열 제외 후 판정). */
|
|
5
5
|
export declare function hasAuthWiring(source: string): boolean;
|
|
6
|
-
/** app.config.ts 소스에 session 배선이 있는지(
|
|
6
|
+
/** app.config.ts 소스에 session 배선이 있는지(주석/문자열 제외 후 판정). */
|
|
7
7
|
export declare function hasSessionWiring(source: string): boolean;
|
|
8
|
+
/** app.config.ts 소스가 JWT 전략(strategy:'jwt')인지(주석 제외 후 판정 · 문자열 내용 보존). */
|
|
9
|
+
export declare function usesJwtStrategy(source: string): boolean;
|
|
8
10
|
/** apps/ 를 훑어 인증 배선 누락을 낸다. */
|
|
9
11
|
export declare function checkAuthWiring(cwd: string): Promise<RuleReport>;
|
|
@@ -11,17 +11,24 @@
|
|
|
11
11
|
import { readdir, readFile } from 'node:fs/promises';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join, relative } from 'node:path';
|
|
14
|
+
import { stripComments, stripCommentsAndStrings } from './source-scan.js';
|
|
15
|
+
// 결정 140(12차 W1): 판정 전 주석/문자열을 지운다. `// session:` 주석이나 문자열
|
|
16
|
+
// 안의 우연한 일치가 배선으로 오탐되면 보안 검사(세션·CSRF)가 통째로 skip 된다.
|
|
14
17
|
/** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
|
|
15
18
|
export function usesAuthSurface(source) {
|
|
16
|
-
return /this\.requireAuth\s*\(|this\.auth\.login\s*\(|this\.currentUser\b/.test(source);
|
|
19
|
+
return /this\.requireAuth\s*\(|this\.auth\.login\s*\(|this\.currentUser\b/.test(stripCommentsAndStrings(source));
|
|
17
20
|
}
|
|
18
|
-
/** app.config.ts 소스에 auth 배선이 있는지(
|
|
21
|
+
/** app.config.ts 소스에 auth 배선이 있는지(주석/문자열 제외 후 판정). */
|
|
19
22
|
export function hasAuthWiring(source) {
|
|
20
|
-
return /\bauth\s*:/.test(source);
|
|
23
|
+
return /\bauth\s*:/.test(stripCommentsAndStrings(source));
|
|
21
24
|
}
|
|
22
|
-
/** app.config.ts 소스에 session 배선이 있는지(
|
|
25
|
+
/** app.config.ts 소스에 session 배선이 있는지(주석/문자열 제외 후 판정). */
|
|
23
26
|
export function hasSessionWiring(source) {
|
|
24
|
-
return /\bsession\s*:/.test(source);
|
|
27
|
+
return /\bsession\s*:/.test(stripCommentsAndStrings(source));
|
|
28
|
+
}
|
|
29
|
+
/** app.config.ts 소스가 JWT 전략(strategy:'jwt')인지(주석 제외 후 판정 · 문자열 내용 보존). */
|
|
30
|
+
export function usesJwtStrategy(source) {
|
|
31
|
+
return /strategy\s*:\s*['"]jwt['"]/.test(stripComments(source));
|
|
25
32
|
}
|
|
26
33
|
/** apps/ 를 훑어 인증 배선 누락을 낸다. */
|
|
27
34
|
export async function checkAuthWiring(cwd) {
|
|
@@ -42,7 +49,7 @@ export async function checkAuthWiring(cwd) {
|
|
|
42
49
|
const acPath = join(appsDir, app, 'app.config.ts');
|
|
43
50
|
const acRel = relative(cwd, acPath);
|
|
44
51
|
const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : undefined;
|
|
45
|
-
const jwt = acSource != null &&
|
|
52
|
+
const jwt = acSource != null && usesJwtStrategy(acSource);
|
|
46
53
|
if (acSource == null || !hasAuthWiring(acSource)) {
|
|
47
54
|
issues.push({
|
|
48
55
|
rule: 'auth-wiring',
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RuleReport } from './types.js';
|
|
2
|
-
/** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점).
|
|
2
|
+
/** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점).
|
|
3
|
+
* 결정 140(12차 W1): 주석/문자열 안의 우연한 일치가 CSRF 검사를 왜곡하지 않도록
|
|
4
|
+
* 판정 전 정규화한다(여는 따옴표 구분자는 보존되어 라우트 매칭은 유지). */
|
|
3
5
|
export declare function hasStateChangingRoutes(routesSource: string): boolean;
|
|
4
6
|
/** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
|
|
5
7
|
export declare function checkCsrfWiring(cwd: string): Promise<RuleReport>;
|
|
@@ -14,14 +14,18 @@
|
|
|
14
14
|
import { readdir, readFile } from 'node:fs/promises';
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
16
|
import { join, relative } from 'node:path';
|
|
17
|
-
import { hasSessionWiring } from './auth-wiring.js';
|
|
18
|
-
|
|
17
|
+
import { hasSessionWiring, usesJwtStrategy } from './auth-wiring.js';
|
|
18
|
+
import { stripCommentsAndStrings } from './source-scan.js';
|
|
19
|
+
/** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점).
|
|
20
|
+
* 결정 140(12차 W1): 주석/문자열 안의 우연한 일치가 CSRF 검사를 왜곡하지 않도록
|
|
21
|
+
* 판정 전 정규화한다(여는 따옴표 구분자는 보존되어 라우트 매칭은 유지). */
|
|
19
22
|
export function hasStateChangingRoutes(routesSource) {
|
|
23
|
+
const src = stripCommentsAndStrings(routesSource);
|
|
20
24
|
// r.post('/x', ...) · r.put/patch/delete — 메서드 + 경로 문자열.
|
|
21
|
-
if (/\.\s*(post|put|patch|delete)\s*\(\s*['"]/.test(
|
|
25
|
+
if (/\.\s*(post|put|patch|delete)\s*\(\s*['"]/.test(src))
|
|
22
26
|
return true;
|
|
23
27
|
// resource(s)('posts') — 리소스 라우트는 create/update/destroy(POST/PUT/DELETE)를 만든다.
|
|
24
|
-
if (/\.\s*resources?\s*\(\s*['"]/.test(
|
|
28
|
+
if (/\.\s*resources?\s*\(\s*['"]/.test(src))
|
|
25
29
|
return true;
|
|
26
30
|
return false;
|
|
27
31
|
}
|
|
@@ -40,7 +44,7 @@ export async function checkCsrfWiring(cwd) {
|
|
|
40
44
|
const acRel = relative(cwd, acPath);
|
|
41
45
|
const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : undefined;
|
|
42
46
|
// JWT/API 앱은 토큰 인증 — CSRF 대상 아님(제외).
|
|
43
|
-
const jwt = acSource != null &&
|
|
47
|
+
const jwt = acSource != null && usesJwtStrategy(acSource);
|
|
44
48
|
if (jwt)
|
|
45
49
|
continue;
|
|
46
50
|
if (acSource != null && hasSessionWiring(acSource))
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
import { readdir, readFile } from 'node:fs/promises';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { stripComments, stripCommentsAndStrings } from './source-scan.js';
|
|
14
|
+
// 결정 140(12차 W1): 판정 전 주석/문자열을 지운다 — 주석의 `seal: {` 나 문자열 안의
|
|
15
|
+
// 우연한 일치가 검사를 왜곡하지 않도록.
|
|
13
16
|
/** app.config.ts 소스에 seal 토글(true 또는 { … })이 켜져 있는지 판정한다(단위 테스트 진입점). */
|
|
14
17
|
export function hasSealEnabled(appConfigSource) {
|
|
15
18
|
// seal: true · seal: { except: [...] } — false/생략은 제외.
|
|
16
|
-
return /\bseal\s*:\s*(true|\{)/.test(appConfigSource);
|
|
19
|
+
return /\bseal\s*:\s*(true|\{)/.test(stripCommentsAndStrings(appConfigSource));
|
|
17
20
|
}
|
|
18
21
|
/**
|
|
19
22
|
* 결정 121(§3.1): seal 앱의 main.ts 가 seal 클라이언트를 배선했는지 판정한다(단위 테스트 진입점).
|
|
@@ -22,19 +25,22 @@ export function hasSealEnabled(appConfigSource) {
|
|
|
22
25
|
* (seal-browser-e2e 로 실측). import + sealClient 전달을 둘 다 확인한다.
|
|
23
26
|
*/
|
|
24
27
|
export function hasSealClientWired(mainSource) {
|
|
25
|
-
|
|
28
|
+
// import 경로 문자열을 봐야 하므로 문자열 내용은 보존(주석만 제거).
|
|
29
|
+
const src = stripComments(mainSource);
|
|
30
|
+
const imported = /import[^\n]*@gaonjs\/seal\/client/.test(src);
|
|
26
31
|
// createGaonApp(...) 호출 안에 sealClient 가 실제로 전달됐는지(멀티라인 객체 허용).
|
|
27
|
-
const passed = /createGaonApp\s*\([\s\S]*\bsealClient\b/.test(
|
|
32
|
+
const passed = /createGaonApp\s*\([\s\S]*\bsealClient\b/.test(src);
|
|
28
33
|
return imported && passed;
|
|
29
34
|
}
|
|
30
35
|
/** gaon.config.ts 소스에서 명시적으로 꺼진 보안 방어층 이름을 뽑는다(rateLimit·securityHeaders·cors). */
|
|
31
36
|
export function disabledDefenses(gaonConfigSource) {
|
|
37
|
+
const src = stripCommentsAndStrings(gaonConfigSource);
|
|
32
38
|
const off = [];
|
|
33
|
-
if (/\brateLimit\s*:\s*false/.test(
|
|
39
|
+
if (/\brateLimit\s*:\s*false/.test(src))
|
|
34
40
|
off.push('rateLimit');
|
|
35
|
-
if (/\bsecurityHeaders\s*:\s*false/.test(
|
|
41
|
+
if (/\bsecurityHeaders\s*:\s*false/.test(src))
|
|
36
42
|
off.push('securityHeaders');
|
|
37
|
-
if (/\bcors\s*:\s*false/.test(
|
|
43
|
+
if (/\bcors\s*:\s*false/.test(src))
|
|
38
44
|
off.push('cors');
|
|
39
45
|
return off;
|
|
40
46
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 주석을 지운 소스를 돌려준다(문자열·코드는 보존 · 라인 번호 유지).
|
|
3
|
+
* 문자열 **내용**을 봐야 하는 탐지기(예: `strategy: 'jwt'` · import 경로)에 쓴다.
|
|
4
|
+
*/
|
|
5
|
+
export declare function stripComments(source: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* 주석 + 문자열/템플릿 리터럴 내용을 지운 소스를 돌려준다(구분자·라인 보존).
|
|
8
|
+
* 프로퍼티 키·키워드만 보는 탐지기(예: `session:` · `auth:` · `rateLimit: false`)에
|
|
9
|
+
* 쓴다 — 주석도 문자열도 키를 위장하지 못한다.
|
|
10
|
+
*/
|
|
11
|
+
export declare function stripCommentsAndStrings(source: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 소스 정규화 유틸 (12차 W1 · 결정 140)
|
|
2
|
+
//
|
|
3
|
+
// 탐지기가 정규식으로 소스를 볼 때 **주석·문자열 안의 우연한 일치**를 배선으로
|
|
4
|
+
// 오탐하지 않도록, TS 스캐너로 토큰을 훑어 주석/문자열을 안전하게 지운다.
|
|
5
|
+
//
|
|
6
|
+
// 왜 스캐너인가(regex 스트립이 아니라): 보안 탐지기(auth·csrf·seal)는 오탐이
|
|
7
|
+
// 검사 skip = 취약점 노출로 이어진다(12차 W1 · gaon g app 스캐폴드의 주석
|
|
8
|
+
// `// session: { ... }` 가 hasSessionWiring 을 만족시켜 CSRF/세션 검사를
|
|
9
|
+
// 통째로 건너뛴 P0). regex 기반 주석 스트립은 문자열 안의 `//` 를 잘못 지우는
|
|
10
|
+
// 등 경계에서 새므로, 정확한 토큰 경계가 필요한 보안 탐지기는 스캐너로 지운다.
|
|
11
|
+
// (connections.ts·schema-relations.ts 가 이미 TS AST 를 쓰는 것과 같은 결.)
|
|
12
|
+
//
|
|
13
|
+
// 라인/컬럼(개행)은 보존한다 — 탐지기가 라인 번호를 그대로 보고할 수 있게.
|
|
14
|
+
import ts from 'typescript';
|
|
15
|
+
function isCommentKind(k) {
|
|
16
|
+
return k === ts.SyntaxKind.SingleLineCommentTrivia || k === ts.SyntaxKind.MultiLineCommentTrivia;
|
|
17
|
+
}
|
|
18
|
+
function isStringKind(k) {
|
|
19
|
+
return (k === ts.SyntaxKind.StringLiteral ||
|
|
20
|
+
k === ts.SyntaxKind.NoSubstitutionTemplateLiteral ||
|
|
21
|
+
k === ts.SyntaxKind.TemplateHead ||
|
|
22
|
+
k === ts.SyntaxKind.TemplateMiddle ||
|
|
23
|
+
k === ts.SyntaxKind.TemplateTail);
|
|
24
|
+
}
|
|
25
|
+
/** 개행만 남기고 나머지 문자는 공백으로 — 라인 번호를 보존한다. */
|
|
26
|
+
function blankKeepLines(text) {
|
|
27
|
+
return text.replace(/[^\n]/g, ' ');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 문자열/템플릿 토큰의 **내용**만 비우고 구분자(따옴표·백틱·`${`·`}`)는 남긴다.
|
|
31
|
+
* `'jwt'` → `' '` · `''` → `''`. 프로퍼티 키·구분자에 걸린 탐지기(예: 라우트
|
|
32
|
+
* `.post('...')` 의 여는 따옴표)는 여전히 매칭되지만, 문자열 내용이 키를 위장하는
|
|
33
|
+
* 오탐은 막는다. 개행은 보존한다.
|
|
34
|
+
*/
|
|
35
|
+
function blankStringContent(text) {
|
|
36
|
+
if (text.length <= 2)
|
|
37
|
+
return text;
|
|
38
|
+
return text[0] + blankKeepLines(text.slice(1, -1)) + text[text.length - 1];
|
|
39
|
+
}
|
|
40
|
+
function scanTransform(source, strings) {
|
|
41
|
+
// skipTrivia=false 로 주석·공백까지 토큰으로 받아 원문을 무손실 재구성한다.
|
|
42
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest,
|
|
43
|
+
/* skipTrivia */ false, ts.LanguageVariant.Standard, source);
|
|
44
|
+
let out = '';
|
|
45
|
+
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
46
|
+
const text = scanner.getTokenText();
|
|
47
|
+
if (isCommentKind(token))
|
|
48
|
+
out += blankKeepLines(text);
|
|
49
|
+
else if (strings && isStringKind(token))
|
|
50
|
+
out += blankStringContent(text);
|
|
51
|
+
else
|
|
52
|
+
out += text;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 주석을 지운 소스를 돌려준다(문자열·코드는 보존 · 라인 번호 유지).
|
|
58
|
+
* 문자열 **내용**을 봐야 하는 탐지기(예: `strategy: 'jwt'` · import 경로)에 쓴다.
|
|
59
|
+
*/
|
|
60
|
+
export function stripComments(source) {
|
|
61
|
+
return scanTransform(source, /* strings */ false);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 주석 + 문자열/템플릿 리터럴 내용을 지운 소스를 돌려준다(구분자·라인 보존).
|
|
65
|
+
* 프로퍼티 키·키워드만 보는 탐지기(예: `session:` · `auth:` · `rateLimit: false`)에
|
|
66
|
+
* 쓴다 — 주석도 문자열도 키를 위장하지 못한다.
|
|
67
|
+
*/
|
|
68
|
+
export function stripCommentsAndStrings(source) {
|
|
69
|
+
return scanTransform(source, /* strings */ true);
|
|
70
|
+
}
|
package/dist/generate.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function patchRoutes(existing: string): string | null;
|
|
|
28
28
|
* · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
|
|
29
29
|
* 성공 시 loadUser import 도 함께 보장한다.
|
|
30
30
|
*/
|
|
31
|
-
export declare function patchAppConfigAddAuth(existing: string): string | null;
|
|
31
|
+
export declare function patchAppConfigAddAuth(existing: string, app?: string): string | null;
|
|
32
32
|
/** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
|
|
33
33
|
export declare function writeAuthScaffold(cwd: string, opts?: AuthScaffoldOptions): ScaffoldResult;
|
|
34
34
|
export interface GenerateAuthOptions {
|
package/dist/generate.js
CHANGED
|
@@ -19,11 +19,38 @@ import { authUiKitFiles, writeUiKitFiles } from './uikit.js';
|
|
|
19
19
|
// ── 템플릿 로드·치환 ───────────────────────────────────────────
|
|
20
20
|
// 템플릿은 이 모듈과 같은 위치의 templates/auth/ 에 있다(빌드가 dist 로 복사).
|
|
21
21
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'auth');
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
22
|
+
/**
|
|
23
|
+
* 앱의 URL 프리픽스 — web 은 루트라 '' (URL 앞에 아무것도 안 붙음), 그 외는 '/<app>'
|
|
24
|
+
* (결정 141 · 12차 W4). 폴더명=프리픽스 관례(§3.3)와 정합. 라우트 **등록** 경로는
|
|
25
|
+
* 프레임웍이 앱 스코프에서 자동으로 프리픽스를 붙이므로 이 값은 라우트 등록이 아니라
|
|
26
|
+
* **절대 URL**(form.post·router.delete·Link href·redirect·loginRedirect)에만 쓴다.
|
|
27
|
+
*/
|
|
28
|
+
function urlPrefixFor(app) {
|
|
29
|
+
return app === 'web' ? '' : `/${app}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 앱의 세션 secret 환경변수명 — web 은 'SESSION_SECRET'(기존), 그 외는
|
|
33
|
+
* '<APP>_SESSION_SECRET'(결정 141 · 앱별 세션 완전 분리 · session.ts 는 앱마다
|
|
34
|
+
* secret 이 달라야 분리가 완성된다고 명시). 앱 이름의 비영숫자는 '_' 로 정규화.
|
|
35
|
+
*/
|
|
36
|
+
function sessionSecretEnvFor(app) {
|
|
37
|
+
if (app === 'web')
|
|
38
|
+
return 'SESSION_SECRET';
|
|
39
|
+
return `${app.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_SESSION_SECRET`;
|
|
40
|
+
}
|
|
41
|
+
/** 앱별 개발용 세션 secret 폴백(32자 이상 · 앱마다 다르게). */
|
|
42
|
+
function devSessionSecretFor(app) {
|
|
43
|
+
return `dev-only-session-secret-${app}-change-me-now!!`;
|
|
44
|
+
}
|
|
45
|
+
/** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}}·{{URL_PREFIX}}·
|
|
46
|
+
* {{SESSION_SECRET_ENV}} 는 Vue 의 {{ }} 보간과 겹치지 않는 고정 리터럴이라
|
|
47
|
+
* 단순 replaceAll 로 안전하다(결정 141). */
|
|
24
48
|
function renderTemplate(name, app) {
|
|
25
49
|
const raw = readFileSync(join(TEMPLATE_DIR, name), 'utf8');
|
|
26
|
-
return raw
|
|
50
|
+
return raw
|
|
51
|
+
.replaceAll('{{APP_NAME}}', app)
|
|
52
|
+
.replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
|
|
53
|
+
.replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app));
|
|
27
54
|
}
|
|
28
55
|
/** 템플릿 파일 → 생성 경로 매핑(라우트 제외 — 라우트는 패치로 처리). */
|
|
29
56
|
const TEMPLATES = [
|
|
@@ -76,18 +103,30 @@ export function patchRoutes(existing) {
|
|
|
76
103
|
* · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
|
|
77
104
|
* 성공 시 loadUser import 도 함께 보장한다.
|
|
78
105
|
*/
|
|
79
|
-
export function patchAppConfigAddAuth(existing) {
|
|
106
|
+
export function patchAppConfigAddAuth(existing, app = 'web') {
|
|
80
107
|
if (/\bauth\s*:/.test(existing))
|
|
81
108
|
return null;
|
|
82
109
|
const m = existing.match(/defineAppConfig\(\s*\{/);
|
|
83
110
|
if (!m || m.index === undefined)
|
|
84
111
|
return null;
|
|
85
112
|
const insertAt = m.index + m[0].length;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
const prefix = urlPrefixFor(app);
|
|
114
|
+
// 결정 141(12차 W4): 대상 앱에 세션이 없으면(예: gaon g app 이 만든 빈 config)
|
|
115
|
+
// 세션도 함께 배선한다 — 세션이 없으면 로그인이 저장될 곳이 없어 왕복이 깨지고,
|
|
116
|
+
// doctor 가 세션/CSRF 누락을 error 로 잡는다(결정 140). secret 은 앱별로 분리한다.
|
|
117
|
+
const hasSession = /\bsession\s*:/.test(existing);
|
|
118
|
+
const lines = [];
|
|
119
|
+
if (!hasSession) {
|
|
120
|
+
const secretEnv = sessionSecretEnvFor(app);
|
|
121
|
+
lines.push(` // 세션 secret 은 32자 이상 — .env 의 ${secretEnv} 로 주입한다(앱별 세션 완전 분리).`, ` session: { secret: process.env.${secretEnv} ?? '${devSessionSecretFor(app)}' },`);
|
|
122
|
+
}
|
|
123
|
+
lines.push(' // 결정 59: 세션 userId → 사용자 로드 배선(g auth). 이게 있어야 로그인 후', ' // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.', ` auth: { loadUser, loginRedirect: '${prefix}/session/new' },`);
|
|
124
|
+
// 결정 141: 닫는 괄호 오염 방지 — 빈 `defineAppConfig({})` 처럼 삽입 지점 바로 뒤가
|
|
125
|
+
// 닫는 `}` 면 삽입 블록 끝에 개행을 더해 `})` 를 제 줄로 내린다(`},})` 방지).
|
|
126
|
+
const rest = existing.slice(insertAt);
|
|
127
|
+
const closesImmediately = /^\s*\}/.test(rest);
|
|
128
|
+
const block = '\n' + lines.join('\n') + (closesImmediately ? '\n' : '');
|
|
129
|
+
let out = existing.slice(0, insertAt) + block + rest;
|
|
91
130
|
// loadUser import 보장 — gaonjs/config import 바로 뒤에 넣는다.
|
|
92
131
|
if (!/from\s+['"]\.\/auth\.js['"]/.test(out)) {
|
|
93
132
|
const im = out.match(/import\s+\{[^}]*\}\s+from\s+['"]gaonjs\/config['"][^\n]*\n/);
|
|
@@ -124,7 +163,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
124
163
|
// 결정 59: auth 배선이 없으면 로그인 후에도 currentUser 가 영구 null 이다.
|
|
125
164
|
if (file.path === `apps/${app}/app.config.ts`) {
|
|
126
165
|
const existing = readFileSync(abs, 'utf8');
|
|
127
|
-
const patchedConfig = patchAppConfigAddAuth(existing);
|
|
166
|
+
const patchedConfig = patchAppConfigAddAuth(existing, app);
|
|
128
167
|
if (patchedConfig) {
|
|
129
168
|
writeFileSync(abs, patchedConfig, 'utf8');
|
|
130
169
|
patched.push(file.path);
|
|
@@ -134,7 +173,8 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
134
173
|
skipped.push(file.path);
|
|
135
174
|
warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 자동 추가하지 못했습니다.\n` +
|
|
136
175
|
`→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
|
|
137
|
-
`
|
|
176
|
+
` session: { secret: process.env.${sessionSecretEnvFor(app)} ?? '32자 이상 비밀' },\n` +
|
|
177
|
+
` auth: { loadUser, loginRedirect: '${urlPrefixFor(app)}/session/new' } // import { loadUser } from './auth.js'\n` +
|
|
138
178
|
`→ 이 배선이 없으면 로그인해도 this.currentUser/requireAuth() 가 사용자를 받지 못합니다.`);
|
|
139
179
|
}
|
|
140
180
|
else {
|
|
@@ -191,11 +231,13 @@ export function runGenerateAuthCommand(opts = {}) {
|
|
|
191
231
|
for (const w of result.warnings)
|
|
192
232
|
lines.push('', ` ⚠ ${w.split('\n').join('\n ')}`);
|
|
193
233
|
lines.push('');
|
|
234
|
+
const prefix = urlPrefixFor(app);
|
|
235
|
+
const secretEnv = sessionSecretEnvFor(app);
|
|
194
236
|
lines.push(' 다음 단계:');
|
|
195
|
-
lines.push(
|
|
237
|
+
lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
|
|
196
238
|
lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
|
|
197
239
|
lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
|
|
198
|
-
lines.push(
|
|
240
|
+
lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
|
|
199
241
|
lines.push('');
|
|
200
242
|
process.stdout.write(lines.join('\n') + '\n');
|
|
201
243
|
return 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
|
|
|
4
4
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, type DevConsole, type DevConsoleOptions, type DevSource, type DevLevel, type ComposeStatus, type ComposeUpOptions, type EnsureInfraResult, type DockerLocateOptions, type TscWatcherOptions, type TscWatcherHandle, type RestartWatcherOptions, type RestartWatcherHandle, } from "./dev/index.js";
|
|
5
5
|
export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckStepStatus, type CheckStepResult, } from "./commands/check.js";
|
|
6
6
|
export { runGenCommand, regenerateProjectGaon, type GenCommandOptions, type GenResult, } from "./commands/gen.js";
|
|
7
|
+
export { runBuildCommand, type BuildCommandOptions, type BuildCommandResult, } from "./commands/build.js";
|
|
8
|
+
export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, type BuildAppOptions, type DistVerifyResult, } from "./dev/build.js";
|
|
7
9
|
export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
|
|
8
10
|
export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
|
|
9
11
|
export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { MILESTONES, VERSION, HOMEPAGE, loadDotEnv } from "@gaonjs/core";
|
|
|
14
14
|
import { runDevCommand } from "./commands/dev.js";
|
|
15
15
|
import { runCheckCommand } from "./commands/check.js";
|
|
16
16
|
import { runGenCommand } from "./commands/gen.js";
|
|
17
|
+
import { runBuildCommand } from "./commands/build.js";
|
|
17
18
|
import { runNewCommand } from "./commands/new.js";
|
|
18
19
|
import { runConsoleCommand } from "./commands/console.js";
|
|
19
20
|
import { runTestCommand } from "./commands/test.js";
|
|
@@ -32,6 +33,8 @@ export { runDevCommand } from "./commands/dev.js";
|
|
|
32
33
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
|
|
33
34
|
export { runCheckCommand, } from "./commands/check.js";
|
|
34
35
|
export { runGenCommand, regenerateProjectGaon, } from "./commands/gen.js";
|
|
36
|
+
export { runBuildCommand, } from "./commands/build.js";
|
|
37
|
+
export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, } from "./dev/build.js";
|
|
35
38
|
export { runNewCommand } from "./commands/new.js";
|
|
36
39
|
export { runConsoleCommand } from "./commands/console.js";
|
|
37
40
|
export { runTestCommand } from "./commands/test.js";
|
|
@@ -102,6 +105,7 @@ function renderHelp(version = VERSION) {
|
|
|
102
105
|
" gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
|
|
103
106
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
104
107
|
" gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
|
|
108
|
+
" gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
|
|
105
109
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
106
110
|
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
|
|
107
111
|
" gaon doctor 정적 검사 (25 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계)",
|
|
@@ -258,6 +262,20 @@ export function runCli(argv, opts = {}) {
|
|
|
258
262
|
});
|
|
259
263
|
return;
|
|
260
264
|
}
|
|
265
|
+
// `gaon build` — 멀티 앱 프론트 프로덕션 빌드(결정 146). gaon gen 뒤 apps/* 를
|
|
266
|
+
// 순회해 앱마다 root/outDir/base 로 vite build. 스캐폴드 build 스크립트가 이걸 부른다.
|
|
267
|
+
if (argv[0] === "build") {
|
|
268
|
+
void runBuildCommand({ json: argv.includes("--json") })
|
|
269
|
+
.then((code) => {
|
|
270
|
+
process.exitCode = code;
|
|
271
|
+
})
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
+
process.stderr.write(` ✗ gaon build 실패: ${msg}\n`);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
261
279
|
// `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
|
|
262
280
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
263
281
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
package/dist/scaffold/app.js
CHANGED
|
@@ -72,9 +72,10 @@ export function appScaffoldFiles(name) {
|
|
|
72
72
|
contents: [
|
|
73
73
|
`// ${name} 앱 설정 — apps/${name}/app.config.ts.`,
|
|
74
74
|
`// 잘 지은 앱의 app.config.ts 는 거의 비어 있다(v0.16 §1.1 ②).`,
|
|
75
|
+
`// 세션·인증 배선은 \`gaon g auth --app ${name}\` 이 여기에 채운다 — 직접 손대지 않는다.`,
|
|
76
|
+
`// 그 전에 상태 변경(POST 등) 라우트를 두면 doctor 가 세션/CSRF 누락을 잡는다(결정 140).`,
|
|
75
77
|
`// 관례에서 벗어날 때만 값을 채운다:`,
|
|
76
78
|
`// hosts: ['${name}.example.com'] // 도메인 바인딩(개발은 ${name}.localhost 자동)`,
|
|
77
|
-
`// session: { cookie: '${name}_session' } // 세션 옵션 덮어쓰기`,
|
|
78
79
|
`import { defineAppConfig } from 'gaonjs/config'`,
|
|
79
80
|
``,
|
|
80
81
|
`export default defineAppConfig({})`,
|
|
@@ -13,10 +13,10 @@ import Button from '@shared/components/ui/Button.vue'
|
|
|
13
13
|
// 이 페이지는 requireAuth 로 보호되므로 런타임엔 항상 로그인 상태(타입은 nullable).
|
|
14
14
|
const shared = useShared()
|
|
15
15
|
|
|
16
|
-
// 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
|
|
16
|
+
// 로그아웃 = DELETE {{URL_PREFIX}}/session (r.resource('session') 의 destroy).
|
|
17
17
|
// HTML <form> 은 DELETE 를 못 보내므로 Inertia 라우터로 실제 메서드를 보낸다(결정 64).
|
|
18
18
|
function logout(): void {
|
|
19
|
-
router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
|
|
19
|
+
router.delete('{{URL_PREFIX}}/session', { headers: { 'x-csrf-token': shared.csrf } })
|
|
20
20
|
}
|
|
21
21
|
</script>
|
|
22
22
|
|
|
@@ -36,7 +36,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
|
36
36
|
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
37
37
|
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
38
38
|
</Alert>
|
|
39
|
-
<Form @submit="form.post('/session')">
|
|
39
|
+
<Form @submit="form.post('{{URL_PREFIX}}/session')">
|
|
40
40
|
<FormField label="이메일" :error="form.errors.email">
|
|
41
41
|
<Input v-model="form.email" type="email" required />
|
|
42
42
|
</FormField>
|
|
@@ -47,7 +47,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
|
47
47
|
</Form>
|
|
48
48
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
49
49
|
계정이 없으신가요?
|
|
50
|
-
<Link href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
|
|
50
|
+
<Link href="{{URL_PREFIX}}/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
|
|
51
51
|
</p>
|
|
52
52
|
</CardContent>
|
|
53
53
|
</Card>
|
|
@@ -33,7 +33,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
|
|
|
33
33
|
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
34
34
|
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
35
35
|
</Alert>
|
|
36
|
-
<Form @submit="form.post('/registration')">
|
|
36
|
+
<Form @submit="form.post('{{URL_PREFIX}}/registration')">
|
|
37
37
|
<FormField label="이름" :error="form.errors.name">
|
|
38
38
|
<Input v-model="form.name" required />
|
|
39
39
|
</FormField>
|
|
@@ -47,7 +47,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
|
|
|
47
47
|
</Form>
|
|
48
48
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
49
49
|
이미 계정이 있으신가요?
|
|
50
|
-
<Link href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
|
|
50
|
+
<Link href="{{URL_PREFIX}}/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
|
|
51
51
|
</p>
|
|
52
52
|
</CardContent>
|
|
53
53
|
</Card>
|
|
@@ -3,9 +3,9 @@ import { defineAppConfig } from 'gaonjs/config'
|
|
|
3
3
|
import { loadUser } from './auth.js'
|
|
4
4
|
|
|
5
5
|
export default defineAppConfig({
|
|
6
|
-
// 세션 secret 은 32자 이상 — .env 의
|
|
7
|
-
session: { secret: process.env.
|
|
6
|
+
// 세션 secret 은 32자 이상 — .env 의 {{SESSION_SECRET_ENV}} 로 주입한다(앱별 세션 완전 분리).
|
|
7
|
+
session: { secret: process.env.{{SESSION_SECRET_ENV}} ?? 'dev-only-session-secret-{{APP_NAME}}-change-me!!' },
|
|
8
8
|
// 결정 59: 세션 userId → 사용자 로드 배선. 이게 있어야 로그인 후
|
|
9
9
|
// this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.
|
|
10
|
-
auth: { loadUser, loginRedirect: '/session/new' },
|
|
10
|
+
auth: { loadUser, loginRedirect: '{{URL_PREFIX}}/session/new' },
|
|
11
11
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { controller } from 'gaonjs/web'
|
|
3
3
|
|
|
4
4
|
export default controller({
|
|
5
|
-
// GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
|
|
5
|
+
// GET {{URL_PREFIX}}/dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
|
|
6
6
|
async show() {
|
|
7
7
|
// 보호만 하고 페이지 데이터는 넘기지 않는다 — 사용자·csrf 는 자동 주입되고
|
|
8
8
|
// (결정 116), 페이지는 useShared() 로 읽는다. requireAuth 로 게이트만 건다.
|
|
@@ -3,11 +3,11 @@ import { controller, hashPassword } from 'gaonjs/web'
|
|
|
3
3
|
import { User } from '../../../domain/models/User.js'
|
|
4
4
|
|
|
5
5
|
export default controller({
|
|
6
|
-
// GET /registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
|
|
6
|
+
// GET {{URL_PREFIX}}/registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
|
|
7
7
|
async new() {
|
|
8
8
|
return this.render('Auth/Signup', { error: null as string | null })
|
|
9
9
|
},
|
|
10
|
-
// POST /registration — 회원가입
|
|
10
|
+
// POST {{URL_PREFIX}}/registration — 회원가입
|
|
11
11
|
async create() {
|
|
12
12
|
const { name, email, password } = this.params({
|
|
13
13
|
_row: {} as { name: string; email: string; password: string },
|
|
@@ -15,6 +15,6 @@ export default controller({
|
|
|
15
15
|
const passwordDigest = await hashPassword(password)
|
|
16
16
|
const user = await User.create({ name, email, passwordDigest })
|
|
17
17
|
this.auth.login(user)
|
|
18
|
-
return this.redirect('/dashboard')
|
|
18
|
+
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
19
19
|
},
|
|
20
20
|
})
|
|
@@ -3,26 +3,26 @@ import { controller, verifyPassword } from 'gaonjs/web'
|
|
|
3
3
|
import { User } from '../../../domain/models/User.js'
|
|
4
4
|
|
|
5
5
|
export default controller({
|
|
6
|
-
// GET /session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
|
|
6
|
+
// GET {{URL_PREFIX}}/session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
|
|
7
7
|
async new() {
|
|
8
8
|
return this.render('Auth/Login', { error: null as string | null })
|
|
9
9
|
},
|
|
10
|
-
// POST /session — 로그인
|
|
10
|
+
// POST {{URL_PREFIX}}/session — 로그인
|
|
11
11
|
async create() {
|
|
12
12
|
const { email, password } = this.params({ _row: {} as { email: string; password: string } })
|
|
13
13
|
const user = await User.where('email', '=', email).first()
|
|
14
14
|
// passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
|
|
15
15
|
if (user && (await verifyPassword(password, user.passwordDigest))) {
|
|
16
16
|
this.auth.login(user)
|
|
17
|
-
return this.redirect('/dashboard')
|
|
17
|
+
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
18
18
|
}
|
|
19
19
|
return this.render('Auth/Login', {
|
|
20
20
|
error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
|
|
21
21
|
})
|
|
22
22
|
},
|
|
23
|
-
// DELETE /session — 로그아웃
|
|
23
|
+
// DELETE {{URL_PREFIX}}/session — 로그아웃
|
|
24
24
|
async destroy() {
|
|
25
25
|
this.auth.logout()
|
|
26
|
-
return this.redirect('/session/new')
|
|
26
|
+
return this.redirect('{{URL_PREFIX}}/session/new')
|
|
27
27
|
},
|
|
28
28
|
})
|
|
@@ -186,15 +186,19 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
- 트랜잭션 안의 `emit` 은 `AsyncLocalStorage` 로 투명하게 감지돼
|
|
189
|
-
아웃박스에 스테이징된다(별도 API 호출 불필요).
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
아웃박스에 스테이징된다(별도 API 호출 불필요). `service()` 본문의 emit 이
|
|
190
|
+
같은 트랜잭션으로 `_gaon_outbox` 에 적재되고, 롤백 시 적재도 취소된다
|
|
191
|
+
(결정 144 · 12차 W3 — 표준 부팅 `gaon serve`·`gaon work` 가 이 배선을
|
|
192
|
+
자동으로 한다 · 앱 개발자가 부팅 코드를 짤 필요 없다).
|
|
193
|
+
- 대부분의 도메인 코드는 `service()` 본문에서 emit 하거나, "커밋 후 즉시
|
|
194
|
+
발행"이면 `afterCommit(fn)`(§서비스)을 쓴다. 저수준 원시 `runInTransaction(db, fn)`
|
|
195
|
+
(커넥션을 직접 넘긴다)은 프레임웍 밖에서 트랜잭션을 손수 열 때만 쓰는 탈출구다.
|
|
193
196
|
- 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 로 아웃박스를 폴링해 발행
|
|
194
197
|
한다 (기본 폴 1000ms · 배치 100).
|
|
195
198
|
- at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
|
|
196
199
|
흡수한다.
|
|
197
|
-
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며
|
|
200
|
+
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
|
|
201
|
+
기동 시 보장된다(결정 144 · nats 설정이 있을 때).
|
|
198
202
|
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
199
203
|
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `gaon work` 의
|
|
200
204
|
`outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(운영 상세는
|
|
@@ -43,8 +43,14 @@
|
|
|
43
43
|
### 2. 세션·CSRF·JWT
|
|
44
44
|
|
|
45
45
|
- 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
|
|
46
|
-
이름(`<app>_sid`) · 서명 secret · Redis 키 prefix
|
|
47
|
-
|
|
46
|
+
이름(`<app>_sid`) · 서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다.
|
|
47
|
+
(쿠키 path 는 `/` — 프리픽스·서브도메인 양쪽 접근에서 쿠키가 실리려면
|
|
48
|
+
정적 path 는 `/` 여야 한다 · 결정 142. 분리는 위 세 축으로 완성된다.)
|
|
49
|
+
- **멀티 앱 인증**: 둘째 앱은 `gaon g app admin` → `gaon g auth --app admin`
|
|
50
|
+
으로 만든다 — 스캐폴드가 로그인/리다이렉트 URL 에 앱 프리픽스(`/admin/...`)를
|
|
51
|
+
자동으로 붙이고, 세션 secret 을 **앱별 env** `<APP>_SESSION_SECRET`(예:
|
|
52
|
+
`ADMIN_SESSION_SECRET`)로 분리 배선한다(결정 141 · 앱별 세션 완전 분리).
|
|
53
|
+
운영 배포 시 그 env 를 web 과 **다르게** 설정할 것.
|
|
48
54
|
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
|
|
49
55
|
강제. `api()` 클라이언트는 `<meta name="csrf-token">` 을 자동으로
|
|
50
56
|
읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
|
|
@@ -55,6 +61,14 @@
|
|
|
55
61
|
라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
|
|
56
62
|
`csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
|
|
57
63
|
- JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
|
|
64
|
+
- **인증(requireAuth) 과 인가(authorize) 는 별개 축** (결정 145):
|
|
65
|
+
- `this.requireAuth()` = **로그인 여부** — 비로그인이면 401(세션 앱은 로그인 페이지 리다이렉트).
|
|
66
|
+
- `this.authorize(condition)` = **권한 여부** — 조건이 거짓이면 **403**. 존재 자체를
|
|
67
|
+
숨겨야 하면 `this.authorize(condition, { notFound: true })` → 404. 조건은 호출자가
|
|
68
|
+
계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`) — v1 은 정책 객체·
|
|
69
|
+
역할 DSL 을 두지 않는다(리치 authz 는 v1.1 백로그).
|
|
70
|
+
- 실시간 채널의 `authorize` 는 **구독 인가 전용**(§realtime) — HTTP 인가는 `this.authorize`.
|
|
71
|
+
- 손으로 403 을 throw 하거나 인가를 `notFound()` 로 우회하지 말 것 — `this.authorize` 가 The One Way.
|
|
58
72
|
|
|
59
73
|
### 3. 시크릿
|
|
60
74
|
|
|
@@ -101,6 +101,11 @@ DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test`
|
|
|
101
101
|
테스트에서도 그대로 돈다. 스토리지 s3 버킷은 `<bucket>-test`, 로컬 root 는
|
|
102
102
|
`<root>-test` 로 격리되고(compose `createbuckets` 가 `<PROJECT>-test` 버킷을 만든다),
|
|
103
103
|
메일은 MailPit(캡처 sink) 그대로다.
|
|
104
|
+
- `connectTestDatabase` 는 **잡·이벤트 전송(NATS)도 배선**한다(결정 143 · `config.nats`
|
|
105
|
+
있을 때) — 서비스가 `.later()`·`emit()` 하는 코드가 테스트에서도 운영과 똑같이 돈다.
|
|
106
|
+
수동 `configureJobs` 는 필요 없다. `expectJobProcessed` 는 **자기 nats 만** 임시로
|
|
107
|
+
쓰고 끝나면 하네스 배선을 복원하므로, 그 nats 를 `close()` 해도 다음 테스트의
|
|
108
|
+
`.later()` 가 깨지지 않는다(결정 143).
|
|
104
109
|
|
|
105
110
|
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
106
111
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// vite.config.ts — 프론트엔드 빌드/개발 서버 설정 (v0.16 §6.4).
|
|
2
2
|
//
|
|
3
3
|
// The One Way: gaon dev 가 Vite 를 middlewareMode 로 붙여 Fastify 한 포트로
|
|
4
|
-
// 서빙한다(§CLAUDE.md 6 · 이 파일의 server 옵션은 그때 재정의된다).
|
|
5
|
-
// build 는 이 파일을 그대로 사용한다.
|
|
4
|
+
// 서빙한다(§CLAUDE.md 6 · 이 파일의 server 옵션은 그때 재정의된다).
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
// 파일
|
|
9
|
-
//
|
|
6
|
+
// 멀티 앱: 각 앱마다 vite.config 를 두지 않는다. `gaon build`·`gaon dev` 가 이 루트
|
|
7
|
+
// 파일 하나를 **configFile 로 명시**하고(vue 플러그인·@shared alias 보장 · 결정 146),
|
|
8
|
+
// 앱마다 root=apps/<앱>·outDir=dist/<앱>·base=/<앱>/(web 은 '/') 를 덮어써 순회 빌드한다.
|
|
9
|
+
// 아래 root·build.outDir 는 apps/web 단독 실행(직접 `vite` 호출) 시의 기본값일 뿐,
|
|
10
|
+
// gaon 파이프라인이 앱별로 재정의한다. gaon g app 으로 늘린 앱은 자동 편입된다.
|
|
10
11
|
import { fileURLToPath } from 'node:url'
|
|
11
12
|
import { defineConfig } from 'vite'
|
|
12
13
|
import vue from '@vitejs/plugin-vue'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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/async": "0.
|
|
30
|
+
"@gaonjs/async": "0.9.0",
|
|
31
31
|
"@gaonjs/core": "0.2.1",
|
|
32
|
-
"@gaonjs/config": "0.
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
32
|
+
"@gaonjs/config": "0.13.0",
|
|
33
|
+
"@gaonjs/data": "0.15.0",
|
|
34
|
+
"@gaonjs/web": "0.15.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})\""
|