@gaonjs/cli 0.39.0 → 0.40.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.
@@ -55,9 +55,9 @@ export const FIXER_CAPABILITIES = [
55
55
  note: '수동 · `gaon db diff` → 리뷰 → `gaon db migrate` (SQL 은 사람이 확인).',
56
56
  },
57
57
  {
58
- rule: 'shared-composable-purity',
58
+ rule: 'shared-purity',
59
59
  hasFixer: false,
60
- note: '수동 · 컴포저블에 든 앱 종속 로직을 앱 쪽으로 옮겨야 합니다(E-5 §2.2).',
60
+ note: '수동 · shared(.ts·.vue)에 든 앱 종속 로직(api/pageProps·domain 값)을 앱 쪽으로 옮기거나 props/인자로 받으세요(E-5 §2.2 · 결정 217).',
61
61
  },
62
62
  {
63
63
  rule: 'no-auto-import',
@@ -0,0 +1,9 @@
1
+ import type { DoctorCheck, RuleReport } from './types.js';
2
+ /**
3
+ * 단일 shared 소스(.ts 원문 또는 .vue script 블록)를 검사해 순수성 위반
4
+ * 목록을 낸다(단위 테스트 진입점). file 확장자가 .vue 면 <script> 만 뽑아
5
+ * 파싱한다 — line 번호는 script 블록 기준(안내 정확도보다 위반 유무가 우선).
6
+ */
7
+ export declare function inspectSharedSource(file: string, source: string, cwd: string): DoctorCheck[];
8
+ /** shared/ 전체(.ts · .vue)를 훑어 순수성 위반을 모두 낸다. */
9
+ export declare function checkSharedPurity(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,188 @@
1
+ // @gaonjs/cli · doctor · shared 순수성 검사 (M9-E 확장 · 결정 217 · errata E-5 §2.2)
2
+ //
3
+ // shared/ 는 앱을 몰라야 한다(§5 · CLAUDE.md rule 5). 라우트를 몰라야 하고
4
+ // (api·pageProps 값 import 금지) · domain 은 타입으로만 참조해야 한다. 필요한
5
+ // 데이터·호출 함수는 props/인자로 받는다. 이 규칙은 shared 컴포저블뿐 아니라
6
+ // shared 컴포넌트(.vue)에도 똑같이 적용된다 — 관례가 컴포저블·컴포넌트에 대칭
7
+ // 이므로(결정 25) 검사도 한 규칙으로 통일한다(결정 217).
8
+ //
9
+ // 이력: M9-E 때는 `shared-composable-purity` 라는 이름으로 shared/composables/
10
+ // 의 .ts 만 훑었다. 그러나 shared/components/*.vue 안에서 domain 모델을 값으로
11
+ // import 해도 27 검사가 전부 통과하는 공백이 12차 실사용(support-desk)에서 드러났다
12
+ // (§4.5 잔재). 결정 217: 규칙명을 `shared-purity` 로 개정하고 검사 범위를
13
+ // shared/ 전체(.ts · .vue)로 넓혀 The One Way 로 통일한다.
14
+ //
15
+ // 검사 대상(파일 종류 무관):
16
+ // 1) 프레임웍 모듈(gaonjs · gaonjs/vue · @gaonjs/vue · @gaonjs/web) 에서
17
+ // 'api' · 'pageProps' 를 value import 하면 error.
18
+ // - type-only 는 무해(구조 참조뿐 · 라우트 지식 필요 없음).
19
+ // 2) domain 을 value import 하면 error.
20
+ // - type-only 는 허용(모델 Row 타입 등 · 순수 타입 참조).
21
+ //
22
+ // 방법: shared/ 아래 .ts 는 소스 그대로, .vue 는 <script> 블록만 뽑아 TS AST 로
23
+ // 파싱해 import 선언만 훑는다. 상대 import 는 실 파일까지 해석하지 않고 경로
24
+ // 접두사(domain/) 로 판정 — 이 검사는 순수성 게이트라 정확도보다 재현성이 우선
25
+ // (false positive 는 오히려 안전).
26
+ import { readdir, readFile } from 'node:fs/promises';
27
+ import { join, relative, resolve, dirname } from 'node:path';
28
+ import ts from 'typescript';
29
+ /** 라우트 지식을 담은 심볼 — shared 는 참조할 수 없다. */
30
+ const FORBIDDEN_FRAMEWORK_NAMES = new Set(['api', 'pageProps']);
31
+ /**
32
+ * 프레임웍 모듈 패턴. 문서 표기(gaon/vue)는 실 패키지 이름의 짧은
33
+ * 별칭이며, 실 배포본은 gaonjs 파사드 subpath 와 @gaonjs 스코프를 쓴다.
34
+ * 하나만 잡으면 우회가 쉬우므로 알려진 표기 3종을 모두 매치한다.
35
+ */
36
+ const FRAMEWORK_MODULE_PATTERNS = [
37
+ /^gaonjs$/,
38
+ /^gaonjs\/vue$/,
39
+ /^@gaonjs\/vue$/,
40
+ /^@gaonjs\/web$/,
41
+ ];
42
+ /** import 선언에서 이름과 type-only 플래그를 추출한다. */
43
+ function collectImportedNames(node) {
44
+ const out = [];
45
+ const clause = node.importClause;
46
+ if (!clause)
47
+ return out;
48
+ const clauseTypeOnly = clause.isTypeOnly;
49
+ if (clause.name)
50
+ out.push({ name: clause.name.text, typeOnly: clauseTypeOnly });
51
+ const bindings = clause.namedBindings;
52
+ if (bindings) {
53
+ if (ts.isNamespaceImport(bindings)) {
54
+ out.push({ name: bindings.name.text, typeOnly: clauseTypeOnly });
55
+ }
56
+ else if (ts.isNamedImports(bindings)) {
57
+ for (const el of bindings.elements) {
58
+ out.push({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly });
59
+ }
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ function isRelativeSpecifier(s) {
65
+ return s.startsWith('./') || s.startsWith('../');
66
+ }
67
+ function matchesFrameworkModule(spec) {
68
+ return FRAMEWORK_MODULE_PATTERNS.some((re) => re.test(spec));
69
+ }
70
+ /**
71
+ * .vue SFC 에서 <script>·<script setup> 블록 안 텍스트만 뽑아 하나로 잇는다.
72
+ * doctor 는 import 선언만 보므로 script 밖(<template>·<style>)은 무시한다.
73
+ * 두 블록(일반+setup)이 다 있을 수 있어 전부 이어 붙인다.
74
+ */
75
+ function extractVueScript(source) {
76
+ const blocks = [];
77
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
78
+ let m;
79
+ while ((m = re.exec(source)) !== null)
80
+ blocks.push(m[1] ?? '');
81
+ return blocks.join('\n');
82
+ }
83
+ /**
84
+ * 단일 shared 소스(.ts 원문 또는 .vue script 블록)를 검사해 순수성 위반
85
+ * 목록을 낸다(단위 테스트 진입점). file 확장자가 .vue 면 <script> 만 뽑아
86
+ * 파싱한다 — line 번호는 script 블록 기준(안내 정확도보다 위반 유무가 우선).
87
+ */
88
+ export function inspectSharedSource(file, source, cwd) {
89
+ const issues = [];
90
+ const rel = relative(cwd, file);
91
+ const code = file.endsWith('.vue') ? extractVueScript(source) : source;
92
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.ES2022, true);
93
+ const baseDir = dirname(file);
94
+ const visit = (node) => {
95
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
96
+ const spec = node.moduleSpecifier.text;
97
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
98
+ const names = collectImportedNames(node);
99
+ // 1) 프레임웍의 api/pageProps 를 value import → error
100
+ if (matchesFrameworkModule(spec)) {
101
+ for (const n of names) {
102
+ if (n.typeOnly)
103
+ continue; // type-only 는 라우트 실행 지식이 아님
104
+ if (!FORBIDDEN_FRAMEWORK_NAMES.has(n.name))
105
+ continue;
106
+ issues.push({
107
+ rule: 'shared-purity',
108
+ level: 'error',
109
+ file: rel,
110
+ line: line + 1,
111
+ message: `${rel} (line ${line + 1})\n` +
112
+ ` '${spec}' 의 '${n.name}' import 발견 · shared 는 라우트를 몰라야 함\n` +
113
+ `→ 옵션: apps/<앱>/ 로 옮기거나 · ${n.name} 결과를 props/인자로 받도록 바꾸라`,
114
+ detail: {
115
+ kind: 'framework-api',
116
+ module: spec,
117
+ name: n.name,
118
+ },
119
+ });
120
+ }
121
+ }
122
+ // 2) domain value import → error (type-only 는 허용)
123
+ if (isRelativeSpecifier(spec)) {
124
+ const abs = resolve(baseDir, spec);
125
+ const parts = relative(cwd, abs).split(/[\\/]/).filter(Boolean);
126
+ if (parts[0] === 'domain') {
127
+ const valueNames = names.filter((n) => !n.typeOnly);
128
+ if (valueNames.length > 0) {
129
+ issues.push({
130
+ rule: 'shared-purity',
131
+ level: 'error',
132
+ file: rel,
133
+ line: line + 1,
134
+ message: `${rel} (line ${line + 1})\n` +
135
+ ` domain 값 import 발견 · shared 는 domain 을 타입으로만 참조해야 함\n` +
136
+ `→ 'import type { ... } from ...' 형태로 바꾸거나 · 필요한 값은 props/인자로 받도록 바꾸라`,
137
+ detail: {
138
+ kind: 'domain-value',
139
+ module: spec,
140
+ names: valueNames.map((n) => n.name),
141
+ },
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ts.forEachChild(node, visit);
148
+ };
149
+ visit(sf);
150
+ return issues;
151
+ }
152
+ /** shared/ 전체(.ts · .vue)를 훑어 순수성 위반을 모두 낸다. */
153
+ export async function checkSharedPurity(cwd) {
154
+ const sharedDir = join(cwd, 'shared');
155
+ const files = [];
156
+ await collectSharedFiles(sharedDir, files);
157
+ const issues = [];
158
+ for (const file of files) {
159
+ const src = await readFile(file, 'utf8');
160
+ issues.push(...inspectSharedSource(file, src, cwd));
161
+ }
162
+ return { rule: 'shared-purity', issues };
163
+ }
164
+ async function collectSharedFiles(root, out) {
165
+ let entries;
166
+ try {
167
+ entries = (await readdir(root, { withFileTypes: true }));
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ for (const e of entries) {
173
+ const name = e.name;
174
+ if (name === 'node_modules' || name === 'dist' || name === '.gaon')
175
+ continue;
176
+ const full = join(root, name);
177
+ if (e.isDirectory())
178
+ await collectSharedFiles(full, out);
179
+ else if (e.isFile() && isScannable(name))
180
+ out.push(full);
181
+ }
182
+ }
183
+ /** shared 순수성 검사 대상 파일인가 — .ts(선언·테스트 제외) 또는 .vue. */
184
+ function isScannable(name) {
185
+ if (name.endsWith('.vue'))
186
+ return true;
187
+ return name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts');
188
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -7,7 +7,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
7
7
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
8
8
  export { checkSchemaRelations } from './doctor/schema-relations.js';
9
9
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
10
- export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
10
+ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
11
11
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
12
12
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
13
13
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
package/dist/doctor.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
8
8
  * 4) connections (v0.15 §4.5)
9
9
  * 5) migration-diff (최소 감지 · 상세는 M9-D)
10
- * 6) shared-composable-purity (errata E-5 §2.2 · 결정 25)
10
+ * 6) shared-purity (errata E-5 §2.2 · 결정 25·217 · shared/ 전체 .ts·.vue)
11
11
  * 7) no-auto-import (errata E-5 §2.4 · v0.15 §1.2)
12
12
  * 8) schema-filename (§1.1 · 결정 38 · 스키마 파일명 camelCase)
13
13
  * 9) agents-doc-index (§0 색인 ↔ agents/ 실 파일 · 결정 40)
@@ -47,7 +47,7 @@ import { checkDependencyDirection } from './doctor/dependency-direction.js';
47
47
  import { checkConnections } from './doctor/connections.js';
48
48
  import { checkSchemaRelations } from './doctor/schema-relations.js';
49
49
  import { checkMigrationDiff } from './doctor/migration-diff.js';
50
- import { checkSharedComposablePurity } from './doctor/shared-composable-purity.js';
50
+ import { checkSharedPurity } from './doctor/shared-purity.js';
51
51
  import { checkNoAutoImport } from './doctor/no-auto-import.js';
52
52
  import { checkSchemaFilename } from './doctor/schema-filename.js';
53
53
  import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -78,7 +78,7 @@ export { extractRelativeImports, checkDependencyDirection } from './doctor/depen
78
78
  export { extractConfigDbKeys, analyzeConfigDb, extractKeyUses, checkConnections, } from './doctor/connections.js';
79
79
  export { checkSchemaRelations } from './doctor/schema-relations.js';
80
80
  export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
81
- export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
81
+ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.js';
82
82
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
83
83
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
84
84
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
@@ -110,7 +110,7 @@ export const ALL_RULES = [
110
110
  'dependency-direction',
111
111
  'connections',
112
112
  'migration-diff',
113
- 'shared-composable-purity',
113
+ 'shared-purity',
114
114
  'no-auto-import',
115
115
  'schema-filename',
116
116
  'agents-doc-index',
@@ -139,7 +139,7 @@ const CHECKERS = {
139
139
  'dependency-direction': checkDependencyDirection,
140
140
  connections: checkConnections,
141
141
  'migration-diff': checkMigrationDiff,
142
- 'shared-composable-purity': checkSharedComposablePurity,
142
+ 'shared-purity': checkSharedPurity,
143
143
  'no-auto-import': checkNoAutoImport,
144
144
  'schema-filename': checkSchemaFilename,
145
145
  'agents-doc-index': checkAgentsDocIndex,
package/dist/generate.js CHANGED
@@ -33,6 +33,20 @@ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates',
33
33
  function urlPrefixFor(app) {
34
34
  return app === 'web' ? '' : `/${app}`;
35
35
  }
36
+ /**
37
+ * 결정 219: `~ 패치` 라벨을 파일별 실제 편집 내용으로 정확히 붙인다. patchEnvFiles·
38
+ * patchAppConfigAddAuth·patchRoutes 가 서로 다른 편집을 하는데 예전엔 전부 "라우트 추가"
39
+ * 로 라벨해 .env·app.config 편집을 오도했다(scaffold 로그 정확성).
40
+ */
41
+ function patchLabel(rel) {
42
+ if (rel === '.env' || rel === '.env.example')
43
+ return '세션 secret 추가';
44
+ if (rel.endsWith('/app.config.ts') || rel === 'app.config.ts')
45
+ return '인증 배선 추가';
46
+ if (rel.endsWith('/routes.ts') || rel === 'routes.ts')
47
+ return '라우트 추가';
48
+ return '수정';
49
+ }
36
50
  /**
37
51
  * 앱의 세션 secret 환경변수명 — web 은 'SESSION_SECRET'(기존), 그 외는
38
52
  * '<APP>_SESSION_SECRET'(결정 141 · 앱별 세션 완전 분리 · session.ts 는 앱마다
@@ -99,6 +113,9 @@ function renderTemplate(name, app, includePublic) {
99
113
  .replaceAll('{{APP_NAME}}', app)
100
114
  .replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
101
115
  .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
116
+ // 결정 218: 시큐어 세션 컨트롤러의 로그인 역할 게이트 기준. 결정 155·145 의 대시보드
117
+ // 역할 게이트(role === 'admin')와 같은 값으로 고정해 두 층이 어긋나지 않게 한다.
118
+ .replaceAll('{{REQUIRED_ROLE}}', 'admin')
102
119
  .replaceAll('{{SIGNUP_LINK}}', includePublic ? signupLinkMarkup(app) : '');
103
120
  }
104
121
  /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
@@ -110,7 +127,13 @@ export function authScaffoldFiles(opts = {}) {
110
127
  { tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
111
128
  { tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
112
129
  { tpl: 'auth.wiring.ts.tpl', out: `apps/${app}/auth.ts` },
113
- { tpl: 'session.controller.ts.tpl', out: `apps/${app}/controllers/session.ts` },
130
+ // 결정 218: 시큐어 앱은 로그인 시점에 역할을 검증하는 세션 컨트롤러를 깐다 — 고객이
131
+ // 인증만 통과해 세션을 얻은 뒤 대시보드에서 403 을 만나는 rough UX 를 진입점에서 막는다.
132
+ // 공개 앱(web 등)은 역할 개념이 없어 표준 세션 컨트롤러(로그인 = 세션 생성)를 쓴다.
133
+ {
134
+ tpl: includePublic ? 'session.controller.ts.tpl' : 'session.secure.controller.ts.tpl',
135
+ out: `apps/${app}/controllers/session.ts`,
136
+ },
114
137
  // 결정 155: 시큐어(비-web · 비-public) 앱은 역할 게이트 대시보드, 공개 앱은 requireAuth 대시보드.
115
138
  {
116
139
  tpl: includePublic ? 'dashboard.controller.ts.tpl' : 'dashboard.secure.controller.ts.tpl',
@@ -266,14 +289,16 @@ export function writeAuthScaffold(cwd, opts = {}) {
266
289
  // 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
267
290
  // 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
268
291
  patched.push(...patchEnvFiles(root, app));
269
- // 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
270
- // 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
292
+ // 결정 155·218: 시큐어 앱(공개 가입 미생성)은 로그인 시점 역할 게이트 세션 컨트롤러 +
293
+ // 역할 게이트 대시보드를 깔았다 role 컬럼이 있어야 동작하므로 추가를 안내한다
294
+ // (§7.5.3 · authorize·로그인 게이트 예시가 실제 역할 규칙이 되도록).
271
295
  if (!includePublic) {
272
- warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155).\n` +
296
+ warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155·218).\n` +
273
297
  `→ 관리자는 직접 만들거나 승격하세요(공개 가입 라우트 없음). 공개 가입이 필요하면 --public 로 다시 생성.\n` +
274
298
  `→ 역할 인가를 완성하려면 domain/schema/users.ts 에 role 컬럼을 추가하세요:\n` +
275
299
  ` role: t.string().default('user'),\n` +
276
- ` 그러면 apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트가 실제 역할로 동작합니다.`);
300
+ ` 그러면 로그인(controllers/session.ts)이 역할 부족 세션을 만들지 않고,\n` +
301
+ ` apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트도 실제 역할로 동작합니다.`);
277
302
  }
278
303
  return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
279
304
  }
@@ -292,8 +317,10 @@ export function runGenerateAuthCommand(opts = {}) {
292
317
  lines.push('');
293
318
  for (const f of result.created)
294
319
  lines.push(` + ${f}`);
320
+ // 결정 219: 패치 대상은 파일마다 편집 내용이 다르다 — routes.ts(라우트) · app.config.ts(인증
321
+ // 배선) · .env(세션 secret). 전부 "라우트 추가" 로 라벨하면 .env·app.config 편집을 오도한다.
295
322
  for (const f of result.patched)
296
- lines.push(` ~ ${f} (라우트 추가)`);
323
+ lines.push(` ~ ${f} (${patchLabel(f)})`);
297
324
  for (const f of result.skipped)
298
325
  lines.push(` · ${f} (이미 있음 — 건너뜀)`);
299
326
  for (const w of result.warnings)
@@ -0,0 +1,46 @@
1
+ // 세션 컨트롤러(로그인/로그아웃 · 관리 앱) — gaon g auth --app {{APP_NAME}}.
2
+ // 시큐어 앱은 로그인 **시점**에 역할을 검증한다(결정 218). 인증(비밀번호)만 통과한
3
+ // 고객이 세션을 얻은 뒤 대시보드에서야 403 을 만나는 rough UX 를 진입점에서 막는다:
4
+ // · 자격 증명이 틀리면 → 인증 실패(401 성격) · 세션 안 만듦 · 재렌더
5
+ // · 자격은 맞지만 역할 부족 → 인가 실패(403 성격) · 세션 안 만듦 · 재렌더
6
+ // 401 vs 403 을 메시지로 구분한다 — 어느 쪽도 세션을 만들지 않는다.
7
+ import { controller, verifyPassword } from 'gaonjs/web'
8
+ import { User } from '../../../domain/models/User.js'
9
+
10
+ // 이 앱에 로그인할 수 있는 역할. domain/schema/users.ts 에 role 컬럼을 두고
11
+ // (예: role: t.string().default('user')) 관리자에게 '{{REQUIRED_ROLE}}' 을 부여하세요.
12
+ const REQUIRED_ROLE = '{{REQUIRED_ROLE}}'
13
+
14
+ export default controller({
15
+ // GET {{URL_PREFIX}}/session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
16
+ async new() {
17
+ return this.render('Auth/Login', { error: null as string | null })
18
+ },
19
+ // POST {{URL_PREFIX}}/session — 로그인 + 역할 게이트
20
+ async create() {
21
+ const { email, password } = this.params({ _row: {} as { email: string; password: string } })
22
+ const user = await User.where('email', '=', email).first()
23
+ // 인증(401 성격): 자격 증명이 틀리면 세션을 만들지 않고 같은 폼을 재렌더한다.
24
+ // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
25
+ if (!user || !(await verifyPassword(password, user.passwordDigest))) {
26
+ return this.render('Auth/Login', {
27
+ error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
28
+ })
29
+ }
30
+ // 인가(403 성격): 자격은 맞지만 역할이 부족하면 세션을 만들지 않고 재렌더한다.
31
+ // 고객이 "로그인은 됐는데 403" 을 겪지 않도록 진입점에서 막는다(결정 218 · 결정 155·145 의
32
+ // 대시보드 역할 게이트와 정합 — 여기서 세션 자체를 안 만들어 관리 앱을 깨끗이 격리).
33
+ if ((user as { role?: string }).role !== REQUIRED_ROLE) {
34
+ return this.render('Auth/Login', {
35
+ error: '이 앱에 접근할 권한이 없습니다.' as string | null,
36
+ })
37
+ }
38
+ this.auth.login(user)
39
+ return this.redirect('{{URL_PREFIX}}/dashboard')
40
+ },
41
+ // DELETE {{URL_PREFIX}}/session — 로그아웃
42
+ async destroy() {
43
+ this.auth.logout()
44
+ return this.redirect('{{URL_PREFIX}}/session/new')
45
+ },
46
+ })
@@ -115,7 +115,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
115
115
  3. `dependency-direction` — 의존 방향 4규칙 위반
116
116
  4. `connections` — 스키마·`getConnection` 이 쓰는 커넥션 키가 `gaon.config.ts` 에 등록됐는지 · db 설정 정적 분석(삼항·`??` 지원 · 못 읽으면 안내) (§4.5 · 결정 135)
117
117
  5. `migration-diff` — 스키마 vs DB 상태 불일치
118
- 6. `shared-composable-purity` — shared `api`/`pageProps` import (결정 25)
118
+ 6. `shared-purity` — shared/ 전체(.ts·.vue)가 `api`/`pageProps`·domain 값을 import (컴포저블·컴포넌트 통일 · 결정 25·217)
119
119
  7. `no-auto-import` — 자동 import 설정 (E-5 §2.4)
120
120
  8. `schema-filename` — 스키마 파일명 camelCase 관례 (결정 38 · `--fix` 지원)
121
121
  9. `agents-doc-index` — 이 문서 색인(§0) ↔ `agents/` 실 파일 불일치 (결정 40)
@@ -153,15 +153,17 @@ shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로
153
153
  | 컴포넌트 | 자유 (pageProps·api 사용 가능) | props 로만 받는 순수 UI |
154
154
  | 컴포저블 | 자유 (api·채널 래핑 가능) | 인자로만 받는 순수 로직 |
155
155
 
156
- **shared 컴포저블 제약:**
156
+ **shared 순수성 제약 (컴포저블·컴포넌트 공통):**
157
157
 
158
158
  - **라우트를 몰라야 한다** — `api()`·`pageProps()` 호출 금지.
159
159
  `.gaon/routes.d.ts` 는 앱별 생성이므로 구조적으로도 불가능하다.
160
- - 필요한 데이터·호출 함수는 **인자로 받는다** (순수 로직).
160
+ - 필요한 데이터·호출 함수는 **props/인자로 받는다** (순수 로직·순수 UI).
161
161
  - domain 은 **타입 import 만** 허용.
162
162
 
163
- `gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
164
- `gaonjs/vue` 의 `api`/`pageProps` import 잡는다.
163
+ `gaon doctor` 의 **shared-purity** 검사가 `shared/` 전체(`.ts` 컴포저블 +
164
+ `.vue` 컴포넌트)에서 `gaonjs/vue` 의 `api`/`pageProps` import domain
165
+ 값 import 를 잡는다 (결정 217 — 컴포저블·컴포넌트 한 규칙으로 통일 · `.vue` 는
166
+ `<script>` 블록을 훑는다).
165
167
 
166
168
  **shared 컴포넌트(UI 킷 §8)의 허용/금지도 같은 기준(결정 25·105):** 허용 = 라우트 키와
167
169
  무관한 범용 API(`useForm`·`Link`·`router`) · 금지 = 앱 라우트 지식(`api()`·`pageProps`).
@@ -395,8 +397,8 @@ async function runSearch(q: string) {
395
397
  경계 우회로 타입 안전 붕괴.
396
398
  - **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
397
399
  결정한다.
398
- - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
399
- shared-composable-purity 위반. 데이터는 props/인자로.
400
+ - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
401
+ doctor **shared-purity** 위반(`.ts`·`.vue` 공통 · 결정 217). 데이터는 props/인자로.
400
402
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
401
403
  `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
402
404
  - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
@@ -9,8 +9,10 @@
9
9
  <!--
10
10
  Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
11
11
  vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
12
- Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 아래와 같은 구조의
13
- <div id="app" data-page="..."> 를 내려보낸다 — 개발과 운영이 같은 셸.
12
+ Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 <div id="app"></div> 와
13
+ 함께 초기 페이지 객체를 <script type="application/json" data-page="app">…</script>
14
+ 엘리먼트로 내려보낸다(data-page 는 div 속성이 아니라 이 script 태그에 실린다) —
15
+ 개발과 운영이 같은 셸이다.
14
16
  -->
15
17
  <div id="app"></div>
16
18
  <script type="module" src="/main.ts"></script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/data": "0.16.1",
31
- "@gaonjs/async": "0.13.0",
32
- "@gaonjs/i18n": "0.2.1",
33
30
  "@gaonjs/config": "0.16.0",
34
- "@gaonjs/web": "0.19.0",
31
+ "@gaonjs/async": "0.13.0",
35
32
  "@gaonjs/mail": "0.2.1",
36
- "@gaonjs/core": "0.2.2"
33
+ "@gaonjs/core": "0.2.2",
34
+ "@gaonjs/i18n": "0.2.1",
35
+ "@gaonjs/data": "0.16.1",
36
+ "@gaonjs/web": "0.19.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""