@gaonjs/cli 0.21.2 → 0.23.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.
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점). */
3
+ export declare function hasStateChangingRoutes(routesSource: string): boolean;
4
+ /** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
5
+ export declare function checkCsrfWiring(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,72 @@
1
+ // @gaonjs/cli · doctor · CSRF/세션 배선 검사 (결정 93)
2
+ //
3
+ // 규칙 8(§2.5.1): 보안 기본값(CORS·rate limit·CSRF)은 코어에서 기본 켬이다.
4
+ // CSRF 는 세션 위에 얹힌다(session.ts) — 세션이 없으면 CSRF 토큰을 저장·검증할
5
+ // 곳이 없어 상태 변경 요청(POST/PUT/PATCH/DELETE)이 **무방비**로 통과한다.
6
+ //
7
+ // 이 검사는 앱 routes.ts 에 비-GET 라우트가 있는데 그 앱 app.config.ts 에
8
+ // session 배선이 없으면 경고한다 — 컴파일은 통과하지만 CSRF 가 실질적으로 꺼진
9
+ // 상태다. JWT/API 앱(strategy:'jwt')은 토큰 인증이라 CSRF 대상이 아니므로 제외한다.
10
+ //
11
+ // 판정은 소스 텍스트 기반(가벼운 정적 검사) — routes.ts·app.config.ts 를 실행하지
12
+ // 않는다. gaon new 스캐폴드는 web 앱에 세션을 기본 배선하므로(결정 93) 관례를
13
+ // 따르면 이 경고는 나오지 않는다.
14
+ import { readdir, readFile } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { join, relative } from 'node:path';
17
+ import { hasSessionWiring } from './auth-wiring.js';
18
+ /** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점). */
19
+ export function hasStateChangingRoutes(routesSource) {
20
+ // r.post('/x', ...) · r.put/patch/delete — 메서드 + 경로 문자열.
21
+ if (/\.\s*(post|put|patch|delete)\s*\(\s*['"]/.test(routesSource))
22
+ return true;
23
+ // resource(s)('posts') — 리소스 라우트는 create/update/destroy(POST/PUT/DELETE)를 만든다.
24
+ if (/\.\s*resources?\s*\(\s*['"]/.test(routesSource))
25
+ return true;
26
+ return false;
27
+ }
28
+ /** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
29
+ export async function checkCsrfWiring(cwd) {
30
+ const appsDir = join(cwd, 'apps');
31
+ const issues = [];
32
+ for (const app of await safeListDirs(appsDir)) {
33
+ const routesPath = join(appsDir, app, 'routes.ts');
34
+ const routesSource = await readFile(routesPath, 'utf8').catch(() => undefined);
35
+ if (routesSource === undefined)
36
+ continue;
37
+ if (!hasStateChangingRoutes(routesSource))
38
+ continue;
39
+ const acPath = join(appsDir, app, 'app.config.ts');
40
+ const acRel = relative(cwd, acPath);
41
+ const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : undefined;
42
+ // JWT/API 앱은 토큰 인증 — CSRF 대상 아님(제외).
43
+ const jwt = acSource != null && /strategy\s*:\s*['"]jwt['"]/.test(acSource);
44
+ if (jwt)
45
+ continue;
46
+ if (acSource != null && hasSessionWiring(acSource))
47
+ continue;
48
+ issues.push({
49
+ rule: 'csrf-wiring',
50
+ level: 'warning',
51
+ file: `apps/${app}/routes.ts`,
52
+ message: `CSRF 미배선: apps/${app} 에 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 ` +
53
+ `${acRel} 에 session 배선이 없습니다 — CSRF 는 세션 위에 얹히므로(규칙 8 · §2.5.1) ` +
54
+ `이대로면 상태 변경 요청이 CSRF 검증 없이 통과합니다(결정 93).\n` +
55
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
56
+ ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' }\n` +
57
+ `→ 루트 gaon.config.ts 에 redis: { url: process.env.REDIS_URL } 이 켜져 있는지 확인하세요(세션 스토어).\n` +
58
+ `→ 토큰 인증 API 앱이라면 app.config 에 auth: { strategy: 'jwt', ... } 를 선언하세요(CSRF 대상 제외).`,
59
+ detail: { app },
60
+ });
61
+ }
62
+ return { rule: 'csrf-wiring', issues };
63
+ }
64
+ async function safeListDirs(dir) {
65
+ try {
66
+ const entries = await readdir(dir, { withFileTypes: true });
67
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ }
@@ -0,0 +1,6 @@
1
+ import type { RuleReport } from './types.js';
2
+ export declare function internalAnchorHref(openTag: string): string | null;
3
+ /** 소스에 내부 이동 일반 앵커가 있는지(단위 테스트 진입점 · 주석은 제외). */
4
+ export declare function usesInternalAnchor(source: string): boolean;
5
+ /** apps/ 의 .vue 를 훑어 내부 이동 일반 앵커를 경고로 낸다. */
6
+ export declare function checkInternalAnchor(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,122 @@
1
+ // @gaonjs/cli · doctor · 앱 내부 이동 일반 <a> 앵커 검출 (결정 96 · 경고)
2
+ //
3
+ // 앱 내부 경로로 이동하는 일반 `<a href="/...">` 는 클릭마다 전체 문서를
4
+ // 다시 로드한다 — Inertia SPA 상태(스크롤·폼·구독)가 초기화되고, "메인
5
+ // 경로" 로 이동할 때마다 풀 리로드가 난다(첫 실사용 블로그에서 관측된 결함).
6
+ // 앱 내부 이동은 `gaonjs/vue` 의 `Link`(선언적) 또는 `router.visit`(프로그램적)
7
+ // 를 쓴다. 이 검사가 내부 경로 일반 앵커를 경고로 잡는다. 판정은 소스 텍스트 기반.
8
+ //
9
+ // 오탐 방지(결정 96): 외부 URL(http/https·//·mailto·tel)·프래그먼트(#)·
10
+ // `target="_blank"`·정적 판별 불가한 바인딩(`:href="expr"` 중 템플릿 리터럴이
11
+ // 아닌 것)은 건드리지 않는다. 내부로 확정 가능한 경우만 — 정적 `href="/..."`
12
+ // 와 `/` 로 시작하는 템플릿 리터럴 `:href="`/...`"` — 잡는다.
13
+ import { readdir, readFile } from 'node:fs/promises';
14
+ import { join, relative } from 'node:path';
15
+ // 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — 안티패턴을
16
+ // "쓰지 말라"고 설명하는 주석(줄/블록/HTML-Vue)이 오탐을 내지 않도록.
17
+ function stripCommentsKeepLines(source) {
18
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
19
+ return source
20
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
21
+ .replace(/<!--[\s\S]*?-->/g, blank)
22
+ .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
23
+ }
24
+ // 값이 앱 내부 경로(루트-상대)인지. 외부·프래그먼트·스킴은 제외.
25
+ function isInternalStaticHref(value) {
26
+ const v = value.trim();
27
+ if (v === '')
28
+ return false;
29
+ if (v.startsWith('//'))
30
+ return false; // 프로토콜-상대 → 외부
31
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v))
32
+ return false; // http:·mailto:·tel:·data: 등 스킴
33
+ if (v.startsWith('#'))
34
+ return false; // 페이지 내 프래그먼트
35
+ return v.startsWith('/'); // 루트-상대만 내부로 확정
36
+ }
37
+ // 여는 `<a ...>` 태그 하나가 "내부 이동 일반 앵커" 인지 판정해, 그렇다면
38
+ // 문제의 href 문자열을 돌려준다(아니면 null). 정적 판별 불가는 null(오탐 방지).
39
+ export function internalAnchorHref(openTag) {
40
+ // target="_blank" = 외부 의도 → 건드리지 않는다.
41
+ if (/\btarget\s*=\s*['"]_blank['"]/i.test(openTag))
42
+ return null;
43
+ // 정적 href="..."
44
+ const staticM = openTag.match(/\shref\s*=\s*(['"])([^'"]*)\1/i);
45
+ if (staticM)
46
+ return isInternalStaticHref(staticM[2]) ? staticM[2].trim() : null;
47
+ // 바인딩 :href / v-bind:href — 템플릿 리터럴 `/...` 만 내부로 확정.
48
+ const boundM = openTag.match(/(?::|v-bind:)href\s*=\s*(['"])([^'"]*)\1/i);
49
+ if (boundM) {
50
+ const expr = boundM[2].trim();
51
+ const tl = expr.match(/^`([^`]*)`$/); // `...` 전체가 템플릿 리터럴
52
+ if (tl && tl[1].startsWith('/') && !tl[1].startsWith('//'))
53
+ return expr;
54
+ return null; // 바인딩된 식(prop·식별자)은 정적 판별 불가 → 건드리지 않는다
55
+ }
56
+ return null;
57
+ }
58
+ /** 소스에 내부 이동 일반 앵커가 있는지(단위 테스트 진입점 · 주석은 제외). */
59
+ export function usesInternalAnchor(source) {
60
+ const stripped = stripCommentsKeepLines(source);
61
+ for (const m of stripped.matchAll(/<a\b[^>]*>/gi)) {
62
+ if (internalAnchorHref(m[0]) !== null)
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ /** apps/ 의 .vue 를 훑어 내부 이동 일반 앵커를 경고로 낸다. */
68
+ export async function checkInternalAnchor(cwd) {
69
+ const appsDir = join(cwd, 'apps');
70
+ const issues = [];
71
+ for (const abs of await walkVue(appsDir)) {
72
+ const source = await readFile(abs, 'utf8').catch(() => '');
73
+ const stripped = stripCommentsKeepLines(source);
74
+ const rel = relative(cwd, abs);
75
+ for (const m of stripped.matchAll(/<a\b[^>]*>/gi)) {
76
+ const href = internalAnchorHref(m[0]);
77
+ if (href === null)
78
+ continue;
79
+ const line = stripped.slice(0, m.index ?? 0).split('\n').length;
80
+ issues.push({
81
+ rule: 'internal-anchor',
82
+ level: 'warning',
83
+ file: rel,
84
+ line,
85
+ message: `내부 이동 일반 앵커 발견: ${rel}:${line} 이 \`<a href="${href}">\` 로 앱 내부 경로를 ` +
86
+ `가리킵니다. 일반 \`<a>\` 는 클릭마다 전체 문서를 다시 로드해 Inertia SPA 상태가 ` +
87
+ `초기화됩니다(첫 실사용에서 관측된 결함 · 결정 96).\n` +
88
+ `→ \`gaonjs/vue\` 의 \`Link\` 를 쓰세요: \`import { Link } from 'gaonjs/vue'\` 후 ` +
89
+ `\`<Link href="${href}">…</Link>\`. 프로그램적 이동은 \`router.visit(...)\`. ` +
90
+ `외부 URL·\`target="_blank"\` 만 \`<a>\` 를 유지합니다.`,
91
+ detail: { file: rel, line, href },
92
+ });
93
+ }
94
+ }
95
+ return { rule: 'internal-anchor', issues };
96
+ }
97
+ /** apps/ 하위 .vue(선언·테스트 제외) 절대경로. */
98
+ async function walkVue(dir) {
99
+ const out = [];
100
+ const walk = async (d) => {
101
+ let entries;
102
+ try {
103
+ entries = await readdir(d, { withFileTypes: true });
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ for (const e of entries) {
109
+ const abs = join(d, e.name);
110
+ if (e.isDirectory()) {
111
+ if (e.name === 'node_modules' || e.name === '.gaon')
112
+ continue;
113
+ await walk(abs);
114
+ }
115
+ else if (e.isFile() && e.name.endsWith('.vue')) {
116
+ out.push(abs);
117
+ }
118
+ }
119
+ };
120
+ await walk(dir);
121
+ return out.sort();
122
+ }
@@ -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';
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';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
@@ -1,9 +1,7 @@
1
1
  // @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
2
2
  //
3
- // 17 검사(response-mixing · n-plus-one · dependency-direction · connections
4
- // · migration-diff · shared-composable-purity · no-auto-import · schema-filename
5
- // · agents-doc-index · column-casing · model-filename · page-filename · auth-wiring
6
- // · ui-kit-wiring)가 모두 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
3
+ // 19 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
4
+ // 낸다. 상위(runDoctorCommand)는 level passed/
7
5
  // warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
8
6
  // errors.length > 0 이면 fail 로 판단한다.
9
7
  //
package/dist/doctor.d.ts CHANGED
@@ -15,6 +15,8 @@ export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
15
15
  export { checkPageFilename } from './doctor/page-filename.js';
16
16
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
17
17
  export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
18
+ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
19
+ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
18
20
  export { renderHuman, renderJson } from './doctor/reporter.js';
19
21
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
20
22
  export interface DoctorCommandOptions {
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 17 검사를 조립한다:
4
+ * 19 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -19,6 +19,8 @@
19
19
  * 15) route-registration (결정 79 · 고아 컨트롤러 = routes.ts 미참조 경고)
20
20
  * 16) static-collision (결정 85 · 정적 파일이 라우트/에셋에 가려짐 경고)
21
21
  * 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
22
+ * 18) csrf-wiring (결정 93 · 비-GET 라우트 + session 미배선 = CSRF 무방비 경고)
23
+ * 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
22
24
  *
23
25
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
24
26
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -48,6 +50,8 @@ import { checkUiKitWiring } from './doctor/ui-kit-wiring.js';
48
50
  import { checkRouteRegistration } from './doctor/route-registration.js';
49
51
  import { checkStaticCollision } from './doctor/static-collision.js';
50
52
  import { checkMethodOverride } from './doctor/method-override.js';
53
+ import { checkCsrfWiring } from './doctor/csrf-wiring.js';
54
+ import { checkInternalAnchor } from './doctor/internal-anchor.js';
51
55
  import { renderHuman, renderJson } from './doctor/reporter.js';
52
56
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
53
57
  import { makeResult, } from './doctor/types.js';
@@ -66,10 +70,12 @@ export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
66
70
  export { checkPageFilename } from './doctor/page-filename.js';
67
71
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
68
72
  export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
73
+ export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
74
+ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
69
75
  export { renderHuman, renderJson } from './doctor/reporter.js';
70
76
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
71
77
  /**
72
- * 실행할 검사 이름. 지정 없음(undefined) = 17개 모두.
78
+ * 실행할 검사 이름. 지정 없음(undefined) = 19개 모두.
73
79
  */
74
80
  const ALL_RULES = [
75
81
  'response-mixing',
@@ -89,6 +95,8 @@ const ALL_RULES = [
89
95
  'route-registration',
90
96
  'static-collision',
91
97
  'method-override',
98
+ 'csrf-wiring',
99
+ 'internal-anchor',
92
100
  ];
93
101
  const CHECKERS = {
94
102
  'response-mixing': checkResponseMixing,
@@ -108,6 +116,8 @@ const CHECKERS = {
108
116
  'route-registration': checkRouteRegistration,
109
117
  'static-collision': checkStaticCollision,
110
118
  'method-override': checkMethodOverride,
119
+ 'csrf-wiring': checkCsrfWiring,
120
+ 'internal-anchor': checkInternalAnchor,
111
121
  };
112
122
  /**
113
123
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
@@ -21,6 +21,14 @@ export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldF
21
21
  * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
22
22
  */
23
23
  export declare function patchRoutes(existing: string): string | null;
24
+ /**
25
+ * 기존 app.config.ts 에 auth 배선을 끼워 넣는다 (결정 93 · gaon new 기본 web 앱은
26
+ * 세션만 배선돼 있어 g auth 는 auth 만 추가하면 완성된다). 반환:
27
+ * · auth 가 이미 있으면 null (그대로 둔다),
28
+ * · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
29
+ * 성공 시 loadUser import 도 함께 보장한다.
30
+ */
31
+ export declare function patchAppConfigAddAuth(existing: string): string | null;
24
32
  /** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
25
33
  export declare function writeAuthScaffold(cwd: string, opts?: AuthScaffoldOptions): ScaffoldResult;
26
34
  export interface GenerateAuthOptions {
package/dist/generate.js CHANGED
@@ -69,6 +69,38 @@ export function patchRoutes(existing) {
69
69
  "\n r.resource('registration') // 회원가입 (gaon g auth)";
70
70
  return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
71
71
  }
72
+ /**
73
+ * 기존 app.config.ts 에 auth 배선을 끼워 넣는다 (결정 93 · gaon new 기본 web 앱은
74
+ * 세션만 배선돼 있어 g auth 는 auth 만 추가하면 완성된다). 반환:
75
+ * · auth 가 이미 있으면 null (그대로 둔다),
76
+ * · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
77
+ * 성공 시 loadUser import 도 함께 보장한다.
78
+ */
79
+ export function patchAppConfigAddAuth(existing) {
80
+ if (/\bauth\s*:/.test(existing))
81
+ return null;
82
+ const m = existing.match(/defineAppConfig\(\s*\{/);
83
+ if (!m || m.index === undefined)
84
+ return null;
85
+ const insertAt = m.index + m[0].length;
86
+ let out = existing.slice(0, insertAt) +
87
+ "\n // 결정 59: 세션 userId → 사용자 로드 배선(g auth). 이게 있어야 로그인 후" +
88
+ "\n // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다." +
89
+ "\n auth: { loadUser, loginRedirect: '/session/new' }," +
90
+ existing.slice(insertAt);
91
+ // loadUser import 보장 — gaonjs/config import 바로 뒤에 넣는다.
92
+ if (!/from\s+['"]\.\/auth\.js['"]/.test(out)) {
93
+ const im = out.match(/import\s+\{[^}]*\}\s+from\s+['"]gaonjs\/config['"][^\n]*\n/);
94
+ if (im && im.index !== undefined) {
95
+ const at = im.index + im[0].length;
96
+ out = out.slice(0, at) + "import { loadUser } from './auth.js'\n" + out.slice(at);
97
+ }
98
+ else {
99
+ out = "import { loadUser } from './auth.js'\n" + out;
100
+ }
101
+ }
102
+ return out;
103
+ }
72
104
  /** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
73
105
  export function writeAuthScaffold(cwd, opts = {}) {
74
106
  const root = resolve(cwd);
@@ -87,19 +119,31 @@ export function writeAuthScaffold(cwd, opts = {}) {
87
119
  for (const file of authScaffoldFiles(opts)) {
88
120
  const abs = join(root, file.path);
89
121
  if (existsSync(abs)) {
90
- skipped.push(file.path);
91
- // 결정 59: app.config.ts 이미 있는데 auth 배선이 없으면 로그인 후에도
92
- // currentUser 영구 null 이다 skip 으로 끝내지 않고 수리 안내를 남긴다.
122
+ // 결정 93: gaon new 기본 web 앱은 app.config.ts 에 세션만 배선돼 있다.
123
+ // g auth 이를 skip 하지 않고 **auth 배선을 패치로 추가**해 완성한다 —
124
+ // 결정 59: auth 배선이 없으면 로그인 후에도 currentUser 영구 null 이다.
93
125
  if (file.path === `apps/${app}/app.config.ts`) {
94
126
  const existing = readFileSync(abs, 'utf8');
95
- if (!/\bauth\s*:/.test(existing)) {
96
- warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 건너뛰었습니다.\n` +
97
- `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음 두 항목을 추가하세요:\n` +
98
- ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' },\n` +
127
+ const patchedConfig = patchAppConfigAddAuth(existing);
128
+ if (patchedConfig) {
129
+ writeFileSync(abs, patchedConfig, 'utf8');
130
+ patched.push(file.path);
131
+ }
132
+ else if (!/\bauth\s*:/.test(existing)) {
133
+ // 패치 불가(비관례 config) — skip + 수리 안내로 폴백.
134
+ skipped.push(file.path);
135
+ warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 자동 추가하지 못했습니다.\n` +
136
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
99
137
  ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
100
138
  `→ 이 배선이 없으면 로그인해도 this.currentUser/requireAuth() 가 사용자를 받지 못합니다.`);
101
139
  }
140
+ else {
141
+ // 이미 auth 배선됨 — 그대로 둔다.
142
+ skipped.push(file.path);
143
+ }
144
+ continue;
102
145
  }
146
+ skipped.push(file.path);
103
147
  continue;
104
148
  }
105
149
  mkdirSync(dirname(abs), { recursive: true });
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { loadDomain, type LoadedDomain } from "./domain.js";
23
23
  export interface RoadmapReport {
24
24
  readonly name: "gaon";
25
25
  readonly version: string;
26
- readonly stage: "stub";
26
+ readonly stage: "stable";
27
27
  readonly homepage: string;
28
28
  readonly docs: string;
29
29
  readonly milestones: readonly {
@@ -34,7 +34,11 @@ export interface RoadmapReport {
34
34
  }
35
35
  /** `--json` 출력용 구조화 리포트. */
36
36
  export declare function roadmapReport(version?: string): RoadmapReport;
37
- /** 사람이 읽는 로드맵 텍스트. */
37
+ /**
38
+ * 인자 없이 실행했을 때의 사람용 배너 — 정식 배포(v1.0) 자기 소개 + 사용법 안내.
39
+ * 결정 94(W3): M1 스텁 시절 "개발 초기·런타임 없음" 오정보를 제거한다 — AI 가
40
+ * 사용 불가로 오판하거나 소형 모델이 멈추는 것을 막는다.
41
+ */
38
42
  export declare function renderRoadmap(version?: string): string;
39
43
  /** runCli 옵션. version 은 파사드가 주입하는 표시 버전(설치 패키지 버전). */
40
44
  export interface RunOptions {
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * @gaonjs/cli — Gaon CLI 구현.
3
3
  *
4
- * 인자 없이 실행하면 로드맵·개발 상태를 출력하는 스텁이다. M9-C 부터
5
- * `gaon dev` 가 통합 개발 오케스트레이션을 담당한다 — Docker Compose
4
+ * 인자 없이 실행하면 정식 배포(v1.0) 배너와 사용법을 출력한다. `gaon dev`
5
+ * 가 통합 개발 오케스트레이션을 담당한다 — Docker Compose
6
6
  * 자동 기동 + .gaon 타입 브리지 재생성 + serve 자식 프로세스 + tsc/vue-tsc
7
7
  * --watch + 소스 변경 시 서버 재시작(commands/dev.ts). 모든 명령은 `--json`
8
8
  * 출력을 함께 제공한다 (CLAUDE.md §4).
@@ -10,7 +10,7 @@
10
10
  * 표시 버전은 호출자(파사드)가 주입한다 — 사용자가 설치한 패키지
11
11
  * (`gaonjs`) 버전을 그대로 보여주기 위함. 미주입 시 core 버전을 쓴다.
12
12
  */
13
- import { MILESTONES, VERSION, HOMEPAGE } from "@gaonjs/core";
13
+ 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 { runNewCommand } from "./commands/new.js";
@@ -54,41 +54,38 @@ export function roadmapReport(version = VERSION) {
54
54
  return {
55
55
  name: "gaon",
56
56
  version,
57
- stage: "stub",
57
+ stage: "stable",
58
58
  homepage: HOMEPAGE,
59
59
  docs: HOMEPAGE,
60
60
  milestones: MILESTONES.map((m) => ({ id: m.id, title: m.title, status: m.status })),
61
61
  };
62
62
  }
63
- /** 사람이 읽는 로드맵 텍스트. */
63
+ /**
64
+ * 인자 없이 실행했을 때의 사람용 배너 — 정식 배포(v1.0) 자기 소개 + 사용법 안내.
65
+ * 결정 94(W3): M1 스텁 시절 "개발 초기·런타임 없음" 오정보를 제거한다 — AI 가
66
+ * 사용 불가로 오판하거나 소형 모델이 멈추는 것을 막는다.
67
+ */
64
68
  export function renderRoadmap(version = VERSION) {
65
69
  const lines = [];
66
70
  lines.push("");
67
71
  lines.push(` Gaon (가온) — AI가 개발을 가장 잘하는 Node.js 풀스택 웹 프레임웍`);
68
- lines.push(` v${version} · 개발 (스텁) · ${HOMEPAGE}`);
72
+ lines.push(` v${version} · 정식 배포 · ${HOMEPAGE}`);
69
73
  lines.push("");
70
- lines.push(" 패키지는 아직 개발 초기 단계입니다. 런타임 기능은 아직 없고,");
71
- lines.push(" 자리(스코프·이름)를 잡아 두기 위한 동작하는 스텁입니다.");
74
+ lines.push(" 데이터·인증·실시간·비동기·메일/스토리지·CLI 배터리가 모두 동작합니다.");
75
+ lines.push(" 프로젝트를 만들고 개발 스택을 통합 기동하려면:");
72
76
  lines.push("");
73
- lines.push(" 로드맵");
74
- lines.push(" ──────");
75
- for (const m of MILESTONES) {
76
- const mark = m.status === "in-progress" ? "▶" : "·";
77
- const tag = m.status === "in-progress" ? " (진행 중)" : "";
78
- lines.push(` ${mark} ${m.id} ${m.title}${tag}`);
79
- }
77
+ lines.push(" gaon new <name> 새 프로젝트 스캐폴드");
78
+ lines.push(" gaon dev 개발 스택 통합 기동 (Docker · .gaon · serve · watch)");
79
+ lines.push(" gaon --help 전체 명령 목록");
80
80
  lines.push("");
81
- lines.push(" 참여 · 소식");
82
- lines.push(" ──────────");
83
- lines.push(` 문서 / 진행 상황: ${HOMEPAGE}`);
84
- lines.push(` JSON 출력: gaon --json`);
81
+ lines.push(` 문서: ${HOMEPAGE} · JSON 출력: gaon --json`);
85
82
  lines.push("");
86
83
  return lines.join("\n");
87
84
  }
88
85
  function renderHelp(version = VERSION) {
89
86
  return [
90
87
  "",
91
- " gaon — Gaon 프레임웍 CLI (v" + version + ", 스텁)",
88
+ " gaon — Gaon 프레임웍 CLI (v" + version + " · 정식 배포)",
92
89
  "",
93
90
  " 사용법:",
94
91
  " gaon 로드맵과 개발 상태를 출력",
@@ -104,7 +101,7 @@ function renderHelp(version = VERSION) {
104
101
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
105
102
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
106
103
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
107
- " gaon doctor 정적 검사 (17 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method)",
104
+ " gaon doctor 정적 검사 (19 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커)",
108
105
  " gaon doctor --json 자동화용 JSON 출력",
109
106
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
110
107
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -149,6 +146,7 @@ export function parseDoctorChecks(argv) {
149
146
  "migration-diff",
150
147
  "shared-composable-purity",
151
148
  "no-auto-import",
149
+ "csrf-wiring",
152
150
  ];
153
151
  const isKnown = (s) => known.includes(s);
154
152
  const out = [];
@@ -165,6 +163,13 @@ export function parseDoctorChecks(argv) {
165
163
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
166
164
  export function runCli(argv, opts = {}) {
167
165
  const version = opts.version ?? VERSION;
166
+ // 결정 92(W1): `.env` 를 **진입점에서 한 번** 로드해 모든 명령이 동일하게
167
+ // 받는다. 이전엔 serve·dev·console 만 개별 호출해 db·work·hub·jobs·doctor·
168
+ // check 가 `.env` 없이 실행됐고(특히 work·hub 는 조용히 안 뜨는 부류),
169
+ // 실사용에서 `set -a; . ./.env` 수동 우회가 필요했다. 명령별 나열은 새
170
+ // 명령이 추가될 때마다 또 빠지므로 여기서 공통화한다. loadDotEnv 는 이미
171
+ // 설정된 env 를 덮지 않고 파일이 없으면 조용히 지나가 멱등하다(재호출 안전).
172
+ loadDotEnv();
168
173
  // `gaon dev` — 통합 개발 오케스트레이션(M9-C · v0.15 §13.5). Docker Compose
169
174
  // 자동 기동 + .gaon 재생성 + serve 자식 + tsc/vue-tsc watch + 서버 재시작 워처.
170
175
  // SIGINT/SIGTERM 시 순서대로 정리(serve → tsc → 워처 → Docker[--stop-docker 시]).
package/dist/serve.js CHANGED
@@ -149,6 +149,13 @@ export async function runServeCommand(opts = {}) {
149
149
  await wired.app.listen({ host, port });
150
150
  const displayHost = host === '0.0.0.0' ? 'localhost' : host;
151
151
  emit({ kind: 'listening', host, port, url: `http://${displayHost}:${port}` });
152
+ // 결정 98: serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않는다 —
153
+ // 수정이 조용히 반영 안 되는 혼란(첫 실사용 관측)을 막으려 한 줄 안내한다.
154
+ // dev 자식(gaon dev · opts.dev)은 이미 감시하므로 그때는 안내하지 않고,
155
+ // production 은 운영 로그 소음을 막으려 출력하지 않는다. json 은 파싱 안전상 제외.
156
+ if (!json && !opts.dev && process.env.NODE_ENV !== 'production') {
157
+ process.stdout.write(' ℹ serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않습니다 → 개발 중이면 `gaon dev` 를 쓰세요.\n');
158
+ }
152
159
  await new Promise((resolvePromise) => {
153
160
  const stop = () => {
154
161
  signals.off('SIGINT', stop);
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, useForm } from 'gaonjs/vue'
2
+ import { pageProps, useForm, Link } from 'gaonjs/vue'
3
3
  import Card from '../../components/ui/Card.vue'
4
4
  import CardHeader from '../../components/ui/CardHeader.vue'
5
5
  import CardTitle from '../../components/ui/CardTitle.vue'
@@ -42,7 +42,7 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
42
42
  </Form>
43
43
  <p class="mt-4 text-center text-sm text-muted-foreground">
44
44
  계정이 없으신가요?
45
- <a href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</a>
45
+ <Link href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
46
46
  </p>
47
47
  </CardContent>
48
48
  </Card>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, useForm } from 'gaonjs/vue'
2
+ import { pageProps, useForm, Link } from 'gaonjs/vue'
3
3
  import Card from '../../components/ui/Card.vue'
4
4
  import CardHeader from '../../components/ui/CardHeader.vue'
5
5
  import CardTitle from '../../components/ui/CardTitle.vue'
@@ -43,7 +43,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
43
43
  </Form>
44
44
  <p class="mt-4 text-center text-sm text-muted-foreground">
45
45
  이미 계정이 있으신가요?
46
- <a href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</a>
46
+ <Link href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
47
47
  </p>
48
48
  </CardContent>
49
49
  </Card>
@@ -0,0 +1,29 @@
1
+ // 웹 앱 팩토리 — gaon g auth 스캐폴드. createWebApp 으로 세션·인증을 배선한다.
2
+ import { createApp, type AppSessionOptions } from 'gaonjs/web'
3
+ import appRoutes from './routes.js'
4
+ import session from './controllers/session.js'
5
+ import registration from './controllers/registration.js'
6
+ import dashboard from './controllers/dashboard.js'
7
+ import { loadUser } from './auth.js'
8
+
9
+ export interface WebAppDeps {
10
+ /** 세션 설정 — { redisUrl, secret } (또는 redis 인스턴스). */
11
+ readonly session: AppSessionOptions
12
+ /** 서명 쿠키/CSRF 용 비밀. */
13
+ readonly cookieSecret?: string
14
+ }
15
+
16
+ export function createWebApp(deps: WebAppDeps) {
17
+ return createApp({
18
+ apps: [
19
+ {
20
+ name: '{{APP_NAME}}',
21
+ routes: appRoutes,
22
+ controllers: { session, registration, dashboard },
23
+ session: deps.session,
24
+ auth: { loadUser, loginRedirect: '/session/new' },
25
+ },
26
+ ],
27
+ cookieSecret: deps.cookieSecret,
28
+ })
29
+ }
@@ -0,0 +1,14 @@
1
+ // 서버 진입점 — gaon g auth 스캐폴드. `node dist/server.js` 로 실행.
2
+ import { createWebApp } from './apps/{{APP_NAME}}/app.js'
3
+
4
+ const app = await createWebApp({
5
+ session: {
6
+ redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
7
+ secret: process.env.SESSION_SECRET ?? 'change-me-to-a-32+char-random-secret!!',
8
+ },
9
+ cookieSecret: process.env.COOKIE_SECRET,
10
+ })
11
+
12
+ const port = Number(process.env.PORT ?? 3000)
13
+ await app.listen({ port, host: '0.0.0.0' })
14
+ console.log(`web 앱이 http://localhost:${port} 에서 실행 중입니다.`)
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
104
104
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
105
105
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
106
106
 
107
- ### 2.2 `gaon doctor` 검사 17
107
+ ### 2.2 `gaon doctor` 검사 19
108
108
 
109
109
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
110
110
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -123,6 +123,8 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
123
123
  15. `route-registration` — 고아 컨트롤러(파일은 있는데 `routes.ts` 미참조 · 도달 불가) (결정 79 · 경고)
124
124
  16. `static-collision` — 정적 파일(`apps/<앱>/static/`)이 라우트/에셋에 가려져 도달 불가 (결정 85 · 경고)
125
125
  17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
126
+ 18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
127
+ 19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
126
128
 
127
129
  ## 3. 로직 배치 One Way 판단표
128
130
 
@@ -163,7 +165,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
163
165
  ```bash
164
166
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
165
167
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
166
- gaon doctor # 정적 검사 17종 (§2.2)
168
+ gaon doctor # 정적 검사 19종 (§2.2)
167
169
  ```
168
170
 
169
171
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -171,8 +173,8 @@ gaon doctor # 정적 검사 17종 (§2.2)
171
173
  | 명령 | 역할 |
172
174
  |---|---|
173
175
  | `gaon new <name>` | 프로젝트 스캐폴드 |
174
- | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·워처) |
175
- | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) |
176
+ | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**코드 변경 감시·재시작**) |
177
+ | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** |
176
178
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
177
179
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
178
180
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -184,6 +186,11 @@ gaon doctor # 정적 검사 17종 (§2.2)
184
186
  으로 더듬는 대신 프레임웍에게 직접 묻는다. `read_agent_doc` 은 §0
185
187
  카테고리 문서를 조회한다 (결정 40).
186
188
 
189
+ **개발 중엔 `gaon dev`, 배포 실행은 `gaon serve`** (결정 98) — `gaon serve` 는
190
+ 빌드된 번들을 서빙하며 **코드 변경을 감시하지 않는다**(수정해도 반영 안 됨).
191
+ 개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
192
+ 비-production 부팅 시 이 안내를 한 줄 출력한다.
193
+
187
194
  ## 5. npm 배포본 (2026-07-23 실측 · `npm view <pkg> version`)
188
195
 
189
196
  | 패키지 | 버전 | 역할 |
@@ -42,6 +42,13 @@ const props = pageProps<'web:posts#index'>()
42
42
  전부 없음 · CLAUDE.md 규칙 12). 페이지 전환은 `gaonjs/vue` 의 `router`
43
43
  (`router.visit(url)`·`router.get/post/delete`), 폼은 `useForm(...)` (결정 64).
44
44
  라우트 정의는 서버의 `apps/<앱>/routes.ts` 뿐이다.
45
+ - **앱 내부 이동 = `Link`(선언적) 또는 `router.visit`(프로그램적) — 내부 경로
46
+ 일반 `<a>` 금지** (결정 96). 내부 경로로 가는 `<a href="/...">` 는 클릭마다
47
+ 전체 문서를 다시 로드해 Inertia SPA 상태(스크롤·폼 입력·채널 구독)가 초기화된다
48
+ (첫 실사용 블로그에서 "메인 경로" 풀 리로드로 관측). 선언적 링크는 `gaonjs/vue`
49
+ 의 `Link`(`<Link href="/posts">글 목록</Link>`), 코드에서의 이동은
50
+ `router.visit(url)`. **외부 URL(`https://…`)·`target="_blank"` 만 `<a>`** 를
51
+ 유지한다. 내부 경로 일반 앵커는 doctor **internal-anchor** 가 잡는다(결정 96).
45
52
  - **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
46
53
  금지 (라우트를 모른다는 순수 규칙).
47
54
 
@@ -175,7 +182,8 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
175
182
  scoped-CSS 미편입 방침은 결정 74 로 뒤집혔다).
176
183
  - **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
177
184
  랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
178
- 의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
185
+ 의 nav 에 `<Link href="/session/new">로그인</Link>` 을 직접 추가한다(내부 이동은
186
+ `Link` · 일반 `<a>` 는 풀 리로드 · 결정 96).
179
187
  - **auth 페이지 경로 = `pages/Auth/`(PascalCase)** — `gaon g auth` 는 `Auth/Login.vue`
180
188
  ·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
181
189
  소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
@@ -265,6 +273,10 @@ async function runSearch(q: string) {
265
273
  shared-composable-purity 위반. 데이터는 props/인자로.
266
274
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
267
275
  `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
276
+ - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
277
+ 로드해 SPA 상태가 초기화된다. 앱 내부 이동은 `gaonjs/vue` 의 `Link`
278
+ (`<Link href="/...">`) 또는 `router.visit(...)`, 외부 URL·`target="_blank"`
279
+ 만 `<a>` (결정 96 · doctor **internal-anchor**).
268
280
  - **`vue-router` import 금지** — Gaon 은 클라이언트 라우터가 없다(Inertia =
269
281
  SPA + 서버 라우팅). `import { useRouter } from 'vue-router'` 는 존재하지 않는
270
282
  의존을 끌어와 컴파일 실패한다 — 전환은 `gaonjs/vue` 의 `router`, 라우트 정의는
@@ -300,4 +312,5 @@ async function runSearch(q: string) {
300
312
  | 결정 74 | Tailwind 스캐폴드 편입(tailwind.config.ts·postcss.config.js·style.css · 결정 69 scoped-CSS 방침 뒤집기) |
301
313
  | 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
302
314
  | 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
315
+ | 결정 96 | 앱 내부 이동 = `Link`(선언적)/`router.visit`(프로그램적) · 내부 경로 일반 `<a>` 금지(풀 리로드) · `Link` 재수출 · doctor internal-anchor |
303
316
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -30,6 +30,12 @@
30
30
  - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
31
31
  강제. `api()` 클라이언트는 `<meta name="csrf-token">` 을 자동으로
32
32
  읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
33
+ - **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
34
+ 세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
35
+ `app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
36
+ 한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
37
+ 라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
38
+ `csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
33
39
  - JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
34
40
 
35
41
  ### 3. 시크릿
@@ -99,3 +105,4 @@ const rows = await Post.query()
99
105
  | §2.5.1 (v0.15) | 보안 기본값 fail-closed (CORS·rate limit·CSRF 기본 켬) |
100
106
  | 결정 24 (E-3 §5) | `this.params` 고정 우선순위 — 파라미터 오염 차단 |
101
107
  | §7 (v0.15) | 세션 앱별 분리 · JWT 는 API 앱 전용 |
108
+ | 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
@@ -100,6 +100,47 @@ export default controller({
100
100
  - 기본 경로는 여전히 `this.params` 하나(The One Way) — 스캐폴드·문서·
101
101
  기본 예시는 `this.params` 만 쓴다 (E-3 §5.3 원문).
102
102
 
103
+ **폼 모양 두 가지 — 스키마 파생 vs 애드혹 (라우트 파라미터는 둘 다 자동 병합 · 결정 95):**
104
+
105
+ 라우트 파라미터·body·query 병합은 폼 모양과 무관하게 항상 일어난다 —
106
+ `this.params(...)` 에 넘기는 폼은 **결과 타입**만 정한다. 그래서 `/posts/:id`
107
+ 같은 라우트 파라미터와 폼 필드를 **한 번의 `this.params` 로 함께** 받는다.
108
+
109
+ - **① 스키마 파생 폼 `this.params(Model.form)`** — 정본. 모델 컬럼 타입으로
110
+ 런타임 강제 변환·검증(대량 할당 차단)까지 한다. 라우트 파라미터는 **키 이름이
111
+ 컬럼과 같으면** 자동으로 그 자리에 들어간다. 그래서 라우트를 컬럼명에 맞춰
112
+ 짓는 게 관례다:
113
+
114
+ ```ts
115
+ // routes: r.post('/posts/:postId/comments', 'comments#create')
116
+ // 스키마 comments 에 postId·author·body 컬럼이 있으면:
117
+ async create() {
118
+ // :postId 는 라우트에서, author·body 는 body 에서 — 한 번에 검증까지.
119
+ const data = this.params(Comment.form) // { postId, author, body, ... } (검증됨)
120
+ const comment = await Comment.create(data)
121
+ return this.redirect(`/posts/${String(data.postId)}`)
122
+ }
123
+ ```
124
+
125
+ - **② 애드혹 폼 `this.params({ _row: {} as { ... } })`** — 전용 모델이 없거나
126
+ 받을 필드를 **정확히** 고를 때. 라우트 파라미터·폼 필드를 자유롭게 섞어 타입을
127
+ 못박는다. 단 **런타임 스키마 검증은 없다**(타입만 · 컬럼 정의가 없어 coerce
128
+ 스킵) — 필요하면 값 검사를 직접 하거나 ①로 간다:
129
+
130
+ ```ts
131
+ // routes: r.post('/posts/:id/comments', 'comments#create')
132
+ async create() {
133
+ // :id(라우트) + author·body(폼) 를 한 폼 모양으로.
134
+ const { id, author, body } = this.params({
135
+ _row: {} as { id: string; author: string; body: string },
136
+ })
137
+ // ...
138
+ }
139
+ ```
140
+
141
+ ①이 검증까지 주므로 **모델이 있으면 ①을 먼저 고른다**. ②는 로그인 폼처럼
142
+ 전용 테이블이 없는 입력의 탈출구다.
143
+
103
144
  ### 4. 데이터 경로 판단 — 루트 판단표가 정본
104
145
 
105
146
  데이터가 필요할 때는 루트 `AGENTS.md` 의 데이터 경로 4종 판단표를 따른다
@@ -290,4 +331,5 @@ export default controller({
290
331
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 — `agents/async.md`) |
291
332
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
292
333
  | 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
334
+ | 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
293
335
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -0,0 +1,14 @@
1
+ // 앱 설정 — apps/web/app.config.ts. 보안 기본값(세션 + CSRF)을 표준 부팅
2
+ // (gaon dev / gaon serve)에 배선한다. 잘 지은 app.config 는 거의 비어 있어야
3
+ // 하지만(§1.1), 세션은 CSRF 토큰 저장소의 토대라 기본으로 켠다.
4
+ //
5
+ // 규칙 8(§2.5.1): CORS·rate limit·CSRF 는 코어에서 **기본 켬** — 끄는 것은
6
+ // 명시적 설정으로만. 세션이 있어야 상태 변경 요청(POST/PUT/PATCH/DELETE)에
7
+ // CSRF 토큰 검증이 걸린다. 세션 스토어는 루트 gaon.config.ts 의 redis(REDIS_URL)
8
+ // 를 wireGaon 이 주입한다(결정 93).
9
+ import { defineAppConfig } from 'gaonjs/config'
10
+
11
+ export default defineAppConfig({
12
+ // secret 은 32자 이상 — .env 의 SESSION_SECRET 로 주입한다(운영은 반드시 교체).
13
+ session: { secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me-now!!' },
14
+ })
@@ -9,6 +9,10 @@
9
9
  // 다크 헤더 + 라이트 본문(결정 69). 스타일은 Tailwind 유틸(결정 74) — 다크
10
10
  // 헤더는 본문 테마와 무관하게 항상 어둡게 두려고 브랜드 색을 명시값으로 박는다.
11
11
  // 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
12
+ //
13
+ // 결정 96: 앱 내부 이동은 `Link`(선언적) — `<a href="/">` 는 전체 문서
14
+ // 리로드라 SPA 가 깨진다. 외부 URL 만 `<a>`(문서·GitHub).
15
+ import { Link } from 'gaonjs/vue'
12
16
 
13
17
  // package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
14
18
  const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
@@ -19,13 +23,13 @@ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
19
23
  <header
20
24
  class="flex items-center justify-between gap-4 border-b border-[#21262d] bg-[#0d1117] px-6 py-3.5 text-[#e6edf3]"
21
25
  >
22
- <a href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
26
+ <Link href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
23
27
  <span
24
28
  class="inline-block rounded-md bg-gradient-to-br from-[#4f8cff] to-[#7c5cff] px-1.5 py-0.5 text-xs font-bold tracking-wide text-white"
25
29
  >가온</span>
26
30
  <span class="text-[0.95rem] font-bold tracking-[0.08em]">GAONJS</span>
27
31
  <span class="text-xs tabular-nums text-[#8b949e]">v{{ version }}</span>
28
- </a>
32
+ </Link>
29
33
  <nav class="flex gap-[1.1rem] text-sm">
30
34
  <a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">문서</a>
31
35
  <a href="https://github.com/gaonjs" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">GitHub</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.21.2",
3
+ "version": "0.23.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.6.0",
31
- "@gaonjs/config": "0.5.1",
32
- "@gaonjs/data": "0.9.0",
33
- "@gaonjs/core": "0.2.0",
34
- "@gaonjs/mail": "0.1.2",
35
- "@gaonjs/web": "0.7.0"
30
+ "@gaonjs/async": "0.6.1",
31
+ "@gaonjs/data": "0.9.1",
32
+ "@gaonjs/web": "0.7.1",
33
+ "@gaonjs/core": "0.2.1",
34
+ "@gaonjs/config": "0.5.2",
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})\""