@gaonjs/cli 0.26.0 → 0.27.1

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
+ /** 소스에 Link>Button 이중 감싸기가 있는지(단위 테스트 진입점 · 주석 제외). */
3
+ export declare function usesLinkButtonNesting(source: string): boolean;
4
+ /** apps/ 의 .vue 를 훑어 Link>Button 이중 감싸기를 경고로 낸다. */
5
+ export declare function checkLinkButtonNesting(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,92 @@
1
+ // @gaonjs/cli · doctor · Link 로 Button 을 감싼 이중 표면 검출 (결정 113 · 경고)
2
+ //
3
+ // `<Link href="/x"><Button>…</Button></Link>` 는 <a><button> 중첩을 만든다 —
4
+ // HTML 비준수(인터랙티브 요소 안에 인터랙티브 요소)이고 접근성 결함이다(첫·넷째
5
+ // 실사용 블로그에서 관측). 버튼 모양 링크는 한 표면으로 낸다: `<Button href="/x">`.
6
+ // Button 이 href 를 받으면 내부에서 gaonjs/vue 의 Link(=<a> · SPA 이동)로 렌더한다.
7
+ //
8
+ // 판정은 소스 텍스트 기반. 오탐 방지: Link 가 **직계 자식으로 Button 을** 감쌀
9
+ // 때만 잡는다(Link 의 `>` 바로 뒤가 `<Button`). 텍스트·다른 요소를 감싼 Link 나
10
+ // Button 을 안 쓰는 Link 는 건드리지 않는다.
11
+ import { readdir, readFile } from 'node:fs/promises';
12
+ import { join, relative } from 'node:path';
13
+ // 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — 안티패턴을
14
+ // "쓰지 말라"고 설명하는 주석이 오탐을 내지 않도록.
15
+ function stripCommentsKeepLines(source) {
16
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
17
+ return source
18
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
19
+ .replace(/<!--[\s\S]*?-->/g, blank)
20
+ .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
21
+ }
22
+ // Link 여는 태그 하나에서 href(정적 또는 바인딩)를 뽑는다 — 수리 안내 문구용(없으면 null).
23
+ function linkHref(openTag) {
24
+ const staticM = openTag.match(/\shref\s*=\s*(['"])([^'"]*)\1/i);
25
+ if (staticM)
26
+ return staticM[2].trim();
27
+ const boundM = openTag.match(/(?::|v-bind:)href\s*=\s*(['"])([^'"]*)\1/i);
28
+ if (boundM)
29
+ return boundM[1] === '"' ? `:href="${boundM[2].trim()}"` : `:href='${boundM[2].trim()}'`;
30
+ return null;
31
+ }
32
+ // Link 가 직계 자식으로 Button 을 감싸는 패턴(주석 제거 후 소스 대상).
33
+ // `<Link ...>`(자기닫힘 아님) 바로 뒤(공백만 허용)가 `<Button`.
34
+ const WRAP_RE = /<Link\b([^>]*)>\s*<Button\b/i;
35
+ /** 소스에 Link>Button 이중 감싸기가 있는지(단위 테스트 진입점 · 주석 제외). */
36
+ export function usesLinkButtonNesting(source) {
37
+ return WRAP_RE.test(stripCommentsKeepLines(source));
38
+ }
39
+ /** apps/ 의 .vue 를 훑어 Link>Button 이중 감싸기를 경고로 낸다. */
40
+ export async function checkLinkButtonNesting(cwd) {
41
+ const appsDir = join(cwd, 'apps');
42
+ const issues = [];
43
+ for (const abs of await walkVue(appsDir)) {
44
+ const source = await readFile(abs, 'utf8').catch(() => '');
45
+ const stripped = stripCommentsKeepLines(source);
46
+ const rel = relative(cwd, abs);
47
+ for (const m of stripped.matchAll(/<Link\b([^>]*)>\s*<Button\b/gi)) {
48
+ const line = stripped.slice(0, m.index ?? 0).split('\n').length;
49
+ const href = linkHref(`<Link${m[1]}>`);
50
+ const hrefAttr = href === null ? 'href="/…"' : href.startsWith(':') ? href : `href="${href}"`;
51
+ issues.push({
52
+ rule: 'link-button-nesting',
53
+ level: 'warning',
54
+ file: rel,
55
+ line,
56
+ message: `Link 가 Button 을 감쌌습니다: ${rel}:${line} 의 \`<Link><Button>…</Button></Link>\` 는 ` +
57
+ `<a><button> 중첩(HTML 비준수·접근성 결함)입니다.\n` +
58
+ `→ 버튼 모양 링크는 한 표면 \`<Button ${hrefAttr}>…</Button>\` 로 내세요 — Button 이 ` +
59
+ `href 를 받으면 내부에서 SPA 이동 링크로 렌더합니다(결정 113). 외부 URL 이면 ` +
60
+ `\`<Button ${hrefAttr} external target="_blank">\`. Link 로 감싸지 마세요.`,
61
+ detail: { file: rel, line, href },
62
+ });
63
+ }
64
+ }
65
+ return { rule: 'link-button-nesting', issues };
66
+ }
67
+ /** apps/ 하위 .vue(선언·테스트 제외) 절대경로. */
68
+ async function walkVue(dir) {
69
+ const out = [];
70
+ const walk = async (d) => {
71
+ let entries;
72
+ try {
73
+ entries = await readdir(d, { withFileTypes: true });
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ for (const e of entries) {
79
+ const abs = join(d, e.name);
80
+ if (e.isDirectory()) {
81
+ if (e.name === 'node_modules' || e.name === '.gaon')
82
+ continue;
83
+ await walk(abs);
84
+ }
85
+ else if (e.isFile() && e.name.endsWith('.vue')) {
86
+ out.push(abs);
87
+ }
88
+ }
89
+ };
90
+ await walk(dir);
91
+ return out.sort();
92
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -20,6 +20,7 @@ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './
20
20
  export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
21
21
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
22
22
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
23
+ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
23
24
  export { renderHuman, renderJson } from './doctor/reporter.js';
24
25
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
25
26
  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
- * 22 검사를 조립한다:
4
+ * 23 검사를 조립한다:
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 규칙)
@@ -24,6 +24,7 @@
24
24
  * 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
25
25
  * 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
26
26
  * 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
27
+ * 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
27
28
  *
28
29
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
29
30
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -58,6 +59,7 @@ import { checkInternalAnchor } from './doctor/internal-anchor.js';
58
59
  import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
59
60
  import { checkAsyncOffload } from './doctor/async-offload.js';
60
61
  import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
62
+ import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
61
63
  import { renderHuman, renderJson } from './doctor/reporter.js';
62
64
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
63
65
  import { makeResult, } from './doctor/types.js';
@@ -81,10 +83,11 @@ export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './
81
83
  export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
82
84
  export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
83
85
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
86
+ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
84
87
  export { renderHuman, renderJson } from './doctor/reporter.js';
85
88
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
86
89
  /**
87
- * 실행할 검사 이름. 지정 없음(undefined) = 21개 모두.
90
+ * 실행할 검사 이름. 지정 없음(undefined) = 23개 모두.
88
91
  */
89
92
  const ALL_RULES = [
90
93
  'response-mixing',
@@ -109,6 +112,7 @@ const ALL_RULES = [
109
112
  'pageprops-destructure',
110
113
  'async-offload',
111
114
  'page-layout-breakpoint',
115
+ 'link-button-nesting',
112
116
  ];
113
117
  const CHECKERS = {
114
118
  'response-mixing': checkResponseMixing,
@@ -133,6 +137,7 @@ const CHECKERS = {
133
137
  'pageprops-destructure': checkPagePropsDestructure,
134
138
  'async-offload': checkAsyncOffload,
135
139
  'page-layout-breakpoint': checkPageLayoutBreakpoint,
140
+ 'link-button-nesting': checkLinkButtonNesting,
136
141
  };
137
142
  /**
138
143
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/index.js CHANGED
@@ -101,7 +101,7 @@ function renderHelp(version = VERSION) {
101
101
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
102
102
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
103
103
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
104
- " gaon doctor 정적 검사 (22 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트)",
104
+ " gaon doctor 정적 검사 (23 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩)",
105
105
  " gaon doctor --json 자동화용 JSON 출력",
106
106
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
107
107
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, router } from 'gaonjs/vue'
2
+ import { useShared, router } from 'gaonjs/vue'
3
3
  import Card from '@shared/components/ui/Card.vue'
4
4
  import CardHeader from '@shared/components/ui/CardHeader.vue'
5
5
  import CardTitle from '@shared/components/ui/CardTitle.vue'
@@ -8,25 +8,24 @@ import CardContent from '@shared/components/ui/CardContent.vue'
8
8
  import CardFooter from '@shared/components/ui/CardFooter.vue'
9
9
  import Button from '@shared/components/ui/Button.vue'
10
10
 
11
- // dashboard#show render props user 직렬화되며 passwordDigest 없다(§4.2).
12
- // pageProps 반응형 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
13
- const props = pageProps<'{{APP_NAME}}:dashboard#show'>()
11
+ // 사용자·csrf 자동 주입 공유 prop 이다(결정 116) 컨트롤러가 넘기지 않고
12
+ // useShared() 읽는다. currentUser 직렬화됨(passwordDigest 없음 · §4.2).
13
+ // 페이지는 requireAuth 로 보호되므로 런타임엔 항상 로그인 상태(타입은 nullable).
14
+ const shared = useShared()
14
15
 
15
16
  // 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
16
- // HTML <form> 은 DELETE 를 보낼 없으므로 Inertia 라우터로 실제 메서드를
17
- // 보낸다(결정 64) — `?_method=DELETE` 우회는 서버가 해석하지 않아 POST /session
18
- // (= 로그인 create) 으로 잘못 라우팅됐다.
17
+ // HTML <form> 은 DELETE 를 보내므로 Inertia 라우터로 실제 메서드를 보낸다(결정 64).
19
18
  function logout(): void {
20
- router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
19
+ router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
21
20
  }
22
21
  </script>
23
22
 
24
23
  <template>
25
24
  <div class="mx-auto max-w-2xl px-4 py-10">
26
- <Card>
25
+ <Card v-if="shared.currentUser">
27
26
  <CardHeader>
28
- <CardTitle>환영합니다, {{ props.user.name }}님</CardTitle>
29
- <CardDescription>{{ props.user.email }}</CardDescription>
27
+ <CardTitle>환영합니다, {{ shared.currentUser.name }}님</CardTitle>
28
+ <CardDescription>{{ shared.currentUser.email }}</CardDescription>
30
29
  </CardHeader>
31
30
  <CardContent>
32
31
  <p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, useForm, Link } from 'gaonjs/vue'
2
+ import { pageProps, useForm, useShared, Link } from 'gaonjs/vue'
3
3
  import Card from '@shared/components/ui/Card.vue'
4
4
  import CardHeader from '@shared/components/ui/CardHeader.vue'
5
5
  import CardTitle from '@shared/components/ui/CardTitle.vue'
@@ -17,9 +17,12 @@ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
17
17
  // 실패로 서버가 같은 페이지를 다시 render 하면 props.error 가 즉시 갱신된다.
18
18
  const props = pageProps<'{{APP_NAME}}:session#new'>()
19
19
 
20
+ // csrf 는 자동 주입 공유 prop 이다(결정 116) — useShared() 로 읽는다.
21
+ const shared = useShared()
22
+
20
23
  // 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
21
24
  // 서버는 redirect(Inertia 응답)로 답하고, 실패 시 같은 페이지를 다시 render 한다.
22
- const form = useForm({ email: '', password: '', _csrf: props.csrf })
25
+ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
23
26
  </script>
24
27
 
25
28
  <template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { pageProps, useForm, Link } from 'gaonjs/vue'
2
+ import { pageProps, useForm, useShared, Link } from 'gaonjs/vue'
3
3
  import Card from '@shared/components/ui/Card.vue'
4
4
  import CardHeader from '@shared/components/ui/CardHeader.vue'
5
5
  import CardTitle from '@shared/components/ui/CardTitle.vue'
@@ -15,8 +15,11 @@ import AlertDescription from '@shared/components/ui/AlertDescription.vue'
15
15
  // pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
16
16
  const props = pageProps<'{{APP_NAME}}:registration#new'>()
17
17
 
18
+ // csrf 는 자동 주입 공유 prop 이다(결정 116) — useShared() 로 읽는다.
19
+ const shared = useShared()
20
+
18
21
  // 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
19
- const form = useForm({ name: '', email: '', password: '', _csrf: props.csrf })
22
+ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
20
23
  </script>
21
24
 
22
25
  <template>
@@ -4,8 +4,9 @@ import { controller } from 'gaonjs/web'
4
4
  export default controller({
5
5
  // GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
6
6
  async show() {
7
- const user = this.requireAuth()
8
- // csrf 로그아웃(DELETE /session · 결정 64)이 헤더로 실어 보낸다.
9
- return this.render('Dashboard', { user, csrf: this.csrfToken() })
7
+ // 보호만 하고 페이지 데이터는 넘기지 않는다 — 사용자·csrf 는 자동 주입되고
8
+ // (결정 116), 페이지는 useShared() 읽는다. requireAuth 게이트만 건다.
9
+ this.requireAuth()
10
+ return this.render('Dashboard', {})
10
11
  },
11
12
  })
@@ -3,9 +3,9 @@ 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 — 회원가입
6
+ // GET /registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
7
7
  async new() {
8
- return this.render('Auth/Signup', { error: null as string | null, csrf: this.csrfToken() })
8
+ return this.render('Auth/Signup', { error: null as string | null })
9
9
  },
10
10
  // POST /registration — 회원가입
11
11
  async create() {
@@ -3,9 +3,9 @@ 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 — 로그인
6
+ // GET /session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
7
7
  async new() {
8
- return this.render('Auth/Login', { error: null as string | null, csrf: this.csrfToken() })
8
+ return this.render('Auth/Login', { error: null as string | null })
9
9
  },
10
10
  // POST /session — 로그인
11
11
  async create() {
@@ -18,7 +18,6 @@ export default controller({
18
18
  }
19
19
  return this.render('Auth/Login', {
20
20
  error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
21
- csrf: this.csrfToken(),
22
21
  })
23
22
  },
24
23
  // DELETE /session — 로그아웃
@@ -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` 검사 22
107
+ ### 2.2 `gaon doctor` 검사 23
108
108
 
109
109
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
110
110
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -128,6 +128,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
128
128
  20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
129
129
  21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
130
130
  22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
131
+ 23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
131
132
 
132
133
  ## 3. 로직 배치 One Way 판단표
133
134
 
@@ -188,7 +189,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
188
189
  ```bash
189
190
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
190
191
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
191
- gaon doctor # 정적 검사 22종 (§2.2)
192
+ gaon doctor # 정적 검사 23종 (§2.2)
192
193
  ```
193
194
 
194
195
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
85
85
 
86
86
  ```bash
87
87
  gaon check # .gaon 재생성 후 타입 검사 (CI 정합)
88
- gaon doctor # 정적 검사 17종 (응답·N+1·의존·커넥션·마이그·순수·자동import·파일명/컬럼·인증·UI킷·라우트 · 상세 AGENTS §2.2)
88
+ gaon doctor # 정적 검사 23종 (상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```
91
91
 
@@ -178,7 +178,8 @@ export const posts = table('posts', {
178
178
  |---|---|---|---|
179
179
  | `where` | `(col, op, val?)` | `Chain` | op 에 따라 val 형태 강제 (위 표) |
180
180
  | `whereIn` | `(col, vals)` | `Chain` | `where(col, 'in', vals)` 축약 |
181
- | `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습 · `model.ts:245-262`) |
181
+ | `whereAny` | `(cols, op, val?)` | `Chain` | **여러 컬럼에 같은 조건을 OR 묶어 괄호로 감쌈** = `(c1 op v OR c2 op v)` · 앞선 where 와 **AND 로 안전 결합**(결정 118). 다중 컬럼 검색의 정본 — `orWhere` 로 흩뜨리면 앞 조건이 샌다(아래 함정) |
182
+ | `orWhere` | `(col, op, val?)` | `Chain` | op 12종 전부 (M2C) · 결합은 `(a AND b) OR c` (Rails 관습). **다중 컬럼 검색엔 쓰지 말 것** — `whereAny` 를 쓴다(결정 118) |
182
183
  | `orderBy` | `(col, dir?)` | `Chain` | dir 기본 `'asc'` · 호출마다 누적 (다중 정렬) · 정렬 뒤 PK 타이브레이커 자동 부가(결정 110) |
183
184
  | `reorder` | `(col, dir?)` | `Chain` | 기존 정렬 전부 버리고 재지정 |
184
185
  | `latest` | `()` | `Chain` | `orderBy('createdAt', 'desc')` 고정 축약 · id 타이브레이커로 결정적(결정 110) |
@@ -186,6 +187,7 @@ export const posts = table('posts', {
186
187
  | `offset` | `(n)` | `Chain` | 페이지네이션 = `orderBy·offset·limit` 조합 |
187
188
  | `first` | `()` | `Promise<Rec \| undefined>` | 자동 `limit 1` |
188
189
  | `all` | `()` | `Promise<Rec[]>` | |
190
+ | `paginate` | `(page, perPage)` | `Promise<PaginatedResult<Rec>>` | **체인 종단 페이지네이션**(결정 119) — `{ rows, total, page, pageCount, perPage }` 를 한 번에. count·rows 를 내부 계산(호출자 쿼리 한 번). page/perPage **클램프 내장**(0·음수·초과 = 마지막 페이지 · total 0 = pageCount 1). total=**number**. **result 통째로 render props 안전**(rows 는 Serialized 경계). `latest()` 타이브레이커(결정 110)로 페이지 경계 결정적. 집계 그룹(GroupChain)엔 없음 |
189
191
  | `count` | `()` | `Promise<bigint>` | driver 별 반환을 **bigint 로 통일** (E-4 (g) · `model.ts:390`) |
190
192
  | `exists` | `()` | `Promise<boolean>` | |
191
193
  | `sum` · `avg` | `(col)` | `Promise<string \| null>` | numeric 정확성 · 대상 행 없으면 null |
@@ -195,6 +197,8 @@ export const posts = table('posts', {
195
197
  | `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
196
198
  | `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
199
  | `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
200
+ | `incrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 증가**(결정 115) — `SET col = col + by` 한 문장 · 수치 컬럼 · 영향 행 수(number). 벌크 계약(limit/offset/orderBy 있으면 throw)은 updateAll 과 동일 |
201
+ | `decrementAll` | `(field, by?=1)` | `Promise<number>` | **원자 벌크 감소**(결정 115) — `SET col = col - by`. incrementAll 의 대칭 |
198
202
 
199
203
  **집계·조인 그룹** (`Chain` · M2E · 결정 34):
200
204
 
@@ -261,6 +265,26 @@ export const posts = table('posts', {
261
265
  (§6 `Serialized<T>` 는 함수 값을 떨군다).
262
266
  - 사용자 메서드 (§8 `methods`) — `this` = Rec 으로 바인딩.
263
267
 
268
+ **원자 프리미티브 (결정 115) — 카운터·플래그는 read-modify-write 하지 않는다:**
269
+
270
+ 값을 읽어 계산해 다시 쓰면(`rec.update({ views: rec.views + 1 })`) 동시 요청에서
271
+ 증가가 유실된다(경쟁 조건). SQL 표현식을 한 문장으로 실행하는 원자 메서드를 쓴다:
272
+
273
+ - `rec.increment(field, by?=1)` / `rec.decrement(field, by?=1)` — `SET col = col ± by`.
274
+ **수치 컬럼만**(number·bigint). 갱신 후 값을 반환하고 `rec` 필드도 그 값으로 맞춘다.
275
+ - `rec.touch(field?='updatedAt')` — 값을 읽지 않고 현재 시각으로 원자 갱신. **날짜 컬럼만** · `void`.
276
+ - `rec.toggle(field)` — `SET col = NOT col`. **boolean 컬럼만** · 갱신 후 boolean 반환.
277
+ - 컬렉션 버전은 체인 `incrementAll`·`decrementAll`(§4 표 · 영향 행 수 반환).
278
+
279
+ ```ts
280
+ // domain/models/Post.ts — 조회수 카운터(원자 · 경쟁 조건 없음)
281
+ methods: {
282
+ async recordView() {
283
+ return await this.increment('viewCount') // NOT: this.update({ viewCount: this.viewCount + 1 })
284
+ },
285
+ }
286
+ ```
287
+
264
288
  **체인 상태 전이 주의** (`model.ts:112-146`):
265
289
 
266
290
  - `select()`·`include()` 이후에도 빌더 메서드(`where`·`orWhere`·
@@ -395,6 +419,26 @@ export const Post = model(posts, {
395
419
  })
396
420
  ```
397
421
 
422
+ - **여러 모델을 조합하는 읽기 질의도 이름을 붙인다 (§5.3 읽기 규칙 · 결정 114).**
423
+ 검색·태그 필터처럼 여러 모델/서브쿼리를 엮는 읽기는 컨트롤러에 인라인 조립하지
424
+ 않는다 — 주 모델이 분명하면 **모델 정적 메서드(스코프)**, 대등한 조합이면
425
+ `domain/services/` 로 이름을 붙인다. 컨트롤러에 허용되는 쿼리는 **스코프 체인
426
+ 한 줄**까지다(`await Post.published().latest().limit(20).all()`).
427
+ ```ts
428
+ // ❌ 컨트롤러 인라인 조립 (검색 교집합을 컨트롤러가 조립)
429
+ // const ids = await Post.where('title','ilike',p).orWhere('body','ilike',p).pluck('id')
430
+ // const rows = await Post.published().whereIn('id', ids).latest().all()
431
+ // ✅ 이름 붙인 스코프 — 컨트롤러는 한 줄. 다중 컬럼 검색은 whereAny(결정 118)로
432
+ // 괄호로 묶어 published 가 안 새게 한다.
433
+ scopes: {
434
+ searchPublished: (q, term: string) =>
435
+ q.where('published', '=', true)
436
+ .whereAny(['title', 'body'], 'ilike', `%${term}%`),
437
+ // → WHERE published AND (title ILIKE .. OR body ILIKE ..) — 미발행 글이 검색에 안 샘.
438
+ }
439
+ // 컨트롤러: const rows = await Post.searchPublished(term).latest().all()
440
+ ```
441
+
398
442
  ### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
399
443
 
400
444
  모든 모델은 `Model.form` 으로 **스키마 파생 폼**을 노출한다 — 컨트롤러의
@@ -550,6 +594,15 @@ const page2 = await Post.latest().offset(20).limit(20).all()
550
594
  // 부분 문자열 검색 · OR 조합
551
595
  const found = await Post.where('title', 'like', '%gaon%').first()
552
596
  const mine = await Post.where('published', '=', true).orWhere('authorId', '=', me.id).all()
597
+ // 다중 컬럼 검색은 whereAny — 앞 조건(published)과 AND 로 안전 결합(결정 118)
598
+ const hits = await Post.where('published', '=', true)
599
+ .whereAny(['title', 'body'], 'ilike', `%${term}%`).latest().all()
600
+
601
+ // 페이지네이션은 paginate — 체인 종단 · {rows,total,page,pageCount,perPage} 한 번에(결정 119)
602
+ const result = await Post.published().include('author').withCount('comments').latest().paginate(page, 20)
603
+ // result.rows → 이 페이지 (include·withCount 반영)
604
+ // result.total → 필터 반영 전체 개수(number) · result.pageCount → 전체 페이지(최소 1)
605
+ // 컨트롤러에서 this.render('Posts/Index', { page: result }) 로 통째로 넘겨도 안전.
553
606
 
554
607
  // 집계
555
608
  const total = await Post.where('published', '=', true).count() // bigint
@@ -635,6 +688,20 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
635
688
  - **폼 변형은 `pick()` 뿐** (결정 104) — `Model.form.omit/extend/merge` 는
636
689
  없다. 검증되는 부분 폼 = `pick()`, 스키마와 무관한 입력만 애드혹
637
690
  `{ _row: {} as T }`(검증 없음 · `agents/web.md` §3).
691
+ - **페이지네이션을 손으로 조립하지 말 것** (결정 119) — "count 쿼리 + `orderBy·offset·limit`
692
+ 목록 쿼리를 따로 조립하고, `count()`(bigint)를 number 로 캐스팅하고, `Math.ceil` 로 페이지 수를
693
+ 계산하고, page 를 손으로 클램프" 하는 것은 **반정본**이다. 정본은 `chain.paginate(page, perPage)`
694
+ 한 줄 — `{ rows, total, page, pageCount, perPage }` 를 한 번에 주고 클램프(0·음수·초과 = 마지막
695
+ 페이지 · total 0 = pageCount 1)와 개수 number 변환을 내장한다. `include`·`withCount`·`latest`
696
+ 뒤에 그대로 붙는다. result 는 통째로 render props 에 실어도 안전하고 UI 킷 `Pagination` 블록
697
+ (`:page`·`:pageCount`)에 필드가 그대로 맞는다. 집계 그룹(`groupBy`)에는 없다(행 목록 전용).
698
+ - **다중 컬럼 검색을 `orWhere` 로 흩뜨리면 앞 조건이 샌다** (결정 118) — `where('published',
699
+ '=', true).orWhere('title','ilike',t).orWhere('body','ilike',t)` 는 `published OR title OR
700
+ body` 로 접혀 **미발행 글이 검색에 새어 나온다**. 정본은 `whereAny(['title','body'], 'ilike',
701
+ t)` — 여러 컬럼을 괄호로 묶어 `published AND (title OR body)` 로 만든다. 컬럼 배열은 타입
702
+ 안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
703
+ 다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
704
+ 그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
638
705
 
639
706
  ## 관련 결정 번호
640
707
 
@@ -652,4 +719,8 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
652
719
  | 결정 104 | 스키마 파생 폼에 name·defs 탑재(실검증) · `Model.form.pick()` 검증되는 부분 폼(§8.1) |
653
720
  | 결정 108 | 정적 default 컬럼 빈 입력 채움(§8.1) · 동적 default 는 DB 위임 |
654
721
  | 결정 110 | `latest()`·`orderBy` 정렬 뒤 PK 타이브레이커 자동 부가(결정적 페이지네이션 · §4) |
722
+ | 결정 114 | 여러 모델 조합 읽기 = 이름 붙임(모델 정적 메서드/`domain/services/`) · 컨트롤러는 스코프 체인 한 줄까지(§8 · §5.3) |
723
+ | 결정 115 | 원자 프리미티브 increment·decrement·touch·toggle(Rec) + incrementAll·decrementAll(Chain) · read-modify-write 금지(§4) |
724
+ | 결정 118 | `whereAny(cols, op, val)` — 다중 컬럼 동일 조건 OR 를 괄호로 묶어 AND 안전 결합(§8·정본 예시·함정) · 범용 그룹핑(whereGroup) 은 기각(복합 논리는 Kysely 탈출구) |
725
+ | 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
655
726
  | E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
@@ -41,6 +41,16 @@ const props = pageProps<'web:posts#index'>()
41
41
  - **라우트 키** = `<app>:<controller>#<action>` (앱 폴더명 · 컨트롤러 파일명
42
42
  stem · 소문자 복수 · 결정 55). `apps/web/controllers/posts.ts` 의 `index`
43
43
  액션 → `'web:posts#index'`.
44
+ - **공유 prop(currentUser·csrf·flash) = `useShared()`(결정 116) — 라우트 키 없이 읽는다.**
45
+ 이 셋은 디스패처가 **모든** 렌더에 자동 주입하므로 컨트롤러가 넘기지 않는다(넘기면
46
+ 컴파일 에러 · 결정 117). 레이아웃·컴포넌트에서 라우트를 모른 채 읽을 때 특히 유용하다:
47
+ ```vue
48
+ import { useShared } from 'gaonjs/vue'
49
+ const shared = useShared() // { currentUser, csrf, flash } · 반응형
50
+ // <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
51
+ ```
52
+ `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
53
+ `useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
44
54
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
45
55
  로 import 하지 않는다.
46
56
  - **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
@@ -258,6 +268,12 @@ import PageShell from '@shared/components/ui/PageShell.vue'
258
268
  - **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
259
269
  넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
260
270
  자동 해소를 하지 않는다 — 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
271
+ - **버튼 모양 링크 = `<Button href>`(결정 113) — `Link` 로 `Button` 을 감싸지 않는다.**
272
+ `<Link href="/x"><Button>…</Button></Link>` 는 `<a><button>` 중첩(HTML 비준수·접근성
273
+ 결함)이다. `Button` 에 `href` 를 주면 내부에서 SPA 이동 링크(`Link`=`<a>`)로 렌더한다:
274
+ `<Button href="/posts/new">새 글</Button>`. 외부 URL 은 `<Button href="https://…" external
275
+ target="_blank">`. 순수 버튼은 `href` 없이 `<Button @click="…">`. doctor **link-button-nesting**
276
+ 이 Link>Button 중첩을 경고한다.
261
277
  - **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
262
278
  `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
263
279
  `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
@@ -265,6 +281,21 @@ import PageShell from '@shared/components/ui/PageShell.vue'
265
281
  한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
266
282
  앱 라우트 지식(`api()`·`pageProps`). 데이터는 props 로 받는다(예 `Pagination` 은
267
283
  `v-model:page` 로 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
284
+ - **`Pagination` 블록은 `paginate()` 결과에 바로 맞는다(결정 119·106)** — 컨트롤러가
285
+ `chain.paginate(page, perPage)` 로 만든 `{ rows, total, page, pageCount, perPage }` 를
286
+ 통째로 넘기면, 블록의 `:page`·`:pageCount` 가 필드명 그대로 붙는다(매핑 보일러플레이트 0).
287
+ ```vue
288
+ <script setup lang="ts">
289
+ import { Pagination } from '@shared/components/ui'
290
+ import { router } from 'gaonjs/vue'
291
+ const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
292
+ function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
293
+ </script>
294
+ <template>
295
+ <article v-for="post in props.page.rows" :key="post.id">…</article>
296
+ <Pagination :page="props.page.page" :page-count="props.page.pageCount" @update:page="goto" />
297
+ </template>
298
+ ```
268
299
  - **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
269
300
  각 앱이 Tailwind 유틸을 받으려면 그 앱에 `style.css` 배선이 있어야 한다.
270
301
  `gaon g app admin` 이 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
@@ -367,4 +398,7 @@ async function runSearch(q: string) {
367
398
  | 결정 106 | 최소 4블록(PageShell·PageHeader·EmptyState·Pagination · 성격 중립) |
368
399
  | 결정 107 | 반응형은 킷 책임(페이지 레이아웃 브레이크포인트 지양 · doctor page-layout-breakpoint 안내 경고 · 터치 44px·폰트 최소 크기 토큰) |
369
400
  | 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
401
+ | 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
402
+ | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
403
+ | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
370
404
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -73,6 +73,26 @@
73
73
  - **`presenceInfo`** — 접속자 목록은 채널 전원에게 공개된다. 공개 메타만
74
74
  (`agents/realtime.md`).
75
75
 
76
+ ### 6. 클라이언트 IP · 프록시 신뢰 (결정 120)
77
+
78
+ **헤더는 클라이언트 입력이다.** `X-Forwarded-For`·`CF-Connecting-IP` 같은 헤더의
79
+ 신뢰는 "**바로 앞 홉이 신뢰 장비이고, 그 장비가 이 헤더를 덮어쓴다**"는 조건에서만
80
+ 성립한다. 이 조건이 없으면 누구나 헤더를 위조해 rate limit 을 우회하고 로그를
81
+ 오염시킬 수 있다. `gaon.config.ts` 의 `web.clientIp` 3모드가 조건을 어떻게 충족하는가:
82
+
83
+ | 모드 | 신뢰 조건 | 네트워크 전제 |
84
+ |---|---|---|
85
+ | `'direct'`(기본) | 헤더를 아예 안 믿음 — 소켓 피어만 | 서버가 인터넷에 직접 노출 |
86
+ | `{ proxy: n \| CIDR }` | 신뢰 홉/대역 안의 XFF 만 반영 · 밖은 위조로 무시 | 리버스 프록시/LB 가 XFF 를 올바로 덮어씀 |
87
+ | `{ header: '<이름>' }` | 지정 헤더를 정본으로 | **오리진 직접 접속 차단이 필수** — 방화벽/대역 allowlist 로 프록시(예 Cloudflare)만 오리진에 닿게 한다. 안 하면 위조로 우회. 상급: Cloudflare **Authenticated Origin Pulls**(mTLS)로 오리진이 CF 만 받게 강제 |
88
+
89
+ - **IP 는 약한 신호다.** NAT·공유 IP·모바일 캐리어·IPv6 프리픽스 회전으로 한 IP 가
90
+ 여러 사람이거나 한 사람이 여러 IP 다. IP 는 **rate limit·어뷰즈 억제·로깅**까지만
91
+ 쓴다 — **인가(authorization) 판단에 쓰지 않는다.** "특정 IP 면 관리자 허용" 같은
92
+ 분기를 만들지 말 것(위조·회전으로 즉시 무너진다). 인가는 세션/JWT 신원으로만.
93
+ - 세 모드의 산출은 전부 `this.request.ip` 하나로 통일된다(별도 표면 없음 · `agents/web.md` §4.4).
94
+ 운영 배치(프록시/Cloudflare 뒤)의 선언은 `compose.prod.yaml` 배치에 맞춘다.
95
+
76
96
  ## 정본 예시
77
97
 
78
98
  ```ts
@@ -97,6 +117,11 @@ const rows = await Post.query()
97
117
  값은 바인딩.
98
118
  - **시크릿 하드코딩 · `.env` 커밋 금지** — env 주입만.
99
119
  - **JWT 를 세션 앱에 섞지 않는다** — JWT 는 API 앱 전용.
120
+ - **프록시 뒤인데 `clientIp` 미선언 = rate limit 무력화** (결정 120) — `web.clientIp` 를
121
+ 안 두면 프록시/CF 뒤에서 전 사용자가 프록시 IP 한 버킷으로 묶인다. 배치 환경에 맞게
122
+ `{ proxy }`·`{ header }` 를 선언한다(§6).
123
+ - **IP 로 인가 판단 금지** (결정 120) — "특정 IP = 관리자" 류는 위조·회전에 무너진다.
124
+ IP 는 rate limit·로깅까지만, 인가는 신원(세션/JWT)으로.
100
125
 
101
126
  ## 관련 결정 번호
102
127
 
@@ -106,3 +131,4 @@ const rows = await Post.query()
106
131
  | 결정 24 (E-3 §5) | `this.params` 고정 우선순위 — 파라미터 오염 차단 |
107
132
  | §7 (v0.15) | 세션 앱별 분리 · JWT 는 API 앱 전용 |
108
133
  | 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
134
+ | 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
@@ -167,12 +167,14 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
167
167
  ```ts
168
168
  // 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
169
169
  // pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
170
+ // csrf 는 자동 주입 공유 prop — useShared() 로 읽는다(결정 116).
170
171
  const props = pageProps<'web:session#new'>()
171
- const form = useForm({ email: '', password: '', _csrf: props.csrf })
172
+ const shared = useShared()
173
+ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
172
174
  // <form @submit.prevent="form.post('/session')"> · 실패 시 {{ props.error }} 가 반응형으로 갱신
173
175
 
174
176
  // HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다.
175
- router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
177
+ router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
176
178
  ```
177
179
 
178
180
  `?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
@@ -203,6 +205,67 @@ async create() {
203
205
 
204
206
  순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
205
207
 
208
+ ### 4.2 공유 prop 자동 주입 — currentUser·csrf·flash (결정 116·117)
209
+
210
+ 디스패처가 **모든** Inertia 렌더에 공유 prop 3종을 자동 주입한다 — 컨트롤러가
211
+ 손으로 넘기지 않는다:
212
+
213
+ - `currentUser` — 현재 로그인 사용자(직렬화 · 미로그인 `null`). 인증 augment 가 채운다.
214
+ - `csrf` — 요청별 CSRF 토큰.
215
+ - `flash` — 세션 플래시 백. `this.flash(key, value)` 로 심고(redirect-then-render),
216
+ 다음 렌더의 `flash.<key>` 로 한 번 읽히면 소멸한다.
217
+
218
+ 페이지·레이아웃은 **`useShared()`(gaonjs/vue)** 로 라우트 키 없이 읽는다(결정 116 ·
219
+ `agents/frontend.md` §1). `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽힌다.
220
+
221
+ - **예약 키는 render props 에 넣으면 컴파일 에러(결정 117).** `currentUser`·`csrf`·`flash`
222
+ 는 자동 주입되므로 `this.render('p', { currentUser })` 는 타입 에러(+런타임 방어)다 —
223
+ 그 이름을 페이지 데이터로 쓰지 말고, 필요하면 다른 이름을 쓴다.
224
+ ```ts
225
+ // ❌ 자동 주입값을 손으로 넘기지 않는다(컴파일 에러)
226
+ // return this.render('Dashboard', { user, csrf: this.csrfToken() })
227
+ // ✅ 보호만 하고 페이지 데이터는 넘기지 않는다 — 페이지가 useShared() 로 읽는다
228
+ async show() {
229
+ this.requireAuth()
230
+ return this.render('Dashboard', {})
231
+ }
232
+ ```
233
+
234
+ ### 4.3 읽기 조합은 컨트롤러 인라인 조립하지 않는다 (§5.3 · 결정 114)
235
+
236
+ 컨트롤러 액션에 허용되는 쿼리는 **스코프 체인 한 줄**까지다. 검색·태그 필터처럼
237
+ 여러 모델/서브쿼리를 엮는 읽기는 이름을 붙인다 — 주 모델이 분명하면 **모델 정적
238
+ 메서드(스코프)**, 대등한 조합이면 `domain/services/`(`agents/data.md` §8). 컨트롤러가
239
+ `pluck`·교집합·사이드바 질의를 인라인 조립하기 시작하면 서비스/스코프로 옮긴다.
240
+
241
+ ```ts
242
+ // ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
243
+ // ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
244
+ // ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
245
+ // const page = await Post.searchPublished(term).latest().paginate(this.query('page') ?? 1, 20)
246
+ // return this.render('Posts/Index', { page }) // page 통째로 안전(rows Serialized · 나머지 number)
247
+ ```
248
+
249
+ ### 4.4 클라이언트 IP · 헤더는 `this.request` (FastifyRequest 탈출구 · 결정 120)
250
+
251
+ 컨트롤러에서 IP·요청 헤더가 필요하면 `this.request`(FastifyRequest)로 내려간다 —
252
+ **`this.request.ip`** 가 클라이언트 IP 다. `this.clientIp` 같은 별도 표면은 없다.
253
+
254
+ **IP 는 프록시 뒤에서 반드시 설정을 선언해야 맞다.** `request.ip`·rate limit(기본
255
+ 켬 · IP 기준)·구조화 로깅이 **같은 산출**을 쓰며, `gaon.config.ts` 의 `web.clientIp`
256
+ 가 그 산출을 정한다(생략 시 `'direct'`).
257
+
258
+ | 모드 | 설정 | request.ip = | 언제 |
259
+ |---|---|---|---|
260
+ | direct(기본) | `web: { clientIp: 'direct' }` | 소켓 피어 주소 | 인터넷에 직접 노출 · XFF/CF 헤더 무시(위조 안 통함) |
261
+ | proxy | `{ clientIp: { proxy: 1 } }` | 신뢰 홉 내 `X-Forwarded-For` | 리버스 프록시/로드밸런서 뒤 · 홉 수 또는 CIDR/IP 로 신뢰 범위 지정 |
262
+ | header | `{ clientIp: { header: 'cf-connecting-ip' } }` | 지정 헤더의 첫 값 | Cloudflare 등 특정 헤더가 정본 · **오리진 직접 접속 차단이 전제**(agents/security.md) |
263
+
264
+ - **프록시/CF 뒤인데 direct 로 두면** 전 사용자가 프록시 IP 하나로 묶여 rate limit 이
265
+ 무의미하고 로그의 IP 가 전부 프록시다 — 배치 환경에 맞게 선언한다(운영은 `agents/security.md`
266
+ 위협 모델 · `compose.prod.yaml`).
267
+ - 잘못된 설정(빈 헤더 이름 등)은 **부팅 에러**로 즉시 잡힌다(→ 수리 안내 포함).
268
+
206
269
  ### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
207
270
 
208
271
  회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
@@ -372,4 +435,9 @@ export default controller({
372
435
  | 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
373
436
  | 결정 108 | 정적 default 컬럼 빈 입력 채움(coerceParams) · 동적 default 는 DB 위임 |
374
437
  | 결정 109 | Inertia 폼 검증 실패 = 303 back + 플래시 errors → `useForm.errors` 자동(§4.1) · JSON/API 는 422 유지 |
438
+ | 결정 114 | 여러 모델 조합 읽기는 이름 붙임(정적 메서드/서비스) · 컨트롤러는 스코프 체인 한 줄까지(§4.3 · `agents/data.md` §8) |
439
+ | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `this.flash(k,v)` · 페이지는 `useShared()`(§4.2) |
440
+ | 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
441
+ | 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
442
+ | 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
375
443
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -4,6 +4,12 @@
4
4
  # hub 를 별도 서비스로 띄운다(운영 프로세스 3종 · CLAUDE.md §2 · 결정 60). 인프라는
5
5
  # 실 pg·redis·nats(목업 금지 §9). 시크릿은 env 로 주입한다 — 이 파일에 값을 박지 말 것.
6
6
  #
7
+ # 리버스 프록시/로드밸런서/Cloudflare 뒤에 배치한다면 gaon.config.ts 의 web.clientIp 를
8
+ # 선언한다(결정 120) — 안 하면 rate limit(IP 기준·기본 켬)이 전 사용자를 프록시 IP 한
9
+ # 버킷으로 묶고 로그의 IP 가 전부 프록시가 된다. 프록시면 { proxy: 1 }, Cloudflare 등
10
+ # 특정 헤더가 정본이면 { header: 'cf-connecting-ip' }(오리진 직접 접속 차단 전제).
11
+ # 위협 모델·전제는 agents/security.md §6.
12
+ #
7
13
  # 기동: docker compose -f compose.prod.yaml up -d --build
8
14
  # 정지: docker compose -f compose.prod.yaml down
9
15
  name: {{PROJECT_NAME}}-prod
@@ -1,14 +1,32 @@
1
1
  <script setup lang="ts">
2
- // UI 킷 · Button (결정 75). variant/size 로 모양을 고르고, 나머지 속성·클래스는
3
- // 자연스럽게 <button> 으로 흘러간다(Vue 폴스루). 부모가 class 를 주면 뒤에 병합된다.
2
+ // UI 킷 · Button (결정 75 · 결정 113). variant/size 로 모양을 고르고, 나머지 속성·
3
+ // 클래스는 자연스럽게 렌더 요소로 흘러간다(Vue 폴스루). 부모가 class 를 주면 뒤에 병합.
4
+ //
5
+ // 결정 113: 버튼 모양 링크는 `<Button href="/x">` 한 표면으로 낸다. `<Link>` 로
6
+ // `<Button>` 을 감싸면 <a><button> 중첩(HTML 비준수·접근성 결함)이 된다 — 대신
7
+ // href 를 주면 이 컴포넌트가 내부에서 알맞은 요소를 고른다:
8
+ // · href 있음(내부) → gaonjs/vue 의 <Link>(=<a> · SPA 이동)
9
+ // · href 있음 + external → 일반 <a>(target 지정 가능 · 외부 URL)
10
+ // · href 없음 → <button>
4
11
  import { computed } from 'vue'
12
+ import { Link } from 'gaonjs/vue'
5
13
  import { cn } from '../../lib/utils.js'
6
14
 
7
15
  type Variant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
8
16
  type Size = 'default' | 'sm' | 'lg' | 'icon'
9
17
 
10
18
  const props = withDefaults(
11
- defineProps<{ variant?: Variant; size?: Size; type?: 'button' | 'submit' | 'reset' }>(),
19
+ defineProps<{
20
+ variant?: Variant
21
+ size?: Size
22
+ type?: 'button' | 'submit' | 'reset'
23
+ /** 주면 버튼 모양 링크가 된다(내부 이동은 SPA · 결정 113). Link 로 감싸지 말 것. */
24
+ href?: string
25
+ /** 외부 URL 이면 true — 일반 <a> 로 렌더(SPA 이동 아님). target 과 함께 쓴다. */
26
+ external?: boolean
27
+ /** external 링크의 target(예: '_blank'). */
28
+ target?: string
29
+ }>(),
12
30
  { variant: 'default', size: 'default', type: 'button' },
13
31
  )
14
32
 
@@ -28,12 +46,14 @@ const SIZES: Record<Size, string> = {
28
46
  }
29
47
  const BASE =
30
48
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ' +
31
- 'ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
49
+ 'no-underline ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
32
50
  'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
33
51
 
34
52
  const classes = computed(() => cn(BASE, VARIANTS[props.variant], SIZES[props.size]))
35
53
  </script>
36
54
 
37
55
  <template>
38
- <button :type="type" :class="classes"><slot /></button>
56
+ <a v-if="href && external" :href="href" :target="target" :class="classes"><slot /></a>
57
+ <Link v-else-if="href" :href="href" :class="classes"><slot /></Link>
58
+ <button v-else :type="type" :class="classes"><slot /></button>
39
59
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,11 +28,11 @@
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
30
  "@gaonjs/async": "0.6.1",
31
- "@gaonjs/config": "0.6.0",
31
+ "@gaonjs/data": "0.13.0",
32
32
  "@gaonjs/core": "0.2.1",
33
- "@gaonjs/web": "0.8.0",
34
- "@gaonjs/data": "0.11.0",
35
- "@gaonjs/mail": "0.1.3"
33
+ "@gaonjs/mail": "0.1.3",
34
+ "@gaonjs/config": "0.8.0",
35
+ "@gaonjs/web": "0.10.0"
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})\""
@@ -1,29 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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} 에서 실행 중입니다.`)