@gaonjs/cli 0.32.0 → 0.34.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.d.ts +10 -1
- package/dist/commands/check.js +51 -6
- package/dist/commands/dev.js +2 -0
- package/dist/commands/gen.js +4 -2
- package/dist/db/projectData.d.ts +14 -0
- package/dist/db/projectData.js +51 -0
- package/dist/db.js +38 -2
- 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/dev.d.ts +11 -1
- package/dist/dev.js +29 -1
- 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 +12 -3
- package/dist/generate.js +113 -42
- package/dist/index.d.ts +3 -0
- package/dist/index.js +25 -5
- package/dist/messages-gen.d.ts +6 -0
- package/dist/messages-gen.js +24 -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 -5
- 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/dashboard.secure.controller.ts.tpl +14 -0
- package/dist/templates/auth/registration.controller.ts.tpl +3 -3
- package/dist/templates/auth/session.controller.ts.tpl +5 -5
- package/dist/templates/project/.env.example.tpl +6 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -1
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +46 -5
- package/dist/templates/project/agents/data.md.tpl +47 -2
- package/dist/templates/project/agents/frontend.md.tpl +3 -1
- package/dist/templates/project/agents/i18n.md.tpl +107 -0
- package/dist/templates/project/agents/mail.md.tpl +92 -0
- package/dist/templates/project/agents/realtime.md.tpl +8 -0
- package/dist/templates/project/agents/security.md.tpl +45 -2
- package/dist/templates/project/agents/testing.md.tpl +11 -0
- package/dist/templates/project/agents/web.md.tpl +32 -0
- package/dist/templates/project/gaon.config.ts.tpl +14 -0
- package/dist/templates/project/package.json.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +6 -5
- package/package.json +7 -6
- package/dist/templates/auth/app.ts.tpl +0 -29
- package/dist/templates/auth/server.ts.tpl +0 -14
|
@@ -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
|
}
|
package/dist/dev.d.ts
CHANGED
|
@@ -11,11 +11,17 @@ export interface DevLayout {
|
|
|
11
11
|
/** .gaon/tables.d.ts */
|
|
12
12
|
readonly tablesOut: string;
|
|
13
13
|
readonly apps: readonly DevApp[];
|
|
14
|
+
/** locales/ (없으면 undefined — messages 축 생략 · 결정 158 · W2). */
|
|
15
|
+
readonly localesDir?: string;
|
|
16
|
+
/** .gaon/messages.d.ts */
|
|
17
|
+
readonly messagesOut: string;
|
|
14
18
|
}
|
|
15
19
|
export interface DevDeps {
|
|
16
20
|
readonly layout: DevLayout;
|
|
17
21
|
regenerateTables(schemaDir: string, out: string): Promise<unknown>;
|
|
18
22
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
23
|
+
/** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
|
|
24
|
+
regenerateMessages?(localesDir: string, out: string): unknown;
|
|
19
25
|
watch(dir: string, opts: WatchOptions): WatchHandle;
|
|
20
26
|
log(event: DevEvent): void;
|
|
21
27
|
onError(err: Error): void;
|
|
@@ -26,7 +32,7 @@ export type DevEvent = {
|
|
|
26
32
|
readonly apps: string[];
|
|
27
33
|
} | {
|
|
28
34
|
readonly kind: 'regen';
|
|
29
|
-
readonly target: 'tables' | 'routes';
|
|
35
|
+
readonly target: 'tables' | 'routes' | 'messages';
|
|
30
36
|
readonly app?: string;
|
|
31
37
|
} | {
|
|
32
38
|
readonly kind: 'stopped';
|
|
@@ -44,12 +50,16 @@ export declare function startDev(deps: DevDeps): Promise<DevHandle>;
|
|
|
44
50
|
export interface RegenDeps {
|
|
45
51
|
regenerateTables(schemaDir: string, out: string): Promise<unknown>;
|
|
46
52
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
53
|
+
/** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
|
|
54
|
+
regenerateMessages?(localesDir: string, out: string): unknown;
|
|
47
55
|
}
|
|
48
56
|
export interface RegenResult {
|
|
49
57
|
/** tables.d.ts 를 재생성했는가(domain/schema 존재 시에만). */
|
|
50
58
|
readonly tables: boolean;
|
|
51
59
|
/** routes.d.ts 를 재생성한 앱 이름. */
|
|
52
60
|
readonly apps: readonly string[];
|
|
61
|
+
/** messages.d.ts 를 재생성했는가(locales/ 존재 · 결정 158 · W2). */
|
|
62
|
+
readonly messages: boolean;
|
|
53
63
|
}
|
|
54
64
|
/**
|
|
55
65
|
* 워처 없이 .gaon 타입 브리지를 1회 전체 재생성한다. `gaon check` 처럼
|
package/dist/dev.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { readdirSync, existsSync } from 'node:fs';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
|
+
/** locales/*.json 변경(메시지 카탈로그) — messages.d.ts 재생성 트리거. */
|
|
17
|
+
const isMessagesChange = (f) => !f.includes('.gaon') && f.endsWith('.json');
|
|
16
18
|
const isSchemaChange = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && !f.includes('.gaon');
|
|
17
19
|
const isRoutesChange = (f) => !f.includes('.gaon') &&
|
|
18
20
|
f.endsWith('.ts') &&
|
|
@@ -35,6 +37,11 @@ export async function startDev(deps) {
|
|
|
35
37
|
await deps.regenerateRoutes(app.appDir, app.routesOut);
|
|
36
38
|
deps.log({ kind: 'regen', target: 'routes', app: app.name });
|
|
37
39
|
}
|
|
40
|
+
// 결정 158(W2): locales/ → messages.d.ts.
|
|
41
|
+
if (layout.localesDir && deps.regenerateMessages) {
|
|
42
|
+
await deps.regenerateMessages(layout.localesDir, layout.messagesOut);
|
|
43
|
+
deps.log({ kind: 'regen', target: 'messages' });
|
|
44
|
+
}
|
|
38
45
|
// 2) 스키마 워치 → tables.d.ts 재생성.
|
|
39
46
|
if (layout.schemaDir) {
|
|
40
47
|
const schemaDir = layout.schemaDir;
|
|
@@ -58,6 +65,19 @@ export async function startDev(deps) {
|
|
|
58
65
|
onError: deps.onError,
|
|
59
66
|
}));
|
|
60
67
|
}
|
|
68
|
+
// 4) locales 워치 → messages.d.ts 재생성(결정 158 · W2).
|
|
69
|
+
if (layout.localesDir && deps.regenerateMessages) {
|
|
70
|
+
const localesDir = layout.localesDir;
|
|
71
|
+
const regenerateMessages = deps.regenerateMessages;
|
|
72
|
+
handles.push(deps.watch(localesDir, {
|
|
73
|
+
filter: isMessagesChange,
|
|
74
|
+
onChange: async () => {
|
|
75
|
+
await regenerateMessages(localesDir, layout.messagesOut);
|
|
76
|
+
deps.log({ kind: 'regen', target: 'messages' });
|
|
77
|
+
},
|
|
78
|
+
onError: deps.onError,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
61
81
|
deps.log({ kind: 'ready', schema: !!layout.schemaDir, apps: layout.apps.map((a) => a.name) });
|
|
62
82
|
return {
|
|
63
83
|
close() {
|
|
@@ -80,7 +100,12 @@ export async function regenerateGaonOnce(layout, deps) {
|
|
|
80
100
|
for (const app of layout.apps) {
|
|
81
101
|
await deps.regenerateRoutes(app.appDir, app.routesOut);
|
|
82
102
|
}
|
|
83
|
-
|
|
103
|
+
// 결정 158(W2): locales/ 가 있으면 messages.d.ts 도 재생성한다(i18n 키 타입 브리지).
|
|
104
|
+
let messages = false;
|
|
105
|
+
if (layout.localesDir && deps.regenerateMessages) {
|
|
106
|
+
messages = (await deps.regenerateMessages(layout.localesDir, layout.messagesOut)) === true;
|
|
107
|
+
}
|
|
108
|
+
return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name), messages };
|
|
84
109
|
}
|
|
85
110
|
/** cwd 관례로 프로젝트 레이아웃을 해석한다(존재하는 것만 포함). */
|
|
86
111
|
export function resolveDevLayout(cwd) {
|
|
@@ -102,10 +127,13 @@ export function resolveDevLayout(cwd) {
|
|
|
102
127
|
});
|
|
103
128
|
}
|
|
104
129
|
}
|
|
130
|
+
const localesDirPath = join(root, 'locales');
|
|
105
131
|
return {
|
|
106
132
|
schemaDir: existsSync(schemaDirPath) ? schemaDirPath : undefined,
|
|
107
133
|
tablesOut: join(root, '.gaon', 'tables.d.ts'),
|
|
108
134
|
apps: apps.sort((a, b) => a.name.localeCompare(b.name)),
|
|
135
|
+
localesDir: existsSync(localesDirPath) ? localesDirPath : undefined,
|
|
136
|
+
messagesOut: join(root, '.gaon', 'messages.d.ts'),
|
|
109
137
|
};
|
|
110
138
|
}
|
|
111
139
|
// runDevCommand · DevCommandOptions 는 M9-C 에서 commands/dev.ts 로 이동.
|
|
@@ -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
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export interface AuthScaffoldOptions {
|
|
2
2
|
/** 대상 앱(apps/<app>). 기본 'web'. */
|
|
3
3
|
readonly app?: string;
|
|
4
|
+
/**
|
|
5
|
+
* 결정 155: 공개 회원가입(registration)을 깔지 여부. 기본은 **앱명 기반 시큐어**
|
|
6
|
+
* — web 은 공개 가입 O, 비-web(admin 등)은 공개 가입 X + 역할 게이트(authorize).
|
|
7
|
+
* `--public` 로 비-web 앱도 공개 가입을 opt-in 한다(공개 비-web 앱용 탈출구).
|
|
8
|
+
*/
|
|
9
|
+
readonly public?: boolean;
|
|
4
10
|
}
|
|
5
11
|
/** 생성할 파일 하나 — 경로는 프로젝트 루트 기준. */
|
|
6
12
|
export interface ScaffoldFile {
|
|
@@ -14,13 +20,14 @@ export interface ScaffoldResult {
|
|
|
14
20
|
/** 손 수리가 필요한 지점 — 수리 안내 문장(§7.5.3)을 그대로 담는다. */
|
|
15
21
|
readonly warnings: string[];
|
|
16
22
|
}
|
|
17
|
-
/** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
|
|
23
|
+
/** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
|
|
24
|
+
* 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
|
|
18
25
|
export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldFile[];
|
|
19
26
|
/**
|
|
20
27
|
* 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
|
|
21
28
|
* `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
|
|
22
29
|
*/
|
|
23
|
-
export declare function patchRoutes(existing: string): string | null;
|
|
30
|
+
export declare function patchRoutes(existing: string, includePublic?: boolean): string | null;
|
|
24
31
|
/**
|
|
25
32
|
* 기존 app.config.ts 에 auth 배선을 끼워 넣는다 (결정 93 · gaon new 기본 web 앱은
|
|
26
33
|
* 세션만 배선돼 있어 g auth 는 auth 만 추가하면 완성된다). 반환:
|
|
@@ -28,13 +35,15 @@ export declare function patchRoutes(existing: string): string | null;
|
|
|
28
35
|
* · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
|
|
29
36
|
* 성공 시 loadUser import 도 함께 보장한다.
|
|
30
37
|
*/
|
|
31
|
-
export declare function patchAppConfigAddAuth(existing: string): string | null;
|
|
38
|
+
export declare function patchAppConfigAddAuth(existing: string, app?: string): string | null;
|
|
32
39
|
/** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
|
|
33
40
|
export declare function writeAuthScaffold(cwd: string, opts?: AuthScaffoldOptions): ScaffoldResult;
|
|
34
41
|
export interface GenerateAuthOptions {
|
|
35
42
|
readonly cwd?: string;
|
|
36
43
|
readonly app?: string;
|
|
37
44
|
readonly json?: boolean;
|
|
45
|
+
/** 결정 155: 비-web 앱도 공개 회원가입을 opt-in(공개 비-web 앱용 탈출구). */
|
|
46
|
+
readonly public?: boolean;
|
|
38
47
|
}
|
|
39
48
|
/** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
|
|
40
49
|
export declare function runGenerateAuthCommand(opts?: GenerateAuthOptions): number;
|